1. 从零构建大语言模型:不只是代码,更是理解

如果你和我一样,对ChatGPT、Claude这些大语言模型(LLM)背后的“黑魔法”感到既兴奋又困惑,那么“从零开始构建一个大语言模型”这个想法,可能既诱人又令人生畏。市面上充斥着各种调用API的教程,或者用几行代码微调预训练模型的指南,但这就像只学会了开车,却对引擎盖下的内燃机、变速箱和电路系统一无所知。当模型输出不合逻辑的结果,或者你想针对特定领域定制一个更高效的模型时,那种无力感就会特别明显。

这正是Sebastian Raschka的《Build a Large Language Model (From Scratch)》及其同名开源项目 rasbt/LLMs-from-scratch 的价值所在。它不满足于让你成为一个“调包侠”,而是邀请你亲手从最基础的文本处理开始,一步步搭建、预训练、微调一个类GPT的模型。这个过程,与其说是在“造轮子”,不如说是一次深度解剖。你会亲手实现注意力机制、编写Transformer块、处理海量文本数据、设计训练循环,直到模型能生成连贯的文本或完成分类任务。当你走完这一程,再回头看那些现成的模型库,感觉会完全不同——你看到的将不再是神秘的黑箱,而是一系列清晰、可理解、可操控的组件。

这个项目(以及配套书籍)的核心目标,是教育而非生产。它旨在用有限的资源(你的笔记本电脑)和清晰的代码,复现构建现代大语言模型的核心流程。通过这个过程,你将获得对LLM内部工作原理的直觉,这种直觉是任何高级API教程都无法给予的。接下来,我将结合项目资料和我个人的学习与实践经验,为你拆解这个“从零构建”之旅的完整蓝图、核心细节与避坑指南。

2. 项目全景与学习路径设计

2.1 核心目标与设计哲学

LLMs-from-scratch 项目的设计非常明确: 教育第一,实践导向 。它没有选择去构建一个参数量达数十亿、需要数百张GPU的巨无霸模型,而是设计了一个规模适中、可在消费级硬件上运行的“微型GPT”。这个选择至关重要,因为它移除了学习道路上最大的硬件壁垒,让你能专注于算法和工程本身。

项目的学习路径严格遵循模型开发的生命周期,这也是其配套书籍的章节结构:

  1. 基础认知(第1章) :理解LLM是什么、能做什么、以及当前的技术全景。这一步建立正确的心理模型,避免后续陷入细节而迷失方向。
  2. 数据基石(第2章) :从原始文本到模型可理解的数字。这里会深入实现或使用分词器(Tokenizer),特别是Byte Pair Encoding (BPE),并构建高效的数据加载管道(DataLoader)。这是所有后续工作的基础,数据管道的效率直接决定了实验迭代的速度。
  3. 核心引擎(第3、4章) :实现Transformer架构的核心——自注意力机制(Attention)和多头注意力(Multi-Head Attention),并最终组装成完整的GPT模型。你会亲手编写每一个线性层、LayerNorm和激活函数。
  4. 赋予知识(第5章) :在无标签的大规模文本数据上预训练你的模型。这是最耗时的步骤,但也是模型获得“语言能力”的关键。项目会指导你设计损失函数、优化器和训练循环。
  5. 专项优化(第6、7章) :在预训练模型的基础上进行微调。第6章专注于有监督微调,例如文本分类任务;第7章则进阶到指令微调,让模型学会遵循人类的指令,这是ChatGPT类模型对话能力的基础。

这种从数据到模型,从预训练到微调的线性推进,模拟了工业界研发LLM的标准流程,确保了学习过程的系统性和完整性。

2.2 环境准备与工具选型

项目主要使用 Python PyTorch 框架。选择PyTorch而非TensorFlow或其他框架,主要基于其动态图特性带来的灵活性和调试便利性,这对于教学和从零实现尤为重要。

注意 :虽然项目声称对PyTorch基础要求不高,附录A也提供了快速入门,但我强烈建议你至少提前熟悉PyTorch的 Tensor 操作、自动求导( autograd )和基本的 nn.Module 编写方式。否则,你可能需要频繁在代码和附录之间切换,影响学习连贯性。

