从零构建大语言模型
从零构建大语言模型:沿着 500 行代码看懂 GPT 的工作原理
大语言模型常常给人一种“高深且遥远”的印象:Transformer、注意力机制、交叉熵、采样策略,这些名词堆叠在一起,很容易让人望而却步。但如果换一种视角,不从抽象概念出发,而是沿着一份可以运行的代码,一层一层往下拆,事情就会清晰很多。
这篇文章的目标,就是用工程化的方式回答一个问题:一个 GPT 风格的大语言模型,到底是怎么从零搭起来的?
这里讨论的不是工业级超大模型的分布式训练,而是一套约 500 行代码的最小可行实现。它足够小,能让我们看清每个模块的职责;也足够完整,涵盖了数据准备、模型结构、训练过程和文本生成这四个关键环节。通过这条主线,我们不仅能理解“大模型由哪些部分组成”,更能明白“这些部分为什么必须存在”。
一、从原始文本到训练样本:模型先要吃对“数据”
任何语言模型的起点都不是神经网络,而是文本数据。模型并不能直接理解字符串,它首先需要把自然语言变成可计算的数值形式。
1. 训练集和验证集为什么要分开
从工程角度看,数据集通常会先划分为训练集和验证集。训练集用于更新模型参数,验证集用于检查模型的泛化能力。
这背后的逻辑并不复杂。训练集相当于“练习题”,模型会不断根据它调整自己;验证集相当于“模拟考试”,用于判断模型是否真的学到了规律,而不是仅仅把训练数据背了下来。如果训练损失一直下降,但验证损失开始上升,通常就意味着模型正在过拟合。
在实现上,这一步往往只是把原始文本按比例切开,例如 90% 用于训练,10% 用于验证。虽然简单,但它决定了后续训练是否具有可解释性。
2. Dataset 和 DataLoader 分别解决什么问题
在 PyTorch 里,数据处理通常由 Dataset 和 DataLoader 配合完成。
Dataset 回答的是“数据是什么”。它负责定义一条样本怎么取、数据集有多大。DataLoader 回答的是“数据怎么喂给模型”。它负责批量化、打乱顺序、多进程加载等工程问题。
这套设计很重要,因为模型训练并不是一次吃下一整本书,而是一批一批地读。训练时的数据顺序通常会被打乱,以避免模型记住样本顺序本身;而验证时则保持顺序稳定,确保评估结果可比较、可复现。
3. 分词:从字符串到 token id
模型无法直接处理句子,必须先把文本切成 token,再把 token 映射为整数 ID。这个过程就是词元化(tokenization)。
其本质是一次“语言到数字”的映射。比如一句文本,会先被拆成词、子词、标点等更小单元,然后每个单元对应词表中的一个整数。后续模型看到的,其实只是一个 ID 序列。
这里一个关键点在于:现代 LLM 通常不会直接以“整词”为最小单位,而是采用 BPE 之类的子词方法。这样做的好处是,即便遇到词表中从未出现过的新词,分词器也能把它拆成更小的已知片段,从而避免真正意义上的“未知词”。
4. 滑动窗口:把连续文本变成监督学习样本
语言模型的核心任务只有一句话:根据上文预测下一个 token。
如果我们已经把整段文本变成了 token 序列,那么训练样本就可以通过滑动窗口自动构造出来:
- 输入:当前位置开始的一段上下文
- 目标:这段上下文整体向右偏移一位后的结果
例如输入是 [t1, t2, t3, t4],目标就是 [t2, t3, t4, t5]。
这样模型在每个位置上都在做同一件事:根据前面的 token,猜当前正确答案是什么。
这一步非常关键,因为它把“阅读原始文本”转化成了一个标准的监督学习问题。
二、模型结构:GPT 到底由哪些模块构成
当数据准备好之后,真正的模型部分才开始登场。一个 GPT 风格模型可以粗略分成三层:
- 输入表示层
- Transformer 核心处理层
- 输出投影层
1. 输入表示层:语义和位置缺一不可
输入给模型的虽然是 token ID,但 ID 本身没有语义,也不携带顺序信息。因此,第一步必须做嵌入。
词嵌入(token embedding)负责把每个离散 ID 映射成一个连续向量。这个向量是可训练的,在训练过程中,模型会逐步把语义相近的 token 拉近,把语义不同的 token 拉远。
但只有词嵌入还不够,因为注意力机制天然不理解顺序。对于模型来说,“人咬狗”和“狗咬人”如果只看词集合,几乎没有区别。因此还必须引入位置嵌入(positional embedding),把“这个 token 出现在第几个位置”也编码进去。
最终,模型真正接收到的输入,是词嵌入和位置嵌入的相加结果。这样每个位置上的向量同时带有“它是谁”和“它在哪”的信息。
2. Transformer Block:GPT 的计算核心
GPT 的主体是一堆 Transformer Block 的堆叠。每个 Block 内部通常包含三类关键组件:
- 多头因果自注意力
- 前馈神经网络
- 层归一化与残差连接
这几个部分共同决定了模型是否既能理解上下文,又能稳定训练。
三、自注意力:模型是如何“看上下文”的
自注意力机制是 LLM 的灵魂。它解决的核心问题是:当前这个 token,到底应该关注上下文里的哪些 token?
1. 最朴素的自注意力:加权汇总上下文
可以把自注意力理解成三步:
- 计算当前 token 与其他 token 的相关性分数
- 用 softmax 把分数归一化成权重
- 按权重对所有 token 的信息做加权求和
最后得到的新向量,不再只是“当前词自身的表示”,而是“当前词在整个上下文中的表示”。
这一步的意义极大。因为模型终于不再是线性读句子,而是可以在任意位置直接回看整段上下文,动态决定哪里更重要。
2. 为什么要引入 Q、K、V
如果直接拿原始嵌入向量做相似度计算,会有一个问题:同一个向量既要代表词义,又要参与注意力打分,职责混在一起,不够灵活。
于是 Transformer 引入了三组可训练变换:
- Query:当前 token 想查询什么
- Key:其他 token 提供什么索引信息
- Value:其他 token 真正携带什么内容
这样注意力就从“直接比向量”升级成了“学习如何比、学习该关注谁”。Q、K、V 的存在,让注意力机制本身也变成了可训练模块,而不只是固定公式。
3. 因果掩码:生成模型不能偷看未来
标准自注意力会看到整个序列,但 GPT 是自回归模型,在预测下一个词时不能看到未来词,否则就等于作弊。
解决办法是加入因果掩码。做法通常是在注意力分数矩阵上盖一个上三角 mask,把当前位置之后的分数全部变成极小值。经过 softmax 后,这些位置的概率就会接近 0。
这样一来,模型在第 i 个位置上,只能利用 1...i 的信息,不能利用 i+1 之后的内容。这正是 GPT 能用于文本生成的根本前提。
4. 多头注意力:让模型从多个角度理解同一句话
单头注意力只有一种“看问题的方式”。但语言是复杂的,有时要关注语法关系,有时要关注语义关联,有时要关注长距离依赖。
多头注意力的思路,就是并行跑多套注意力计算。每个头都有独立的 Q、K、V 变换,它们会在不同表示子空间里学习不同的关注模式。最后再把多个头的结果拼接起来,统一投影回模型维度。
这相当于让模型拥有多组“观察员”,从不同角度同时审视一句话,再把各自的发现汇总起来。
四、除了注意力,为什么还需要 LayerNorm、FFN 和残差连接
注意力机制很强,但一个 Transformer Block 并不只有注意力。
1. LayerNorm:解决深层网络训练不稳定
深度网络一旦堆得很深,就容易出现数值分布不断漂移的问题。某些层输出过大,某些层输出过小,最终导致训练不稳定,甚至梯度爆炸或梯度消失。
LayerNorm 的作用,就是在特征维度上对每个样本做标准化,让输入保持在一个更稳定的分布上。这样后续模块看到的数据范围更可控,训练也更容易收敛。
更重要的是,LayerNorm 后面通常还会带上可学习的缩放和偏置参数。这意味着它不是死板地“强制统一”,而是在稳定数值的基础上,仍允许模型学回自己想要的分布。
2. 前馈网络:负责“进一步思考”
注意力更像是“信息整合器”,负责从上下文中取回相关内容;前馈网络则更像“局部加工器”,负责对已经整合好的信息做更复杂的非线性变换。
典型实现是两层线性层加一个 GELU 激活,中间先升维,再降维。这个结构为模型提供了更强的表达能力,使它不只是会“找关系”,还能对关系进行深度加工。
3. 残差连接:给信息和梯度修一条高速公路
如果每经过一层都完全重写表示,原始信息很容易在深层传播中被冲淡;同时,反向传播时梯度也容易逐层衰减。
残差连接的做法非常直接:把输入直接绕过当前模块,加回输出上。
这相当于给网络修了一条高速公路:
- 前向时,原始信息可以直接传递
- 反向时,梯度也可以更顺畅地流回浅层
正因为有残差连接,Transformer 才能稳定堆叠很多层,而不至于“越深越学不动”。
五、输出层:从隐藏状态回到词表概率
经过多层 Transformer Block 处理后,模型得到的是一串上下文化的隐藏向量。但这些向量仍然只是内部表示,不能直接变成文字。
所以最后还需要一个输出投影层,把每个位置上的隐藏向量映射回词表大小的维度。
如果词表大小是 50,257,那么每个位置最终都会得到一个长度为 50,257 的向量,里面每个数都对应一个 token 的原始置信度分数,也就是 logits。
再对 logits 做 softmax,就能得到“下一个 token 是谁”的概率分布。
至此,模型才真正完成了一次“预测”。
六、训练过程:模型如何从随机参数变得会说话
刚初始化出来的 GPT 结构虽然完整,但参数全是随机的,输出自然也是随机噪声。模型之所以能学会语言,靠的是训练循环。
1. 训练的四步循环
每一个 batch 的训练,本质上都在重复以下过程:
- 前向传播,得到预测 logits
- 计算损失,衡量预测与真实答案的差距
- 反向传播,计算每个参数该往哪个方向改
- 优化器更新参数
这个过程反复执行,模型就会逐渐学会:在什么样的上下文后面,什么 token 更可能出现。
2. 交叉熵损失为什么适合语言模型
语言模型的每个位置,其实都可以看成一个多分类任务:
在整个词表中,正确答案只有一个,模型需要给它尽量高的概率。
交叉熵损失正适合做这件事。它本质上是在惩罚模型“没有把正确答案的概率抬高”。如果模型对正确 token 非常自信,损失就小;如果模型把概率分配给了错误 token,损失就大。
因此,最小化交叉熵的过程,实际上就是在逼迫模型学会构造更接近真实语言分布的预测。
3. 为什么既要看训练损失,也要看验证损失
训练时不能只盯着一个数字看。训练损失下降,只说明模型越来越会拟合训练数据;验证损失下降,才说明模型学到的是可迁移的规律。
一个健康的训练过程通常表现为:
- 训练损失下降
- 验证损失也同步下降
如果后者停止下降甚至反弹,就要警惕过拟合。这也是为什么训练集和验证集的划分不是形式主义,而是整个训练监控体系的一部分。
七、文本生成:训练完以后,模型怎么把字一个个“写出来”
模型学会预测下一个 token 后,就可以进入生成阶段了。生成的基本流程很简单:
- 给模型一个起始文本
- 模型预测下一个 token 的概率分布
- 选择一个 token 作为输出
- 把这个 token 拼回输入
- 重复以上过程
但“怎么选下一个 token”其实很讲究。
1. 贪婪解码的问题
最简单的方法是每次都选概率最大的 token,也就是贪婪解码。
它的问题也很明显:
- 容易重复
- 缺乏多样性
- 经常陷入局部最优
对于开放式文本生成任务,这种策略通常会让输出显得机械又无聊。
2. 温度缩放:控制模型的保守与发散
温度本质上是在 softmax 前调整 logits 的尖锐程度。
- 温度小于 1:分布更尖锐,模型更保守
- 温度大于 1:分布更平缓,模型更发散
温度不是决定“对错”,而是决定“敢不敢冒险”。它直接影响生成文本的稳定性与创造性。
3. Top-k 采样:只在靠谱候选里随机
即便做了温度调整,词表仍然太大,很多低概率词虽然没被排除,但其实并不合理。Top-k 采样的思路就是:只保留概率最高的 k 个 token,然后在这 k 个里面按概率随机抽样。
这样既避免了完全放飞,也避免了过度死板,通常能在连贯性和多样性之间取得更平衡的效果。
实际系统中还常结合 Top-p 等更动态的采样策略,本质上都是在回答同一个问题:如何既让模型说得通,又让它别太无聊。
八、从零实现 GPT,真正能学到什么
从工程视角回看,一个 GPT 风格的大语言模型其实可以拆解成一套非常清晰的流水线:
- 文本被分词并数值化
- 连续文本被切成输入-目标训练样本
- 输入经过嵌入和位置编码进入模型
- Transformer 用注意力机制融合上下文
- 前馈网络、归一化和残差连接保证表达能力与训练稳定
- 输出层把隐藏表示映射回词表概率
- 训练循环通过交叉熵和反向传播持续更新参数
- 推理阶段通过采样策略逐 token 生成文本
真正有价值的,不是“会背 Transformer 结构图”,而是理解这条链路上每个模块分别解决了什么问题。
为什么必须先做 tokenization?
为什么位置编码不可少?
为什么 GPT 一定要做 causal mask?
为什么没有残差连接,深层网络会训练困难?
为什么生成阶段不能只靠 argmax?
当这些问题都能从实现角度讲清楚时,我们对大语言模型的理解,才算真正从“知道名词”进入“理解机制”。
结语
大语言模型并不是一个无法触碰的黑盒。把它还原成代码之后,你会发现它本质上是很多经典工程思想的组合:数据管道、矩阵运算、可微优化、模块堆叠、概率采样。
所谓“从零构建 LLM”的意义,也不在于真的用 500 行代码复刻工业级模型,而在于通过一个最小实现,把复杂系统拆成一组可以被逐个理解、逐个验证的子问题。理解了这条主线,再去看更大的模型、更复杂的训练框架,很多东西都会顺理成章。


