我认真做“ai-engineering-from-scratch”这个项目的时候,身边不少朋友问过我同一个问题:现在TensorFlow、PyTorch这些框架这么成熟,预训练模型一抓一大把,为什么还要从零开始做?这个问题其实问到了点子上。AI Engineering(AI工程)和“用AI做个demo”完全是两码事——工程要解决的是稳定性、可复现性、可维护性和可观测性,而“from scratch”的核心价值,不是让你重新造轮子,而是让你把轮子内部的结构看穿。这样哪天轮子出问题,你至少知道怎么拆、怎么修、怎么改造。
这篇文章适合两类人。一类是刚入门、想真正搞懂模型内部原理的新手;另一类是已经能跑通模型、但一遇到梯度异常、loss不收敛、线上推理性能差就抓瞎的工程师。接下来我会按自己实践时走过的完整路径,从整体设计思路、数据结构与自动求导、数据工程、训练细节、部署监控,再到问题排查,把每个关键环节拆开揉碎讲一遍,并尽量给出可复现的代码和可以直接“抄作业”的参数配置。
1. 先想清楚:“from scratch”到底从哪儿开始
1.1 AI工程的关键维度
先说一个容易被忽略的事实:模型训练在AI工程里通常只占很小一部分时间。我在实际项目里做过一个统计,一个从数据到上线的完整周期里,数据清洗和特征工程能占到40%~50%的时间,训练和调参占20%左右,剩下30%基本都花在部署、监控、排查和版本迭代上。很多初学者以为AI工程就是“写个网络搭个训练循环”,这个理解偏差很大。
真正的AI工程至少包含四个维度:数据侧(数据获取、清洗、校验、版本管理)、训练侧(模型设计、损失函数、优化器、超参数)、部署侧(模型转换、推理服务、性能优化)、监控侧(延迟、吞吐、数据漂移、模型退化)。四个维度缺一不可,任何一个环节出问题,整个系统都可能崩。我用一个类比理解这件事:训练模型像学开车,AI工程像造一条能稳定运行的高速公路。你光会踩油门没用,道路养护、交通调度、事故预警都得有人管,而这些恰恰是工程化的重点。
1.2 “从零开始”的多层含义
“from scratch”这个词在不同人眼里含义不一样。有人以为是要从数学公式开始推导,有人以为是从编译器开始写,我觉得都不需要这么极端。对我来说,它有三层比较务实的含义。
第一层是从原理层开始:理解梯度下降为什么有效、反向传播的链式法则到底在算什么东西、不同的权重初始化为什么对收敛有这么大的影响。第二层是从框架底层开始:不直接用PyTorch的autograd,而是手写一个简化版Tensor和计算图,搞清楚自动求导的机制。第三层是从工程流程开始:不跳过数据质量检查,不跳过模型监控,不等上线出问题了才补课。
这三层不用全部手写,那是低效的自虐。我建议的原则是:核心路径手写,周边生态使用成熟工具。比如张量运算和自动求导要手写一遍,但数据读取用Pandas、可视民用matplotlib、上线部署用ONNX Runtime和FastAPI,这些没必要重复造轮子。这样做既能建立对底层机制的直觉,又不至于让项目进度拖到天荒地老。
2. 从零实现的核心:张量与自动求导
2.1 基础数据结构:张量(Tensor)
所有深度学习框架的底层,都躲不开张量这个数据结构。你可以把它简单理解成一个高性能的多维数组,但跟Numpy数组比,它多了两个关键东西:requires_grad标志和grad梯度缓存。初始实现不用搞太复杂的CUDA和分布式,先把单机CPU版本跑通,核心逻辑就清楚了。
我当时写了一个简化版Tensor,只保留最核心的成员变量:data存放数值,requires_grad记录这个节点是否需要梯度,grad保存反向传播后累积的梯度值,还有一个_backward函数和_prev集合用来记录计算图中的关联关系。代码框架大致长这样:
import numpy as np class Tensor: def __init__(self, data, requires_grad=False): self.data = np.asarray(data, dtype=np.float32) self.requires_grad = requires_grad self.grad = None self._backward = None self._prev = set() def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) result = Tensor(self.data + other.data) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + result.grad if other.requires_grad: other.grad = (other.grad or 0) + result.grad result._backward = _backward result._prev = {self, other} return result为什么requires_grad这么重要?因为在真实训练中不是所有参数都要求导。比如输入数据的某些预处理步骤、不需要训练的embedding层,都可以把requires_grad设为False,减少计算开销。而梯度用加法累积而不是直接赋值,是因为一个节点可能有多个下游节点,反向传播时它会收到多份梯度,必须累加才能得到正确的总梯度,这是反向传播里特别容易踩坑的地方。
2.2 计算图与反向传播的实现
张量有了,接下来就是整个实现中最有意思的部分:计算图和反向传播。理解它的关键只有一句话:正向传播时把每一步运算记录下来,反向传播时按记录的逆序,用链式法则逐层回传梯度。
我一开始写反向传播犯过一个典型错误:直接在递归里对每个节点调用_backward,结果导致同一个节点被重复访问多次,梯度被重复累乘,数值全错了。正确的做法是先对计算图做一次拓扑排序,保证每个节点都在它的所有子节点之前被处理。思路就是深度优先遍历,记录访问顺序,然后逆序执行每个节点的_backward。你去看PyTorch源码的话会发现,它的backward()内部也是类似逻辑,只是优化程度高得多。
一个最简单的例子,y = (x + 1) * 2。正向传播时,先算a = x + 1,再算y = a * 2,计算图就是x -> a -> y。反向传播时先从y出发,梯度是1,传到乘法节点时,a的梯度是1 * 2,再传到加法节点时,x的梯度是y的梯度 * 1,最终得到2。这个过程看着简单,但它就是所有深度学习框架自动求导的原型。手工推理一遍之后,你会发现以前看不懂的那些“梯度消失”“梯度爆炸”现象,本质都发生在这些看似简单的乘法和加法链路上。
这里我再补充一个工程层面的细节:动态图和静态图的取舍。我实现的是动态图,也就是每次前向传播都重新构建计算图。动态图的优点是灵活,方便调试,PyTorch用的就是这一套;静态图则是在运行前先把图建好,再整体编译优化,TensorFlow 1.x和现在的JAX比较偏这种思路。动态图调试体验好,适合研究;静态图推理性能更好,适合生产。理解了这一点,你就知道为什么很多工业部署环节要把PyTorch模型转成ONNX静态图了。
3. 真实AI工程的完整落地流程
3.1 数据工程:拿到的原始数据几乎都不能直接用
数据工程的比重经常被低估。我从零开始做这个项目时,用到的是一个包含特征变量和标签的中等规模表格数据集,表面看起来很干净,但实际一检查全是坑:有缺失值、有量纲差异巨大的特征、有极端离群值,标签还有轻微的不均衡。
面对缺失值,我的处理优先级是这样的:先看缺失比例,超过50%的字段直接删除,因为补全的可靠性太低;缺失不多的数值字段,先用中位数填充,因为中位数对离群值不敏感;如果是时间序列数据,优先用相邻值前向填充。这里最忌讳的就是所有字段一刀切用均值填充,均值会被异常值拉偏,而且不同字段的缺失机制完全不同。建议每个有缺失的字段单独分析一下再做决定。
量纲差异的问题也值得展开说。为什么要归一化?我用一个很直观的方式理解:如果特征A的取值范围是0到1,特征B的取值范围是0到10000,那么MSE损失函数里特征B的权重天然就比特征A大很多。梯度下降时,参数更新方向会被大尺度的特征主导,导致loss曲面变得“瘦长”,优化过程就会像走迷宫一样反复震荡。归一化之后,所有特征尺度一致,梯度方向更直接,收敛速度会明显变快。常见的做法有Min-Max归一化和标准化(Z-score),如果数据分布接近正态分布用标准化更好,如果数据分布比较均匀或已经落在有界范围内,Min-Max归一化更稳定。
3.2 训练过程:损失函数、优化器与学习率的土办法
训练这部分,我建议从最简单的线性回归起步,跑通整个流程后再换成多层感知机。不要一上来就上Transformer、注意力机制,万丈高楼平地起这句话在工程实践里是真的。
损失函数的选择要结合任务类型。回归问题首选均方误差(MSE)或平均绝对误差(MAE),MSE对异常值敏感,梯度大收敛快;MAE更稳健,但梯度恒定,后期可能会在最优解附近徘徊。分类问题用交叉熵损失,我见过不少新手把分类问题硬套MSE,结果训练半天loss降不下去,原因就是MSE对分类概率的梯度特征不够好,交叉熵和Softmax的组合在数学上配合得更紧密。
优化器方面,我推荐按这个顺序学习:SGD -> SGD Momentum -> Adam。SGD的逻辑最干净,每一步都是param = param - lr * grad。Momentum加了一个“惯性”项,能减少震荡,我用的动量系数默认是0.9,这个值来自经验,它能在保持方向一致性的同时,不会让历史梯度影响过大。Adam则是在Momentum的基础上加了对梯度平方的指数移动平均,相当于给每个参数单独调整学习率,对新手最友好,因为它对初始学习率没那么敏感。
学习率是整个训练流程里最需要花心思调的超参数。我试过一种很实用的“学习率探测器”方法:从一个极小值比如1e-6开始,每步训练把学习率乘以一个常数,绘制loss变化曲线,很快就能找到loss下降最快的区间。一般曲线的“悬崖点”附近就是比较合适的初始学习率。批量大小也有影响,经验上小批量(32到64)能带来一定正则化效果,优化过程更容易跳出局部极小值,但GPU利用率会偏低;大批量(256以上)训练更快,但需要同步调高学习率,否则收敛可能变慢甚至不收敛。
3.3 模型部署与监控:落地才是真正开始
模型在笔记本上跑得准,和能在生产环境稳定服务,完全是两件事。我当时从训练好的PyTorch模型转为ONNX格式,原因很简单:部署环境不一定有PyTorch,而且ONNX是静态图,推理引擎可以做更多优化。导出的关键配置是动态轴,这样在线服务时可以自由指定推理的批大小:
torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )转完ONNX之后,还可以继续用TensorRT或OpenVINO做推理加速。TensorRT对NVIDIA GPU做了深度优化,在一些视觉模型上能获得比较大的吞吐提升;OpenVINO则对CPU场景更友好。但这些加速引擎有各自的算子支持范围,不是所有模型转了就能直接跑,我的建议是先在本地用ONNX Runtime验证输出一致性,误差控制在1e-3以内再考虑上层加速。
部署架构上,我用了比较轻量的方案:FastAPI起一个HTTP推理服务,模型加载到内存并预热一遍,避免首次请求时冷启动延迟。监控指标重点关注四个:推理延迟的P50/P95/P99分位数、每秒请求数(QPS)、错误率和数据漂移指数。延迟分位数里,P99比平均值更重要,因为最慢的那1%请求往往决定了用户体验的上限。数据漂移可以用群体稳定性指数(PSI)来度量,它的计算逻辑是把当前特征分布和训练时的特征分布分箱对比,PSI超过0.25就说明分布发生明显变化,需要警惕模型性能退化。灰度发布和版本回滚也必须提前设计好,最简单的方式是服务端保留上一版本模型,新版本流量逐步从10%放量到100%,一旦监控指标异常立刻切回旧版本。
4. 实战中的常见问题与排查技巧
4.1 训练不收敛:从梯度消失到学习率失当
训练不收敛是大家问得最多的问题,loss曲线要么横着不动,要么直接飞到NaN。我总结了三板斧排查法。
第一板斧是看loss曲线形态。如果loss一开始就在高位震荡甚至持续升高,优先怀疑学习率太大,把学习率降一个数量级试试。如果loss稳步下降但到某个点就卡死,可能是学习率太小,或者在鞍点附近停滞,这时可以尝试momentum或切换Adam。如果loss突然跳变到NaN,几乎可以肯定是数值溢出了,常见原因是梯度爆炸或者数据里有Inf值。
第二板斧是检查各层梯度的范数。在训练脚本里加上梯度打印逻辑,观察前向传播和反向传播过程中每一层梯度的L2范数。如果浅层梯度非常小,深层梯度正常,那就是梯度消失;如果梯度范数随层数增加爆炸式增长,就是梯度爆炸。这个检查能让问题定位快很多,不用瞎猜。
第三板斧是对症下药。梯度消失可以考虑换激活函数,把Sigmoid换ReLU或它的变体;或者用更好的初始化方法,Xavier初始化适合标准激活函数,He初始化适合ReLU系。梯度爆炸最直接的手段是梯度裁剪,把梯度L2范数限制在阈值内,比如1.0或5.0,超参数可以先从1.0试起。还有一个经常被忽略的原因:数据里存在极大或极小的特征值,即使归一化没做好也会导致梯度异常,回头检查数据预处理不是浪费时间。
4.2 过拟合的几种典型特征与应对方案
过拟合的表现很典型:训练loss持续下降,但验证loss到某个节点后开始反弹。新手最容易犯的错是死磕训练集指标,反复加网络层数追求训练loss归零,这是典型的本末倒置。正确的做法是以验证集指标为准,训练集指标只要接近验证集指标就可以了。
应对过拟合,我按优先级排序:增加数据量和数据增强永远是第一选择,对于图像数据可以做随机裁剪、翻转、色彩扰动;对于表格数据,可以在特征层面加噪声或做SMOTE过采样。数据量短期内上不去的,再用正则化手段。L2正则化(权重衰减)是把权重的平方和加到损失函数里,让权重尽量小,限制模型的复杂度;Dropout则是随机丢弃一部分神经元的输出,强迫模型学到更冗余的特征,而不是依赖某几个特定的节点。Dropout比率我通常设在0.2到0.5之间,层数越深、神经元越多,比率可以取大一些,但太高会导致欠拟合。
早停(Early Stopping)是我强烈推荐每个项目都加的策略,它不改变模型结构,只监控验证集loss,连续多个epoch(我一般设10到15轮)没有改善就停止训练并恢复到最佳模型权重。这个策略相当于给训练自动加了一个安全阀,能避免大量无效的计算时间。还有一点必须强调:只能在验证集上做早停和调参,测试集必须到最后才用一次,否则你就在用测试集训练,评估结果会虚高得离谱。
4.3 推理性能优化:量化、剪枝与服务化配置
线上推理性能不够时,我通常先做量化再做剪枝,原因很简单:量化对代码侵入小,效果又立竿见影。把模型权重从FP32降到FP16,在支持FP16加速的GPU上能获得几乎翻倍的吞吐提升,精度损失一般可以忽略;再激进一点降到INT8,需要引入校准数据集来计算每个激活值的动态范围,精度损失大约在1%到3%,但推理速度通常能提升2到4倍。前提是模型对噪声不能太敏感,如果业务场景对精度极其敏感(比如医疗诊断),INT8要谨慎测试后再上线。
剪枝的原理是把权重接近零的连接直接去掉,减小计算量。非结构化剪枝会把权重矩阵变成稀疏矩阵,理论计算量降低了,但实际硬件对稀疏矩阵的支持参差不齐;结构化剪枝是整行或整列去除神经元,对硬件更友好,更容易获得真实加速。我实操时发现一个反直觉的现象:剪枝比例在10%到30%区间时精度几乎不掉,超过50%后精度会断崖式下跌。所以别贪心,逐步剪、逐步评估,才是稳妥路线。
服务化配置方面,一个容易被人忽视的点是请求批量处理。如果推理接口是单样本输入,可以通过合并请求的方式把多个样本攒成一个batch,显著提升GPU利用率。但要注意的是批大小不能无限增大,延迟的P99会随之上升,需要在吞吐和延迟之间找平衡。另外,如果模型响应时间波动大,考虑加一个简单的排队机制,避免突发流量把服务打垮。
| 优化手段 | 预期加速比 | 精度影响 | 实施难度 |
|---|---|---|---|
| FP16量化 | 1.5~2倍 | 几乎无损 | 低 |
| INT8量化 | 2~4倍 | 1%~3%损失 | 中 |
| 结构化剪枝 | 1.2~2倍 | 小范围可控 | 中高 |
| 模型蒸馏 | 1.5~3倍 | 需重新训练 | 高 |
5. 走完一遍之后:我的扩展方向与个人体会
5.1 下一步可以尝试的进阶方向
把基础版本跑完,我对整个流程有了完整的直觉,接下来就可以往上延伸了。我目前比较看好的几个方向分别是分布式训练、自动调参和MLOps全链路。
分布式训练解决的是模型和数据规模超出单机能力的问题。从零开始写过Tensor之后,再去看PyTorch的DistributedDataParallel,理解起来会快很多,因为你知道每个进程手里有模型参数的一份副本,梯度通过通信协议做AllReduce聚合,然后各进程再用聚合后的梯度同步更新参数。这个通信开销是一笔额外的成本,所以分布式训练的效果不是线性的,小模型用单机甚至更快。
自动调参方面,Optuna这类工具已经很成熟,核心是贝叶斯优化,在超参空间中用较少的实验次数找到不错的组合。我建议把学习率、批大小、Dropout比率、权重衰减这几个超参先放进自动调参框架跑一轮,得到一组相对靠谱的基线数值,再手动细调,这样效率最高。MLOps则是把整个流程做成可重复、可审计的流水线,数据有版本、模型有版本、训练参数有记录,模型上线之前生成一份包含数据摘要、评估指标、部署配置的报告,出问题时能快速回溯。
5.2 此刻回头看,我最想提醒你的事
踩过这么多坑之后,如果只能总结成一句话,我会说:先手写最小闭环,再拥抱成熟工具。最小闭环指的是数据读取、模型构建、训练、评估、导出推理这几步全部自己跑通,哪怕模型只是一个线性回归,但这个闭环是理解AI工程的最佳路径。有了闭环,你再引入PyTorch、TensorRT、MLFlow这些工具时,会很清楚每个工具在解决哪个环节的问题,而不是盲目堆叠。
第二个提醒是,排查问题不要慌,把矩阵维度和梯度形状手写出来。我遇到过很多次“loss是NaN但找不到原因”的情况,最后都是靠把各层输入输出形状一步步标出来,才发现是某个reshape操作改变了数据含义,导致反向传播梯度错位。纸上推演看起来笨,却是最快定位的方式。
第三个实用的技巧是,每调整一个超参数只做一处变更,并记录实验结果。不要同时改学习率和网络结构,否则你根本不知道是哪个改动让效果变好了。我习惯用一个简单的表格记录每个实验的配置和验证集指标,这个习惯看起来普通,但能让你从“凭感觉调参”变成“有依据地优化”。
真要说这个项目带给我最大的收获,倒不是“我手写了一个深度学习框架”这件事本身,而是它让我建立了对整个AI系统运行机制的直觉。当线上模型表现异常时,我能更快判断问题出在数据、模型还是部署环境;当新框架出现时,我也不会觉得它是黑魔法。这种掌控感,才是从零开始真正值得花时间去做的事情。