
1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调一下API跑通了就觉得自己会了。我刚开始也这么干过结果遇到模型输出不稳定、推理延迟忽高忽低、显存莫名其妙爆掉的时候整个人是懵的——因为底层发生了什么我完全不知道。ai-engineering-from-scratch这个方向之所以值得聊恰恰是因为它逼着你把每一层都掀开看一遍数据怎么进、张量怎么算、梯度怎么回传、模型怎么存、服务怎么起。这些东西你调包的时候永远学不到但一旦线上出问题能救你的只有这些。这篇文章适合两类人一类是刚入行、想真正搞懂AI系统内部运转逻辑的工程师另一类是用惯了高层框架、但总觉得心里没底、想补一补底层功课的老手。我会从最朴素的矩阵运算开始一路讲到怎么把一个能跑的小模型部署成可用的服务中间穿插我自己踩过的坑和那些文档里不会写的细节。全程不依赖任何“一键式”平台所有东西都自己动手搭。需要先说明一点从零构建不等于什么都自己造轮子。我的原则是——核心链路必须自己写一遍外围工具可以用现成的。比如张量库我可以用NumPy但反向传播我必须自己推优化器我可以用现成的但损失函数我必须自己实现一遍。这样既不会陷入无意义的重复劳动又能保证对关键环节有真正的掌控力。2. 先把“张量”这件事说透不然后面全是坑2.1 张量不是多维数组那么简单几乎所有教程都会告诉你“张量就是多维数组”这话对但只说了一半。在实际工程里张量至少还包含三个隐含信息形状shape、数据类型dtype、存储布局strides。你调包的时候这三个东西框架帮你管了自己写的时候如果不管就会出现“明明形状对但结果就是错”的诡异情况。我举个真实例子。早期我手写一个简单的全连接层输入是(batch, features)权重是(features, out)做矩阵乘法得到(batch, out)。看起来没问题对吧但我当时把权重存成了(out, features)然后直接乘结果形状居然也能对上因为batch和out恰好相等但数值全错。这种bug在调包时几乎不可能出现因为框架会帮你检查但自己写的时候形状对不代表语义对。所以我的建议是自己实现张量类的时候至少要把shape和dtype作为强制校验项。每次运算前先断言形状匹配宁可多写几行检查代码也不要让错误悄悄传播。2.2 广播机制方便但危险广播broadcasting是张量运算里最容易被滥用的特性。它让不同形状的张量能自动扩展成兼容形状写起来很爽但也是bug重灾区。比如你想把一个(batch, 1)的偏置加到(batch, features)的输出上广播会自动把偏置扩展成(batch, features)这没问题。但如果你不小心把偏置写成了(features,)广播会把它当成(1, features)再扩展结果也对——可一旦batch维度顺序变了就会静默出错。我的做法是在关键运算处显式reshape不依赖隐式广播。多写一个reshape调用换来的是可预测的行为。性能上损失可以忽略但调试时间能省下好几个小时。2.3 内存布局对性能的影响自己实现张量时很多人用嵌套列表或者连续的一维数组加索引计算。前者简单但极慢后者快但容易写错索引。我实测下来用一维数组加strides的方式在矩阵乘法上比嵌套列表快20倍以上。具体做法是数据存成一维data数组额外维护shape和strides通过offset sum(i_k * stride_k)来定位元素。这个设计还有一个好处转置操作不需要真正移动数据只需要交换对应的shape和strides即可。对于大矩阵来说这能省下大量内存拷贝时间。当然代价是索引计算变复杂了但这是值得的——AI工程里内存带宽往往比计算本身更瓶颈。3. 反向传播手推一遍胜过看一百篇教程3.1 计算图到底该怎么建反向传播的核心是链式法则但工程实现上关键在“计算图”怎么组织。有两种主流做法一种是定义时构建静态图像早期TensorFlow另一种是运行时动态记录像PyTorch。从零实现的话我强烈建议用动态方式因为调试友好得多。具体来说每个张量除了存数据还要存三样东西grad梯度、_backward反向函数、_prev前驱节点集合。每次做运算时不仅算出结果还要定义好“当梯度从结果传回来时我该怎么把梯度分给输入”。这就是一个局部反向函数。我踩过的一个坑是忘记在反向函数里处理“一个张量被多次使用”的情况。比如y x * xx在前向里用了两次反向时梯度应该累加两次。如果只写一次梯度就少了一半。这个bug非常隐蔽因为前向结果完全正确只有训练时loss下降变慢很难定位。解决办法很简单反向函数里一律用而不是。3.2 梯度检查自己写的反向到底对不对手写反向传播最大的风险是公式推错。我的经验是每实现一个新算子立刻用数值梯度做校验。方法很朴素——对某个输入分量加一个极小扰动ε算前向输出的变化量除以ε得到数值梯度再和自己写的解析梯度对比。如果相对误差在1e-5以内基本可以认为正确。这个检查过程很枯燥但能帮你省下后面几天的调试时间。我一般会写一个小工具函数传入任意前向函数和输入自动跑一遍数值校验。这个投入绝对值得。3.3 常见算子的反向公式与实现要点这里列几个最核心的算子以及我在实现时特别注意的点算子前向反向要点加法z x y梯度直接传回注意广播时要sum回原形状乘法z x * y对x的梯度是grady对y是gradx矩阵乘z x y对x是grad y.T对y是x.T gradReLUz max(0, x)梯度在x0处传回否则为0Softmaxz exp(x)/sum(exp(x))需要处理数值稳定性先减最大值广播的反向特别容易错。如果前向时x被广播了反向时梯度必须沿着被广播的维度求和还原成x的原始形状。我见过不少人在这里出错导致梯度形状和参数形状不匹配训练直接崩掉。4. 训练循环那些让loss不下降的隐形杀手4.1 参数初始化不是随便填个数很多人从零实现时参数直接用random.randn初始化然后发现loss根本不降。问题往往出在初始化尺度上。如果权重太大前向输出会爆炸太小信号会逐层衰减。对于ReLU网络我一般用He初始化标准差取sqrt(2 / fan_in)其中fan_in是输入维度。对于tanh网络用Xavier初始化sqrt(1 / fan_in)。这个细节在调包时框架帮你做了但自己写的时候如果忽略训练根本跑不起来。我建议把初始化单独写成一个函数针对不同激活函数用不同策略并且在前向传播时打印每层输出的均值和标准差确认信号没有异常放大或缩小。4.2 学习率先找范围再精调学习率设错是loss不下降的第二大原因。我的做法是先跑一个学习率范围测试——从1e-6开始每个batch乘以1.1倍记录loss变化画出loss vs lr的曲线。loss下降最快的区间就是合理范围通常取该区间中点的十分之一作为初始学习率。这个技巧来自fast.ai的实践我用了之后再也没有盲目试过学习率。具体实现上只需要在训练循环里动态调整优化器的lr参数即可不需要改模型代码。4.3 梯度裁剪防止训练中途崩掉即使学习率合适训练过程中也可能遇到某个batch梯度特别大导致参数一步跳飞loss变成NaN。解决办法是梯度裁剪把所有参数的梯度拼成一个向量算它的L2范数如果超过阈值就整体缩放。阈值一般取1.0到5.0之间我常用1.0。这个操作在RNN类模型里几乎是必须的在深层网络里也很有用。实现上就是在反向传播之后、优化器更新之前加一段裁剪逻辑。注意要裁剪所有参数不能只裁一部分。4.4 一个最小可用的训练循环长什么样把上面这些串起来一个从零实现的训练循环大概是这样for epoch in range(num_epochs): for batch_x, batch_y in dataloader: # 前向 pred model(batch_x) loss cross_entropy(pred, batch_y) # 反向 model.zero_grad() loss.backward() # 梯度裁剪 clip_gradients(model.parameters(), max_norm1.0) # 更新 optimizer.step() # 每个epoch打印验证集指标 val_loss evaluate(model, val_loader) print(fepoch {epoch}, val_loss {val_loss:.4f})看起来简单但每一行背后都有上面说的那些细节。我建议初学者先把这段代码手敲一遍不要复制敲的过程中你会自然思考每个环节的必要性。5. 从训练到部署模型怎么变成服务5.1 保存模型不是pickle一下就完事训练完的模型要保存下来供后续使用。很多人直接pickle整个模型对象但这有几个问题一是pickle依赖代码结构代码改了旧模型可能加载不了二是pickle有安全风险不要加载不可信来源的文件三是跨语言部署时pickle完全没用。我的做法是只保存参数张量用自定义的二进制格式或npz。同时保存一份模型结构描述比如每层的类型和维度加载时先按描述重建模型再填入参数。这样即使代码重构了只要结构描述兼容旧参数依然能用。5.2 推理优化batch和cache的取舍部署时第一个要决定的是batch size。训练时batch大一点能提高GPU利用率但推理时如果请求是逐个来的攒batch会引入延迟。我的经验是在线服务用动态batch设置一个很小的等待窗口比如5毫秒窗口内到的请求攒成一个batch一起推理。这样既能利用并行计算又不会让单个请求等太久。另一个优化点是KV cache。对于自回归生成类模型每次生成一个token都要重新计算前面所有token的注意力非常浪费。KV cache的思路是把已经算过的key和value存下来下一步直接复用。这个优化能把生成速度提升好几倍但代价是显存占用随序列长度线性增长。实际部署时要根据显存大小设置最大序列长度。5.3 服务框架自己写还是用现成的如果只是内部测试用Flask或FastAPI包一层就够了。但生产环境要考虑并发、超时、限流、监控这些东西。我的建议是核心推理逻辑自己写服务框架用成熟的。比如用FastAPI做HTTP接口用uvicorn做ASGI服务器推理部分自己控制。这样既保证了推理链路的可控性又不用重复造服务治理的轮子。接口设计上我一般提供两个端点一个同步接口直接返回结果一个异步接口提交任务后返回task_id后续轮询结果。同步接口适合短输入异步接口适合长文本生成这类耗时操作。5.4 监控没有监控的部署等于裸奔服务上线后必须监控几个核心指标请求延迟P50/P95/P99、每秒请求数、错误率、显存占用、GPU利用率。这些指标能帮你快速定位问题——比如P99延迟突然升高可能是某个长请求堵住了队列显存持续增长可能是KV cache没释放。我一般用Prometheus采集指标Grafana做面板。如果不想搭这套至少也要把日志打好记录每个请求的输入长度、输出长度、耗时。出问题时能回溯。6. 那些让我熬夜的坑你可以直接跳过6.1 数值稳定性exp和log的陷阱自己实现softmax和交叉熵时如果不做数值处理很容易遇到inf或nan。原因是exp(x)在x稍大时就会溢出。解决办法是先减去最大值exp(x - max(x))这样最大指数是0不会溢出。交叉熵同理不要先算softmax再算log而是直接用log_softmax把两步合并成一个数值稳定的操作。这个坑我在第一次手写分类器时踩过当时输入数据没做归一化某个特征值到了100多exp直接溢出loss变成nan查了半天才发现是数值问题。6.2 内存泄漏计算图没释放动态计算图的一个副作用是如果你把每次前向的结果都存到一个列表里比如为了记录训练过程整个计算图就不会被释放显存会持续增长直到爆掉。解决办法是记录指标时只存标量值不要存张量。如果确实需要存中间结果用.detach()断开计算图或者转成NumPy数组。我在早期实现时习惯把每个batch的loss存下来画曲线结果存的是张量跑了几百个batch后显存就满了。后来改成loss.item()存浮点数问题解决。6.3 随机种子可复现性不是小事做实验时如果不固定随机种子两次运行结果不一样你根本分不清是改动有效还是随机波动。我的做法是在程序入口处固定所有随机源包括Python的random、NumPy的random、以及自己实现的任何随机初始化。同时记录下种子值方便复现。但要注意固定种子不代表结果完全确定——GPU上的某些操作本身有非确定性多线程数据加载也可能引入随机性。对于要求严格复现的场景需要额外设置环境变量禁用这些非确定性行为。6.4 数据类型float32和float16的取舍训练时一般用float32精度够用。但推理时可以用float16甚至int8来加速和省显存。不过float16有范围限制太小或太大的值会溢出。我的经验是推理时用float16但要在关键位置做数值检查比如softmax之前确认输入没有极端值。如果发现溢出可以混合使用——大部分层用float16少数敏感层保持float32。int8量化更激进能进一步压缩模型但需要校准过程而且不是所有模型都适合。我一般先试float16不够再考虑int8。7. 从零构建之后我看调包的心态变了把这一整套走下来之后我再用那些高层框架感觉完全不一样了。以前看到model.fit()就觉得万事大吉现在会去想它内部怎么做梯度累积怎么处理变长序列显存不够时怎么切分这些问题在框架文档里往往一笔带过但自己实现过之后看源码就能快速定位关键逻辑。更重要的是遇到问题时排查思路变了。以前是“换个参数试试”现在是“先确认梯度有没有传对、数值有没有溢出、内存有没有泄漏”。这种从原理出发的排查方式效率比盲目试错高得多。如果你也想走一遍这条路我的建议是不要追求功能完整追求链路打通。先实现一个最简单的线性回归从数据生成、前向、反向、更新到预测全部自己写。跑通之后再逐步加激活函数、加层数、换损失函数。每加一个东西就做一次梯度检查。这样一步步来两周左右就能建立起完整的直觉。后续如果想继续深入可以往两个方向扩展一是性能方向比如用SIMD指令加速矩阵运算、用多线程做数据加载二是规模方向比如实现简单的分布式训练、梯度累积、混合精度。这两个方向都需要前面这些基础作为支撑跳过基础直接搞高级特性最后还是会回来补课。