"ai-engineering-from-scratch" 这个标题,翻译过来就是从零开始搞AI工程化。说实话,我在这个领域摸爬滚打了几年,见过太多做算法的同学和研究员的项目,模型在笔记本上跑得活蹦乱跳,一上生产就浑身不自在。这个现象背后缺的不是模型知识,而是工程化的那一整套东西。这篇文章我想拆开讲讲,一个人或者一个小团队,在没有专人做MLOps的情况下,怎么用手边最基础的工具,把AI项目从"能跑通"整治成"能长期稳定跑",整个过程不依赖那些重型平台,纯手工搭建,顺便把每一个环节的选择理由也讲清楚。
先说清楚一个边界,我讲的"AI工程化",重点在数据、实验、部署、监控这条链路,不涉及从头训练大模型、不涉及复杂的分布式训练调度。你可能是做NLP的、做CV的、做推荐的,或者搞了个回归模型预测销量,这套方法论都一样适用。核心诉求就两个:第一,别人或者两个月后的自己,能轻松复现当前这个模型是怎么来的;第二,模型离开Jupyter Notebook之后,能稳定对外提供服务,就算出问题也能快速定位。
我梳理了一下,一套可以长期运转的AI项目,最少需要走通六个环节,下面按顺序拆开讲。
1. 价值的起点:不是算法,是问题定义和拆分
很多从零开始的AI项目,头一个月就翻车的核心原因,不是技术不够,是问题没有想清楚。我见过有人花了三周时间给模型调参,最后发现业务方需要的只是一个简单的规则脚本,准确率反而更高。所以从零开始做AI工程,第一步不是装PyTorch,而是把问题用一句话写下来,然后不断追问它。
1.1 用一句话定义模型存在的理由
这句话的模板可以是:"在【什么场景】下,输入【什么数据】,模型需要输出【什么结果】,用【什么指标】衡量好坏,当前【没有模型/旧的方案】的基线是多少。"
举个例子。你要做一个客服工单自动分类系统,这句话就是:"在工单系统里,输入工单标题和描述文本,模型输出对应的类别标签,用宏平均F1衡量,当前人工规则的准确率是70%。"写清楚之后你才知道,后续做的每一件事,清洗数据、选模型、定阈值、设计接口,全都是在为这句话服务。
这个环节还要逼自己做一个动作:拆分模型边界。用户上传的工单截图里可能同时有文字和图片,你是不是真的要做一个多模态模型?多数场景不需要。文字走文本模型,图片走OCR工具,然后把结果拼接起来,工程上更稳。AI工程的一个核心思维是"能不交给模型的就不交给模型",能用规则、函数、数据库查询解决的,绝不塞进神经网络。
1.2 建立第一个朴素的baseline
在写任何深度学习代码之前,先逼自己搭一条最笨的链路。文本分类就用TF-IDF加上逻辑回归,CV任务就用一个已经训练好的分类器直接跑,推荐系统就先用热门榜或规则召回。这条baseline的价值有三层。
第一,它会给你一个真实的性能下限,后面所有花哨模型都必须超过这个下限,否则就是白忙。第二,它会暴露数据管道里的脏东西,比如字段缺失、标签错乱、重复样本,这些问题在复杂模型里会被隐藏很久,但简单模型会立刻表现出异常。第三,它直接形成一个可运行的最小系统,前端、接口、监控全都可以先接上,这就是迭代的雏形。
这一步跑通之后,你对整个项目的复杂度会有非常真实的感知:数据量大概多大、清洗逻辑有多脏、预测延迟需求是多少。后边的所有工程决策,都基于这些感知来定,而不是拍脑袋。
2. 环境与依赖:从"在我机器上能跑"到"在哪都能跑"
AI项目一个臭名昭著的问题就是环境问题。换了机器,依赖冲突,驱动版本不对,模型推理结果直接变掉。谁都不想花两天时间复现别人代码最后死于CUDA版本对不上。所以从零开始,环境的工程化就要定好规矩。
2.1 把Python环境当成"一次性用品"
我强烈建议用虚拟环境,conda或者venv都行,但关键规矩是:创建环境便宜、销毁环境不可惜,环境里的一切配置都必须由代码或文件声明,而不是靠人记着在环境里敲过什么命令。换句话说,你的环境必须"可重建"。
实操上,每个项目在根目录放一份environment.yml(conda)或pyproject.toml(poetry/uv),所有依赖都钉在文件里。包管理工具有很强的版本锁定能力,它生成的lock文件里记录着每一个依赖包的精确版本。这么做之后,换机器、加新人、上线到服务器,都是同一个命令恢复环境,不再有"你先跑一下pip install xxx试试"这种低效对话。
说一下我在Python版本和依赖管理上踩过的坑:如果把numpy这种底层库的版本随便放了,过了几个月它升级之后,老代码可能出现诡异的运行结果变化,但又不报错——这是最阴的。所以lock文件不只是给你看的,是给未来的机器和环境看的,必须提交进Git仓库。
2.2 GPU环境的检查脚本要固定下来
如果你的项目涉及深度学习训练,GPU是不可少的。我试过在三台不同配置的机器上复现同一个训练脚本,会话记录里显示NVIDIA驱动都是"最新版",但实际型号差距很大。torch在A100上默认用某种算子实现,到消费级显卡上又会回退到另一种实现,速度天差地别。
所以我在每个AI项目的根目录放一个env_check.py,脚本会输出:Python版本、CUDA版本、GPU型号和显存、PyTorch/CUDA是否匹配、关键算子能不能正常执行。每次换机器跑项目,先跑这个脚本再动手,三分钟把环境问题扼杀在源头。
这个脚本还有一个隐藏的岗位价值:让实习生或者新同事收敛那个"在我电脑上能跑啊"的bug黑洞。环境信息一目了然之后,问题都变成可复现、可对照的了。
2.3 统一入口:一个Makefile解决所有操作
一个AI项目可能会出现这些频繁操作:创建环境、跑数据清洗、启动训练、跑评估、启动API服务。依赖人脑去记这些命令,一定会漏或者记混。我的习惯是项目顶部放一个Makefile,把高频操作全部暴露成短命令。
setup: pip install -e . data: python -m src.data.prepare train: python -m src.train --config configs/exp1.yaml evaluate: python -m src.evaluate --config configs/exp1.yaml serve: uvicorn src.api:app --host 0.0.0.0 --port 8000命令变短了,靠记忆的东西变少了,项目入口统一了。这也算工程化一个很朴素的体现:人对项目的理解成本越低,出错的概率就越小。
3. 数据管道:比模型更值得花时间的部分
一个残酷的事实是,模型选型造成的性能差距往往在几个点以内,但数据质量造成的差距可以是数量级的。从零开始做AI工程,数据工程要占掉大概六成精力。
3.1 原始数据的落盘规矩
很多项目一开始就是从某个数据库里"select一下"拿到数据,然后直接在Notebook里洗。这在探索期可以,一旦进入工程期,必须改成脚本化处理。我的规矩是:每一份原始数据都整份落盘保存,路径、时间戳、来源写清楚,不做任何改动。后面所有的清洗、采样、增强,都是在这个只读的原始数据层上另起炉灶。
这么做的原因很现实:数据会更新、源头表结构会变,如果你直接在源头数据上改来改去,那你的模型复现流程里就埋着一个不定时炸弹——今天能跑通,明天源头数据变了,跑出来的特征分布也就变了。我在实际项目中就遇到过一次:上游数据表悄悄加了几个新字段,下游代码读进来之后特征顺序乱了,模型预测结果批量错乱,最后排查了半天才发现数据源头变过。
落盘格式我用过不少,parquet是目前最顺手的:压缩率高、带schema、读取速度快,处理大一点的数据集完全不虚。CSV适合人眼瞄一眼,不适合作为工程管线的主格式。
3.2 用最朴素的方式管住"三个版本"
数据管道里最烦的三个东西是:代码版本、原始数据版本、清洗后数据集版本。代码用Git管,这个没问题。关键是后两个。我不建议一上来就上重型的数据版本管理工具,小团队学习成本太高。用最朴素的方案完全可以:
- 每个清洗后的数据集文件夹,命名带上日期和来源数据的hash值,例如
train_20250110_v2.parquet中的v2即对应原始数据某一次处理的第二版。 - 清洗脚本里固定输出一个
dataset_meta.json,里面记录清洗规则、输入文件路径、输出行数、每个字段的均值/缺失率/唯一值数量。
这样一来,哪怕没有专门的DVC也不是大问题。三个月之后你想知道训练数据是不是已经过期了,直接对比文件夹命名时间和Git记录,立刻清楚。
3.3 数据质量门禁:哪怕只是几个if
清洗完的数据要过一个质量门禁,这个门禁不复杂,通常是一段脚本加上若干断言:
- 断言缺失率不超过设定阈值。
- 断言目标字段的分布和上一版不能有明显偏移(用简单的卡方检验或者直接看分布图采样对比)。
- 断言字段类型正确,比如价格列没有出现负数或字符串。
- 断言训练集和验证集没有泄重叠,或者泄重叠的比例控制在可控范围。
这些检查跑完,脚本再输出一份报告,看到报告绿灯了才允许进入训练环节。这个步骤大部分人会跳过,但从工程视角看,它相当于生产流水线的质检工位。没有质检,后面所有环节的异常都变成"薛定谔的锅"——说不清是数据坏了还是模型坏了。再加上一句踏实话:数据管道是最值得加日志的地方,每一批数据的处理时间、处理量、异常量,全都要留下痕迹。AI项目的可观测性,永远从数据源头开始。
4. 训练实验的工程化:告别黑色Notebook
Jupyter Notebook是探索的利器,但直接作为工程载体,后患无穷。我见过一个团队把最终训练代码放在Notebook里跑,然后往Git里提交了一个几百MB的.ipynb文件,里面塞满了代码片段和输出,连哪一段是最终用的都看不出来。从零开始搞AI工程,训练这条线必须"脚本化"。
4.1 一切实验都可脚本化、可传参
训练入口长这样:一段可以被命令行参数控制的Python脚本,例如python train.py --config config.yaml。关键参数(学习率、batch size、模型尺寸、数据路径、损失权重等)都不该写死在代码里,而是通过一个配置文件传入。我更习惯用Hydra这类配置管理库,支持配置继承、命令行覆盖、每次实验自动生成文件夹。用大白话说,就是"我一次实验跑完,所有参数和代码状态都被自动记录在一个文件夹里"。
这样做的直接收益是,实验不再靠人脑记"上次好像用了0.001的学习率效果还不错"。每一个模型权重,都自带完整的参数清单和使用的数据版本。这大大降低了调参过程中"试了十几个配置、最后忘了最佳配置是什么"的翻车概率。
4.2 固定随机种子,但不迷信随机种子
深度学习训练里有一个经典话题:设了随机种子,但实验结果还是不完全可复现。原因是显卡算子本身有非确定性、多进程数据加载的顺序也可能影响结果。所以我的做法是:脚本里设置所有能设的随机种子(Python、NumPy、PyTorch),并且在日志里记录当前机器的GPU型号和线程数,方便定位环境因素。
有一个实操经验:如果对复现性有硬需求,比如某些需要用生成的合成数据来评估的业务,建议把数据加载改成固定顺序(不开shuffle,或者自己先打乱再固定)。模型权重初始化一般也建议固定。我试过开了不设种子的实验,同一份代码隔两天Trainer跑出来,指标差了两个点——于是我们整个团队养成了一个习惯:所有待公布的对比实验,全部设置固定种子,并且每次都启动新进程跑,避免缓存残留影响结果。
4.3 检查点策略:别只留一份"最终模型.bin"
训练中途崩掉、存储被占满、复盘时想恢复某个中间状态,这三件事都靠检查点(checkpoint)兜底。我的策略是:
- 每轮(或每隔固定步数)保存一个检查点。
- 检查点文件夹里除了权重,还有优化器状态、当前学习率、当前epoch/step、随机数生成器状态。
- 至少保留最近两个检查点和一次最佳验证指标的检查点,磁盘允许的话保留更多。
检查点策略还有一个通用的经验:模型的指标不是只看验证集爆出来的那个数,最好同时保存验证集上每个样本的预测结果。这样后面回看bad case、做误差分析时,不需要重新推理一遍,省时间省得不是一星半点。
5. 评估与离线反馈闭环:模型好不好,不能凭感觉
很多项目在训练结束、看几个指标就急着部署,结果模型上线被用户一顿吐槽,原因往往在于评估环节太薄。从零开始做工程,至少要在评估环节建立三个层次。
5.1 离线评估的"三个不能只看"
第一个,不能只看平均指标。分类模型宏观F1再高,可能某一个不常见类别上表现极差。建议评估报告里带上每个类别的精确率、召回率、F1,以及样本数。回归模型不能只看MSE,具体业务组里存在多少"误差特别大"的离群样本,也很关键——例如预测价格,标准误差很小,但某个区间段的产品售价全预测偏了。
第二个,不能只在一个测试集上评估。要切时间切片测试集、更难样本集、特定细分场景集。最好准备一份"挑战集"或"对抗集",专门选那些老模型预测错的样本、边界模糊的样本。模型在这份挑战集上的分数,往往反映了真实世界的表现边界。
第三个,不能只看预测准确的过程。输出置信度分布也要看。很多模型在低置信度时,准确率掉得飞快。真做工程时,我们可以设置一个"低置信度拒判"规则,把这些样本抛给人工或者规则兜底,线上实际效果比不分青红皂白全自动跑要高不少。
5.2 建立一份"golden test set"
我强烈建议项目从第一天起就维护一份"金标测试集",这份数据原则上不参与训练、不参与调参,是专门在发版前用来做最终验收的。哪怕一开始只有几百条,也要把它单独锁起来。它的价值在于:不同版本的模型,用同一份金标测试集评估,分数才有可比性。
为了对比方便,每次训练结束的评估脚本要把输出预测值存成一个标准格式的表格,里面至少包括样本id、标签真值、模型预测值、置信度。有了这个,后面分析模型升级带来的真实收益/回退时,可以直接对比预测结果,不用重跑模型。
5.3 用shadow模式做离线到在线的脚手架
很多人会把"上线"直接等同于"切流量"。工程化更稳的做法是上"影子模式":模型在线上实时接收真实的请求数据并完成预测,但预测结果不直接返回给用户,而是回放到日志系统里,供离线评估去对比"如果是新模型,当时的预测是什么"。影子模式跑上一两周,拿真实流量校准一下评估结论,再灰度切流,整个上线进程会从容得多。
这其实是把"离线评估"和"在线评估"的鸿沟填上的一种做法。影子模式的日志数据也会积累成宝贵的再训练样本。双赢。
6. 部署、监控与迭代:模型上线才是工程的开始
如果训练算造车,部署和监控就是上路跑。很多人觉得模型train好扔到服务器上就完事了,实际上路之后的问题远比训练阶段更磨人。
6.1 一个轻量级的模型服务怎么搭
如果你的推理延迟要求不那么极端(毫秒级高并发是另一套玩法),一个FastAPI应用就足够撑起一个小模型服务。它可以直接加载模型检查点,封装一个接口:接收原始JSON数据,做特征处理,返回预测结果。我把这种设计叫"胖服务瘦调用"——服务内部负责数据清洗、特征构造、预测、输出解释,接口层保持简单。
服务启动前还应该有一个自检接口(health check),除了返回"我是活的"之外,应该返回一些模型健康信息,比如"当前加载的是哪个检查点版本、最后训练时间、数据schema的版本号码"。这样监控系统想确认线上服务和最新的工件是否同步,直接打这个就知道。
6.2 推理性能和安全:必须过一遍的东西
模型上线,至少要把这三件事做一遍:
- 用batch推理还是单条推理,要实测延迟和吞吐量,不要拍脑袋。有的模型单条延迟100ms看着还行,但并发一上来,CPU排队会爆炸。
- 输入异常怎么处理。空字符串、超长文本、未知类别字段,必须提前设计好行为(返回错误码还是走兜底规则)。我见过线上一个空值导致模型输出乱码,下游直接给用户推送了垃圾内容,教训惨痛。
- 推理输出要不要做后处理。比如分类模型阈值怎么定、回归模型下限怎么截断,都必须成文写进服务代码,而不是散落在某个人的笔记本里。
如果推理延迟压力很大,可以用ONNX Runtime或者TensorRT把模型转换加速一下。绝大多数PyTorch模型,导出到ONNX之后延迟能有比较可观的下降。这个可以作为项目稳定之后的优化方向,不急着第一步就上。
6.3 监控什么:五类信号,五个警报
模型监控不是盯着CPU和内存就够的。我常用的监控清单长这样:
| 监控对象 | 信号来源 | 预警阈值示例 |
|---|---|---|
| 数据漂移 | 请求数据的特征分布 vs 训练时分布 | 特征均值偏差超3个标准差 |
| 预测分布漂移 | 模型预测类别概率分布的变化 | 热门类别的频次突变超50% |
| 业务反馈 | 用户点击率/订单转化率等业务指标 | 连续3天日同比下降超10% |
| 延迟与错误 | API响应时间、5xx/4xx错误比例 | P99延迟超1秒,错误率超1% |
| 工件状态 | 线上模型版本与最新评估通过的版本是否一致 | 版本不一致即告警 |
具体怎么监控,可以用一个脚本定时跑,把指标推到Kafka、日志系统或者简单的时序数据库里,再配上告警。不一定非要重型监控平台,哪怕是定时任务加邮件通知,也比"出了问题被用户投诉才发现"来得强。
6.4 模型迭代的正式流程,哪怕只有两个人
从零开始的团队也要有迭代流程,否则就是没有章法的"改改再发"。最小可用的流程长这样:
- 新数据到达,跑数据质量门禁。
- 在保留的已验证环境里重训练,自动生成新一轮检查点。
- 用金标测试集评估,对比和当前线上模型的差异。
- 评估通过,更新模型工件版本号,同时更新线上服务的加载配置。
- 灰度放量,先放5%流量,观察监控警报,平稳之后再放开。
- 全量之后继续跑影子模式留着新旧对比,观察一到两周。
这个流程不复杂,但它保证了模型的每一次更新都有依据、有记录、有回退方案。实际经验是,这套流程哪怕一开始执行得糙一点,也比没有强——因为它强制所有人对模型行为负责。
还有一条容易被忽略的心得:给模型起名和版本号一定不能用"final_final_v2"这种命名。我见过太多项目毁在"这个才是绝对最终版"的文件夹里。正确的模型工件命名应该是模型名_训练数据版本_时间戳_评估指标,例如cls_v1_data20250110_20250201_f085。从工件名就能读出信息,在协作里省下的沟通成本非常可观。
7. 写在最后:AI工程化的本质是"反脆弱"
一个人搞AI项目,最容易把全部精力放在模型结构上,沉浸在调参的快感里。但决定一个项目能不能长期活下去的,永远是"当它出错的时候,你能不能快速定位、快速回滚、快速修好"。从零开始搞AI工程,本质上就是不断给自己上保险:数据的保险、实验的保险、部署的保险、监控的保险。
我在实际项目里反复验证过一件事:那些看着不起眼的小规则,比如"每次训练前自动核对数据schema""发布后自动检查预测分布偏移",长期价值远远大于精心挑选的那个模型网络层数。AI工程不是锦上添花,而是项目从实验室样品走向产品化过程中必需的地基。地基打得越扎实,后续加新功能、换新模型、接新数据源的时候,你越能睡得着觉。
如果你现在正打算从零开始搞一个AI项目,我的建议是:先不要急着写模型代码,按这篇文章的思路把骨架搭起来——哪怕有些步骤看起来没必要、有些流程先做得糙一点,都胜过硬着头皮直接开卷训练。等你走过一整个从数据到监控的闭环之后,回头看这个"从零开始",你会感谢当初愿意花时间搭建脚手架的自己。