2022年我给自己定了一个目标:搞一个叫ai-engineering-from-scratch的长期项目,从零开始把 AI 应用真正做出来,而不是一直停留在"看论文、刷榜单、跑通别人代码"的阶段。两年前我还是一个只会调库的脚本小子,看着 HuggingFace 上几十个模型,却连把一个模型部署成稳定的在线服务都没底。现在回头看,这个项目让我踩遍了数据、训练、部署、监控、重训全链路能踩的坑,也让我真正理解了"AI 工程化"到底意味着什么。
如果你也在 AI 领域入门,或者已经能做简单实验、但想把系统放到生产环境,这篇博文就是给你写的。我不会从"什么是神经网络"开始灌输,而是讲我这个从零开始项目里最值得复制的方法、工具、路线和真实教训,包括怎么选型、怎么设计训练闭环、怎么排查线上事故。很多东西不是教程里有的,是踩完坑才知道的。
1. 为什么这个项目不叫 machine learning,而叫 ai engineering
先说清楚一个常见误区:很多人觉得"我会训练模型"就等于"AI 工程化"了。实际上,训练出一个实验阶段的模型,在机器学习里叫 prototyping;而把一个模型变成稳定、可维护、能持续迭代的产品服务,这才是工程问题。ai-engineering-from-scratch这个命名,重点在 engineering,而不在 ai。
我理解的 AI 工程,至少包含三层:
- 算法层:模型结构、损失函数、训练策略、评估方法,这是传统机器学习课程最重视的部分。
- 数据层:数据采集、清洗、标注、漂移监测、数据版本管理,这层几乎没有课程系统讲,却决定了模型实际效果的上限。
- 系统层:API 封装、部署方式选择、容器化、CI/CD、分布式训练、GPU 资源调度、日志与监控、回滚策略,这属于软件工程方法论在 AI 场景的延伸。
这个项目真正的起点不是选择一个模型,而是把这个金字塔搭起来。我给自己定了几个特别朴素的验收标准:
- 一个文本分类小项目,从原始数据到线上 API,全程不需要手动执行脚本,用 pipeline 一键跑通;
- 线上服务平均延迟低于 200ms,可用性超过 99.9%;
- 给模型更新的数据后,能自动重训、自动评估,评估不达标就拒发新版本;
- 线上丢一个模型,2 分钟内能定位到原因,5 分钟内能回滚到旧版本。
这些标准听起来不像"学 AI",倒更像做后端系统的 SLO。但这才是我想要的:只有把工程约束绑在模型上,你才会逼自己去理解那些真正决定成败的细节。
1.1 为什么大多数人从零开始都失败在"工具太多,任务太小"
这个项目一开始也差点夭折。当时我打开了一张工具图谱,上面有几十个组件:GPU 管理、特征平台、模型注册表、实验跟踪、A/B 测试、自动标注……每个看起来都是必备。我花了一周时间去研究 K8s、Prometheus、MLflow、Kubeflow,结果发现自己连训练脚本都还没有。
后来我砍掉了 90% 的想法,只留下一条最小链路:Git 仓库 + Python 环境 + PyTorch + Flask + Docker + 一个数据库。先把最小能跑通的应用做出来,再往上面加复杂度。这是我这个项目学到的第一个工程原则:面向问题而不是面向工具。
AI 工程的落地是一个逐步加权的过程。从零开始的人,先把数据用手工标了一份 5000 条的小数据集,把模型用 4 层全连接网络跑通,把服务用 Flask 部署在本机,就已经超过大多数"只会跑 notebook"的人了。后面再谈分布式训练、自动重训这些高级玩法,才不会空中楼阁。
2. 从零开始第一课:把地基打实在 Python、Linux 和版本管理上
ai-engineering-from-scratch的第一阶段,我不是去学模型,而是花了两周时间把两样东西练熟:一个是 Linux 终端,另一个是 Python 的虚拟环境与依赖管理。听起来很基础,但至少一半的项目毁在环境依赖上。
我在实际项目中见过太多次"代码没问题但复现不了"的情况,最后原因就是 conda 环境与 pip 包版本不一致。所以从第一天起,我给这个项目定了一条死规矩:所有依赖必须在requirements.txt或 pyproject.toml 里写死版本号,并且用同一套方式构建环境。
我推荐直接使用 conda 来管理 Python 运行时,再配合 pip-tools 来冻结依赖。创建环境时,尽量把 Python 版本固定在 3.10 或者 3.11,太大的版本跨度很容易让一些底层库的预编译包缺失。
conda create -n ai-engineering python=3.11 conda activate ai-engineering pip install pip-tools然后写一个 requirements.in,声明直接依赖,再用 pip-compile 生成 requirements.txt。为什么不用直接 pip install?因为依赖项之间会存在隐式传递依赖冲突。生成锁定文件后,任何人 clone 我的 repo,都只需要执行pip-sync就能得到完全一致的环境。
这种"可复现"意识,是 AI 工程和科研实验最大的区别。科研实验里"运气好的结果"也是结果;但工程里,系统是长期运行的,它要能随时重新训练、重新推理、换服务器迁移,如果环境都是碰运气搭出来的,根本谈不上维护。
2.1 工程化里被严重低估的 Git 技能
同样的,很多 AI 初学者对 Git 的理解停在commit、push。但是当一个项目涉及多人协作、模型文件、数据文件和代码变化时,Git 的运用水平直接影响你开发效率。
我在这个项目里只做两块内容,也建议你参考:
- 把大文件(模型 checkpoint、数据集)排除在 Git 之外,用 Git LFS 或 DVC 管理;
- 每个实验分支必须与数据版本、代码版本建立明确映射关系。
其中数据版本管理是 AI 工程特有的问题。普通软件工程,代码定了行为就定了;但 AI 工程里,模型的行为由"代码 + 权重 + 数据"共同决定。任何一个变了,结果都变了。所以我在仓库里用.gitignore排除 data/ 目录,然后引入 DVC(Data Version Control)来对数据集和指标做版本跟踪。
DVC 的作用很像 Git,但它拿来管理的是大文件或目录。它会生成一个很小的.dvc文件,里面存着数据文件的哈希值和存储位置。当你修改了原始数据,DVC 会识别出文件内容变化,自动生成新版本。这样,我可以随时从历史记录里找到"用了哪份数据、哪个代码 commit、哪个权重版本"训练出的模型。这在排查线上效果下降时,简直是救命稻草。
后来我意识到,所谓 AI 工程化,有一半工作是"让别人能复现你的结果"。Git 管理代码,DVC 管理数据,MLflow 管理实验指标——这三者合在一起,终于能回答"为什么昨天效果还好、今天上线就不行"这种灵魂拷问。
3. 第一个端到端概率题:从文本分类服务看最小闭环
说一千道一万,不如亲手做一个完整闭环。这里我就拿这个项目里训练的第一个"能上线"的模型举例:一个垃圾评论分类器。任务不复杂,但麻雀虽小五脏俱全。
我坚持用真实场景而非经典数据集,是因为真实数据能逼着你处理大量脏活:标签噪声、长尾类别、类别不平衡、延迟波动。从这些脏活里学到的,恰恰是工程能力的核心。
3.1 数据准备与标注:比想象中花更多时间的环节
项目刚开始,我从客服后台导出了 3 万条用户评论,全部是文本。我第一想法是直接用现成模型做文本分类,但后来发现没有领域标注根本不可靠。于是我从零开始做标注策略。
第一步是清洗。HTML 标签、HTML 实体、邮箱、网址统一替换,全半角字符转成半角。只用正则做基础清洗就行,不需要引入多么复杂的 NLP 库。
第二步是采样。因为真实数据里正常评论占绝大多数(约 80%),垃圾评论很少,直接拿原始数据训练会导致模型把所有样本都预测成正常类。我先从里面挑出了正例(垃圾类)2500 条,又随机采样了 2500 条正常评论,组成了 5000 条均衡训练集。这是一开始提高基线的最简单方法。
第三步是标注。我采用"一人标注,一人复核"的方式,我标注完后请一个朋友复核不一致的地方。只标注两个标签,避免主观性太强。产出是:
| 字段 | 说明 |
|---|---|
| text | 清洗后的评论文本 |
| label | 0 表示正常,1 表示垃圾 |
| split | train / val / test |
| source_id | 原始数据 ID |
我把数据分成 train/val/test,比例 8:1:1,并且保证测试集完全独立。这里有个非常容易踩的坑:如果用同一个数据生成训练集和测试集,模型会因具体样本记忆而显得效果好,但实际上线后会崩溃。数据切分必须按照时间顺序或 ID 哈希来防泄漏。我当时直接用train_test_split,但通过设置固定随机种子并确保类别比例一致,这才算是个合格的开始。
3.2 模型选型与训练:从简单模型开始,不要一上来就用大模型
分类这种任务,当时 BERT 系列已经是标配了。但为了真正理解训练链路,我第一个模型不是 BERT,而是用 TF-IDF + 逻辑回归作为 baseline。不要小看这个 baseline:在 5000 条小数据上,简单的线性模型往往能到 0.89 的 F1,而完全不需要 GPU。
baseline 的作用是给整个 pipeline 跑通提供一条最便宜的路径。当我从 TF-IDF + LR 开始,把任务分解成"特征化、训练、评估、部署"四步,之后迁移到 BERT 就只是换模型层而已。
BF16 和 GPU 的问题,是在我切换到 transformer 之后才出现的。我用的是bert-base-chinese(如果是中文场景),通过 HuggingFace 的AutoModelForSequenceClassification加载。
from transformers import AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=2)训练参数我给出一个经验值:batch size 32,学习率 2e-5,epochs = 3。因为数据规模很小,三个 epoch 足够让验证 loss 收敛,再多就容易过拟合。在 5000 条数据上,即使单卡 T4,也只需要三分钟左右。
这里有一个我在真实项目中遇到的训练参数坑:max_length别设置太高。文本评论一般不到 128 个 token,但我一开始图省事设成了 512,导致训练内存占用巨大、速度很慢。实际上直接把 tokenizer 的max_length设成 128,足以覆盖 99% 以上的样本。
训练完成后,我保存了两个产物:一个是 PyTorch 的pytorch_model.bin权重,一个是「向量化+模型」的打包产物。后面部署时发现,单独保存模型还不够,必须把 tokenizer 一起保存,因为推理时需要相同的分词规则。这个看似理所当然的细节,我忘了无数次。
3.3 把模型封装成可观测的服务:你远不需要微服务
模型训完,下一步就是部署。我最开始看到别人用 Kubernetes 部署,自己也学着写一堆 yaml 文件,结果本机连个稳定的容器编排都搭不起来。后来我才意识到,对于一个单模型服务,最简单可靠的方式就是:单体 FastAPI 服务 + Docker 容器。
FastAPI 天然支持异步,而且有自动生成的 API 文档。我把推理逻辑封装在/predict接口:
from fastapi import FastAPI from pydantic import BaseModel class Item(BaseModel): text: str app = FastAPI() @app.post("/predict") def predict(item: Item): result = classifier.predict(item.text) return {"label": result["label"], "probability": result["score"]}但为了不让模型占用过多内存,加载模型这一步需要优化:模型加载很耗时,不能在每次请求里做。我把模型加载放在lifespan事件或者模块初始化位置,确保只有一个进程共享模型。对了,还有一个很重要的设置:启动时设置model.eval(),并且把torch.no_grad()包在推理里,否则模型会保留 dropout 行为,推理结果不稳定。
线上环境的部署还涉及并发问题。Gunicorn 可以选择多 worker,但每个 worker 都会加载一份模型,内存倍增。一台 8GB 的机器,如果模型 500MB,开 4 个 worker 就会显得很吃力。我实际测试下来,对于这种推理延迟在几十毫秒的模型,开两个 worker 就够了,后面的吞吐量可以通过扩容器解决,不必在单机死磕。
3.4 用 Docker 固定一切未知:让打包后不可变
部署 AI 模型,最大的敌人是"本地跑得通,服务器跑不起来"。从零开始这个项目时,我吃了太多系统库缺失、CUDAToolkit 版本不对的亏。所以后来我强制要求所有服务必须走 Docker。
一个最小可用的 Dockerfile 是这样的:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]注意镜像最好不要选择体积巨大的 pytorch 官方镜像,而是先安装 CPU 版还是 GPU 版要想清楚。如果线上只有 CPU 推理,就用 CPU 版的 torch,体积直接减少好几 GB;如果线上有 GPU,就使用带 CUDA runtime 的镜像。我这里因为用到小文本模型,CPU 推理也只要 30ms 左右,所以初期直接走 CPU 部署,省了很多 CUDA 兼容性问题。
构建并运行后,我能通过docker logs查看日志,通过docker ps查看状态。再配一个docker-compose.yml,同时拉起 API 服务和监控面板。这一套在单机上可以做小型生产,也完全足够支持我这个项目的第一个真实用户(我自己)使用。
4. 工程化铁三角:数据版本、实验跟踪与线上监控
当第一个服务上线后,最难的部分不是上线,而是持续迭代。AI 模型是有生命周期的,它需要被更新、被评估、被监控,还要在效果滑坡时被拉回。这是ai-engineering-from-scratch项目最有价值的一章。
4.1 用 MLflow 组织每一次训练:实验不记录,等于没做
我在第二个模型迭代时就后悔了:没有记录任何实验配置,导致过了两周后,我忘记上一次训练用了什么学习率、什么数据版本。后来我把 MLflow 引入了项目。
MLflow 是一个开源平台,给我提供三个核心能力:
mlflow.start_run()记录每次训练的指标(loss、acc、F1);mlflow.log_params()记录超参数;mlflow.log_artifact()保存模型文件和指标图。
这样每次训练都会生成一个 run,里面完整保留了代码状态、依赖环境、参数和产物。我甚至可以在那之后轻松地对比两个 run 的指标,选出最好的模型。
这里我推荐在训练脚本里把 MLflow 接入函数写成一个装饰器,这样可以避免每个训练脚本都重复一段初始化代码。我的项目里做了类似track_experiment的工具类,它自动收集代码版本、系统环境、GPU 型号等信息。对于从零开始的项目,这些元信息越早收,后面的分析成本越低。
4.2 模型注册表的引入:不是所有模型都要直接上线
MLflow 还有一个功能是 Model Registry。它把训练好的模型登记在统一命名空间中,并标注阶段:Staging、Production、Archived。在生产环境,我部署的是 registry 中标记为Production版本的模型,然后配合 model version 做灰度。
这里的关键决策是:先评估,后生产。每次新模型训练完,我都会计算它在测试集上的 F1、延迟,并和当前线上模型的 baseline 进行比较,只有满足一定条件才把版本推送到生产。这本身是一个简单的 gate:
- 候选模型 F1 必须不低于线上模型;
- 候选模型推理延迟低于 100ms(在测试机配置下);
- 候选模型的数据分布与线上分布无明显漂移。
最后一个条件是我后来加的,因为它解决了一个大坑:数据分布漂移会导致候选模型跑分很漂亮,上线后却没效果。稍后我会仔细讲这个案例。
4.3 监控不只是看 CPU:要盯准确率和输入分布
部署完成不是终点,而是运营的起点。传统的监控面板看 CPU、内存、请求延迟、错误率就够了,但对 AI 服务来说,还有一类特有的监控:模型相关指标。
我建立了三层监控:
- 系统层:请求量、P95 延迟、错误率、CPU/内存占用。这部分用 Prometheus 抓取指标,Grafana 展示。
- 数据层:线上输入文本的 Token 长度分布、类别关键词频率、ASCII 字符占比等。一旦这些分布漂移,模型可能开始"看不懂"新到的样本。
- 预测层:对模型输出的概率置信度、正类率、预测稳定性做统计分析。比如垃圾评论识别,线上预测的正类率突然从 9% 升到 20%,可能是流量环境变了,也可能模型坏了。
监控的作用不是"好看",而是要能在事故发生时快速缩小排查范围。我把这个经验总结为:系统监控告诉你哪里出问题,数据监控告诉你为什么模型出问题,两者缺一不可。
5. 一次真实的线上事故:推理延迟飚升,根因竟是数据漂移
这套体系到底有没有用,要用一场事故来检验。项目上线第三个月,一个晚上我收到报警:系统 P95 延迟从 70ms 涨到了 500ms,错误率没有明显变化,但服务已经开始大量排队。我当时的排查链路,可以说把这个项目里的知识全串起来了。
第一步,看系统监控。CPU 使用率到达 90% 以上,但 GPU(如果有)是空闲的。这首先说明瓶颈不在模型计算,而在上游或者预处理。我立刻打开最近的日志,看有没有超长文本进入。
第二步,看数据分布监控。发现新增评论的平均 token 长度从原来的 60 变为 160,而且有大量包含大量空白字符和重复标点的样本。原来是一个新的内容运营渠道加了大量长文本导入,导致 tokenizer 处理时间翻倍,Transformer 的序列长度也拖慢了整个推理过程。
第三步,看预测分布。模型的"垃圾评论判定概率"也变了,因为长文本中有大量重复信息,segments 长度导致注意力计算范围太大,输出概率被挤压到边界。我甚至看到部分正常长文被误判为垃圾。
这个问题的解决不是靠"改代码",而是靠规范上游:新渠道的内容接入必须经过长度截断和清洗规则。我加了一个预处理拦截函数:当文本 token 长度超过 512 时,只截取前 512 个 token。同时,给 API 增加请求文本长度上限,拒绝异常超长输入。
这次事故让我最深的体会是:AI 服务是一个输入输出高度依赖分布的活系统。如果你不对"数据分布"设防,那么再好的模型也会被来自渠道侧的异常数据打垮。
5.1 从事故回溯"重训"的正确姿势
事故解决完,还需要重训模型吗?答案是肯定的,但重训不是盲目训练。我在这个项目里总结了一套"重训触发时机":
- 当线上输入的 token 长度分布、句式复杂度等明显飘移时;
- 当模型输出的置信度整体下降时;
- 当分类任务的个别类别召回率下降超过 5% 时;
- 当用户投诉(或业务侧反馈)增多时。
具体到事故场景:因为新渠道带来了大量长文本,我重新标注了一批长评论样本,并入训练集,再跑一轮训练。此时 MLflow 会把新 run 与旧 run 并列展示,我对比各项指标后,确认新模型在"长文本类人"上召回率提升了 15%,且不损害短文本效果。这种"有数据支撑"的决策过程,和那种"因为新模型看起来好就上线"的野路子完全不同。
6. 给同样从零开始的人:实操建议、工具清单与长期维护经验
写到现在,ai-engineering-from-scratch这个项目已经跑了半年多。我最大变化是,从"我能不能训练出一个模型"变成了"我能不能保证模型在生产里稳定变好"。如果你也准备从零开始,我建议你把学习路径设计成这样:
- 先跑一个完整的传统机器学习项目:用 sklearn、pandas,不用深度学习,体验"特征+模型+评估+部署"的完整逻辑。
- 再切换到 PyTorch 做一个小型深度学习项目,但要加上模型版本管理、Docker 部署、监控。
- 之后再引入自动重训练和复杂部署架构,不要一上来就上 K8s。
工具清单我整理成表,你可以按需取用:
| 阶段 | 工具 | 用途 |
|---|---|---|
| 数据管理 | DVC | 数据版本控制、存储外部化 |
| 环境管理 | conda + pip-tools | 依赖固定、可复现环境 |
| 模型开发 | PyTorch + HuggingFace | 模型训练与微调 |
| 实验跟踪 | MLflow | 指标/参数/产物记录、模型注册表 |
| 服务化 | FastAPI + Uvicorn | 模型 API 封装 |
| 容器化 | Docker + Docker Compose | 环境隔离、一键部署 |
| 监控 | Prometheus + Grafana | 系统与业务指标采集与可视 |
| CI/CD | GitHub Actions | 训练后自动测试、构建镜像并推送 |
这里面没有一项是特别复杂的黑科技,但组合在一起就会产生质变。我还想特别提一下笔记:我从这个项目开始,给每个模块写一个 README 文档,内容包括目标、接口、错误代码和重新训练的命令。这个方法让我在半年后还能轻松接手自己当初的项目。
6.1 长期维护这个项目的三个心法
最后,作为从零开始的过来人,我想分享三个让项目持续下去的心法。
第一,把失败当作数据。每次事故、bug 都记录到一个incidents.md文件里,写上时间、现象、根因、处理办法。一年后你会拥有一份珍贵的错误集。这些错误集比任何课程都能让你快速变强。
第二,永远保留一条"手动逃生通道"。不管自动化 pipeline 多完善,我始终保留了用 shell 脚本手动重跑一遍训练加部署的能力。自动化会出 bug,但手动脚本永远是你理解系统的参照物。
第三,不要追求一步到位。这个项目里我有一半时间是在删掉之前不怎么使用的工具。每隔一到两周,我都会问自己:这个工具还在实际工作流里吗?不在了就删。保持系统精简,才愿意继续维护它。
昨天我又打开这个项目,看到自己一年多前刚开始时写的训练脚本,那时候连train_test_split的random_state都不知道怎么固定。但也就是在这样一次一次犯傻、排查、补全之后,数据版本的映射、模型的 SLO、漂移的响应机制才一点点长进肌肉里。
如果你的目标也是做出一个真正可以被用户使用的 AI 产品,我建议你直接复制我这条路线:从一个最小的任务开始,构建一个最简闭环,然后让工程约束逼着你学会那些教科书里没有的东西。AI 工程这条路没有捷径,但每踩一个坑,你的系统都会更硬实一点。