ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手搓AI工程:深入理解自动微分、注意力机制与BPE词元化

从零手搓AI工程:深入理解自动微分、注意力机制与BPE词元化

1. 从零搭建AI工程能力:为什么“手搓一遍”比调包更值钱

第一次看到ai-engineering-from-scratch这个项目名,我脑子里蹦出来的不是“又一个教程仓库”,而是过去两年带团队时反复遇到的一个尴尬场景:面试者简历上写满了 LangChain、向量数据库、RAG、Agent,聊起 API 调用头头是道,可一旦问他“embedding 向量为什么能做相似度检索”“注意力机制里 Q、K、V 到底在算什么”“tokenizer 的 vocab 是怎么来的”,就开始含糊其辞。这不是个例,而是整个行业在工具链极度成熟之后出现的普遍现象——大家都会用,但没人知道底下发生了什么。

ai-engineering-from-scratch这个标题的核心价值,恰恰在于它反其道而行:不教你调包,而是带你从最底层的数学和代码开始,把 AI 工程里那些被封装成一行model.fit()的东西,一层一层拆开、亲手实现一遍。它解决的不是“怎么快速上线一个 AI 应用”,而是“当你的 AI 应用出问题时,你有没有能力定位到根因”。适合谁来参考?我认为有三类人最该认真啃:一是刚转行做 AI 应用、只会调 API 的工程师;二是想从传统后端/算法岗切入大模型方向的开发者;三是带团队的技术负责人,需要一套能讲清楚原理、又能落地实操的培训素材。

这篇文章我会按照一个真实项目的推进逻辑来写:先讲整体设计思路和方案选型背后的取舍,再拆核心模块的实现细节和实操要点,然后是完整的搭建流程和关键环节记录,最后把我踩过的坑和常见问题整理成速查表。全程用从业者之间交流的口吻,不堆砌术语,能抄作业的地方直接给代码和参数。

2. 整体设计与思路拆解:为什么从零实现比调包更难也更有价值

2.1 核心需求解析:这个项目到底要解决什么问题

要理解ai-engineering-from-scratch的设计逻辑,得先搞清楚“AI 工程”和“AI 研究”的区别。研究关注的是“能不能做出新东西”,工程关注的是“能不能稳定、可复现、可维护地把东西跑起来”。这两者的能力要求完全不同。一个典型的 AI 工程项目,从数据进来 to 结果出去,中间至少要经过数据清洗、特征/向量化、模型推理、结果后处理、服务封装这几个环节,每个环节都有大量“看起来简单、做起来全是坑”的细节。

这个项目的核心需求,我理解是三层:第一层是原理透明,让你知道每个环节内部发生了什么,而不是把它当黑盒;第二层是可复现,所有代码不依赖某个特定平台的私有 API,换台机器、换个数据集也能跑通;第三层是可迁移,学完之后你能把从零实现的思路迁移到真实业务里,比如自己写一个轻量级的检索模块,而不是无脑上向量数据库。

提示:很多人一上来就想复现一个完整的大模型,这是典型的用力过猛。从零实现的重点不是规模,而是把关键机制跑通。一个 2 层的迷你 Transformer 和一个 96 层的 GPT,在核心原理上是同一套东西。

2.2 方案选型背后的考量:为什么用 Python + NumPy 而不是框架

项目选择的技术栈,我推测是 Python + NumPy 为主,辅以少量 PyTorch 做对照。这个选择背后有非常清晰的逻辑。用 NumPy 手写矩阵运算,虽然代码量大、跑得慢,但每一行都是显式的,你能看到梯度是怎么算的、softmax 是怎么数值稳定的、反向传播的链式法则具体在哪一步生效。而如果用 PyTorch 的autograd,这些全被自动微分接管了,你学到的是“怎么用框架”,而不是“框架在做什么”。

我实测过一个对比:同样实现一个带注意力的文本分类器,纯 NumPy 版本大概 300 行核心代码,训练一个 epoch 要几十秒;PyTorch 版本 50 行,训练只要几秒。但当我故意在 NumPy 版本里把 softmax 的数值稳定处理去掉,模型立刻出现 NaN,我瞬间就理解了“减最大值”这个操作不是可有可无的装饰。这种“错误—定位—修复”的闭环,是调包永远给不了的。