硬件要求 是该项目的一大亮点: 常规笔记本电脑即可 。这意味着你不需要访问昂贵的云端GPU或专业计算设备。代码会自动检测CUDA(NVIDIA GPU),如果可用则利用GPU加速,否则回退到CPU。对于预训练章节,在CPU上运行可能会比较慢,但绝对是可行的。这打破了“没有GPU就别玩LLM”的刻板印象。

我的实操心得 :在开始前,务必按照项目 setup/ 目录下的指南配置环境。我推荐使用 conda venv 创建独立的Python虚拟环境,并使用 uv pip 安装依赖。避免使用系统全局Python环境,以免包版本冲突。如果你在Windows上遇到问题,可以优先参考项目CI工作流中Windows专用的配置脚本。

3. 核心模块深度解析与实现要点

3.1 文本数据处理的魔鬼细节

第2章“处理文本数据”是万里长征第一步,也是最容易低估其复杂性的一步。这里的关键在于 分词 数据流

分词器实现 :项目引导你理解并应用BPE算法。BPE的核心思想是迭代地合并文本中最常见的字节对,从而构建一个词表。你需要实现的不是一个“玩具”版本,而是一个能处理真实文本、支持 unk (未知词)和特殊标记(如 <bos> , <eos> )的健壮分词器。

# 一个简化的BPE合并步骤示意
import re
from collections import Counter

def get_stats(vocab):
    """统计相邻符号对的出现频率"""
    pairs = Counter()
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i], symbols[i+1]] += freq
    return pairs

def merge_vocab(pair, v_in):
    """将词表中最频繁的符号对合并"""
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

关键点 :BPE是在字节级别操作的,这使其能处理任何语言甚至代码,且能有效缓解未登录词问题。在实现时,要特别注意处理空格和Unicode字符。

DataLoader设计 :LLM训练需要处理TB级别的文本。你不能一次性把全部数据加载进内存。项目会教你编写一个高效的 DataLoader ,它能够:

  1. 流式读取大文件。
  2. 动态地对文本进行分词和编码。
  3. 批量组织数据,并生成用于因果语言建模的输入-目标对(即预测下一个token)。
  4. 支持随机打乱,确保每个epoch看到的数据顺序不同。

避坑指南 :数据管道的效率往往是训练瓶颈。务必使用PyTorch的 Dataset DataLoader 类,并设置合适的 num_workers 参数以利用多进程进行数据加载,避免让GPU等待数据。对于超大数据集,考虑使用 IterableDataset

3.2 注意力机制:Transformer的灵魂

第3章专门攻克 注意力机制 。你需要从最基础的缩放点积注意力公式开始实现:

[ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]

这里的 Q (查询)、 K (键)、 V (值)都来自同一输入序列(自注意力)。 sqrt(d_k) 这个缩放因子是为了防止点积结果过大导致softmax梯度消失。

从单头到多头 :单头注意力只能关注一种模式的信息。多头注意力并行运行多个“注意力头”,每个头学习在不同子空间中的关注模式,最后将结果拼接并投影。这是模型理解复杂语言关系的关键。

import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        assert d_model % num_heads == 0
        self.d_k = d_model // num_heads
        self.num_heads = num_heads
        self.q_linear = nn.Linear(d_model, d_model)
        self.k_linear = nn.Linear(d_model, d_model)
        self.v_linear = nn.Linear(d_model, d_model)
        self.out_linear = nn.Linear(d_model, d_model)

    def forward(self, q, k, v, mask=None):
        batch_size = q.size(0)
        # 线性变换后,拆分成多头
        q = self.q_linear(q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        k = self.k_linear(k).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        v = self.v_linear(v).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        # 计算注意力分数
        scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attn_weights = F.softmax(scores, dim=-1)
        # 应用注意力权重到V上
        context = torch.matmul(attn_weights, v)
        # 合并多头,输出投影
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.num_heads * self.d_k)
        return self.out_linear(context)

