1. 这个项目到底在解决什么问题
第一次看到 "ai-engineering-from-scratch" 这个标题,我脑子里蹦出来的第一个念头是:又是一个教你怎么调库的教程仓库?但翻了一圈之后发现,它想做的事情比"调包"要硬核得多——它试图回答一个被大多数人跳过的问题:当你把 transformers、langchain、pytorch 这些库全部拿掉之后,你还剩多少真本事?
这个项目本质上是一套"从零手搓 AI 工程"的学习路径集合。它不满足于让你会model.fit(),而是要求你理解 attention 是怎么算出来的、tokenizer 是怎么把一句话切碎的、反向传播的梯度是怎么一层层传回去的、推理服务是怎么把显存榨干的。适合谁看?适合那些已经会用框架、但心里总觉得"我好像只是个大号 API 调用员"的工程师,也适合刚入门但不想被框架惯坏的新人。
我自己的判断是:这类内容的价值不在于让你真的在生产环境里手写一个 transformer,而在于当框架出问题、当性能不达标、当你要做别人没做过的定制时,你能知道该往哪个方向挖。框架是别人抽象好的东西,抽象就意味着有信息丢失,而"from scratch"就是把这些丢失的信息补回来。
下面我按自己的理解,把这个项目涉及的核心内容拆成几个部分来讲,包括它为什么这么设计、每个环节的关键点在哪、实操时容易踩什么坑,以及我自己在类似路径上的一些经验。
2. 为什么"从零实现"比"直接调库"更值得投入
2.1 框架抽象带来的认知盲区
先说一个我观察到的现象。现在很多做 AI 应用的人,工作流是这样的:找一个开源模型,套一个推理框架,写个 prompt,接个 API,上线。这套流程能跑通,但一旦遇到下面这些情况就抓瞎了:
- 模型输出不符合预期,不知道是数据问题、tokenizer 问题还是采样参数问题
- 推理速度慢,不知道该优化哪一层
- 想改模型结构做定制,发现连 forward 函数里张量的形状变化都理不清
- 显存爆了,不知道是 KV cache 还是中间激活占的
这些问题的共同点是:它们都发生在框架抽象的"缝隙"里。你调model.generate()的时候,框架帮你做了几十件事,但你不知道是哪一件出了问题。从零实现一遍,就是把这些"黑盒"一个个拆开看。
2.2 从零实现的三层价值
我把这个项目的价值分成三层,从浅到深:
第一层是"祛魅"。很多人觉得 transformer 很神秘,其实把 attention 的矩阵运算手推一遍,你会发现它就是几个矩阵乘法加一个 softmax,没什么魔法。这种祛魅能极大降低你的心理门槛。
第二层是"可控"。当你自己写过一遍 tokenizer,你就知道 BPE 的合并规则是怎么定的,遇到中文、emoji、特殊符号时该怎么处理。当你自己写过一遍采样,你就知道 temperature、top-k、top-p 到底在做什么,而不是盲目调参。
第三层是"可改"。这是最高价值。当你要做一个别人没做过的模型结构改动时,如果你只有调库经验,你连从哪下手都不知道;但如果你手搓过,你知道改哪一层、影响哪些张量、需要重新训练哪些参数。
2.3 一个反直觉的观点
这里我要说一个可能有点反直觉的观点:从零实现的目的不是让你以后都手写,而是让你以后调库时调得更明白。
我见过一些人,学完从零实现之后就走火入魔了,什么都要自己写,结果项目进度慢得要死。这是走偏了。正确的姿势是:你知道框架内部在干什么,所以你调库的时候知道哪些参数重要、哪些坑要避、出问题往哪查。框架依然是你的主力工具,只是你不再是个"盲人"。
3. 核心模块拆解:一个 AI 工程从零实现要过哪几关
3.1 第一关:Tokenizer——把文字变成数字
这是整个流程的入口,也是最容易被忽视的一环。很多人觉得 tokenizer 就是个工具,调一下就行。但实际上,tokenizer 的设计直接决定了模型的很多行为。
从零实现一个 tokenizer,核心是理解BPE(Byte Pair Encoding)或者WordPiece的合并逻辑。BPE 的思路很朴素:从字符级别开始,统计相邻字符对的出现频率,把最高频的对合并成一个新 token,重复这个过程直到达到目标词表大小。
我自己手写过一遍 BPE,踩过的坑包括:
- 中文和英文的处理差异。英文按字符切分很自然,但中文如果按字符切,词表会爆炸;如果按词切,又需要分词工具。实践中常见做法是对中文按字符处理,因为中文字符本身信息密度高。
- 特殊 token 的处理。
[PAD]、[UNK]、[CLS]这些特殊 token 必须在训练 BPE 之前就预留好位置,否则后面会乱。 - 词表大小的权衡。词表太小,序列会很长,推理慢;词表太大,embedding 层参数多,小数据集上容易过拟合。常见的选择是 32k 到 50k 之间。
提示:手写 tokenizer 时,建议先用一个小语料(比如几千行)跑通流程,确认合并逻辑正确后,再上大规模语料。直接上大语料调试,一次训练几十分钟,效率极低。
3.2 第二关:Attention——transformer 的心脏
Attention 是 transformer 的核心,也是从零实现里最值得花时间的地方。它的数学形式很简单:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V但真正手写的时候,有几个细节必须搞清楚:
为什么要除以 sqrt(d_k)?因为当 d_k 很大时,QK^T 的点积结果方差会变大,导致 softmax 的输入进入饱和区,梯度接近零。除以 sqrt(d_k) 是把方差拉回到 1 附近,保证梯度健康。这个细节如果你只调库,永远不会注意到。
Multi-Head 是怎么切的?不是把 Q、K、V 各切成几份分别算,而是把 d_model 维度切成 num_heads 份,每份独立做 attention,最后 concat 起来再过一个线性层。切的时候要注意 reshape 和 transpose 的顺序,这里张量形状最容易搞错。
Mask 怎么加?Decoder 里的 causal mask 是一个上三角为负无穷的矩阵,加到 attention score 上,softmax 之后这些位置就变成 0。手写的时候要注意 mask 的形状要能 broadcast 到 (batch, heads, seq_len, seq_len)。
我自己的经验是:手写 attention 时,每一步都打印张量形状。这是最笨但最有效的方法。形状对了,逻辑基本就对了;形状错了,后面全是错的。
3.3 第三关:训练循环——梯度是怎么流的
从零实现训练循环,核心是理解这几件事:
- 前向传播:输入经过每一层,得到输出和 loss
- 反向传播:loss 对每个参数的梯度,通过链式法则一层层传回去
- 参数更新:用优化器(SGD、Adam 等)根据梯度更新参数
如果你用 PyTorch,loss.backward()和optimizer.step()两行就搞定了。但如果你想真正理解,可以尝试用 numpy 手写一个简单的反向传播。我试过用 numpy 实现一个两层 MLP 的训练,写完之后对梯度的理解上了一个台阶。
关键点在于:梯度消失和梯度爆炸不是抽象概念,而是你能亲眼看到的现象。当你手写反向传播,打印每一层的梯度范数,你会看到深层网络的梯度确实在变小,这时候你就理解为什么需要残差连接、为什么需要 LayerNorm、为什么需要好的初始化。
3.4 第四关:推理优化——把显存和速度榨干
训练和推理是两回事。训练时你关心的是收敛,推理时你关心的是延迟和吞吐。从零实现推理,核心是理解KV Cache。
KV Cache 的思路是:自回归生成时,每次生成一个新 token,只需要计算这个新 token 的 Q,而 K 和 V 可以复用之前所有 token 的。这样就把每次生成的计算量从 O(n^2) 降到 O(n)。
但 KV Cache 也带来显存问题。序列越长,KV Cache 占的显存越多。一个 7B 模型,序列长度 4096,batch size 1,KV Cache 大概占 1-2GB。这就是为什么长上下文推理这么吃显存。
我实测下来,推理优化里性价比最高的几个手段是:
| 优化手段 | 效果 | 实现难度 |
|---|---|---|
| KV Cache | 显著降低计算量 | 中 |
| 量化(INT8/INT4) | 降低显存占用 | 中 |
| Batch 推理 | 提高吞吐 | 低 |
| Flash Attention | 降低显存+加速 | 高(通常用现成实现) |
注意:量化不是万能的。INT4 量化在小模型上可能掉点明显,在大模型上相对安全。做量化前一定要在自己的任务上评估效果,不要盲目上。
4. 实操路径:我是怎么安排学习顺序的
4.1 不要一上来就啃 transformer
很多人一上来就想手写一个完整的 GPT,结果卡在 attention 那里就放弃了。我的建议是分阶段推进,每个阶段都有可运行的产出。
阶段一:基础工具链。先手写一个简单的 tokenizer(BPE),再手写一个线性回归的训练循环(用 numpy)。这两个任务都很小,但能让你把"数据→模型→loss→梯度→更新"这条链路走通。
阶段二:核心组件。手写 attention、LayerNorm、FeedForward、残差连接。每个组件单独测试,确认输入输出形状和数值正确。
阶段三:组装模型。把组件拼成一个完整的 transformer block,再堆成多层。这时候可以跑一个小规模的语言模型训练,比如在莎士比亚文本上训练一个字符级模型。
阶段四:推理优化。实现 KV Cache、采样策略(temperature、top-k、top-p)、batch 推理。
阶段五:工程化。模型保存加载、服务化部署、性能监控。
这个顺序的好处是:每一步都有正反馈,不会因为目标太大而中途放弃。
4.2 每个阶段的关键检查点
我在每个阶段都会设一个"检查点",通过了才往下走:
- 阶段一检查点:tokenizer 能把一句话正确编码解码,训练循环能让 loss 下降
- 阶段二检查点:attention 的输出和 PyTorch 官方实现数值接近(误差 1e-5 以内)
- 阶段三检查点:小模型能过拟合一个小数据集(loss 降到接近 0)
- 阶段四检查点:KV Cache 版本的输出和朴素版本一致,速度有提升
- 阶段五检查点:模型能保存后重新加载,推理结果一致
这些检查点看起来简单,但能帮你快速定位问题。比如阶段三过拟合不了,说明模型或训练循环有问题;阶段四输出不一致,说明 KV Cache 实现有 bug。
4.3 一个具体的实操记录
我拿阶段三举例,说一下我实际跑的过程。
数据集用的是莎士比亚全集,大概 1MB 文本。字符级 tokenizer,词表大概 65 个字符。模型配置:4 层 transformer,d_model=128,num_heads=4,context_length=128。
训练参数:batch_size=32,learning_rate=3e-4,Adam 优化器,训练 5000 步。
第一次跑,loss 从 4.2 降到 2.1 就卡住了。排查发现是学习率太大,改成 1e-4 后 loss 降到 1.5。继续排查,发现是 LayerNorm 的位置放错了(应该用 Pre-LN 而不是 Post-LN),改过来之后 loss 降到 1.2。
最后生成的文本大概是这样的:
ROMEO: What sayest thou, my lord? JULIET: I know not what to say.虽然语法还有点问题,但已经能看出莎士比亚的风格了。这个过程让我对 transformer 的训练动态有了直观感受。
5. 常见问题与排查技巧实录
5.1 训练不收敛怎么办
这是最常见的问题。我的排查顺序是:
- 先检查数据。打印几条样本,确认输入输出对应正确。我遇到过一次数据加载时 shuffle 和 label 没对齐,导致模型学了个寂寞。
- 再检查 loss。loss 一开始应该是 ln(vocab_size) 左右。如果一开始就很小,说明模型可能作弊了(比如 label 泄漏)。
- 然后检查学习率。太大 loss 震荡,太小 loss 下降慢。可以先用一个很小的学习率跑几百步,确认 loss 在下降,再逐步调大。
- 最后检查模型结构。用一个小数据集(比如 100 条)让模型过拟合,如果过拟合不了,说明模型结构有问题。
5.2 显存不够怎么办
显存问题分训练和推理两种情况。
训练时显存不够,可以:
- 减小 batch size(最直接)
- 用梯度累积模拟大 batch
- 用混合精度训练(fp16/bf16)
- 用 gradient checkpointing(用时间换显存)
推理时显存不够,可以:
- 量化模型
- 减小 batch size
- 限制序列长度
- 用 PagedAttention 之类的技术(如果框架支持)
我自己的经验是:先定位显存被什么占了。用torch.cuda.memory_summary()能看到显存分配情况。有时候是中间激活占的,有时候是优化器状态占的,有时候是 KV Cache 占的,不同原因对应不同解法。
5.3 推理结果和训练时不一致
这个问题通常有几个原因:
- Dropout 没关。推理时要
model.eval(),否则 dropout 还在随机丢弃。 - LayerNorm 的统计量问题。训练时用 batch 统计量,推理时用滑动平均,如果实现不对会不一致。
- 位置编码问题。如果位置编码是动态生成的,推理时序列长度和训练时不同,可能出问题。
- KV Cache 实现 bug。这是最常见的,建议先用小序列对比 KV Cache 版本和朴素版本的输出。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss 不下降 | 学习率、数据、模型结构 | 小数据集过拟合测试 |
| loss 震荡 | 学习率太大、batch 太小 | 降低学习率、增大 batch |
| 显存爆了 | batch 太大、序列太长、KV Cache | memory_summary 定位 |
| 推理慢 | 没用 KV Cache、没量化 | 对比不同配置的延迟 |
| 输出乱码 | tokenizer 问题、解码问题 | 检查 encode/decode 一致性 |
| 梯度爆炸 | 学习率太大、没做梯度裁剪 | 加 gradient clipping |
6. 我踩过的几个坑和一点个人体会
6.1 不要追求一次写对
我一开始手写 attention 的时候,总想一次写对,结果调了两天才跑通。后来我改变策略:先写一个最笨的版本,跑通了再优化。比如 attention 先写一个 for 循环版本,确认逻辑对了,再改成矩阵运算。这样虽然一开始慢,但总体效率高得多。
6.2 数值稳定性是个大坑
手写 softmax 的时候,如果不做数值稳定处理(减去最大值),遇到大数会溢出。手写 LayerNorm 的时候,如果方差算错,会出 NaN。这些细节在调库时都被框架处理好了,但手写时必须自己注意。
我的做法是:每个涉及指数、除法、开方的操作,都检查一遍数值范围。softmax 减最大值、LayerNorm 加 epsilon、除法检查分母,这些都是基本操作。
6.3 测试驱动开发很有用
手写每个组件时,我都会先写一个测试:给定输入,期望输出是什么。比如 attention 的测试是:输入全零,输出应该也是全零;输入相同,输出应该相同。这些测试能帮你快速定位问题。
6.4 最后分享一个小技巧
如果你觉得从零实现太慢,可以用一个折中方案:用 PyTorch 的底层 API,但不用高层封装。比如用torch.nn.functional而不是torch.nn,用torch.autograd手动算梯度而不是loss.backward()。这样既能理解底层,又不用完全从 numpy 开始。
这个项目后续还可以这样扩展:加入分布式训练的从零实现、加入 RLHF 的从零实现、加入多模态模型的从零实现。每一个方向都是一个大坑,但每填一个坑,你对 AI 工程的理解就深一层。
我个人在实际操作中的体会是:从零实现最大的收获不是那些代码,而是一种"我知道底下发生了什么"的底气。这种底气让你在面对新框架、新模型、新问题时,不会慌。