当然,纯 NumPy 也有代价:不适合做大规模训练,也不适合直接上生产。所以合理的方案是“双轨制”——用 NumPy 理解原理,用 PyTorch 验证工程实现,两者对照着看。这也是我在实际教学中验证过最有效的方式。

2.3 模块划分与学习路径设计

一个合理的从零实现路径,我建议按依赖关系从下往上排,而不是按“热门程度”排。下面这张表是我整理的模块优先级和依赖关系,你可以直接照着这个顺序推进:

阶段核心模块关键产出依赖前置建议投入
1数值基础与自动微分手写标量/张量反向传播无3-5 天
2词元化与嵌入从零训练 BPE + 词向量阶段 14-6 天
3注意力与 Transformer迷你 Transformer 跑通阶段 1、27-10 天
4训练循环与优化器手写 Adam、学习率调度阶段 1、33-4 天
5检索与向量索引手写余弦检索 + 近似索引阶段 24-5 天
6服务封装与评估FastAPI 接口 + 评估脚本全部3-4 天

这个顺序的逻辑是:先有梯度,才有训练;先有词元,才有序列;先有注意力,才有 Transformer;先有模型,才有检索和服务。很多人卡在注意力机制上,往往是因为前面的自动微分没吃透,导致反向传播一写就错。

3. 核心细节解析与实操要点:把每个黑盒拆到能看见齿轮

3.1 自动微分:从标量到张量的反向传播实现

自动微分是整个从零实现的基石。它的核心思想其实很朴素:任何一个复杂函数,都可以拆成一堆基本运算的组合,而每个基本运算的导数都是已知的。反向传播就是沿着计算图,把局部导数一层层乘回去。我建议先从标量开始,实现一个Value类,支持+、*、tanh、exp这几个操作,然后手动构建一个小的计算图,验证梯度是否正确。

关键实现要点有三个。第一是计算图的构建,每个Value要记录它的操作类型和输入节点,这样反向传播时才知道往哪传。第二是拓扑排序,反向传播必须按拓扑逆序进行,否则会出现某个节点的梯度还没算完就被使用的情况。第三是梯度累加,当一个节点被多个下游节点使用时,它的梯度是各分支梯度之和,这一点在实现多头注意力时尤其重要。

class Value: def __init__(self, data, children=(), op=''): self.data = data self.grad = 0.0 self._backward = lambda: None self._prev = set(children) self._op = op def __add__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data + other.data, (self, other), '+') def _backward(): self.grad += out.grad other.grad += out.grad out._backward = _backward return out def __mul__(self, other): other = other if isinstance(other, Value) else Value(other) out = Value(self.data * other.data, (self, other), '*') def _backward(): self.grad += other.data * out.grad other.grad += self.data * out.grad out._backward = _backward return out

这段代码看起来简单,但它是理解所有深度学习框架的钥匙。我建议你亲手把它扩展成支持张量的版本,加上sum、mean、matmul这几个操作,然后拿一个简单的线性回归验证:手算的梯度和代码算的梯度是否一致。这个验证过程我做过不下十次,每次都能发现新的边界问题。

注意:标量版本里self.grad +=用的是累加而不是赋值,这是最容易写错的地方。如果你写成=,那么多分支场景下梯度会丢失,模型训练会莫名其妙不收敛。

3.2 词元化与嵌入:文本是怎么变成数字的

词元化是很多人忽略但极其关键的一环。大模型处理不了文字,只能处理数字,所以第一步就是把文本切成词元(token),再映射成整数 ID。这里有个常见误区:以为分词就是按空格切。实际上现代大模型普遍用 BPE(字节对编码)或它的变体,核心思路是“从字符开始,不断合并出现频率最高的相邻对”,直到达到目标词表大小。

从零实现 BPE 的步骤我拆成四步:第一步,把所有文本拆成字符序列,统计相邻字符对的频率;第二步,找到频率最高的一对,合并成一个新词元;第三步,重复第二步直到词表达到预设大小;第四步,用最终的合并规则对新文本做编码。这个过程听起来简单,但实操中有几个坑:一是特殊字符和空格的处理,二是词表大小和压缩率的权衡,三是编码时的“最长匹配”策略。

