
1. 为什么我要从零手搓一套AI工程框架市面上关于AI工程化的资料绝大多数都在教你调库。pip install transformers三行代码跑通推理然后呢然后就没有然后了。一旦遇到显存溢出、推理延迟抖动、多卡通信瓶颈、模型版本回滚这些真实生产环境里的问题调库选手和从零构建过的工程师之间的差距就暴露无遗。ai-engineering-from-scratch这个项目标题本身就说明了一切——它不是又一个“大模型应用开发速成”而是一条从底层张量操作开始一步步搭建出可训练、可推理、可部署的AI工程管线的路径。我花了将近三个月的时间把这条路径完整走了一遍踩了无数坑也积累了一些在官方文档里根本找不到的经验。这篇文章就是把这些东西摊开来聊。适合谁看如果你已经会用PyTorch或TensorFlow跑通几个Demo但对“一个完整的AI系统到底由哪些模块组成”“每个模块的边界在哪里”“哪些地方最容易出问题”这些问题还没有清晰答案那这篇内容就是为你准备的。如果你是完全零基础建议先补一下线性代数和Python基础否则后面有些地方会比较吃力。我先把整体思路说清楚从零构建AI工程能力核心不是让你去重复造轮子而是让你理解轮子为什么是圆的。当你亲手实现过一遍反向传播、手写过DataLoader、自己搭过推理服务再回头用现成框架的时候你的调试能力和架构判断力会完全不一样。2. 从张量到自动微分地基到底怎么打2.1 为什么不能直接上PyTorch很多人会问PyTorch已经这么成熟了为什么还要从零写这些底层东西我的回答是因为你迟早会遇到PyTorch解决不了的问题。举个我实际遇到的例子。有一次线上推理服务出现了一个诡异的现象同一个模型同样的输入在不同批次的请求中输出结果有微小差异。排查了很久才发现是自定义算子里的一个原地操作在特定条件下触发了非确定性行为。如果你不理解计算图的构建过程和自动微分的实现原理这种问题你连从哪里下手都不知道。从零实现一个微型自动微分引擎不需要多复杂。核心就是定义一个Tensor类里面存数据和梯度再实现add、mul、matmul这几个基础运算的前向和反向。关键点在于理解计算图的动态构建每次前向运算时记录下输入和输出的依赖关系反向传播时沿着这个图链式求导。class Tensor: def __init__(self, data, requires_gradFalse): self.data data self.grad None self.requires_grad requires_grad self._backward lambda: None self._prev set() def __add__(self, other): other other if isinstance(other, Tensor) else Tensor(other) out Tensor(self.data other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad out.grad if self.grad is None else self.grad out.grad if other.requires_grad: other.grad out.grad if other.grad is None else other.grad out.grad out._backward _backward out._prev {self, other} return out这段代码看起来简单但它揭示了自动微分的本质反向传播就是沿着计算图反向应用链式法则而计算图是在前向过程中动态构建的。理解这一点之后你再去看PyTorch的autograd机制就会发现它做的事情本质上是一样的只是用C实现了更高的性能并且处理了更多边界情况。2.2 梯度累积与计算图释放的坑自己实现自动微分的时候有一个坑几乎每个人都会踩梯度累积。在PyTorch里每次调用loss.backward()之前都需要optimizer.zero_grad()否则梯度会累加。这个设计不是PyTorch的缺陷而是有意为之——它支持梯度累积训练可以在小显存上模拟大batch。但如果你自己实现自动微分引擎很容易忘记处理梯度清零的逻辑。更隐蔽的坑是计算图的释放。PyTorch默认在反向传播完成后释放中间激活值以节省显存但如果你需要多次反向传播比如计算二阶梯度或者某些强化学习场景就需要设置retain_graphTrue。自己实现的时候如果不小心保留了不必要的中间变量引用显存会迅速膨胀。我的经验是在实现自动微分引擎时显式地区分“叶子节点”和“中间节点”。叶子节点是需要保留梯度的参数中间节点在反向传播完成后应该被释放。这个区分在调试显存问题时非常关键。2.3 数值稳定性从Softmax说起手写Softmax是检验你是否真正理解数值稳定性的一个好测试。直接按照公式exp(x) / sum(exp(x))实现当x的数值较大时exp(x)会溢出为inf导致结果为nan。正确的做法是减去最大值exp(x - max(x)) / sum(exp(x - max(x)))。这个操作在数学上是等价的因为分子分母同时乘以了exp(-max(x))但在数值上稳定得多。def stable_softmax(x): shifted x - np.max(x, axis-1, keepdimsTrue) exp_shifted np.exp(shifted) return exp_shifted / np.sum(exp_shifted, axis-1, keepdimsTrue)类似的问题还出现在交叉熵损失、LayerNorm、对数运算等地方。我的建议是任何涉及指数、对数、除法的运算都要先问一句“极端情况下会不会溢出或下溢”。这个习惯能帮你避免大量线上事故。3. 数据管线被低估的工程重灾区3.1 为什么DataLoader比模型更容易出问题在真实项目中数据管线出问题的概率远高于模型本身。模型结构是静态的调通了就不会变但数据是动态的格式、分布、质量都在变化。我见过太多项目模型指标上不去最后发现是数据管线里有个shuffle没开或者某个字段的编码方式不一致。从零构建数据管线核心要解决三个问题读取效率、内存管理、数据增强。读取效率方面如果数据量不大比如几十GB以内直接全量加载到内存是最简单的方案。但如果数据量很大就需要实现流式读取。这里的关键是预取在当前batch训练的同时后台线程提前读取下一个batch的数据。PyTorch的DataLoader通过num_workers参数实现这一点自己实现的话可以用concurrent.futures或者简单的生产者-消费者队列。from queue import Queue from threading import Thread class PrefetchLoader: def __init__(self, data_iter, prefetch_size2): self.data_iter data_iter self.queue Queue(maxsizeprefetch_size) self.thread Thread(targetself._worker, daemonTrue) self.thread.start() def _worker(self): for batch in self.data_iter: self.queue.put(batch) self.queue.put(None) def __iter__(self): while True: batch self.queue.get() if batch is None: break yield batch3.2 内存管理的实战技巧数据管线的内存管理有几个实用技巧都是我在实际项目中踩坑总结出来的。第一个是内存映射。对于大规模数据集用numpy.memmap或者h5py把数据存在磁盘上按需读取可以大幅降低内存占用。代价是读取速度会慢一些但对于训练场景来说只要预取做得好这个代价是可以接受的。第二个是数据类型优化。默认的float64在很多场景下是不必要的换成float32甚至float16可以节省一半到四分之三的内存。图像数据用uint8存储训练时再归一化到浮点数也能省不少内存。第三个是及时释放。Python的垃圾回收机制在处理大数组时有时候不够及时手动调用del和gc.collect()在某些场景下是必要的。特别是在验证阶段验证集的数据用完就应该释放不要一直占着显存。3.3 数据增强的边界条件数据增强是提升模型泛化能力的有效手段但增强策略的设计需要非常小心。我见过一个案例在文本分类任务中有人用同义词替换做数据增强结果把“这个产品不好用”增强成了“这个产品好用”标签还是“负面”直接把模型带偏了。从零实现数据增强关键是要保证增强后的数据不改变语义标签。对于图像任务翻转、裁剪、颜色抖动通常是安全的对于文本任务回译、同义词替换需要谨慎验证对于表格数据加噪声的幅度需要根据特征的实际分布来确定。我的做法是每设计一种增强策略都先在小批量数据上人工检查增强结果确认标签一致性后再大规模应用。这个检查步骤花不了多少时间但能避免很多返工。4. 训练循环那些教程不会告诉你的细节4.1 学习率调度的实际影响学习率是训练过程中最重要的超参数没有之一。但很多教程只告诉你“用余弦退火”或者“用StepLR”却不解释为什么。我做过一组对比实验同样的模型和数据只改变学习率调度策略最终指标差异可以达到5%以上。余弦退火适合大多数场景因为它能在训练初期保持较大的学习率快速下降后期逐渐减小到接近零有利于收敛到更优的局部极小值。但如果你训练的总步数不确定比如早停策略余弦退火就不太合适因为它的周期是预设的。Warmup是另一个关键技巧。训练初期模型参数是随机初始化的梯度方向可能非常不稳定直接用大学习率容易导致训练发散。Warmup在前几百步用很小的学习率让模型先“站稳脚跟”然后再逐步增大到目标学习率。def get_lr(step, warmup_steps, total_steps, max_lr): if step warmup_steps: return max_lr * step / warmup_steps progress (step - warmup_steps) / (total_steps - warmup_steps) return max_lr * 0.5 * (1 math.cos(math.pi * progress))4.2 梯度裁剪的正确用法梯度裁剪是防止训练发散的重要手段但用错了反而会拖慢收敛。常见的错误是裁剪阈值设得太小导致大部分梯度都被裁剪模型实际上在以很小的步长更新。正确的做法是先监控梯度范数的分布了解正常情况下梯度范数在什么范围然后把裁剪阈值设在略高于这个范围的位置。比如如果95%的梯度范数都在1.0以下那裁剪阈值设在2.0到5.0之间比较合理。另一个细节是裁剪应该在所有参数梯度计算完成之后、优化器更新之前进行。如果在反向传播过程中逐层裁剪会破坏梯度的相对比例关系。4.3 混合精度训练的坑混合精度训练AMP能显著降低显存占用并加速训练但有几个坑需要注意。第一个是损失缩放。FP16的表示范围比FP32小很多小梯度在FP16下会下溢为零。解决方案是给损失乘以一个缩放因子反向传播后再除回来。PyTorch的GradScaler自动处理这个过程但你需要确保所有前向计算都在autocast上下文中。第二个是某些操作不适合FP16。比如Softmax、LayerNorm、损失函数等涉及归约的操作在FP16下容易出问题。autocast会自动把这些操作切换到FP32但如果你自己实现算子就需要手动处理。第三个是梯度累积与AMP的配合。如果你用梯度累积来模拟大batch需要确保缩放因子在累积过程中保持一致否则梯度会出错。5. 推理部署从实验室到生产环境的最后一公里5.1 模型导出与格式转换训练好的模型要部署到生产环境第一步是导出。PyTorch的torch.save保存的是Python pickle格式依赖模型类的定义不适合跨环境部署。正确的做法是导出为ONNX或者TorchScript。ONNX的优点是跨框架兼容性好TensorRT、OpenVINO等推理引擎都支持。但ONNX导出有几个常见问题动态维度需要显式指定某些自定义算子不支持控制流需要特殊处理。我的经验是导出后一定要用ONNX Runtime跑一遍验证对比PyTorch和ONNX的输出差异确保数值一致性。TorchScript的优点是保留在PyTorch生态内支持动态控制流。但TorchScript对Python语法的支持有限很多动态特性用不了。如果模型结构比较复杂TorchScript的导出可能会比较折腾。5.2 批处理与动态形状推理服务的吞吐量很大程度上取决于批处理策略。静态batch size最简单但灵活性差。动态batch size可以根据请求量自动调整但需要推理引擎支持动态形状。ONNX Runtime和TensorRT都支持动态形状但需要在导出时指定维度范围。比如batch_size可以设为[1, 32]表示支持1到32的任意batch size。实际运行时推理引擎会根据输入自动选择最优的kernel。这里有个经验动态形状虽然灵活但性能通常不如静态形状。如果请求量比较稳定建议用静态batch size配合请求队列等凑够一个batch再推理。如果请求量波动很大可以用几个不同batch size的模型实例根据负载动态切换。5.3 服务化与监控推理服务上线之后监控是必不可少的。需要关注的指标包括延迟P50、P95、P99、吞吐量、错误率、GPU利用率、显存占用。延迟方面P99延迟比平均延迟更重要因为它反映了用户体验的下限。如果P99延迟很高说明有少量请求处理特别慢可能是遇到了异常输入或者资源竞争。GPU利用率方面如果利用率长期低于50%说明推理服务不是瓶颈可以考虑合并多个模型到同一张卡上。如果利用率接近100%但吞吐量上不去可能是kernel效率问题可以考虑用TensorRT优化。错误率方面需要区分是输入错误比如格式不对还是系统错误比如显存溢出。输入错误应该返回明确的错误码系统错误需要触发告警并自动重启服务。6. 版本管理与实验追踪别让混乱毁掉你的项目6.1 模型版本管理的核心问题AI项目和传统软件项目最大的区别之一是代码版本和模型版本是分离的。同一份代码用不同的数据训练会得到不同的模型同一个模型用不同的推理配置会得到不同的结果。我见过太多团队在这上面翻车线上模型效果下降了想回滚到上一个版本结果发现不知道上一个版本对应的是哪份代码、哪份数据、哪份配置。最后只能凭记忆重新训练浪费大量时间。解决方案是建立模型注册表每次训练完成后把模型文件、训练配置、数据版本、代码commit hash、评估指标打包成一个版本存入注册表。推理服务加载模型时从注册表拉取指定版本而不是直接从文件系统读取。class ModelRegistry: def __init__(self, storage_path): self.storage_path storage_path def register(self, model, metadata): version self._next_version() path f{self.storage_path}/{version} os.makedirs(path, exist_okTrue) torch.save(model.state_dict(), f{path}/model.pt) with open(f{path}/metadata.json, w) as f: json.dump(metadata, f) return version def load(self, version): path f{self.storage_path}/{version} model self._build_model() model.load_state_dict(torch.load(f{path}/model.pt)) with open(f{path}/metadata.json) as f: metadata json.load(f) return model, metadata6.2 实验追踪的轻量方案实验追踪工具很多MLflow、Weights Biases、TensorBoard各有优劣。但如果你的团队规模不大或者不想引入外部依赖一个轻量级的方案是用SQLite加文件系统。核心思路是每次实验启动时生成一个唯一的实验ID把超参数、代码版本、开始时间写入数据库。训练过程中定期把指标写入数据库。训练结束后把模型文件和日志存入对应的目录。这个方案的好处是完全可控数据都在自己手里不依赖外部服务。坏处是需要自己实现可视化和查询功能。如果只是小团队内部使用花一两天时间搭一个简单的Web界面就够了。6.3 可复现性的关键要素可复现性是AI工程中最容易被忽视、但最重要的问题之一。一个实验如果无法复现那它的结论就没有任何价值。保证可复现性需要控制以下几个要素随机种子、数据顺序、硬件环境、软件版本。随机种子方面Python的random、NumPy的np.random、PyTorch的torch.manual_seed都需要设置。如果用了CUDA还需要设置torch.cuda.manual_seed_all。另外某些CUDA操作是非确定性的需要设置torch.backends.cudnn.deterministic True代价是性能会有所下降。数据顺序方面如果用了shuffle需要确保每次实验的shuffle种子一致。如果用了多进程数据加载还需要确保每个worker的种子不同但可复现。硬件环境方面不同型号的GPU即使算力相近数值精度也可能有细微差异。如果对可复现性要求极高需要在实验记录中注明GPU型号和驱动版本。7. 我踩过的那些坑与对应的解决方案7.1 显存泄漏的排查思路显存泄漏是AI工程中最常见也最难排查的问题之一。症状是训练过程中显存占用持续增长最终OOM。排查思路是先定位是哪个环节泄漏再定位是哪个变量泄漏。定位环节的方法是用torch.cuda.memory_summary()查看显存分配情况对比不同step之间的差异。如果显存增长主要发生在数据加载阶段那问题可能在数据管线如果发生在反向传播阶段那问题可能在计算图。定位变量的方法是用gc.get_objects()遍历所有Python对象找出占用显存最多的对象。或者用torch.cuda.memory_snapshot()查看显存块的分配栈。常见的泄漏原因包括在训练循环中累积了loss列表但没有释放、保留了不必要的中间变量引用、DataLoader的worker没有正确关闭、自定义算子中分配了显存但没有释放。7.2 多卡训练的通信瓶颈多卡训练能加速训练但通信开销可能抵消掉计算加速的收益。如果发现多卡训练的加速比远低于卡数那大概率是通信瓶颈。通信瓶颈的常见原因有两个梯度同步频率过高、通信量过大。梯度同步频率方面如果每个step都做AllReduce通信开销会很大。解决方案是用梯度累积累积几个step的梯度后再同步减少通信次数。通信量方面如果模型参数量很大梯度同步的数据量也会很大。解决方案是用梯度压缩只同步梯度中绝对值较大的部分或者用量化方法减少通信量。另一个容易被忽视的点是网络拓扑。如果多张卡之间的通信需要经过PCIe交换机或者网络延迟会远高于NVLink。这种情况下可以考虑用模型并行代替数据并行减少跨卡通信。7.3 推理服务的冷启动问题推理服务刚启动时前几个请求的延迟会明显高于后续请求。这是因为模型权重需要从内存加载到显存CUDA kernel需要编译和缓存。解决方案是预热服务启动后先用一些随机输入跑几轮推理把CUDA kernel和显存都预热好再开始接收真实请求。预热的数据可以用全零或者随机数不需要真实数据。预热的轮数取决于模型大小和硬件环境一般10到20轮就够了。如果模型特别大可以适当增加。预热完成后建议记录一下预热耗时作为服务启动时间的一部分。8. 从零构建之后我获得了什么走完这一整套从零构建的流程之后最大的收获不是“我会手写自动微分了”或者“我会搭推理服务了”而是对AI系统的每一个环节都有了清晰的边界感。以前用现成框架的时候遇到问题只能靠猜、靠试、靠搜索。现在遇到问题我能快速判断问题出在哪个环节应该用什么工具去排查大概需要多长时间能解决。这种判断力是调库调不出来的。另一个收获是对性能优化有了更实际的认知。以前觉得“用TensorRT就能加速”现在知道TensorRT的加速效果取决于模型结构、batch size、精度模式等多个因素不是万能的。以前觉得“多卡就能线性加速”现在知道通信开销、负载均衡、数据分发都会影响实际加速比。如果你也在考虑从零构建一套AI工程能力我的建议是不要追求大而全选一个你最熟悉的场景把整条链路走通。比如图像分类、文本分类、推荐排序选一个从数据加载到模型训练到推理部署全部自己实现一遍。走通一条链路之后再迁移到其他场景就会容易很多。最后分享一个小心得在整个构建过程中保持写文档的习惯。每解决一个问题就把问题现象、排查过程、解决方案记录下来。这些记录不仅以后自己用得上团队里其他人遇到类似问题时也能快速参考。我自己的文档库现在已经积累了几百条这样的记录价值远超任何一本教材。