本文是「零基础学大模型原理」系列的第 8 篇,实战篇。这个系列记录一个没有任何机器学习背景的工程师,在 ChatGPT 发布之后从零补原理的过程。
第 7 篇结尾设了一个目标:参考 nanoGPT 自己写一遍最小 GPT,并写成系列第 8 篇。前后搞了快一个多月终于搞完了,仓库名为 simple-gpt。要查看完整实现或复盘调试过程,可以访问仓库并按提交顺序查看。
把原理写成代码的原因
第 7 篇的盘点标准是「能复述机制,核心步骤亲手算过」。代码能检验这两项是否落实到实现。手算注意力矩阵时维度是 2,错误容易发现;代码中维度是 128,掩码条件写反时编译器不会提示,loss 可能变成 nan,也可能仍是看似正常的数。第 4 篇中我以为自己理解了因果掩码:上三角置负无穷。实际编码时,「置谁」和「留谁」的差别会让整行打分都变成负无穷,softmax 产生 nan。手算时关注的是语义,实现中需要同时确认下标和张量形状。
我还想确认最小实现需要哪些部分。nanoGPT 用几百行代码就能从头训练一个 GPT;我想知道,把其中每一行都换成自己写的之后,哪些代码仍不可少。最终模型 130 行,训练 104 行,采样 43 行,数据准备 48 行。
数据与分词:公版小说和现成的 BPE
语料需要满足三个条件:公版可随意使用,规模能在笔记本上训练,内容适合观察生成结果。我选了古腾堡计划上的《今古奇观》(eBook #24230),它是明代抱瓮老人辑的白话小说集,原始 TXT 约 2.9MB(b72f59b)。Gutenberg 文件的头尾含有版权样板,我写了一个清洗脚本按标记行删除(d310c41)。
分词器没有从零实现,直接使用 tiktoken 的 gpt2 BPE,词表大小为 50257(9e07af1)。BPE 的训练是另一项工作:它按相邻字节对的出现频率逐步合并。这次的目标是验证模型原理,因此把分词器当作现成组件使用。代价是 gpt2 词表按英文数据训练,基于字节的编码对中文通常不能形成稳定的整词切分,文言小说的 token 序列也会更长。这样取舍后,数据管线在一个下午内跑通。
train/val 按 95%/5% 切分,切分点向后对齐到段落边界,避免 val 集从一个故事中间开始(2b0fe20)。最终 train 201 万 token,val 7.6 万 token,存为 uint16 的 bin 文件;训练时通过 numpy memmap 读取,不将整个文件载入内存。
模型骨架:130 行对照第 4 篇
model.py 最终为 130 行,每个模块都能在第 4 篇中找到对应概念。搭建顺序与 commit 历史一致:先写骨架,再补全模块。
- 1547560:配置加入 token/position embedding。位置编码使用可学习的 embedding,而不是第 4 篇介绍的正弦方案;原始 Transformer 论文报告两种方案的结果接近,可学习版少写十行代码。
- 60e3007:先写单头自注意力,用于验证从 (B, T, C) 到 (B, nh, T, hd) 的形状变换。qkv 用一个 Linear 一次计算,再 split 为三份;在各切片拥有独立参数时,它与三个独立投影矩阵的计算等价。
- 9ef8927:改成多头,192 维切为 6 头,每头 32 维。打分除以根号 head_dim,对应第 4 篇推导的缩放步骤。
- ec72288:MLP 加残差,组成完整 block。MLP 先升到 4 倍维度,经 GELU 后再降回原维度。block 的顺序是 ln → attn → 残差 → ln → mlp → 残差,LayerNorm 位于子层之前,即 pre-norm;这与原始 Transformer 论文使用的 post-norm 顺序不同。
- 9c67ec6:block 堆叠为 4 层,经最后一个 LayerNorm 后接输出头。输出头与输入 embedding 共享权重;词表大小为 50257、维度为 128 时,这项共享减少约六百多万参数。
因果掩码通过 register_buffer 保存为下三角全 1 矩阵,forward 中将 mask 为 0 的位置填为负无穷。调试实录会讲这一行的错误。
最终配置为 4 层、4 头、128 维、上下文长度 128、dropout 0.1,总参数约七百万,其中 embedding 占六百多万。第一版是 6 层、192 维、context 256;笔记本难以承受训练时间后,在 e08a80b 改为当前规模。M 系 Mac 使用 mps,一晚上跑完。
训练循环:104 行对照第 1 篇
train.py 最终为 104 行,主干是第 1 篇 numpy 练习中的闭环:取 batch、forward 计算 loss、backward、optimizer.step()、zero_grad()。具体设置如下:
- 损失函数为交叉熵,目标是每个位置预测下一个 token。get_batch 中 x 是 data[i:i+128],y 是 data[i+1:i+129],两者相差一位。
- 使用 AdamW。weight decay 0.1 只作用于二维及以上的参数,LayerNorm 和 bias 不衰减;分组方式参考 nanoGPT。
- 学习率为 3e-4,前 200 步线性 warmup,之后 cosine 衰减到 3e-5(d18daff)。
- 使用 1.0 的梯度范数裁剪,以限制单步更新中的过大梯度。
- 每 250 步在 train/val 上各评估 20 个 batch 并取平均,写入 logs/train.log(17a230f)。这些日志用于后续定位问题。
训练日志的第一个数通过了 sanity check:step 0 的 loss 是 10.84,而 ln(50257) 约等于 10.82,对应对词表均匀分布预测时的交叉熵。未训练模型的输出接近均匀时,loss 应接近该值。结果吻合只能说明损失函数、词表大小和 forward 通路在这个检查下没有明显错误;这个检查来自 nanoGPT 的注释。
正式训练运行 2500 步,曲线见下图。train 为 2.70,val 为 2.84;val 一直略高,末段 train 略有上升而 val 基本持平。这些现象表明训练集与验证集存在小幅差距,也可能是轻微过拟合的迹象,因此在该步数停止训练。
采样时的 temperature 和 top-k
sample.py 为 43 行,核心循环在 model.py 的 generate 中:将当前序列截到最近 128 个 token 后送入模型,取最后一个位置的 logits,除以 temperature,进行 top-k 截断,softmax 归一化后用 multinomial 抽取一个 token 并拼回序列,循环执行。这是第 5 篇介绍的自回归生成过程。
prompt 统一使用「話說」,这是《今古奇观》中常见的开场。跑了几组参数,完整输出在 samples/ 目录。
temperature 0.8 / top-k 40(samples/sample-t0.8-k40.txt):
話說,知我個那。貴相被發公,細至相,不處高把了。 那中在馬頭還是,就便色晚,殿般活詩,莫人走景在致,卻不見來。
temperature 0.6 / top-k 10(samples/sample-t0.6-k10.txt):
話說。」道:「時自身詩,不是起,不得為起至個爺山等。」兒道:「那把還,就是接約了。」
两组参数下,生成结果多为常用字,标点和段落形式接近语料。temperature 较低的一组还出现了「道:」对白格式和成对引号。文本带有明清小说的字面形式,但句子没有形成连贯文义。训练目标只要求按语料分布续写,表面统计结构通常比跨句语义更容易在小模型中显现;生成质量还受模型容量、训练数据和训练过程共同影响。
temperature 1.3 且不做 top-k(samples/sample-t1.3-notopk.txt)的输出如下:
話說,鴅中縣�了,怎袃�半雦今使糭耒臣來眾�歎刊生
输出含有较多生僻字。单个生僻字的概率可能较低,但长尾候选的总概率质量仍不可忽略;提高 temperature 会使 softmax 分布更平坦,使长尾 token 更容易被采样。top-k 将候选限制在概率最高的 k 个 token 内。这组参数不适合作为默认采样设置,但能说明同一模型和权重下,采样参数会改变输出分布。
调试实录
第 7 篇预告过掩码位置、矩阵维度和 loss 下降都可能出问题。实际遇到了五个问题,相关 commit 均已保留。
loss 从头就是 nan(b6f8d34 → 17a230f)。训练脚本第一版可以运行,但 loss 是 nan。我没有先改模型,而是先加日志:17a230f 加入定时 eval,将 train/val loss 写入文件。日志确认 nan 从第一步开始出现,排除了训练中途数值发散,排查范围缩到 forward 和取数。
忘调 zero_grad(2caa9bc)。逐行检查训练循环时,发现没有调用 optimizer.zero_grad(),梯度持续累积。这是一个 bug,但修复后 loss 仍是 nan,说明它不是 nan 的直接原因。训练循环中遗漏固定步骤不会报错,结果可能只会在日志中体现。
mask 条件写反(64b20ea)。继续检查 nan 时,forward 中唯一可能使整行都为负无穷、随后令 softmax 产生 nan 的位置是掩码。attention 的 buffer 是下三角 mask,允许位置为 1,因此 masked_fill 应填充 mask == 0 的未来位置。我将条件写成 == 1,把允许查看的过去和当前位置都填为负无穷,每个位置都无法关注任何 token,整行变为 -inf。修复后重跑,loss 开始下降。这里需要同时确认「置负无穷」的对象、下标和掩码的取值含义。
targets 没右移(d285cce)。这个问题没有报错,反而让 loss 异常低:修完 mask 后 250 步,loss 降到 0.05。语言模型在这份数据和训练步数下不应如此快地接近零,因此我检查了 get_batch,发现 y = x.clone(),targets 没有右移。由于当前位置的输入 token 对模型可见,模型可以学习输入到相同 token 的映射,交叉熵会快速下降。修复为右移一位后,清空日志重跑。这个问题说明,异常低的指标也需要结合训练目标判断。
top-k 参数传反(25b3846)。采样脚本完成后(13724f9),生成结果全是生僻字。模型的 loss 正常,因此我直接检查采样代码。generate 中使用 torch.topk(logits, top_k, largest=False),它选择的是 logits 最小的 k 个候选,也就是 softmax 后概率最低的候选。改为默认的 largest=True 后,输出恢复正常。修复前的输出保存在 samples/sample-buggy-topk-inverted.txt,可与修复后的结果对照查看。
这些问题都发生在把已知机制落实为代码的过程中。定位时仍依赖相同的机制:nan 从 softmax 的输入查起,异常低的 loss 从训练目标查起,生僻字输出从采样分布查起。理解概念不等于实现无误,代码还要求每个概念对应正确的下标、形状和循环。
这次实现能说明什么
先看规模。这个模型约七百万参数,GPT-3 为 1750 亿,相差超过四个数量级;训练数据为 201 万 token,GPT-3 使用 3000 亿 token 量级的数据;算力是一台笔记本的 M 系芯片运行一晚上,而 GPT-3 的训练使用大规模集群。生成结果也如实记录:续写有小说的字面形式,但缺少连贯文义。在这次模型规模、训练数据和测试 prompt 下,没有观察到第 5 篇讨论的 in-context learning 行为。
本实现与 GPT-3 共享 decoder-only、因果掩码、下一词预测、残差连接、LayerNorm、AdamW、warmup 和 cosine 衰减等组成部分。它没有复现 GPT-3 的参数规模、数据规模、训练基础设施或评估过程。数据、算力和工程实现会影响这些结构在大规模训练中的实际能力;
回看这一个多月的 commit 历史,最有价值的是中间的修 bug 提交。纸面推导只需确认概念之间的关系;代码还要把概念落实为下标、形状和循环,任一环节出错都会反映在 loss 曲线或生成结果中。第 1 篇手写反向传播,第 4 篇手算注意力矩阵,本文实现完整 GPT,至此完成这一轮动手验证。下一步会回到应用层,讨论 LangChain 和 RAG 中模型外部的连接方式。
本篇参考的资料
- karpathy/nanoGPT:参考实现。weight decay 分组、初始 loss 的 sanity check、memmap 读数据这些做法直接借自这里。
- Let’s build GPT: from scratch, in code, spelled out(karpathy,2023-01):两个小时的视频,跟着敲过一遍再自己写。先写单头跑通形状再改多头的搭建顺序就是从视频里学的。
- 《今古奇观》Project Gutenberg eBook #24230:训练语料,公版书。
- 神经网络入门:从感知机到反向传播:训练闭环的对照基准,本篇 train.py 的主干就是它的工程版。
- Transformer 详解:自注意力、多头与位置编码:模型结构的对照基准,model.py 逐模块回指这篇。
- GPT 系列:从 GPT-1 到 GPT-3 的演进逻辑:「预测下一个词」与规模判断的对照基准,采样和结论两节回指这篇。