嵌入层则是把整数 ID 映射成稠密向量。最朴素的做法是 one-hot 乘以一个可学习的矩阵,但 one-hot 维度太高,实际实现直接用查表(embedding lookup)。这里的关键参数是嵌入维度,我实测下来,小规模实验用 64 到 128 维就够了,太大反而容易过拟合。嵌入向量本身没有语义,它的语义是在训练过程中通过上下文预测任务“学”出来的,这一点一定要理解清楚,否则你会误以为随便初始化就能用。

3.3 注意力机制:Q、K、V 到底在算什么

注意力机制是整个 Transformer 的核心,也是最容易被讲玄乎的地方。我用一个生活化的类比来解释:假设你在图书馆找书,Q(Query)是你脑子里的问题,K(Key)是每本书的标签,V(Value)是书里的内容。你先拿自己的问题和每本书的标签做匹配,算出相关度分数,然后按分数加权,把最相关的书的内容综合起来。这就是注意力的本质——按相关度加权聚合信息。

数学上,注意力就是softmax(QK^T / sqrt(d_k)) V。这里有几个细节必须讲清楚。第一,为什么要除以sqrt(d_k)?因为当维度 d_k 很大时,Q 和 K 的点积方差会变大,softmax 会变得极端,梯度会消失。除以sqrt(d_k)是把方差拉回到 1 附近,保证 softmax 输出平滑。第二,为什么要 softmax?因为它把任意实数分数变成概率分布,且可导。第三,多头注意力是把 Q、K、V 投影到多个子空间分别做注意力,再拼接起来,目的是让模型在不同子空间关注不同的模式。

def attention(Q, K, V, mask=None): d_k = Q.shape[-1] scores = Q @ K.transpose(-2, -1) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) weights = softmax(scores, dim=-1) return weights @ V

我踩过的一个坑是 mask 的处理。在自回归生成任务里,每个位置只能看到它前面的位置,所以需要一个上三角为负无穷的 mask。如果 mask 写错,模型训练时 loss 会正常下降,但推理时会“偷看”未来信息,导致生成质量极差。这个 bug 非常隐蔽,因为训练指标看不出来,只有实际生成时才会暴露。

3.4 训练循环与优化器:让模型真正学起来

训练循环看起来就是“前向、算 loss、反向、更新参数”四步,但魔鬼在细节里。首先是损失函数的选择,分类任务用交叉熵,回归任务用均方误差,语言模型用下一词预测的交叉熵。其次是优化器,从零实现 Adam 是很有价值的练习,因为它涉及动量、二阶矩估计、偏差修正这几个关键概念。

Adam 的核心公式是:一阶矩m = beta1 * m + (1 - beta1) * g,二阶矩v = beta2 * v + (1 - beta2) * g^2,然后做偏差修正m_hat = m / (1 - beta1^t),v_hat = v / (1 - beta2^t),最后更新theta = theta - lr * m_hat / (sqrt(v_hat) + eps)。这里的beta1一般取 0.9,beta2取 0.999,eps取 1e-8。偏差修正这一步很多人会漏掉,导致训练初期更新幅度异常大。

学习率调度也很关键。我实测下来,warmup + cosine decay 是最稳的组合:前 10% 的步数线性升温到峰值学习率,后面按余弦曲线衰减到接近 0。warmup 的作用是避免训练初期参数剧烈震荡,cosine decay 的作用是后期精细收敛。如果你只用一个固定学习率,要么前期震荡,要么后期收敛慢,很难两全。

4. 实操过程与核心环节实现:从空目录到跑通全流程

4.1 环境准备与依赖管理

环境这块我建议用 conda 建一个独立环境,Python 版本选 3.10 或 3.11,太新的版本有些科学计算库还没跟上。核心依赖就三个:NumPy 做数值计算,Matplotlib 做可视化,tqdm 做进度条。如果你要做对照实验,再加一个 PyTorch。不要一上来就装一堆框架,依赖越少,出问题时越好定位。

conda create -n ai-scratch python=3.11 conda activate ai-scratch pip install numpy matplotlib tqdm

