从零手写AI工程:拆解框架黑盒,用NumPy构建完整训练推理系统

做AI工程这些年,从最早调PyTorch的API,到后来做模型压缩和推理部署,我越来越觉得哪里有点不对劲——框架帮我包了太多“理所当然”:梯度是自动算的,反向传播是自动做的,显存管理是自动的,甚至分布式训练也只要加个参数。听起来很美好对吧?但直到有一次线上模型效果异常,我连排查入口都找不到,才猛然意识到,自己对这套系统底层的理解几乎是空的。

这个“ai-engineering-from-scratch”项目就是那段时间启动的。核心目标只有一条:不依赖任何主流深度学习框架,从矩阵乘法开始,手工实现一个能训练、能推理、能持久化、能部署的完整AI系统。框架限制死:不能用PyTorch、TensorFlow、JAX,只允许用最基础的NumPy和Python标准库。这条路一走就是几个月,中途无数次想摔键盘,但撑过来之后,我对AI工程的理解跟以前根本不是一个层级。

这篇文章就做个完整复盘,不聊虚的理论,只讲我实际怎么设计、怎么选型、怎么写、怎么踩坑又怎么填坑。给同样想从零手写AI工程的朋友一条可以“抄作业”的路线,也帮那些正准备入行的人看清楚,AI工程这套东西底下到底长什么样。

1. 项目概述:为什么我要从零手写AI工程

1.1 项目起源:被框架惯坏之后

这个故事开头其实很狼狈。我当时在做一个文本分类服务的性能调优,模型是常规的BERT变体,推理用ONNX Runtime加速。有一天线上突然出现大量超时,我照老办法去看模型输入输出的shape、看预处理和后处理逻辑、看推理引擎的配置,全都正常。最后把锅甩给并发,加了机器还是不行。折腾两天后,一个经验丰富的后端同事提醒我:“你有没有想过,是模型里某个算子在动态shape下触发了一次隐式的内存重分配?”

我听完一脸懵。什么算子?什么隐式重分配?我甚至不知道模型里有多少个算子、每个算子的计算量是多少、哪些算子内部有状态。因为我用的都是封装好的nn.Module,模型对我来说就是黑盒。那次线上事故最后虽然通过改用固定shape解决了,但我意识到一个很危险的问题:我对AI系统的理解,全部建立在框架的“善意封装”之上。

这就是项目的起点。我不打算等出下一次事故再被动补课,我想主动把AI工程这层壳拆开,亲手把底下的地基砌一遍。

1.2 from-scratch到底意味着什么

很多人听到from-scratch,第一反应是“从零开始学AI”。但我的定义更狠:所有框架帮你做完的事,你都得自己实现一遍。具体来说,包括但不限于:

  • 张量数据结构(N维数组的存储、切片、广播、reshape)
  • 自动求导机制(计算图的构建、反向传播、梯度累计与释放)
  • 常用算子及其前向/反向实现(线性层、卷积、池化、激活、损失函数)
  • 优化器(SGD、Adam等)与学习率调度
  • 训练循环(前向、反向、参数更新、验证、早停)
  • 模型持久化与加载
  • 推理阶段的性能优化(算子融合、内存复用、批处理)

这个清单列出来就知道工作量不小。我给自己定的“完工标准”不是把每个算子都手写一遍——那太疯狂了,而是用自制系统完整跑通一个真实任务:在MNIST手写数字识别上达到95%以上准确率,然后导出模型文件,再用自己的推理引擎加载并输出预测结果。整套流程不依赖任何第三方深度学习库,但可以用NumPy做底层数值计算。

1.3 这条路适合谁走

如果你已经有了两三年调框架的经验,但总觉得AI内部是一团浆糊,刷了很多博客还是觉得隔了一层,那这个项目特别适合你。反过来,如果你是纯粹刚入门的小白,我建议先去用框架把基本的模型训练跑顺,再来碰这种从零实现——否则容易卡在“为什么这里要除根号二”这种数学细节里出不来,反而把工程兴趣磨没了。

从技术栈上讲,这不算难。会Python,懂一点点线性代数,知道什么是矩阵乘法就够了。真正难的是耐心,因为你会有无数个瞬间面对一个纯粹由“维度不匹配”引爆的报错,而报错信息还是你自己写的。

2. 全盘路线拆解:手写AI工程的核心决策

