我在一次面试里被问住了。面试官没有让我推Transformer的八股,也没让我手撕一道LeetCode算法题,他只是很平静地问了一句:“你用深度学习框架也有两三年了,那你说说,反向传播的时候,中间层的激活值为什么要缓存下来?”我愣了好几秒,本能地开始从“框架不是会自动微分吗”这个角度找答案,但越说越虚。那是2021年底,AI工程(AI engineering)这个热词还没像今天这样满天飞,但那一次之后我突然明白:我天天在终端里敲的model.fit()、trainer.train()背后,有一大块地基,我从来没有真正凿开过。
后来我就做了一件事:把已经跑顺了的框架全部放下,从头用NumPy一点一点把经典的机器学习算法、反向传播、训练循环、数据管线重新搭了一遍。这就是我这篇想讲的ai-engineering-from-scratch路线。这篇文章不是写给想三天速成大模型的人看的,而是写给那些已经能调包跑通模型、但总觉得心里没底的人——你不需要永远停在调包的层,也不用真的把PyTorch源码全读一遍,你只需要在最关键的几个点上亲手推一次、写一遍、debug一遍,工程直觉会在那个瞬间变扎实。
1. 为什么“框架调包”不等于“AI工程能力”
1.1 一个让我重新思考的失败面试
那次面试发生在我自认为“什么模型都跑过”的时期:CV做过分类,NLP做过情感分析,推荐系统也拿DeepFM试过水。简历上一排项目,看起来非常充实。面试官的问题看似很简单,但问题在于,我从来没有真正思考过中间激活值为什么非缓存不可——自动微分框架把这件事包得太干净了,干净到我压根意识不到它做了这件事。
这个经历让我总结出一句话:用框架能跑通模型,只能证明你会使用工具;真正体现AI工程能力的,是你在工具“不告诉你为什么”的地方,仍然能判断出问题出在哪。框架把大量细节封装成了黑盒,但一个AI工程师的核心竞争力恰恰是黑盒出问题时的判断力:loss为什么发散?显存为什么爆掉?为什么训练曲线很漂亮但测试效果一塌糊涂?这些问题没有哪个框架会直接告诉你答案。
1.2 框架替你做了哪些事,又掩盖了哪些事
为了把这个说清楚,我列了一个表,不复杂,但挺能说明问题:
| 框架(PyTorch/TF)替你做了什么 | 同时掩盖了什么 |
|---|---|
| 自动计算梯度 | 梯度到底是沿着哪条链传回去的,中间哪些量会被复用 |
| 预置算子库(Conv、LSTM、Attention) | 算子的时间复杂度、显存占用、为什么输入维度顺序这么定 |
| 统一的训练循环接口 | batch、epoch、学习率调整之间到底如何相互作用 |
| 分布式训练封装 | 数据并行和模型并行的通信瓶颈在哪里 |
| 混合精度训练开关 | 为什么fp16有时候训练出来精度反而高,有时候直接NaN |
这张表可以看出一个规律:框架解决的是**“把模型跑起来”的效率问题,但它不会帮你建立“模型为什么会这样工作”**的因果直觉。一个AI工程师最值钱的部分,恰恰是在模型表现异常时,能从数据、梯度、优化器、评估协议这些基础层面快速定位根因。这种能力只能靠亲手把事情从0到1做一遍来积累。
1.3 from-scratch路线真正训练的是什么能力
很多人一听“从零实现”,第一反应是“重复造轮子没必要”。这个判断部分是对的——生产环境里没有人会手写反向传播。但 from-scratch 的价值不在轮子本身,而在逼你把轮子的受力结构看清楚。
我自己的体会是,手工实现一遍之后,有三个能力是瞬间变强的:
- 调试能力:以前训练出NaN,我只会把学习率调低再试;现在我会先检查是前向输出爆了,还是反向梯度爆了,分别对应什么处理策略。
- 论文阅读能力:很多论文里的技巧,比如gradient clipping、weight initialization的方差缩放、学习率warmup,以前看是“记住了”,手写之后再看是“这其实是在解决我踩过的那个问题”。
- 模型选型判断力:为什么这个任务用CNN不用Transformer?以前只能凭经验猜,现在能从归纳偏置、数据规模、训练成本三个维度自己推出来。
所以这条路真正补的不是代码量,而是工程判断力——这是框架替你代劳不了的东西。
2. 先搭认知地图:从零开始学AI工程的四个阶段
2.1 第一阶段:数学底子只需要“够用”,不需要“完备”
一听到学AI要先补数学,很多人第一反应是去啃整本《线性代数》《概率论》《凸优化》,然后就被劝退了。我走过弯路了,负责任地说:你做AI工程不需要成为数学家,你需要的是把三类数学工具用到“肌肉记忆”的程度。
第一是线性代数:矩阵乘法、转置、形状匹配、范数。你不需要会证明特征值定理,但必须对(batch, features) @ (features, hidden)为什么能得到(batch, hidden)有本能的直觉。这一条我建议用大量手算小矩阵来磨,比如2×3乘3×2,反复推,推到不看公式也能心算形状。
第二是概率统计:期望、方差、条件概率、极大似然估计。损失函数里那一堆交叉熵、log-likelihood,本质上全是从“假设数据服从某个分布”推导出来的。你不需要会做假设检验的完整推导,但一定要能回答“为什么分类用交叉熵而不是用MSE”——这两个问题我面试里问过很多人,能答清楚的不超过三成。
第三是优化:梯度下降、SGD、动量、学习率。这个在深度学习中几乎每天都在用。至少要在二维平面上手动追踪一次参数更新的轨迹,感受一下学习率迈太大步和太小步的差别。
我当时花了一个半月补这三块,用的方法也很笨:不求系统,只求“每一章都能回答一个AI里的具体问题”。线性代数就问“矩阵形状不匹配时报错是什么意思”,概率就问“为什么softmax输出的东西可以当概率”,优化就问“学习率到底是怎么影响loss曲线的”。带着问题去补数学,效率远高于从头啃教科书。
2.2 第二阶段:用NumPy手动实现经典机器学习算法
有了数学底子,第二步是动手写代码。这一阶段的目标不是实现复杂的模型,而是把你已经会调包的经典算法,用NumPy从0写一遍。我建议按这个顺序来:
- 感知机:最简单,几十行就能写完。它能让你理解“模型-损失-优化”三件套的最小闭环。
- 线性回归(含Ridge/Lasso正则化):有闭式解,可以和梯度下降版本对照,验证梯度推导是否正确。
- 逻辑回归:这是理解“sigmoid + 交叉熵”的起点,也是后面所有分类模型的雏形。
- K-Means / KNN:帮助你建立“无监督”和“距离度量”的直觉。
这阶段最重要的产出不是模型精度,而是两个习惯:第一,算法里的每一步都要能用一句话解释它在做什么;第二,每个模型都要自己写一个评估流程,而不是只输出预测结果。
我印象很深的是写逻辑回归时,我用了最笨的“数值梯度校验”来验证自己推导的梯度公式有没有写错——结果真的抓到一个符号错误(sigmoid的导数应该是sigma * (1 - sigma),我把减法写成了加法)。如果直接开PyTorch,这种错误会被自动微分框架直接掩盖掉,我永远不会知道自己会在这个地方犯错。
2.3 第三阶段:神经网络的最小复刻
第三阶段是整条路线里我收获最大的一块:手工实现一个两层神经网络,包括全连接层、ReLU激活、Softmax输出、交叉熵损失,以及最关键的反向传播。
这个阶段的代码量大约在150~300行之间,耗时一到两周比较正常。核心目标是:
- 前向传播里每一步,中间张量的形状都清清楚楚。
- 反向传播里每个梯度,都能和正向计算对上。
- 用数值梯度校验(后面会细讲)确认反向传播代码是对的。
- 跑通完整的训练循环:数据切分、batch采样、学习率调节、早停。
这一步做完之后,你再回去看PyTorch里的nn.Linear、nn.ReLU、nn.CrossEntropyLoss,感受会完全不同:你不再只是使用者,而是知道每个模块内部“大概发生了什么”的人。
2.4 第四阶段:工程化进阶——数据、实验、部署
有了手写的模型,接下来要做的是把它放进一个完整的工程闭环里。这个阶段不再是“从零实现”,而是“把从零实现的东西工程化”。具体包括:
- 数据管线:清洗、标准化、切分、数据增强、泄漏检查。
- 实验管理:固定随机种子、记录超参、保存指标、版本控制。
- 评估体系:选择合适的指标(准确率、精确率/召回率、AUC、Perplexity等),并理解它们的局限。
- 部署与推理优化:模型导出、批处理推理、简单性能测试。
我见过太多人止步于第二阶段——模型写出来、loss降下来,就觉得自己“会AI了”。但一个模型能跑通训练,距离它能稳定交付到线上业务,中间差的恰恰是这第四阶段的工程能力。而且这部分工作在实际工作中往往占掉70%以上的时间,却最容易被自学路径忽略。
3. 手推反向传播:一次改变直觉的实战
3.1 计算图拆解:把网络当电路一样看
反向传播的教科书解释是“链式法则”,但这个解释太抽象了。我更愿意把网络看成一条“数据流动的管道”,每一层的算子都是一个“节点”,张量沿着管道向前流动(前向),梯度沿着管道向后流动(反向)。
拿两层网络举例:
输入 x -> 线性层(W1,b1) -> ReLU -> 线性层(W2,b2) -> Softmax+交叉熵 -> loss前向传播时,除了输出loss之外,中间每个节点的“输出值”都必须缓存下来,因为反向传播时需要用到它们。这时候就能回答文章开头那个面试问题了:为什么中间层的激活值要缓存?因为反向传播计算梯度时,需要用到上游传回来的梯度乘以本层输入(或激活值)来得到参数梯度。不缓存就得在前向时重新算一遍,要么时间爆炸,要么内存爆炸——框架的选择是“空间换时间”。
3.2 为什么Softmax+交叉熵的梯度是 (probs - y)/N
这部分是手写神经网络里最“魔法”的一步。你去看PyTorch源码会发现CrossEntropyLoss的反向传播里,梯度就是(softmax输出 - onehot标签) / batch_size,简洁得不像话。但这个式子是怎么来的?
简单推导一下。交叉熵损失是L = -Σ y_j · log(p_j),其中p_j是softmax输出的第j个类别的概率。Softmax是p_i = exp(z_i) / Σ exp(z_k),其中z是logits。要求∂L/∂z_i,因为p_i在计算时依赖所有z,所以需要区分i = j和i ≠ j两种情况。
- 当
i = j时:∂p_i / ∂z_i = p_i(1 - p_i) - 当
i ≠ j时:∂p_j / ∂z_i = -p_i · p_j
代入链式法则:
∂L/∂z_i = -Σ... 展开后 = p_i - y_i(中间几项相消的细节建议自己推一遍,推完会非常爽。)
最后再除以batch_size,对应平均损失对每个样本的导数。你看,这么复杂经过一通化简后,最后变成一个极简的表达式——这也就是为什么框架里实现这个损失时效率极高。手推这一步最大的价值,是你以后再也不会怀疑这个式子的正确性。
3.3 最小实现代码与数值梯度校验
下面给一个最基本的、只包含核心逻辑的两层网络实现(省略了训练循环里的数据加载和评估部分)。这个代码按FP32跑在MNIST这类数据集上,准确率能达到97%左右——不用框架,纯NumPy。
import numpy as np class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, init_scale=1e-2): # 紧凑的初始化:scale取0.01,防止初始loss过大 self.params = { 'W1': np.random.randn(input_size, hidden_size) * init_scale, 'b1': np.zeros(hidden_size), 'W2': np.random.randn(hidden_size, output_size) * init_scale, 'b2': np.zeros(output_size) } self.grads = {k: np.zeros_like(v) for k, v in self.params.items()} self.cache = {} def forward(self, x): W1, b1 = self.params['W1'], self.params['b1'] W2, b2 = self.params['W2'], self.params['b2'] z1 = x @ W1 + b1 a1 = np.maximum(0, z1) # ReLU z2 = a1 @ W2 + b2 # 数值稳定的softmax exp_z2 = np.exp(z2 - np.max(z2, axis=1, keepdims=True)) probs = exp_z2 / np.sum(exp_z2, axis=1, keepdims=True) self.cache = {'x': x, 'a1': a1} return probs def backward(self, probs, y_onehot, reg_lambda=0.0): x = self.cache['x'] a1 = self.cache['a1'] batch_size = y_onehot.shape[0] # 这是整段里唯一一个“魔法公式”,推导见上一节 dlogits = (probs - y_onehot) / batch_size # 反向传播第二层 self.grads['W2'] = a1.T @ dlogits + reg_lambda * self.params['W2'] self.grads['b2'] = np.sum(dlogits, axis=0) da1 = dlogits @ self.params['W2'].T # ReLU的梯度:小于等于0的位置直接置0 dz1 = da1 * (a1 > 0) # 反向传播第一层 self.grads['W1'] = x.T @ dz1 + reg_lambda * self.params['W1'] self.grads['b1'] = np.sum(dz1, axis=0) def update(self, lr=0.1): for k in self.params: self.params[k] -= lr * self.grads[k]backward里da1 * (a1 > 0)这个操作,就是ReLU的反向传播。你可能会问:为什么是“乘以一个bool mask”?因为ReLU的导数在输入大于0时是1,小于等于0时是0。这个知识点如果只读文档,你会觉得“哦”,但亲手写出来之后,你会真的记住“ReLU层在反向传播里本质就是一个门控开关”。
接下来是最关键的验证步骤:数值梯度校验。原理是用导数的定义(极限)近似计算梯度,和你推出来的解析梯度对比。如果两者的相对误差在1e-5以内,说明你的反向传播写对了。
def compute_loss(probs, y_onehot): # 平均交叉熵 return -np.mean(np.sum(y_onehot * np.log(probs, where=y_onehot > 0), axis=1)) def grad_check(net, x, y_onehot, epsilon=1e-5, threshold=1e-5): net.forward(x) probs = net.forward(x) net.backward(probs, y_onehot) analytic = net.grads['W2'][0, 0] # 数值梯度:W2[0,0] 增加/减小 epsilon original = net.params['W2'][0, 0] net.params['W2'][0, 0] = original + epsilon loss_plus = compute_loss(net.forward(x), y_onehot) net.params['W2'][0, 0] = original - epsilon loss_minus = compute_loss(net.forward(x), y_onehot) net.params['W2'][0, 0] = original numeric = (loss_plus - loss_minus) / (2 * epsilon) rel_error = abs(analytic - numeric) / max(1e-8, abs(analytic) + abs(numeric)) print(f"analytic={analytic:.8f}, numeric={numeric:.8f}, rel_error={rel_error:.2e}")我当初跑这个函数时,第一次输出rel_error=2.3e-6,那个瞬间比之后训练出97%准确率还让我兴奋。因为你第一次确确实实“看见”了梯度是怎么算出来的,而不是凭空信任一个黑盒。
4. 数据工程与实验管理:最容易被低估的70%工作量
4.1 一份数据的生命周期
很多自学AI的人会把90%的时间花在模型结构上,但进入真实项目后会发现,模型结构其实是最容易的部分,真正折磨人的是数据。一份数据从原始日志变成模型能用的训练集,至少要经历这几步:
- 采样与去重:日志数据里相邻样本高度相似,直接训练会导致过拟合和指标虚高。
- 清洗:处理缺失值、异常值、格式不一致。这一步没有标准答案,需要你先明确“这一列数据的业务含义是什么”。
- 归一化/标准化:特征尺度相差过大时,梯度下降会被“压扁”的等高线拖慢,甚至训练不稳定。
- 泄漏检查:这个最危险。比如做时间序列预测时,如果把未来时刻的统计量(例如全段时间的均值)算进了特征里,模型在验证集上会表现得极好,上线后立刻失效。
我那会儿踩过一个特别经典的坑:在预处理阶段对全体数据做了标准化,然后才切分训练集、验证集。看起来完全合理的操作,但其实验证集的信息已经流入了训练过程——因为全局均值和方差是在包含验证集在内的全量数据上计算的。正确做法是只用训练集计算均值和方差,再把它应用到验证集/测试集上。这个错误非常隐蔽,但危害极大,直接导致我的模型在离线评估时AUC高达0.92,上线后只剩0.71。
4.2 没有MLflow时怎么做实验追踪
实验追踪是“一个人也能保持清醒”的关键。很多人一开始不记录,光靠“记忆”判断哪个超参组合跑出了最好结果,然后过两天就完全蒙圈。
在没有专门工具的时候,我建议用这套最朴素的方案:
- 每个实验一个文件夹,命名规则是
日期_模型_关键超参,例如20260612_lr0.001_bs32_hidden256。 - 每次训练保存一个
metrics.json,记录train_loss、val_loss、accuracy、epochs、学习率、网络结构、数据版本。 - 固定随机种子,并且在文件名里标注,否则两次实验结果不同,你无法判断是超参影响还是随机波动。
这套方案非常土,但已经能解决80%的实验可复现问题。等实验量上来了再引入现成MLflow也一样。重要的是从第一天就养成记录习惯。
4.3 训练策略的试错方法论:每次只改一个变量
训练过程本身也是有“实验方法论”的。我见过很多新手一看到loss不降,立刻把学习率、网络深度、优化器、batch size全部改一遍,结果模型更糟,还不知道到底是哪一步改坏了。
正确的做法是一次只改一个变量。我的标准排查路径是:
- 先确认loss可以降:把网络砍到最浅、数据用最小的子集、学习率用手写模型里验证过的值(比如0.1),如果连这种极简设置都降不下来,问题在代码或数据。
- 再逐步放大规模:加层数、加数据量、调整正则化强度。
- 每次调整后记录三样东西:loss曲线形状、训练集/验证集差距、训练耗时。这三个数据能帮你快速判断是没拟合(欠拟合)还是记性好但泛化差(过拟合)。
这套流程看起来慢,实际上是最快的。因为它在每一步都给了你“归因”的能力,而不是在一团乱麻里瞎猜。
5. 踩坑实录:从零实现时的三类经典事故
5.1 训练loss波澜不惊?先怀疑学习率和初始化
从零开始写训练循环,最常见的现象是loss完全不降,甚至直接输出NaN。我当初遇到过两次,排查思路很值得参考。
第一次是NaN。定位过程:我打印出每一层的输出和梯度,发现第一层线性输出z1在几个batch之后就变成几百上千的数值——说明前向输出爆了。再查原因,发现我的输入特征没有做标准化,有些维度在0~1000范围,另一些在0~1范围。权重初始化又是标准正态乘以0.01,矩阵乘法后数值稍微累积,直接就溢出到FP32的上限。解决办法:输入做标准化,权重初始化scale从1e-2调到1e-3,并把学习率从默认的1.0降到0.1。
第二次是loss不降但也没爆炸。这个更容易迷惑人。我打印了梯度统计,发现grads['W1']的均值接近0,标准差也极小——说明梯度在反向传播的过程中被“稀释”了。原因是我没有做ReLU之外的梯度缩放,用了太深的网络(虽然只有5层),激活饱和导致梯度消失。解决办法是换用带残差的连接或降低网络深度。这件事给我的教训是:不要光看loss曲线,要习惯性地打印梯度的分布。
5.2 训练集、验证集曲线差很远?先查数据泄漏而不是加正则
第二个高频事故是“明显过拟合”。很多人第一反应是加dropout、加L2正则、减小模型容量。但我想指出一个容易被忽略的前提:在动手加正则之前,先确认验证集和训练集之间没有信息重叠。
我举两个常见泄漏场景:
- 文本分类:做文本清洗时,如果对全量语料做了tf-idf向量化再接切分,验证集里每个词的特征分布已经包含了训练集的统计信息。
- 时间序列:如果数据是按时间排的,但你用随机shuffle切分训练/验证集,那么验证集里会包含“未来”的信息。
正确做法分别是:文本语料只用训练部分来拟合vectorizer;时间序列必须按时间戳顺序切分,或者用滚动窗口验证。
如果你已经确认没有泄漏,但过拟合依然存在,那才轮到正则化上场。顺序错了,你会花无数时间调正则,最后才发现是评估协议本身有问题。
5.3 复现自己实验时的“版本地狱”
第三个坑是我自己的血泪教训。有段时间我为了省事,没有记录模型结构变化,只改了文件名。结果一周后想复现最好的结果时,发现那个模型权重文件和当前代码的结构对不上,加载就报错,根本无法复现当时的指标。
后来我给自己定了一个铁律:
- 代码仓库里每个实验必须对应一个可运行脚本(或者至少是一个完整的参数配置)。
- 权重文件名里包含模型结构hash、数据版本、epoch数。
- 实验记录里写清楚“这个实验复现时需要跑哪条命令”。
这套规矩看起来很琐碎,但能让你在一周、一月后依然能自信地复现自己做过的事情。很多人的“经验”因为没法复现,最后都变成了玄学。
6. 从学习到交付:把最小模型推上线的一次复盘
6.1 选题原则:先做垂直战场的“小”项目
手写网络练完之后,我建议立刻做一次端到端的交付,而不是继续无限扩充模型的复杂度。我当初选择的是一个“评论情感分类”任务,理由有三个:
- 数据能在公开渠道拿到,且不需要大量标注。
- 任务本身有明确的正确率可以衡量。
- 模型不需要很深,但要从数据清洗、训练、部署走完整条链路。
选题时要克制,不要去挑战那些靠海量算力和顶尖结构才能解决的问题。目标不是“做出SOTA”,而是“走通一个最小但完整的交付闭环”。
6.2 从基线到迭代:先运行再优化
很多新手一上来就想用BERT、想用大模型。但我的建议是先从最朴素的基线开始:基于词频的朴素贝叶斯,或者一个简单的词袋模型+逻辑回归。这个基线可能只有85%准确率,但它能在2小时内跑完并上线,给你一个可供对比的“锚点”。
在此基础上再迭代:换成词向量、加上注意力机制、换成一个小的预训练模型。每一次迭代后都对比基线,看提升是否够大、成本是否可接受。我见过太多人一上来就是“大模型”,结果训练成本高、推理延迟大、可解释性差,最后收益和成本完全不成比例。
6.3 上线前的最后检查清单
一个模型从“笔记本里能跑”变成“线上稳定服务”,差的不是一点点。我最精简的上线检查清单有六项:
- 数据版本和模型训练时完全一致,预处理逻辑在生产环境按同一份代码执行。
- 验证集和测试集指标达标,且排除了数据泄漏。
- 推理延迟满足业务要求,失败时的降级处理已写好。
- 模型行为对边界输入有兜底(比如全零输入、超长输入)。
- 监控方案落地:至少记录推理分布、预测置信度、和业务指标的关联。
- 有明确的数据漂移应对策略:线上真实分布如果和训练分布差异超过阈值,需要触发重新训练。
这一步走完,你才算真正完成了一个AI工程的最小闭环。它和调包跑通模型是完全两种体验:前者让你对整个系统的每一个环节负责,后者只是对训练脚本负责。
7. 写在最后:我对这条路的得失看法
从决定走 from-scratch 路线到现在,我花了不少时间,也走了一些弯路。如果让我重新选择,我依然会走这条路,但会给当年的自己几条建议。
第一,不要追求从零实现所有的模型。这条路的目的是建立核心直觉,不是复刻整个深度学习生态。实现过感知机、逻辑回归、两层神经网络之后,就足够了。CNN、RNN、Attention这类模型,理解原理并会用框架实现即可,没必要每个都从零写一遍。
第二,要有明确的“毕业标准”。我对自己定的标准是:能用NumPy从零训练一个非玩具级的数据集,准确率超过95%,并且能清楚解释训练过程中每一个超参的作用。达到这个标准之后,就大大方方回PyTorch/大模型生态,带着底层理解去用高级工具,效率会翻倍。
第三,别把这篇文章当成教程,把它当成一份路线图。很详细的路线图也不可能替代你实际写那300行代码和熬夜排查那个loss不降的夜里真正获得的成长。那些困难恰恰是这份经验里最有价值的部分——撑过去了,你的AI工程地基就算真正打起来了。