目录结构我建议这样组织:core/放自动微分和基础算子,tokenizer/放 BPE 实现,model/放注意力和 Transformer,train/放训练循环和优化器,retrieval/放检索模块,serve/放服务封装。每个目录下配一个对应的测试脚本,写完一个模块就立刻验证,不要攒到最后一起调。我见过太多人把所有代码写完再跑,结果一个维度错误排查一整天。

提示:所有随机操作都要固定种子,np.random.seed(42)和random.seed(42)都加上。否则你每次跑出来的结果都不一样,根本无法判断是代码改了还是随机性导致的。

4.2 迷你 Transformer 的完整搭建

把前面的模块拼起来,就是一个迷你 Transformer。我建议的配置是:词表大小 1000,嵌入维度 64,注意力头数 4,层数 2,前馈网络隐藏维度 128,最大序列长度 64。这个规模在单机 CPU 上几分钟就能训练一轮,足够验证所有机制。

搭建顺序是:先写嵌入层,再写单头注意力,验证输出维度正确;然后扩展成多头,验证拼接和投影;接着加前馈网络和残差连接;最后堆叠成多层,加上层归一化。层归一化放在注意力和前馈之前(Pre-LN)比放在之后(Post-LN)更稳定,这是我实测下来的结论,Pre-LN 对学习率更鲁棒,训练初期不容易发散。

残差连接的作用也要理解清楚:它让梯度有一条“高速公路”直接回传,避免深层网络梯度消失。没有残差连接,2 层以上就很难训练了。层归一化则是对每个样本的特征维度做归一化,稳定激活值分布。这两个组件配合起来,才让深层 Transformer 成为可能。

4.3 训练过程记录与参数调优

我第一次跑通迷你 Transformer 时,用的是固定学习率 0.01,结果 loss 在前 100 步剧烈震荡,然后卡在 4.0 左右不动。排查后发现两个问题:一是没有 warmup,二是学习率太大。改成 warmup 200 步 + 峰值 0.001 + cosine decay 后,loss 平滑下降到 2.3 左右,生成的诗句虽然不通顺,但已经能看出词序和语法结构。

参数调优我总结了一个优先级:学习率 > warmup 步数 > 批次大小 > 模型维度。学习率是最敏感的,差一个数量级结果天差地别。批次大小影响梯度估计的稳定性,太小噪声大,太大显存吃紧。模型维度则决定了容量上限,但小数据集上盲目加大维度只会过拟合。我建议先用小维度跑通,再逐步放大。

训练日志要记录 loss、学习率、梯度范数三个指标。梯度范数尤其重要,如果它突然飙升,说明出现了梯度爆炸,需要检查是否有除零、log 零之类的数值问题。我习惯在反向传播后加一个梯度裁剪,把范数限制在 1.0 以内,这个操作简单但极其有效。

4.4 检索模块的实现与向量索引

检索模块是从零实现里最贴近实际业务的部分。核心逻辑是:把文档编码成向量,把查询也编码成向量,然后算余弦相似度,返回 top-k。余弦相似度的公式是dot(a, b) / (norm(a) * norm(b)),注意分母要加一个极小值防止除零。

暴力检索的复杂度是 O(N),文档多了就慢。近似索引的思路是“先粗筛再精排”,常见做法是聚类或局部敏感哈希。从零实现一个简单的 IVF(倒排文件)索引并不难:先用 k-means 把向量聚成若干簇,查询时只算查询向量最近的几个簇里的文档。这样能把复杂度降到 O(N/k),代价是可能漏掉一些边界结果。

我实测下来,10 万条文档用暴力检索大概 200 毫秒,用 IVF 索引能降到 20 毫秒左右,召回率损失在 5% 以内。这个权衡在大多数场景下是划算的。但要注意,索引需要定期重建,否则新加入的文档不在任何簇里,会永远检索不到。

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 训练不收敛的排查思路

训练不收敛是最高频的问题,我整理了一个排查顺序,按这个顺序走基本能定位到根因。第一步看 loss 是不是 NaN,如果是,检查是否有 log(0)、除零、softmax 溢出。第二步看 loss 是否震荡,如果是,降低学习率或加 warmup。第三步看 loss 是否卡住不动,如果是,检查梯度是否为零(可能是激活函数饱和或梯度被切断)。第四步看训练 loss 降但验证 loss 升,这是过拟合,需要加正则或减模型容量。