实现要点

  1. 因果掩码 :对于语言模型,必须确保当前位置只能关注到过去及当前位置的信息,不能“偷看”未来。这通过一个下三角矩阵掩码实现。
  2. 矩阵维度 :时刻检查 Q, K, V 的维度 (batch_size, seq_len, d_model) ,以及在view和transpose操作后的维度变化。这是调试时最常见的错误来源。
  3. 效率 :项目的高级章节会探讨更高效的注意力实现(如Flash Attention的原理),但初期理解基础版本是关键。

3.3 组装GPT模型:架构与细节

第4章将前面实现的注意力机制与其它组件结合,搭建完整的GPT模型。一个标准的GPT解码器块通常包含:

  1. 多头自注意力层(带残差连接和层归一化)。
  2. 前馈神经网络(通常是两层MLP,带激活函数,同样有残差连接和层归一化)。

层归一化的位置 :GPT采用“Pre-LN”结构,即在注意力层和前馈层 之前 进行层归一化。这与原始Transformer的“Post-LN”不同,通常能使训练更稳定。

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.ln1 = nn.LayerNorm(d_model)
        self.attn = MultiHeadAttention(d_model, num_heads)
        self.dropout1 = nn.Dropout(dropout)
        self.ln2 = nn.LayerNorm(d_model)
        self.ff = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(), # GPT通常使用GELU激活函数
            nn.Linear(d_ff, d_model),
            nn.Dropout(dropout)
        )

    def forward(self, x, mask=None):
        # Pre-LN: 先归一化,再计算注意力
        attn_output = self.attn(self.ln1(x), self.ln1(x), self.ln1(x), mask)
        x = x + self.dropout1(attn_output) # 残差连接
        # 前馈网络同样采用Pre-LN
        ff_output = self.ff(self.ln2(x))
        x = x + ff_output # 残差连接
        return x

位置编码 :由于Transformer本身不具备序列顺序信息,必须注入位置编码。GPT使用可学习的“位置嵌入”,与词嵌入相加作为输入。

最后的线性层 :经过N个Transformer块后,输出的每个位置的特征向量需要通过一个线性层(无偏置)映射回词表大小,再通过softmax得到下一个token的概率分布。

我的实操心得 :在调试模型时,一个非常有效的方法是创建一个极小的模型(例如2层,隐藏维度很小),用极小的批量数据和序列长度进行前向传播和反向传播,确保没有维度错误,并且损失能正常下降。这比直接用大参数模型开始训练要高效得多。

4. 训练与微调全流程实操

4.1 预训练:在无标签文本中学习

第5章的预训练是整个项目的重头戏。你需要准备一个大规模的文本数据集(如维基百科、开源书籍等)。目标函数是标准的 因果语言建模 :给定前N个token,预测第N+1个token。

训练循环核心步骤

  1. 数据批次获取 :从DataLoader中获取一个批次的 (input_ids, target_ids) 。注意, target_ids 通常是 input_ids 向右偏移一位。
  2. 前向传播 :将 input_ids 输入模型,得到每个位置对词表中所有token的预测logits。
  3. 计算损失 :使用交叉熵损失函数,比较预测logits和 target_ids 。通常忽略对填充符(padding)的计算。
  4. 反向传播与优化 :清空梯度 -> 反向传播 -> 使用优化器(如AdamW)更新参数。
  5. 日志记录与评估 :定期输出训练损失,并在验证集上计算困惑度(Perplexity, PPL),这是衡量语言模型好坏的核心指标。 PPL = exp(loss)

超参数选择

  • 学习率 :这是最重要的超参数之一。项目可能会使用带热身(Warmup)的学习率调度器,例如在训练初期线性增加学习率,再余弦衰减。
  • 批量大小 :在GPU内存允许的情况下尽可能大。大的批量大小通常能带来更稳定的梯度估计。
  • 序列长度 :决定了模型能看到的上下文窗口。越长越好,但受内存和计算量平方级增长的限制。
  • 梯度裁剪 :防止梯度爆炸,是稳定训练的必要技巧。

实操现场记录 :在预训练初期,损失会快速下降,然后进入一个缓慢下降的平台期。不要因为几天内损失变化不大而轻易放弃。困惑度从几千降到几十可能只需要几天,但从几十降到十几可能需要数周。定期保存模型检查点至关重要。