2.1 先定骨架:五个递进阶段

动手之前,我花了一周时间把整个项目拆成了五个递进阶段。这个东西不提前规划好,很容易写着写着就陷进某个细节出不来。

第一阶段是“数值地基”。实现张量类和最基本的内存管理,包括用NumPy的ndarray当底层存储,自己封装Tensor对象,支持shape、dtype、切片、广播、reshap、transpose这些基础操作。这个阶段不涉及任何梯度。

第二阶段是“自动求导”。这是整个项目最难啃的部分,我需要设计一个极简的计算图:每个Tensor记住自己是“由什么操作产生”的,并保存前向计算中必要的中间结果。反向传播时从输出出发,按链式法则逐层回传梯度。

第三阶段是“算子库”。我实现了线性层、ReLU、Sigmoid、Tanh、交叉熵损失、均方误差损失,以及对应的反向逻辑。卷积和池化是后加的,因为MNIST用线性网络加ReLU已经能到97%,足以验证系统的正确性。

第四阶段是“训练管线”。封装优化器和训练循环,实现了SGD和Adam两个优化器,加一个最简单的StepLR学习率调度。

第五阶段是“推理与部署”。把训练好的网络参数存成JSON格式,再写一个独立的推理模块,不带任何训练代码,加载参数直接跑前向,输出预测。

我把这张路线图画在一张纸上,贴在显示器边上。不但自己的目标清晰了,后面每次卡住,我也能一眼看出当前卡在哪一层。

2.2 关键决策:为什么先手写推理,再手写训练

原计划是先实现前向传播和推理,再补反向传播。这个顺序在后来的实践中被证明是极其正确的。原因很简单:如果你连前向算出来的数对不对都不知道,那反向传播出了问题,你根本无法区分是梯度算错了,还是损失本来就算错了。

我前两周的时间全花在“前向对齐”上。做法很笨但很有效:用手算一个2x2的输入,走一遍线性层+激活函数,把每一层的中间输出写在草稿纸上;然后在自己的代码里加打印,比对数值。这一步做扎实之后,后面接反向传播时,每次出错都能快速定位是“前向传播数值问题”还是“梯度计算问题”。

还有一个很现实的工程原因:推理引擎是相对独立且稳定的一块,先把这块跑通,后面调训练的时候心理压力小很多。每次训练出问题,我都能把模型参数导出来,送到自己的推理引擎里看输出,等于多了一双独立的眼睛去debug。

2.3 工具选型:为什么用NumPy而不是纯Python

有人问,既然要从零开始,为什么不用纯Python写数组运算?原因很简单:纯Python的嵌套列表在矩阵乘法上慢到不可接受,而且内存布局完全不可控。用NumPy做载体,本质上相当于我自己管理了一块连续内存,但我保留了对所有操作的显式控制权。我的代码可以不调用深度学习框架,但并不需要在数学函数这一层重新造轮子。

另一个决策是:不用torch.autograd这种现成梯度工具,但是允许参考框架里关于梯度公式的文档。这里的界限要划清楚——参考公式是正常的理论学习,照抄实现就没有意义了。我会手写推导每一个算子的反向公式,再用数值梯度的方式去验证正确性。

2.4 为什么不用现成框架:这是一场“自我拆解”

手写过程中最意想不到的收获,是开始理解框架的设计哲学。当我实现了自己的计算图之后,再去看PyTorch源码,发现它的核心架构思路和我的小型实现非常像——当然它多了很多工程细节,比如内存池、分布式、设备调配。但骨架是同构的。

这种感觉就像你先自己用纸糊了一个简单的轮子,然后忽然明白了汽车轮毂为什么是那个形状。没有这层实践,看源码就是看天书。

3. 核心细节实现:从计算图到完整训练系统

3.1 张量类与基础运算的实现

我先把Tensor类设计出来。它内部持有一个numpy.ndarray作为数据,还有一个布尔标志requires_grad控制是否记录梯度。当requires_grad为真时,这个节点会出现在计算图里,并保存两个内容:一个是grad——反向传播累积到这个节点的梯度;另一个是_backward——一个闭包函数,它知道如何把外层传来的梯度继续传给这个节点的输入。

张量的基础运算其实是一套“包装-计算-闭包”的模板。以矩阵乘法为例:

import numpy as np class Tensor: def __init__(self, data, requires_grad=False): self.data = np.asarray(data, dtype=np.float64) self.requires_grad = requires_grad self.grad = None self._backward = lambda: None def matmul(self, other): out = Tensor(self.data @ other.data, requires_grad=self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad if self.grad is not None else 0) + out.grad @ other.data.T if other.requires_grad: other.grad = (other.grad if other.grad is not None else 0) + self.data.T @ out.grad out._backward = _backward return out

这里有个容易踩坑的细节:梯度是“累加”而不是“覆盖”。因为同一参数可能被多次使用,例如权重矩阵在矩阵乘法中同时作用于多个位置,梯度需要累加到一起再更新。我一开始没注意,直接覆盖赋值,结果模型训练到一半梯度越来越小最后消失,就是这个原因。

3.2 计算图的构建与逆向传播

上面的matmul里,我用闭包把“反向函数”挂在输出节点上。整个计算图就是这么链起来的:每个算子生成的输出,都会记住如何把梯度传回自己的输入。这意味着我在前向过程中不需要显式画图,图结构是隐式的,由初始化时保存的闭包链构成。

反向传播的入口是一个递归调用链:

def backward(self): topo = [] visited = set() def build_stack(node): if id(node) not in visited: visited.add(id(node)) for child in node._children: # 每个Tensor记录自己的输入节点 build_stack(child) topo.append(node) build_stack(self) self.grad = np.ones_like(self.data) for node in reversed(topo): node._backward()

这里我显式维护了一个拓扑序列,确保每个节点在调用_backward时,它的输出梯度已经计算完毕。实际上,写这个部分最能检验你对“链式法则”的理解——不只是公式,还包括谁先谁后执行的问题。

3.3 损失函数与优化器:数学到代码的映射

实现交叉熵损失是一个经典“坑”。数学公式看起来简单,但实际工程中,直接套用-log(softmax(x)[y])会在数值稳定性上翻车。当softmax的输出非常接近0时,log会计算出负无穷。我采用的稳定版本是:先把输出减去每行最大值,再计算softmax,最后在损失层面用“log_softmax合并实现”的方式避免中间数值溢出。

def cross_entropy_loss(logits, labels): # logits: (batch, num_classes), labels: (batch,) shifted = logits - np.max(logits, axis=1, keepdims=True) log_softmax = shifted - np.log(np.sum(np.exp(shifted), axis=1, keepdims=True)) batch_size = logits.shape[0] loss = -np.mean(log_softmax[np.arange(batch_size), labels]) return loss

优化器的实现相对直接。SGD就是权重减学习率乘梯度;Adam则维护一阶矩和二阶矩的指数滑动平均。这里有一个让我调了两天的bug:Adam更新公式里的偏差校正因子,在时间步t很小时影响巨大,我一开始因为初始化t=0导致第一步除零。修正后模型收敛速度肉眼可见地变快。

3.4 训练循环:显式控制每个环节

手写训练循环让我重新理解了平时框架自动处理的东西:

for epoch in range(epochs): running_loss = 0.0 for batch_x, batch_y in data_loader: logits = model(batch_x) loss = cross_entropy_loss(logits, batch_y) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item()

这段代码里看似简单的zero_grad()多次救了我的命。我最早没有设计这个步骤,导致梯度在整轮训练中不断累积,损失函数忽大忽小,完全无法收敛。后来对照PyTorch的习惯,才意识到每次迭代前清零梯度是必须的。

3.5 模型持久化与推理引擎

训练完成后,我把模型内部的权重矩阵和偏置变成一个嵌套字典,用json.dump保存成文件。推理引擎则是独立模块,它只负责三件事:读取参数文件,搭建一个前向计算链,把输入数据过一遍输出预测概率。

def predict(model_params, x): h = x @ model_params["w1"] + model_params["b1"] h = np.maximum(0, h) # ReLU h = h @ model_params["w2"] + model_params["b2"] probs = softmax(h) return np.argmax(probs, axis=1)

这个推理引擎不带任何训练代码,逻辑极其干净。每次训练程序出了诡异问题,我都可以把中间参数导出,在这个引擎上跑一遍,确认问题出在训练还是推理阶段。这种“左脚踩右脚”的交叉验证方式,是单人调试复杂系统时最有效的手段。

4. 训练调参与正确性验证:确保系统真的在“学”

4.1 数值梯度验证:给你的反向传播上保险

