
1. 从零手搓AI工程为什么我不建议你直接调包很多人一听到“AI工程”这四个字第一反应就是打开某个云平台拖几个组件调几个API然后跑通一个Demo就觉得自己已经入门了。我刚开始也是这么想的直到有一次线上推理服务在高峰期直接雪崩日志里全是显存溢出和请求超时我才意识到那些被封装得严严实实的接口一旦出了问题我连从哪里下手排查都不知道。这就是我决定从零开始搭建一套AI工程体系的直接原因也是“ai-engineering-from-scratch”这个项目最核心的出发点。所谓从零手搓并不是说要你去造GPU或者重写CUDA内核而是指你要亲手把数据加载、模型训练、推理服务、监控告警这条链路完整地走一遍每一个环节都自己写代码、自己调参数、自己处理异常。这样做的好处非常明显当推理延迟突然飙升时你能立刻判断是数据预处理阻塞了主线程还是模型本身的计算图没有优化好而不是对着一个黑盒干瞪眼。这个项目适合那些已经会用PyTorch或TensorFlow跑通Demo但一上生产环境就心里发虚的开发者也适合想系统理解AI系统全貌的运维和后端同学。我见过太多团队模型指标刷得很漂亮但一上线就各种问题。根本原因在于训练和推理是两个完全不同的工程场景。训练可以容忍慢可以容忍偶尔的失败重试但推理服务对延迟、吞吐、内存占用极其敏感。从零搭建的过程就是强迫你去面对这些差异去理解每一个设计决策背后的权衡。比如为什么批处理大小不能随便设为什么模型量化有时候反而会变慢这些问题只有你自己亲手实现一遍才能真正搞明白。接下来的内容我会按照我实际搭建的顺序把数据管道、模型训练、推理服务、性能调优这几个核心模块拆开来讲。每个部分我都会说明为什么这么设计以及我在实操中踩过哪些坑。你可以把这篇文章当成一个施工手册也可以当成一份避坑指南根据自己的基础挑着看。2. 数据管道别让IO成为你的第一个瓶颈2.1 为什么我把数据加载单独抽成一个服务刚开始的时候我图省事直接在训练脚本里用DataLoader加载本地图片。小数据集上跑得挺欢但数据量一上来问题就暴露了GPU利用率忽高忽低有时候直接掉到10%以下。用nvidia-smi一看GPU在等数据。这就是典型的IO瓶颈。很多人第一反应是加num_workers但盲目增加进程数会导致内存爆炸而且如果磁盘IO本身就到了上限加再多进程也没用。我的做法是把数据加载单独抽成一个轻量级服务训练脚本通过本地网络请求去拉数据。这样做的好处有三个第一数据预处理可以独立扩展不会拖累训练主进程第二可以方便地做数据缓存和预取第三训练和推理可以共用同一套数据接口保证线上线下一致性。具体实现上我用FastAPI起了一个服务内部维护一个内存缓存池把常用的数据块缓存在内存里减少磁盘随机读。这里有个细节需要注意缓存池的大小不能拍脑袋定。我的经验是先估算单个样本预处理后的内存占用然后根据可用内存的70%来反推缓存条目数。比如一个样本预处理后是2MB机器有32GB内存那缓存池最多放10000条左右留出余量给模型和其他进程。超过这个数系统就开始频繁换页性能反而下降。2.2 预处理流水线的并行化陷阱数据预处理里最耗时的通常是图像解码和增强。我一开始用Python的多进程池来做结果发现进程间通信的开销比计算本身还大。后来改成用共享内存加向量化操作性能提升了将近三倍。具体来说就是把一批图像先读成二进制块放到共享内存里然后每个工作进程直接从共享内存读取并解码避免了大数组在进程间来回拷贝。另一个坑是数据增强的顺序。很多人习惯先做随机裁剪再做归一化这本身没问题但如果你用的是GPU做增强就要注意显存碎片的问题。我实测下来把增强操作尽量放在CPU端用NumPy或OpenCV做然后只把最终张量传到GPU整体吞吐更稳定。GPU适合做大规模矩阵运算不适合做大量小规模的条件分支和随机操作。还有一个容易被忽略的点是数据格式。我强烈建议在预处理阶段就把数据转成适合模型输入的格式比如把图片统一转成RGB、统一尺寸、统一数据类型。不要指望模型内部去处理各种奇怪的输入格式那会让你的推理服务变得极其脆弱。我见过一个线上事故就是因为用户上传了一张CMYK模式的图片导致整个批次的推理全部失败。如果预处理阶段做了强制转换这种问题根本不会发生。2.3 数据版本管理与回滚策略数据管道还有一个容易被忽视的工程问题版本管理。模型迭代的时候数据往往也在变。如果没有版本记录你根本不知道线上模型是用哪份数据训练的出了问题也无法复现。我的做法是给每次数据预处理的结果打上哈希标签训练脚本必须指定数据版本。这样即使数据更新了旧模型依然可以加载旧版本的数据做验证。回滚策略也很重要。新数据上线后如果发现模型指标下降要能快速切回旧数据。我通常会在数据服务里保留最近三个版本的数据用不同的路由区分。训练任务通过环境变量指定数据版本切换只需要改一个配置不需要重新部署服务。这个设计在紧急情况下能省下大量时间。3. 模型训练从能跑到跑得好之间的鸿沟3.1 混合精度训练不是万能药混合精度训练现在几乎是标配了用好了能省显存、提速但用不好就是灾难。我刚开始用的时候直接开了amp结果损失函数里出现了NaN。排查了很久才发现是某些层的梯度在FP16下溢出了。解决办法是给这些层加梯度缩放或者干脆把这些层保持在FP32。PyTorch的amp模块提供了自动缩放但你需要监控缩放因子的变化如果它一直往下掉说明模型里有数值不稳定的地方。另一个经验是混合精度对batch size很敏感。batch size太小的时候梯度噪声大FP16的精度损失会被放大。我一般建议batch size至少到32以上再考虑开混合精度否则老老实实用FP32。还有混合精度对某些优化器也不友好比如Adam的一些变体在FP16下更新量会被截断。如果你发现开了混合精度后收敛变慢先检查优化器的状态更新是不是在FP32下进行的。3.2 检查点保存的时机与策略训练过程中保存检查点看起来很简单但里面有不少门道。我一开始是每个epoch存一次结果磁盘很快就被撑爆了。后来改成按验证指标保存只保留最好的三个检查点。但这里有个问题如果验证集本身有波动可能会错过真正的最优点。我的做法是同时保存“最佳指标”和“最新”两个检查点最佳指标用于最终部署最新检查点用于恢复训练。保存检查点的时候一定要把优化器的状态也存下来。很多人只存模型参数结果恢复训练后优化器状态重置导致训练曲线出现明显抖动。另外如果用了学习率调度器调度器的状态也要存。我一般会把所有需要恢复的状态打包成一个字典用torch.save保存。加载的时候用map_location指定设备避免因为设备不一致导致加载失败。还有一个细节是检查点的写入方式。直接覆盖旧文件有风险如果写入过程中程序崩溃检查点就损坏了。我习惯先写到一个临时文件写完后再原子性地重命名。这样即使写入失败旧检查点依然完好。这个习惯在长时间训练中能救命。3.3 分布式训练的数据分片与梯度同步当单卡放不下模型或者训练太慢时就需要上分布式。我一开始用DataParallel结果发现主卡显存占用远高于其他卡而且速度提升有限。后来换成DistributedDataParallel情况好了很多。DDP的核心是每个进程独立加载数据然后通过all-reduce同步梯度。这里的关键是数据分片要均匀否则快的进程要等慢的进程整体效率被拖累。数据分片我一般用DistributedSampler它会自动把数据按进程数切分。但要注意如果数据集大小不能被进程数整除最后一个batch可能会多出一些样本导致不同进程的batch size不一致。解决办法是设置drop_lastTrue或者手动补齐。梯度同步方面DDP默认是每个batch同步一次如果通信开销大可以考虑梯度累积每N个batch同步一次。但梯度累积会改变有效的batch size学习率也要相应调整。还有一个坑是随机种子。分布式训练中每个进程的随机种子必须不同否则数据增强会完全一样相当于变相减小了batch size。我通常用base_seed rank来设置每个进程的种子保证数据增强的多样性。4. 推理服务把模型变成产品的最后一公里4.1 模型序列化与加载的性能考量训练完的模型要部署第一步就是序列化。PyTorch提供了torch.save和torch.jit.trace两种方式。torch.save保存的是Python对象加载时需要完整的类定义部署环境稍微有点不一致就可能加载失败。torch.jit.trace会把模型转成TorchScript脱离Python依赖加载更快但要求模型的计算图是静态的。我一般优先用TorchScript如果模型里有动态控制流再退回torch.save。加载性能方面模型文件越大加载越慢。如果服务需要频繁重启加载时间会直接影响可用性。我的做法是在服务启动时就把模型加载到内存并预热几次推理让CUDA内核完成编译和缓存。预热很重要第一次推理往往比后续慢好几倍如果不预热第一个请求的延迟会非常高。预热的数据可以用随机张量也可以用真实数据的采样后者效果更好。还有一个细节是模型文件的存储位置。放在网络存储上加载会慢很多我一般会把模型文件复制到本地SSD再加载。如果模型特别大可以考虑分片加载但实现复杂度较高一般场景下没必要。4.2 批处理与动态批处理的实现逻辑推理服务的吞吐和延迟是一对矛盾。批处理能提高吞吐但会增加延迟因为要等齐一个批次。静态批处理实现简单但灵活性差。动态批处理根据请求到达情况动态组批能在延迟和吞吐之间取得更好的平衡。我实现过一个简单的动态批处理维护一个请求队列当队列长度达到阈值或者等待时间超过上限时就触发一次推理。动态批处理的关键参数是最大批大小和最大等待时间。最大批大小受显存限制需要根据模型大小和输入尺寸来估算。最大等待时间决定了最坏情况下的延迟。我的经验是如果服务对延迟敏感最大等待时间设小一点比如10毫秒如果追求吞吐可以设到50毫秒甚至更高。这两个参数需要根据实际流量压测来确定没有万能值。还有一个容易忽略的点是批内样本的尺寸对齐。如果输入尺寸不一致组批时需要padding到最大尺寸这会浪费计算资源。我一般会在预处理阶段就把输入统一到固定尺寸避免动态padding。如果业务上确实需要不同尺寸可以考虑按尺寸分桶同桶内组批。4.3 健康检查与优雅退出推理服务上线后健康检查是必不可少的。我见过很多服务只检查进程是否存活不检查模型是否可用。结果模型加载失败进程还在但所有请求都返回错误。我的做法是健康检查接口里实际跑一次轻量推理比如用一个固定的小张量过一遍模型确认输出正常。这样能及时发现模型损坏或显存不足的问题。优雅退出也很重要。服务收到终止信号时不能直接杀进程要先把队列里的请求处理完再释放资源。我一般会设置一个超时时间比如30秒超时后强制退出。退出前要把模型从显存中释放避免影响其他进程。如果用了GPU还要注意CUDA上下文的清理否则可能出现显存泄漏。还有一个实践是给服务加一个就绪探针在模型加载完成之前就绪探针返回失败这样负载均衡不会把流量导进来。等模型加载完、预热完成后就绪探针再返回成功。这个机制在滚动更新时特别有用能保证新实例完全准备好之后才接收流量。5. 性能调优那些文档里不会写的参数5.1 显存占用的构成与优化手段显存占用是推理服务最核心的资源约束。很多人只知道模型参数占显存其实还有几大块模型参数、中间激活值、CUDA上下文、显存碎片。模型参数是固定的中间激活值跟batch size和输入尺寸相关CUDA上下文一般几百MB显存碎片则跟分配策略有关。我一般用torch.cuda.memory_summary()来查看详细占用定位大头在哪里。优化显存的手段有几个第一用更小的数据类型比如FP16或INT8但要注意精度损失第二减少中间激活值的保存比如用梯度检查点但推理时一般不需要第三及时释放不再使用的张量Python的垃圾回收有时候不及时可以手动调用torch.cuda.empty_cache()但频繁调用会拖慢速度第四设置PYTORCH_CUDA_ALLOC_CONF环境变量来调整分配策略比如用expandable_segments来减少碎片。我实测下来最有效的还是降低batch size和用更小的数据类型。但batch size太小会影响吞吐所以要在延迟和吞吐之间找平衡点。我一般会做一个压测画出不同batch size下的延迟和吞吐曲线选一个拐点作为默认值。5.2 推理延迟的拆解与定位推理延迟可以拆成几个部分网络传输、预处理、模型计算、后处理。定位延迟瓶颈的第一步是打点计时。我在每个阶段前后都加了时间戳记录耗时。如果预处理占了大部分时间就要优化数据加载和增强如果模型计算是大头就要考虑模型压缩或换更快的硬件。网络传输延迟容易被忽略。如果服务部署在远程请求和响应的传输时间可能比计算还长。解决办法是把服务部署在离用户近的地方或者用gRPC代替HTTP来减少协议开销。我实测过gRPC的延迟比HTTP低不少尤其是在小请求场景下。模型计算延迟的优化空间很大。除了换硬件还可以用TensorRT或ONNX Runtime来加速。TensorRT对NVIDIA GPU的优化非常到位能自动做层融合、精度校准和内核选择。但TensorRT的转换过程比较繁琐对模型结构有要求。ONNX Runtime则更通用支持多种硬件后端。我一般先用ONNX Runtime跑一遍如果还不够快再上TensorRT。5.3 监控指标的选择与告警阈值监控是生产环境的眼睛。我关注的指标分几类资源类GPU利用率、显存占用、CPU使用率、内存占用、性能类QPS、P50/P95/P99延迟、错误率、业务类请求量、成功率、特定业务指标。资源类指标能帮你发现瓶颈性能类指标能反映用户体验业务类指标能评估服务健康度。告警阈值不能拍脑袋定。我的做法是先跑一周收集基线数据然后根据均值和标准差来设阈值。比如P99延迟的均值是100毫秒标准差是20毫秒那告警阈值可以设在160毫秒均值加三个标准差。这样既能及时发现异常又不会因为正常波动频繁告警。还有一个经验是告警要分级。轻微异常发通知严重异常直接打电话。我见过太多团队把所有告警都设成电话结果大家很快就麻木了真正严重的问题反而被忽略。分级告警能保证重要问题得到及时处理。6. 我在从零搭建过程中踩过的几个典型坑6.1 环境依赖的版本地狱从零搭建最头疼的就是环境依赖。PyTorch、CUDA、cuDNN、Python、各种第三方库版本之间互相制约。我一开始用最新版结果发现某个库不兼容又降级降级后又发现另一个库不兼容。折腾了两天才跑通。后来我学乖了用Docker把环境固化下来每个组件都锁定版本再也没出现过“在我机器上能跑”的问题。Dockerfile里我一般会指定基础镜像的精确版本比如pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime而不是用latest。Python依赖用requirements.txt锁定每个包都写死版本号。构建镜像的时候用--no-cache确保每次都是干净构建。镜像构建好后推到私有仓库部署时直接拉取保证线上线下环境一致。还有一个细节是CUDA版本和驱动版本的兼容性。CUDA版本不能高于驱动支持的版本否则会报错。我一般会在文档里记录驱动版本和CUDA版本的对应关系换机器的时候先检查驱动版本。6.2 日志与追踪的缺失导致排查困难刚开始的时候我没怎么在意日志觉得能跑就行。结果线上出问题日志里只有一行“推理失败”什么上下文都没有。后来我加了详细的日志包括请求ID、输入尺寸、模型版本、各阶段耗时、错误堆栈。这样出问题的时候能快速定位是哪个请求、哪个阶段出的错。追踪也很重要。我用OpenTelemetry给每个请求打上trace记录从接收到响应的完整链路。这样不仅能看单次请求的耗时分布还能分析不同服务之间的调用关系。如果某个下游服务变慢能很快发现。日志的级别也要合理设置。DEBUG级别日志太多会影响性能ERROR级别太少会漏掉重要信息。我一般用INFO级别记录关键事件用WARNING记录异常但可恢复的情况用ERROR记录需要人工介入的问题。日志要结构化用JSON格式方便后续检索和分析。6.3 压测数据与真实分布的偏差压测是上线前的必要环节但压测数据的选择很关键。我一开始用随机生成的张量做压测结果线上表现和压测差很多。原因是真实数据的分布和随机数据完全不同某些输入会导致模型走不同的分支耗时差异很大。后来我改用真实数据的采样做压测结果就准确多了。压测的流量模式也要模拟真实情况。真实流量往往有波峰波谷如果只按恒定QPS压测无法发现突发流量下的问题。我一般会设计几种流量模式恒定流量、阶梯上升、突发尖峰。每种模式都跑一遍观察服务的表现。还有一个坑是压测时的资源竞争。如果压测客户端和服务部署在同一台机器上客户端的资源消耗会影响服务性能。我一般会把压测客户端单独部署或者用多台机器分布式压测确保压测结果反映的是服务本身的性能。7. 从零搭建之后我对AI工程的理解变化自己亲手搭过一遍之后再看那些封装好的平台和框架感觉完全不一样了。以前觉得某个参数无关紧要现在知道它背后可能影响显存分配或者计算图优化。以前遇到问题只会重启现在能根据日志和监控快速定位。这种从底层到上层的理解是看多少文档都换不来的。还有一个变化是对“简单”的重新认识。以前觉得代码越少越好现在觉得代码少不代表简单可能只是把复杂度藏起来了。从零搭建的代码量肯定比调包多但每一行都是可控的出了问题知道去哪里找。这种可控性在生产环境中极其重要。如果你也想从零搭建一套AI工程体系我的建议是从小处着手先跑通一个最小闭环再逐步加功能。不要一上来就追求大而全那样很容易半途而废。每加一个模块都要想清楚它解决了什么问题引入了什么新的复杂度。这样一步步走下来你对整个系统的理解会非常扎实。最后分享一个我常用的调试技巧当推理结果不符合预期时先别急着改模型用相同的输入分别在训练模式和推理模式下跑一遍对比中间层的输出。很多时候问题出在预处理或者后处理而不是模型本身。这个技巧帮我省下了大量排查时间。