4.2 有监督微调:让模型做分类

第6章展示了如何将预训练好的生成式模型,用于文本分类任务。核心思想是 任务适配

方法 :对于分类任务,我们通常只关心整个输入序列的聚合表示。标准的做法是:

  1. 在输入序列前添加一个特殊的分类标记(如 [CLS] )。
  2. 将整个序列输入模型。
  3. 取这个 [CLS] 标记对应位置的最终隐藏状态,作为整个序列的表示。
  4. 在这个表示上接一个线性分类器(全连接层),输出各个类别的概率。

训练策略

  • 全部微调 :解冻整个预训练模型,连同新添加的分类头一起训练。这通常效果最好,但计算成本最高,且容易在小数据集上过拟合。
  • 部分微调 :只训练顶部的几层Transformer块和分类头,冻结底部的层。这是一种折中方案。
  • 仅训练分类头 :冻结整个预训练模型,只训练新添加的线性层。计算成本最低,适用于数据量极少的情况,但性能可能受限。

项目实践 :项目代码会引导你加载预训练的权重,在IMDb电影评论数据集上进行情感分类(正面/负面)微调。你会观察到,即使预训练数据没有明确的电影评论,模型也能快速学会分类,这证明了预训练获得的通用语言理解能力的强大迁移性。

4.3 指令微调:通向对话能力

第7章是指令微调,这是让模型从“续写文本”转变为“遵循指令”的关键一步。你需要一个 (指令, 输入, 输出) 格式的数据集。

数据格式构建 :例如:

  • 指令: “将以下英文翻译成中文。”
  • 输入: “Hello, world!”
  • 输出: “你好,世界!”

在训练时,我们将指令和输入拼接作为模型的上下文,然后要求模型生成输出部分。损失函数只计算在输出token上的交叉熵,忽略指令和输入部分。

与预训练的区别

  1. 训练目标 :预训练是预测下一个token(无差别);指令微调是学习在特定指令格式下生成期望的输出。
  2. 数据 :指令数据需要人工构造或利用大模型合成,规模远小于预训练数据,但质量要求更高。
  3. 评估 :不再用困惑度,而是用人性化评估或基于GPT-4等模型的自动评估,看生成结果是否相关、有用、无害。

高级话题 :项目还触及了 直接偏好优化(DPO) 。这是指令微调后的进一步对齐技术,使用人类对模型多个回答的偏好数据,直接优化模型使其更符合人类价值观,而不是简单的模仿。

我的实操心得 :指令微调的数据质量决定上限。嘈杂或矛盾的指令数据会让模型困惑。在开始训练前,务必仔细清洗和检查你的指令数据集。此外,指令微调很容易过拟合,要密切监控在保留验证集上的表现,并适时早停。

5. 进阶探索与性能调优

5.1 效率优化技巧

当你的模型跑起来后,你会自然地问:怎么能让它更快、更省内存?项目在附录和Bonus材料中提供了大量实用技巧:

  1. 混合精度训练 :使用 torch.cuda.amp 进行自动混合精度训练,在保持数值精度的同时,大幅减少GPU显存占用并加速计算。
  2. 梯度检查点 :用计算时间换显存。在反向传播时重新计算某些中间激活值,而不是全部存储,可以训练更深的模型或更长的序列。
  3. KV缓存 :在生成文本时(自回归解码),先前时间步的Key和Value向量可以被缓存并复用,避免重复计算,这是推理加速的关键技术。项目在 ch04/03_kv-cache 中提供了实现。
  4. 更高效的注意力实现 :如Flash Attention(原理讲解),通过优化GPU内存访问模式来加速注意力计算。
  5. 参数高效微调 :如LoRA,只在原始模型旁添加少量的可训练参数,而不是微调全部权重,极大节省了微调所需的资源和时间。附录E专门讲解了LoRA的实现。

5.2 扩展模型家族