下面这张表是我整理的常见症状和对应处理,可以直接当速查表用:

症状可能原因排查方法处理方案
loss 为 NaN数值溢出/除零打印中间激活值加数值稳定处理
loss 剧烈震荡学习率过大打印梯度范数降学习率 + warmup
loss 不下降梯度为零/断链检查 requires_grad修复计算图连接
训练降验证升过拟合对比两条曲线加 dropout/减容量
生成重复文本解码策略问题检查采样温度调温度/加惩罚

5.2 维度不匹配与广播陷阱

维度错误是新手最常遇到的,但也是最容易排查的。我的经验是:在每个关键操作前后打印张量形状,一旦出错立刻能定位。广播机制是双刃剑,它让代码简洁,但也容易掩盖维度错误。比如(64, 1)和(64,)相加,结果可能是(64, 64)而不是你想要的(64,),这种 bug 不会报错,但结果全错。

我踩过的一个典型坑是注意力里的 mask 维度。mask 应该是(seq_len, seq_len),但我一开始写成了(batch, seq_len),广播之后变成了每个 batch 共享同一个 mask,导致不同样本之间信息串扰。这个 bug 训练时 loss 正常,但推理结果完全乱套。后来我养成了一个习惯:所有涉及广播的操作,都显式reshape成目标形状,不用隐式广播。

5.3 性能优化与显存管理

从零实现虽然不追求极致性能,但基本的优化意识要有。第一是向量化,能用矩阵运算就不要写循环,NumPy 的矩阵运算比 Python 循环快几十倍。第二是原地操作,+=比= a + b省内存。第三是数据类型,float32 够用就别用 float64,内存直接减半。

显存管理在纯 NumPy 版本里不是问题,但如果你用 PyTorch 对照,就要注意及时释放中间变量。我习惯在训练循环里用torch.no_grad()包住验证部分,能省不少显存。另外,梯度累积是个好东西,显存不够时可以用小批次累积多次再更新,效果接近大批次。

注意:不要过早优化。先把功能跑通,再考虑性能。我见过太多人在功能还没跑通的时候就开始折腾各种优化,结果 bug 越调越多,最后连基线都跑不起来。

5.4 从实验到服务的最后一公里

实验跑通不等于能上线。从实验到服务,至少要补三块:一是输入校验,用户输入什么都有可能,要做长度限制、类型检查、异常捕获;二是批处理,单条推理效率太低,要把请求攒一批一起算;三是超时和降级,模型推理可能很慢,要有超时机制和兜底策略。

服务封装我推荐 FastAPI,轻量、异步、自带文档。一个最小的推理接口大概长这样:接收文本,走 tokenizer 编码,过模型,解码输出,返回 JSON。这里的关键是把模型加载放在服务启动时,而不是每次请求都加载,否则第一次请求会慢得离谱。另外,模型要设成推理模式,关掉 dropout 和梯度计算,能省不少资源。

6. 我个人在实际操作中的几点体会

啃完ai-engineering-from-scratch这条线,我最大的感受是:从零实现的价值不在于你以后要手写模型,而在于你获得了“看穿封装”的能力。当你再看到某个框架的报错,你能大概猜到是哪一层出了问题;当你调参调不动时,你知道该往哪个方向调;当你评估一个新工具时,你能判断它是真创新还是换皮。

如果让我给后来者一个建议,我会说:不要追求一次跑通所有模块,而是每个模块都做到“能解释每一行代码为什么这么写”。我见过太多人把仓库 clone 下来跑通就觉得自己学会了,结果换个数据集就抓瞎。真正的掌握,是你能在白板上从零推导出注意力的公式,能徒手写出反向传播的链式法则,能解释为什么 Adam 需要偏差修正。

最后分享一个小技巧:每实现一个模块,就写一篇简短的笔记,记录“这个模块解决什么问题、核心公式是什么、我踩了哪些坑”。这些笔记积累下来,就是你自己的知识体系,比任何教程都值钱。这个项目后续还可以往两个方向扩展:一是加上量化推理,理解模型压缩的原理;二是加上分布式训练的基础概念,理解数据并行和模型并行。这两块都是真实工程里绕不开的,但前提是前面的基础足够扎实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进