手写的反向传播第一次跑通时,千万别急着整个模型跑训练。我强烈建议先做一个“数值梯度检查”:对每个参数theta中的每个元素,加一个微小扰动epsilon,用前向传播算两次损失,(loss(theta+epsilon) - loss(theta-epsilon)) / (2 * epsilon) 就是数值梯度。然后和你的反向传播算出来的梯度对比。

def numerical_grad(f, theta, i, epsilon=1e-6): theta_pos = theta.copy() theta_neg = theta.copy() theta_pos[i] += epsilon theta_neg[i] -= epsilon return (f(theta_pos) - f(theta_neg)) / (2 * epsilon)

我第一次跑这个检查时,发现大部分参数梯度误差在1e-7级别,但ReLU的输入在负半轴附近差了好几个数量级。原因是数值微分在不可导点附近的行为和解析公式不一样,正好验证了ReLU这个函数的不可导点在实际工程中其实不需要特殊处理——因为采样到那个点上的概率几乎为零,而在正负半轴的梯度都是稳定的。

4.2 学习率与初始化的搭配

从零手写训练系统后,我才真正理解到网络起跳点的重要性。实践下来,我发现两个稳定好用的原则:第一,权重用“均匀分布初始化”时,范围一般在[-1/sqrt(fan_in), 1/sqrt(fan_in)],其中fan_in是输入维度;第二,学习率要先从一个较大的值开始,观察损失曲线动态调整。

我拿MNIST做了个实验,同一套代码,初始化范围从[-std, std]调到[-2*std, 2*std],训练1000步后的损失直接从2.3降到了2.1。这看起来只差0.2,但后续收敛速度差距非常大。初始化分布和后续层数的配合,效果上是“乘法”而不是“加法”。

4.3 过拟合与验证集的作用

由于MNIST任务相对简单,我的两层MLP模型(784-128-10)很快就能在训练集上达到100%准确率。这时候如果不看验证集,很容易误以为系统完美了。实际上,我验证集上的准确率停留在97%左右,说明出现了过拟合。

触发过拟合的原因有两个:模型容量对MNIST来说偏大,以及训练轮数过长。我加了两个缓解手段:一个是简单的L2正则化,把权重的平方和加到损失后面;另一个是早停策略,当验证集准确率连续5轮不提升时就终止训练。这两个手段配合,验证集准确率能稳定在97.5%以上,同时训练时间缩短了近三分之一。

4.4 训练到收敛的完整指标记录

训练过程中我记录了每一轮的训练损失、验证损失和验证准确率。一个非常健康的训练曲线应该是:训练损失和验证损失同时下降,且两者差距不大。如果验证损失开始上升而训练损失还在降,就说明过拟合了;如果两边都不动,大概率是学习率过小或者梯度消失了;如果两边都在震荡,可能是学习率过大。

这个“看曲线诊断”的能力,是我在这个项目里收获最大的工程直觉。以前用高级框架,我只会看准确率数字,很少主动分析损失曲线的形态。现在看到不健康曲线,我脑子里会立刻弹出几种可能的成因和对应的排查方向。

5. 常见问题与排查手册:那些让你怀疑人生的坑

5.1 梯度消失:网络学不动了怎么办

我在第三阶段实现三层线性网络时,发现训练无论跑多久,训练集的准确率始终停留在88%左右,上不去了。打印每一层的梯度范数,发现第三个线性层的梯度范数比第一层大了三个数量级,靠近输入端的那几层几乎学不到东西。

原因很经典:sigmoid激活函数在饱和区梯度趋近于0,多层叠加后梯度越传越弱。解决方式是我把所有激活统一替换成ReLU,配合合理的初始化,问题立刻消失。这也让我彻底理解了为什么现代网络里ReLU系列是主流——不是因为它数学上更优雅,而是它在工程上很好地缓解了梯度消失问题。

5.2 训练损失震荡不降:问题可能出在优化器上

某次训练中,损失值每轮都稳定地呈现“下降-反弹”的锯齿状。我排查了数据预处理、批次大小、学习率,都没有头绪。最后发现是optimizer.zero_grad()在每次更新后没有把动量缓存清掉,导致Adam的二阶动量估计被污染了。这个问题的隐蔽性在于:损失曲线看起来没有完全发散,但你永远等不到它收敛。

5.3 数值不稳定的另一个罪魁祸首:过大权重的矩阵运算

