1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
很多人第一次接触AI工程,脑子里想的都是“赶紧跑通一个模型”。装个环境,pip install几个库,拿现成的预训练权重推理一把,看到输出结果就觉得自己入门了。这种路径不能说错,但它有个致命问题:你只是在“用”AI,而不是在“做”AI工程。这两者之间的差距,大概相当于会开车和会造车——平时通勤当然没问题,可一旦路况变了、车坏了、要改装了,你就彻底抓瞎。
“ai-engineering-from-scratch”这个标题,核心讲的其实就是一件事:把AI工程当作一门需要从底层搭建的手艺来对待,而不是当作一堆API的拼装游戏。它适合谁?适合那些已经会调包、但总觉得心里没底的人;适合想搞清楚模型训练、推理、部署整条链路到底发生了什么的人;也适合团队里需要做技术选型、要评估方案可行性的工程师。你不需要是数学博士,但得愿意动手写代码、看日志、调参数。
我自己的经历很典型。早些年做推荐系统,模型都是现成的框架,调一调参数就上线。直到有一次线上推理延迟突然飙升,排查了两天才发现是特征预处理里一个归一化操作在特定数据分布下触发了大量内存拷贝。如果当时我对整条链路有从零搭建的认知,这个问题半小时就能定位。从那以后,我开始刻意用“从零”的视角去重构自己的知识体系,把每个环节都拆开看一遍。这篇文章就是把这些年踩过的坑、总结的方法,按一个可复现的路径整理出来。
2. 整体设计思路:先画数据流,再选工具链
2.1 为什么“从零”不等于“什么都自己写”
一听到“from scratch”,很多人第一反应是:难道要自己实现矩阵乘法、自己写反向传播?那也太原始了。这里需要澄清一个关键认知:从零搭建AI工程体系,指的是你清楚每一个环节的输入输出、依赖关系和失败模式,而不是拒绝使用成熟库。你完全可以用PyTorch做张量计算,用NumPy做数据处理,但你必须知道PyTorch在那个环节替你做了什么、代价是什么。
我通常把AI工程分成五层:数据层、特征层、模型层、服务层、监控层。从零搭建的意思是,这五层的边界你要自己划清楚,每层之间的契约你要自己定义。比如数据层输出什么格式、特征层如何保证训练和推理一致、模型层如何版本化管理、服务层如何做批处理与流式处理的切换、监控层采集哪些指标。这些决策,现成框架不会替你做完,也不应该替你做完。
2.2 方案选型的核心原则:可观测性优先于性能
在选工具链的时候,很多人容易陷入“哪个快用哪个”的陷阱。实测下来,对于从零搭建的项目,可观测性比绝对性能重要得多。一个推理速度快10%但日志稀烂的框架,和一个速度中等但每一步都能打点追踪的框架,我毫不犹豫选后者。因为从零搭建的过程中,你最大的成本是调试和排查,不是那点计算时间。
具体来说,我建议在早期阶段坚持三个原则。第一,数据流必须可序列化,每一步的中间结果都能落盘检查。第二,配置必须外置,所有超参数、路径、开关都放在配置文件里,不硬编码。第三,每个模块必须有独立的冒烟测试,不依赖上下游就能跑通。这三条看起来简单,但能帮你省下大量“到底哪一步错了”的时间。
2.3 一个典型的从零搭建路线图
假设你要做一个文本分类服务,从零搭建的路线大概是这样:先用脚本把原始数据清洗成统一格式的JSONL,每行包含文本和标签;然后写一个特征提取模块,把文本转成token id序列,同时保存词表;接着写训练循环,不用高级Trainer,自己写epoch、batch、梯度更新和验证;训练完成后,把模型权重和词表一起打包成版本化的产物;再写一个推理服务,加载产物,暴露HTTP接口;最后加监控,记录请求量、延迟分布和预测置信度分布。
这条路线里,每一步你都可以选择用现成库来加速,但你必须清楚这一步的输入是什么、输出是什么、失败会怎样。比如特征提取,你可以用HuggingFace的tokenizer,但你要知道它内部做了截断、填充、特殊符号添加,这些操作在推理时必须完全一致,否则训练推理不一致的坑就会找上你。
3. 核心细节解析:数据、特征、模型的三层契约
3.1 数据层:别急着清洗,先做数据审计
从零搭建最容易犯的错误,就是拿到数据直接开始清洗。我踩过的坑是:花了两天写清洗脚本,结果发现原始数据里有30%的样本标签是错的,清洗得再干净也没用。所以第一步应该是数据审计,而不是数据清洗。
数据审计要回答几个问题:样本总量多少、标签分布如何、有没有重复样本、文本长度分布怎样、有没有乱码或特殊字符。这些信息不需要复杂的工具,写个脚本统计一下就行。我通常会生成一个审计报告,包含直方图和随机抽样样本,人工看一遍。这一步花的时间,后面会加倍省回来。
审计完成后,才是清洗。清洗的原则是:只做可逆的操作,不可逆的操作必须记录。比如去除HTML标签是可逆的(你知道原始文本),但截断超长文本是不可逆的(你丢失了信息)。对于不可逆操作,一定要保存原始数据备份,并且记录截断规则。我习惯把清洗后的数据和清洗脚本一起版本化,确保任何时候都能复现。
3.2 特征层:训练推理一致性是生命线
特征层的核心挑战只有一个:训练时和推理时的特征处理必须完全一致。听起来像废话,但实际操作中,这是最容易出问题的地方。训练时你用pandas做归一化,推理时你用NumPy做,浮点精度差异就可能导致结果偏移。训练时你用了某个默认参数,推理时忘了传,特征分布就变了。
我的做法是:把特征处理逻辑封装成一个独立的类或函数,训练和推理都调用同一个入口。这个入口的输入是原始数据,输出是模型可用的张量。所有参数从配置文件读取,不依赖任何全局状态。然后写一个一致性测试:用同一批原始数据,分别走训练路径和推理路径,比较输出是否完全一致。这个测试要纳入CI,每次改动特征逻辑都跑一遍。
另外,特征层要保存元数据。比如词表、归一化参数、类别映射表,这些必须和模型权重一起保存。我见过太多人只保存了模型权重,部署时发现词表对不上,只能重新训练。元数据和模型权重应该是一个不可分割的产物,加载时一起加载,版本号一致。
3.3 模型层:训练循环要自己写一遍
现在高级框架很多,Trainer一行代码就能训练。但从零搭建的角度,我强烈建议你自己写一遍训练循环。不是为了炫技,而是为了理解每个环节。自己写一遍,你会被迫回答这些问题:损失函数怎么算、梯度怎么累积、学习率怎么调度、验证集怎么评估、早停怎么判断、检查点怎么保存。
写训练循环的时候,有几个细节值得注意。第一,随机种子要固定,包括Python、NumPy、框架本身的种子,否则结果不可复现。第二,验证集评估要独立于训练模式,记得切换eval模式并关闭梯度。第三,检查点保存要包含优化器状态,否则恢复训练时学习率调度会乱。第四,日志要记录每个epoch的训练损失、验证损失、学习率、耗时,最好用结构化日志,方便后续分析。
模型版本管理也是从零搭建必须考虑的问题。我的做法是:每次训练生成一个版本号,包含时间戳和配置哈希。模型权重、配置文件、词表、评估指标一起打包成一个目录,目录名就是版本号。推理服务加载时指定版本号,不加载最新,避免意外更新。这样回滚的时候,直接切版本号就行。
4. 实操过程:从空目录到可服务的最小闭环
4.1 环境准备与依赖锁定
从零搭建的第一步,是创建一个干净的环境。我习惯用conda创建独立环境,然后手动安装依赖,而不是一次性装一堆。每装一个包,记录版本号。最终生成一个requirements.txt,所有版本号都锁定。这一步的目的是保证可复现,今天能跑通的代码,三个月后换台机器也能跑通。
依赖锁定有个技巧:不要只锁定直接依赖,间接依赖也要锁定。用pip freeze生成完整列表,然后手动整理,去掉不必要的包。我通常会保留一个最小依赖集,加上一个完整依赖集。最小依赖集用于生产部署,完整依赖集用于开发调试。这样部署时镜像小,启动快。
环境准备好后,先写一个hello world脚本,验证框架能正常导入、GPU能正常识别。不要小看这一步,我遇到过CUDA版本和框架版本不匹配,折腾半天才发现是环境问题。提前验证,后面省心。
4.2 数据管道搭建:从原始文件到可迭代数据集
数据管道的目标是:给定一个原始数据目录,能自动生成训练、验证、测试三个数据集,并且支持随机打乱、批处理、预取。我通常写一个Dataset类,实现__len__和__getitem__,然后用DataLoader包装。关键点是:__getitem__里只做轻量操作,重操作放在预处理阶段完成。
预处理阶段,我会把原始数据转成统一的JSONL格式,每行一个样本,包含输入和标签。然后写一个统计脚本,计算样本数、类别分布、长度分布。这些统计信息保存下来,后面分析模型表现时有用。预处理脚本要幂等,重复运行结果一致,方便调试。
数据加载的时候,要注意内存管理。如果数据量大,不要一次性加载到内存,用流式读取。DataLoader的num_workers设置也有讲究,不是越大越好。我一般从2开始试,观察CPU和IO负载,找到瓶颈再调整。预取因子prefetch_factor也要配合调整,太小会饿着GPU,太大会占内存。
4.3 训练循环实现:手写一个可复现的训练器
训练循环的骨架大概是这样:外层循环epoch,内层循环batch。每个batch做五件事:数据搬到设备、前向传播、计算损失、反向传播、更新参数。每个epoch结束做验证,记录指标,保存检查点。听起来简单,但每个环节都有坑。
数据搬设备的时候,要用non_blocking=True配合pin_memory,能提升一点速度。前向传播要注意模型模式,训练时model.train(),验证时model.eval()。损失函数要根据任务选,分类用交叉熵,回归用MSE,但要注意标签格式。反向传播前记得optimizer.zero_grad(),否则梯度会累积。更新参数后可以加梯度裁剪,防止梯度爆炸。
验证阶段,除了算损失,还要算任务指标。分类任务算准确率、F1,回归任务算MAE、RMSE。这些指标用sklearn或者自己写都行,关键是和训练指标区分开。我习惯用字典保存所有指标,每个epoch追加,最后画曲线。早停策略也要实现,验证损失连续N个epoch不下降就停,N一般取3到5。
检查点保存要包含:模型状态字典、优化器状态字典、当前epoch、当前最佳指标、随机种子状态。这样恢复训练时,能从完全相同的状态继续。我还会保存一个配置文件副本,确保检查点和配置对应。
4.4 推理服务封装:从模型文件到HTTP接口
训练完成后,模型文件不能直接对外服务,需要封装成推理服务。我通常用FastAPI写一个简单的HTTP服务,加载模型和元数据,暴露一个predict接口。接口接收原始输入,内部走和训练一致的特征处理,然后模型推理,返回结果。
服务封装有几个关键点。第一,模型加载只做一次,放在服务启动时,不要每次请求都加载。第二,批处理要支持,但不要过度设计,先支持单条推理,有性能需求再加批处理。第三,超时和错误处理要做好,模型推理可能失败,要返回明确的错误码。第四,日志要记录请求ID、输入摘要、输出摘要、耗时,方便追踪。
我还会加一个健康检查接口,返回服务状态和模型版本。这样负载均衡和监控系统能感知服务是否正常。服务启动后,用curl或者Python requests做冒烟测试,确保接口能通。然后压测一下,看看QPS和延迟,心里有个底。
4.5 监控与日志:让服务会说话
服务上线后,没有监控就是裸奔。从零搭建的监控不需要多复杂,但几个核心指标必须有:请求量、延迟分布、错误率、预测置信度分布。请求量和错误率用计数器,延迟用直方图,置信度分布用摘要。这些指标可以用Prometheus客户端库暴露,然后Grafana展示。
日志方面,我习惯用结构化日志,每条日志是一个JSON,包含时间戳、级别、请求ID、消息、额外字段。这样方便用ELK或者Loki查询。关键日志点包括:请求进入、特征处理完成、模型推理完成、响应返回、异常抛出。每个点记录耗时,这样能快速定位瓶颈。
还有一个容易被忽略的监控点:输入数据分布。线上请求的输入分布可能和训练数据不同,导致模型表现下降。我通常会定期采样线上输入,计算一些统计量,和训练数据对比。如果发现偏移,就要考虑重新训练或者加规则过滤。
5. 常见问题与排查技巧实录
5.1 训练损失不下降,我该从哪查起
训练损失不下降是最常见的问题,排查要按顺序来。第一步,检查数据标签对不对,随机抽样看一批样本,人工判断标签是否合理。第二步,检查特征处理有没有问题,把特征可视化或者打印统计量,看是否在合理范围。第三步,检查模型结构,输出层维度是否匹配类别数,激活函数是否合适。第四步,检查学习率,太大导致震荡,太小导致不收敛,可以试几个数量级。第五步,检查损失函数,分类任务用交叉熵时,标签要是整数而不是one-hot。
我遇到过一次,损失一直不降,最后发现是数据加载时shuffle没开,每个batch都是同一类样本,模型学不到东西。还有一次是学习率设成了0.1,太大,损失直接飞了。这些坑踩过一遍,后面就有直觉了。
5.2 推理结果和训练结果不一致,怎么排查
这个问题几乎每个从零搭建的人都会遇到。排查思路是:固定一条训练数据,分别走训练路径和推理路径,比较每一步的中间输出。通常问题出在三个地方:特征处理不一致、模型模式不一致、数值精度不一致。
特征处理不一致最常见,比如训练时用了padding,推理时忘了;或者训练时用了某个默认参数,推理时没传。模型模式不一致是指推理时忘了调eval(),dropout和batchnorm还在训练模式。数值精度不一致是指训练用float32,推理用float16,导致微小差异。排查的时候,把中间结果打印出来,逐层对比,很快就能定位。
5.3 服务延迟高,有哪些优化方向
服务延迟高,先定位瓶颈。用日志记录每个阶段的耗时:网络接收、特征处理、模型推理、结果返回。如果特征处理慢,考虑预计算或者缓存。如果模型推理慢,考虑量化、剪枝、或者换更小的模型。如果网络慢,考虑压缩响应或者加CDN。
我通常先做 profiling,用cProfile或者py-spy看热点函数。然后针对性优化。一个常见问题是Python GIL导致多线程推理效率低,这时候可以考虑多进程或者用异步IO。另一个常见问题是批处理大小不合适,太小浪费计算资源,太大增加延迟,需要压测找平衡点。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 训练损失不下降 | 标签错误、学习率不当、特征问题 | 抽样检查标签、试不同学习率、打印特征统计 | 修正标签、调整学习率、修复特征 |
| 推理结果不一致 | 特征处理不一致、模型模式错误 | 固定样本对比中间输出 | 统一特征入口、调eval模式 |
| 服务延迟高 | 特征处理慢、模型大、批处理不当 | 分阶段计时、profiling | 缓存特征、量化模型、调批大小 |
| 显存溢出 | 批太大、模型太大、梯度累积 | 减小批大小、检查模型参数量 | 梯度累积、混合精度、模型并行 |
| 指标波动大 | 数据分布变化、随机种子未固定 | 固定种子、监控输入分布 | 重新训练、加数据过滤 |
5.5 几个我踩过的坑和对应技巧
第一个坑:配置文件用了相对路径,换目录运行就找不到文件。后来改成基于项目根目录的绝对路径,或者用环境变量指定配置目录。第二个坑:日志里打印了完整输入数据,导致日志文件暴涨。后来改成只打印摘要和哈希。第三个坑:模型保存时只保存了权重,忘了保存优化器状态,恢复训练时学习率调度乱了。后来养成习惯,检查点保存完整状态。
还有一个技巧:在训练脚本里加一个“干跑”模式,用少量数据跑一遍完整流程,验证代码没有语法错误和逻辑错误。这个模式在CI里跑,每次提交都执行,能提前发现很多问题。干跑模式的数据量设为正常量的1%,耗时短,但覆盖了所有代码路径。
6. 从零搭建的扩展方向与个人体会
6.1 后续可以怎么扩展
最小闭环跑通后,可以从几个方向扩展。第一,加实验管理,用MLflow或者Weights & Biases记录每次训练的参数和指标,方便对比。第二,加自动化调参,用Optuna或者Ray Tune搜索超参数。第三,加模型解释,用SHAP或者LIME分析特征重要性。第四,加A/B测试,线上同时跑两个模型,比较业务指标。第五,加数据版本管理,用DVC或者LakeFS管理数据变更。
这些扩展不需要一次性全上,按需选择。我的建议是先把监控和实验管理做好,这两个对后续迭代帮助最大。调参和解释可以等模型稳定后再加。A/B测试需要业务配合,可以最后考虑。
6.2 我个人在实际操作中的体会
从零搭建AI工程体系,最大的收获不是某个具体技术,而是一种掌控感。你知道每个环节在做什么,出了问题知道去哪查,要改东西知道改哪里。这种掌控感,是调包调不出来的。
另一个体会是:文档和测试比代码重要。从零搭建的项目,代码可能就几千行,但配置、文档、测试加起来可能上万行。这些“非代码”部分,决定了项目能不能持续维护。我现在的习惯是,每写一个模块,先写接口文档和冒烟测试,再写实现。这样思路清晰,后面改起来也放心。
最后分享一个小技巧:定期做“灾难恢复演练”。把模型文件删掉,看能不能从原始数据和配置重新训练出来。把服务停掉,看能不能按文档重新部署。这个演练能暴露很多隐藏的依赖和缺失的文档。我每季度做一次,每次都能发现几个问题,修完之后系统就更健壮了。