除了基础的GPT架构,项目还引导你实现其他流行的开源模型架构,这对于理解LLM生态至关重要:

  • Llama :Meta开源的模型,采用了RMSNorm、SwiGLU激活函数、旋转位置编码等改进。
  • Qwen :阿里通义千问模型,支持更长的上下文。
  • Mixture of Experts :一种稀疏化技术,每个输入只激活网络中的一部分参数(专家),在保持参数量巨大的同时,实际计算成本可控。

通过动手实现这些变体,你能深刻理解不同技术选择(如不同的位置编码、归一化方式)对模型性能和训练稳定性的影响。

5.3 调试与问题排查实录

在从零构建的过程中,你一定会遇到各种问题。以下是我和许多学习者常见的问题及解决思路:

问题1:训练损失不下降,或者变成NaN。

  • 可能原因 :学习率过高、梯度爆炸、数据中存在异常值(如NaN文本)。
  • 排查 :首先检查数据预处理环节,确保输入中没有NaN或inf。在训练循环开始时,打印第一个批次的数据和标签,确保它们看起来正常。 启用梯度裁剪 。将学习率调低1-2个数量级重新尝试。在损失计算后添加 assert not torch.isnan(loss).item() 进行断言。

问题2:模型生成的结果是乱码或重复的单词。

  • 可能原因 :这通常被称为“模型崩溃”,在训练不充分或数据质量差时常见。也可能是采样温度参数设置不当。
  • 排查 :检查验证集困惑度是否真的在下降。如果训练损失下降但验证损失不降,可能是过拟合。尝试在更高质量、更多样的数据上训练。在生成时,调整 temperature 参数(降低温度会使输出更确定但可能重复,提高温度更随机但可能胡言乱语),并尝试使用Top-p(核采样)或Top-k采样。

问题3:GPU内存溢出(CUDA out of memory)。

  • 可能原因 :批量大小或序列长度太大;模型参数太多;中间激活值占用了大量内存。
  • 排查 :减小批量大小或序列长度。使用 torch.cuda.empty_cache() 清理缓存。考虑使用梯度累积:以小批量进行多次前向传播,累积梯度后再一次性更新,模拟大批量效果。启用混合精度训练和梯度检查点。

问题4:训练速度非常慢。

  • 可能原因 :数据加载是瓶颈;没有使用GPU;模型某些操作在CPU上。
  • 排查 :使用 torch.utils.data.DataLoader 并设置 pin_memory=True 和合适的 num_workers 。确保模型和输入数据都通过 .to(device) 移到了GPU上。使用 torch.backends.cudnn.benchmark = True 允许CuDNN为你的网络和硬件选择最优的卷积算法。

6. 从学习到创造:你的下一步

完成这个“从零构建”项目,你获得的远不止一个能运行的GPT模型。你获得的是 构建、诊断和改进LLM的底层能力 。当现成的模型表现不佳时,你知道该从数据、模型架构还是训练策略上去寻找问题。

项目之外的延伸

  1. 尝试更大规模的数据和模型 :在云GPU服务上,用同样的代码尝试训练参数更多(例如数亿)、数据量更大的模型,观察性能变化。
  2. 探索不同的架构 :基于你对GPT的理解,去阅读并尝试复现LLaMA、Gemma或Qwen的论文与官方实现,比较差异。
  3. 解决实际问题 :找一个你感兴趣的垂直领域(如法律、医疗、代码生成),收集领域文本,从零预训练或微调一个专属模型。
  4. 深入推理与对齐 :Sebastian的后续书籍《Build A Reasoning Model (From Scratch)》正是这个方向的延伸,涵盖了思维链、自洽性、强化学习对齐等高级主题。

最后,学习这个过程最宝贵的体会是:大语言模型并非遥不可及的魔法。它是一系列精妙但可理解的工程与算法组件的集合。通过这个项目,你亲手触摸了这些组件。这种第一性的理解,是你在快速变化的AI时代保持竞争力的最佳基石。记住,你构建的第一个模型可能很简陋,但它完全属于你,而且你确切地知道它的每一行代码在做什么。这种掌控感,是任何现成API都无法给予的。现在,代码和书籍都在那里,是时候启动你的Jupyter Notebook,开始这趟激动人心的建造之旅了。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