有一次训练时,损失在第500轮突然变成nan。排查方式是逐步回退检查数据范围,最后发现是因为某一层的权重更新过大,导致矩阵乘法结果指数级放大,再经过ReLU后直接溢出。

这个问题的安全网很简单:在优化器更新后,检查参数是否有非有限值。如果发现np.isfinite返回False,立刻停止训练并回退这一轮更新。这个保护逻辑现在成了我的默认配置,极大节省了排查时间。

5.4 维度不匹配:反复出现的“老朋友”

说实话,这个项目里80%以上的报错都是维度不匹配。比如矩阵乘法需要的维度对不上、广播规则搞错、softmax沿错了轴。我的习惯是写一个极小的“维度断言函数”,在每个关键算子入口检查输入shape是否符合预期:

def assert_shape(tensor, expected_shape): if tensor.data.shape != expected_shape: raise ValueError(f"期望shape {expected_shape},实际 {tensor.data.shape}")

这类断言放在debug模式里,上线时去掉,能帮你把大量“明明能跑但结果不对”的问题,提前掐死在摇篮里。

5.5 手写系统调不通时的黄金排查顺序

踩过无数坑之后,我总结出一套自己的排查顺序:

  1. 先跑数值梯度检查,确认“反向传播”公式正确;
  2. 用固定输入跑一次前向,和相关论文或框架输出对齐;
  3. 检查损失曲线走势,判断是否发生梯度爆炸或消失;
  4. 检查优化器状态(动量缓存是否清零、学习率是否生效);
  5. 最后查数据预处理和打标逻辑。

这套顺序像一个漏斗,从最靠内的“数学正确性”往外抽丝剥茧,比瞎试参数效率高十倍。

5.6 参考实现与自己的实现对比时的注意事项

在验证正确性时,我用框架实现了一个相同结构的模型做对照。这里有一个大坑:框架默认使用了global avg pooling、残差连接等隐式结构,如果我没仔细看,会误以为是我实现错了。对比实验必须保证“结构对齐”而不仅仅是“参数数量对齐”。我后来养成了把两种模型的每一层输出shape和损失函数值一并打印对照的习惯,才彻底避免这种乌龙。

6. 写在最后:几个值得带走的经验

整个项目结束后,我最大的感受不是“我会手写一个AI系统了”,而是“我终于能看懂别人代码里的细节了”。以前看开源项目的源码,脑子里是一团浆糊,现在拿到一个模型文件,我能大概猜到它内部的算子构成、可能的内存瓶颈、以及反向传播过程中哪个环节最容易爆。

如果让我提炼几条经验,大概是这几点。

第一,从零手写是值得的,但要控制好范围。不用把整个Transformer手写一遍才算成功,从一个26行代码的MLP开始,跑通全流程,价值已经很大。重点在于你亲手跨过“前向”“反向”“训练”“部署”这四个坎,而不是每一步都做到极致。

第二,数值化验证要比直觉可靠一万倍。我推导反向传播公式时,好几次觉得自己逻辑无懈可击,结果数值梯度检查一跑,立刻暴露错误。从那以后,我养成了习惯:任何自定义层或损失函数,第一件事就是跑数值梯度对比。

第三,调参的基本功藏在底层实现里。当你亲手写优化器时,你才真正理解学习率、动量、权重衰减之间如何互相影响。用框架时只会套参数,手写一遍后就知道了哪个参数在什么条件下是主角,哪个只是配角。

第四,训练过程中的“日志”是你的第二双眼睛。我养成了记录每一步梯度范数、权重范数、激活值范围的惯例。这些看似不起眼的指标,在你遇到不收敛、发散、过拟合的问题时,能帮你节省至少半天的时间。

最后再分享一个小技巧:当你手写系统调不通过时,试着把网络规模缩小到极简——两个输入,一个隐藏层,两个输出。然后手算一遍前向和反向的每个数值,再去和代码输出对照。我第一次这么做时发现了一个隐藏的正负号错误,那个错误在正常规模下根本不可能发现。

从框架调用者变成系统构建者,这个过程带来的改变是根本性的。现在再有人问我“TensorFlow和PyTorch该选哪个”,我的回答变成了:“你先随便选一个入个门,然后去手写一个两层的MLP,再回来选。”没有这个“从零开始”的过程,你的选择永远只是黑盒上的一个开关。