翻开去年的项目仓库,我还能想起当时从一堆 Notebook 里硬生生"提炼"出一个可交付 AI 服务的那种狼狈。说起来是 AI 工程师,实际干的是数据清洗、环境修复、接口调试的杂活。所以朋友问我"从零开始学 AI 工程到底要经历什么"时,我决定把这个从 scratch 搭建的完整项目复盘出来:不谈大模型参数,不谈炫酷算法,只讲从原始数据到稳定服务之间那段最容易被忽略、也最值得投入的工程链路。
这篇文章适合三类人:已经在跑模型但没做过工程化交付的算法工程师,想从后端或数据分析转 AI 工程方向的新人,以及正在搭团队协作规范的小组负责人。我会从项目拆解、技术选型、核心原理、最小落地实操到高频坑点全部过一遍,很多内容不是文档里能直接抄到的,属于被坑过之后才理解的细节。
1. 项目整体定位与知识地图
1.1 这个项目到底想解决什么问题
这个项目的名字叫 ai-engineering-from-scratch,核心目标不是"训练出一个 SOTA 模型",而是从零搭建一套可复现、可测试、可观测、可交付的 AI 应用流水线。很多从 Notebook 入门的朋友最大的困惑就在这里:单机实验里 loss 降到 0.2,一到线上就崩;本地能跑,换台机器就报错;模型文件用网盘传来传去,版本都对不上。这个项目就是为了把这些"最后一公里"的问题一次性趟完。
我用一个非常经典的场景作为载体:中文短文本分类服务。数据来自公开的带标签评论集,规模不大,几万条。任务本身很简单,但麻雀虽小五脏俱全,从数据清洗、特征工程、模型训练、实验记录、服务化部署到监控告警,每个环节都能独立展开。这个选择是有意为之,如果一开始就冲多模态大模型,工程细节会被模型复杂度掩盖,根本练不到基本功。
项目交付物也不只是一堆代码文件,而是一套完整可复现的工程资产:数据版本库、训练脚本、实验记录、Docker 镜像、部署配置、监控面板和给团队看的协作文档。这也回答了很多人"学 AI 工程到底学什么"的问题——学的是如何让算法稳定复现、团队高效协作、系统持续运转。
1.2 从零到一的技术栈选型逻辑
我见过太多人一上来就争论 PyTorch 和 TensorFlow 哪个好,然后陷入框架选型泥潭。选型真正的逻辑只有一条:能不能让你的系统在三个月后依然有人愿意维护。这个项目里我选的是 PyTorch,不是因为它在所有场景都碾压,而是因为社区生态活跃、调试时出错信息可读性好、迁移到生产环境时的工具链最全。
数据版本化用的是 DVC(Data Version Control),搭配本地 MinIO 作为远端存储。为什么不直接用 Git 管理数据?因为 Git 设计目的是文本源码,一个几百 MB 的二进制数据集提交进去,仓库立刻膨胀,clone 一次要等半天。DVC 用 Git 记录数据文件的元信息和哈希,真正的数据丢到对象存储里,需要的时候拉取指定版本,这才是数据版本管理的标准姿势。
实验跟踪平台我选了 MLflow,模型服务框架用 FastAPI,容器化用 Docker,编排调度先用简单的 Shell + Makefile 顶住。这套组合最大的好处是每个组件都只解决一个问题,互相之间用标准接口通信,不会被某个全家桶套牢。就像装修不用全屋定制,而是挑了几件耐用、修起来方便的家具。
1.3 为什么"AI 工程"不等于"写模型"
很多人的误区是把 AI 工程理解为"训练模型",其实训练在整个交付生命周期里只占很小一块。一个完整的 AI 工程岗位日常大约是这样分布的:数据准备工作量排在第一位,因为喂给模型的数据质量直接决定效果上限;其次是上线后的监控和维护,你得能判断模型是不是悄悄"生病"了;再然后是训练实验和调优;最后才是写接口、调并发这些服务化事务。
用生活类比解释这个问题:训练模型有点像做菜,很多人以为菜好吃全靠掌勺那一下,但真正的后厨大师会告诉你,食材处理、火候控制、出餐顺序才是稳定出品的关键。厨师要是只盯着锅里的翻炒,前面配菜没备好、后面出餐盘子没准备好,整桌菜照样砸掉。AI 工程就是这样一套"后厨管理",而不是单纯的"炒菜手艺"。
2. 核心环节拆解与关键原理
2.1 数据工程:喂给模型的可复现数据
数据环节第一件事是数据版本化。我用 DVC 给原始数据、清洗后数据、特征工程产物分别建了版本节点,每个节点对应一个 Git commit。这样做的好处很直接:模型效果突然反弹时,我能精确定位是哪一批数据进入训练导致的,而不是靠记忆去猜"好像是上周三那份文件"。如果你经历过模型损失函数神秘恶化,只因为训练集被人悄悄换了个版本,就会明白这条流程的重要性。
数据质量校验也被提到了代码层面。我写了一个 validation 脚本,每次数据入库前检查字段完整性、类型合法性、类别分布和重复率。举个例子,短文本分类数据的标签字段偶尔会出现空值,或者混入奇怪的换行符;不经校验直接喂给模型,轻则训练崩溃,重则线上推理出错。这个脚本相当于给数据设了一道安检闸口,不干净的数据根本进不了下游。
特征工程最容易被忽略的坑是训练/推理不一致。我在训练脚本里写了一个清洗函数,推理服务里又"随手"写了一份类似逻辑,结果某天服务端的文本预处理多做了一个截断操作,线上准确率肉眼可见地掉了好几个点。这种 bug 最难察觉,因为两边逻辑看着一样,实际行为却不同。后来我把特征代码抽成一个独立模块,训练和推理只从同一个入口 import,彻底杜绝了双套逻辑分叉的问题。
2.2 训练工程:从实验到可复现
训练环节第一原则是每次实验必须可复现。我用 MLflow 记录环境信息、Git commit、超参数、指标曲线和模型产物。听起来多此一举,实际上救过我很多次:某次调参后效果不错,但根本想不起来用的是哪个学习率;有了实验记录,我可以像查账一样回溯每一步改动。MLflow 的 Model Registry 还承担了模型版本管理,可以不重启服务地切换模型版本。
随机种子管理也被上升到工程规范。PyTorch 里设置torch.manual_seed(42)还不够,还要管住 NumPy、Python 内置 random 和 DataLoader 的 worker 进程,否则每次训练的验证集划分都会漂移,实验对比结果根本没有可信度。我会把所有种子相关的设置统一放在一个set_seed()函数里,训练脚本开头就调用,强迫自己养成习惯。
训练资源的估算也是工程基本功。很多人只盯着模型参数量,实际上训练显存占用主要来自四个部分:模型权重、优化器状态、梯度和激活值/中间缓存。以 1.1 亿参数的 BERT-base 微调为例,FP32 权重就占 440MB(参数量 × 4 字节);Adam 优化器要为每个参数保存一阶矩和二阶矩,再加上权重本身,约等于 12 字节每参数,也就是 1.2GB;梯度还要 440MB,激活值则在几百 MB 到数 GB 之间波动。所以一口咬定"4G 显存能跑 BERT"是极其危险的,我在 6G 显存的卡上试过,batch size 稍大就 OOM。入门阶段最好养成用nvidia-smi观察显存实际占用的习惯。
2.3 模型服务:把模型封装成稳定接口
模型训练完只是一个.pt文件,真正面向业务的是它对外提供服务的能力。我选择了 FastAPI 作为服务框架,最重要的理由是它自带 Pydantic 数据校验:请求体结构会在入口处被校验,字段类型不对、字段缺失直接返回 400 错误,不会让脏数据一路穿透到模型内部把进程打崩。这一点比早期 Flask 时代需要自己手写参数校验舒服太多。
服务接口设计我坚持了两个原则:一是响应结构里必须带版本号,比如{"model_version": "3", "result": ...},这样前端和下游可以放心缓存;二是模型推理必须放在异步线程池或者独立进程中,避免一个慢请求把整个事件循环堵死。实际部署时我还加了加载预热,服务启动后先向模型发送少量 sample 数据触发权重加载,避免第一个真实请求被冷启动延迟拖垮。
在线服务按延迟敏感度可以分成几类,我在梳理架构时把各场景列了个表,方便大伙按需选择:
| 场景 | 推理方式 | 典型框架 | 延迟要求 | 并发特点 |
|---|---|---|---|---|
| 实时 API | 在线同步 | FastAPI + TorchServe | 毫秒到百毫秒级 | 流量波动大,需要弹性扩缩容 |
| 离线批量 | 异步任务 | Spark + 批量脚本 | 分钟到小时级 | 吞吐优先,可排队 |
| 边缘设备 | 本地推理 | ONNX Runtime | 离线可得 | 算力受限,模型需量化 |
2.4 评估与监控:模型也会"生病"
离线评估指标不能直接等同于线上效果。我在项目里同时维护两套指标:一套是训练时的准确率、F1、ROC-AUC,用来看模型能力有没有提升;另一套是线上的业务指标,比如请求成功率、平均延迟、预测标签分布、用户反馈率。两者的关系有点像体检报告和实战状态,体检指标正常的人也可能因为熬夜状态下滑,线上监控的作用就是捕捉这种"状态下滑"。
数据漂移检测是很多人完全不设防的地方。某个类别的输入文本风格一变,模型预测分布就跟着偏,但模型本身不会主动报错。我在服务里加了预测类别分布的定期统计,每 1000 次请求算一次分布,如果和历史训练集分布出现显著差异就告警。这个功能只花了一天时间实现,却让我免于一次"线上准确率莫名其妙下降"的长时间排查。
模型上线也不是一步到位的动作。我先跑 shadow deployment,把线上流量复制一份打到新模型上,但结果不返回给用户,只记录到日志里。观测几天确认新模型效果不差,再切真实流量。如果直接把新模型全量替换,万一它有隐藏 bug,影响面就是全部用户了。
3. 实操过程:一个最小可行 AI 项目的完整落地
3.1 第一阶段:搭建可复现的工程骨架
项目目录从一开始就按模块化设计,而不是把脚本全扔在根目录。下面这个结构是我实践后觉得比较顺手的模板:
ai-engineering-from-scratch/ ├── data/ # 数据目录,DVC 管理 │ ├── raw/ # 原始数据 │ ├── processed/ # 清洗后数据 │ └── features/ # 特征工程产物 ├── src/ # 核心源码 │ ├── data/ # 数据加载与清洗 │ ├── features/ # 特征工程 │ ├── models/ # 模型定义 │ ├── training/ # 训练脚本 │ └── serving/ # 推理服务 ├── notebooks/ # 探索性分析专用 ├── configs/ # 超参数与部署配置 ├── tests/ # 单元测试 ├── scripts/ # 运维脚本 ├── Dockerfile ├── requirements.txt └── Makefile依赖管理我用了两层:requirements.txt记录顶层依赖,再用pip freeze生成requirements.lock固定所有传递依赖的精确版本。这么做能避免常见的"我这能跑你那报错"问题。每次新装包后我会重新生成 lock 文件并提交到 Git,方便任何协作者在完全相同的依赖环境下复现实验。
工程骨架还有一个细节容易被忽略:DataLoader 在多进程模式下如果主进程没有if __name__ == "__main__"保护,Windows 和部分容器环境会报错或无限重启。我们在所有可执行脚本的入口处都加了这行保护,这属于典型的"不踩一次不会长记性"的经验。
3.2 第二阶段:搞定数据管道
数据管道我分成了三步:原始数据入库、清洗校验、特征生成与切分。原始数据来自公开的评论数据集,格式是 CSV,字段包含text和label。入库前先跑一个校验脚本,检查文件是否损坏、编码是否为 UTF-8、列数是否一致,一旦检查不通过直接中止管道,不把脏数据带到下游。
清洗逻辑看起来简单,但坑藏在细节里。HTML 标签、多余空格、全角半角标点、URL 都要处理;更麻烦的是部分短文本本身全是大写或乱码。我用一个统一的clean_text()函数集中处理,同时记录清洗前后的字符数变化,方便后面定位异常。清洗完成后,把类别分布打印出来,发现某个类别样本过少就触发警报,避免模型对少数类别完全失明。
数据切分我用的是分层采样而不是随机切分,保证训练、验证、测试三个集合里的类别比例大致一致。有一点我特别想强调:切分操作必须在特征工程之前完成,否则全部数据参与特征变换,再将切分后的子集用于训练,就引入了"特征泄漏",离线指标会虚高到让人误以为自己训练出了神级模型。第一次入坑的时候验证集 F1 高达 0.98,上线后真实准确率只有 0.72,就是被这个环节坑的。
3.3 第三阶段:训练与验证的工程规范
训练脚本我坚持用命令行参数接收所有超参数,而不是在代码里写死。简单版本长这样:
# train.py import argparse import mlflow import torch from src.models import build_model from src.training import train_epoch, validate def main(): parser = argparse.ArgumentParser() parser.add_argument("--lr", type=float, default=2e-5) parser.add_argument("--batch_size", type=int, default=32) parser.add_argument("--epochs", type=int, default=5) parser.add_argument("--seed", type=int, default=42) args = parser.parse_args() mlflow.set_experiment("comment_classifier") with mlflow.start_run(): mlflow.log_params(vars(args)) model = build_model() train_loader, val_loader = load_data(args.batch_size) for epoch in range(args.epochs): train_loss = train_epoch(model, train_loader, args.lr) val_acc = validate(model, val_loader) mlflow.log_metric("train_loss", train_loss) mlflow.log_metric("val_acc", val_acc) mlflow.pytorch.log_model(model, "model")这段骨架体现了几个工程原则:超参数不埋在代码里,因为调参记录和复现都依赖它们;每次实验都自动记录指标曲线,方便横向对比;模型产物以 MLflow 标准格式保存,后续可以直接从 Model Registry 加载,不需要手工翻找文件。
训练过程中我还做了 Early Stopping,但严格一点:只有在验证指标连续 N 个 epoch 没提升时才停止,并保留最佳 checkpoint。最容易犯的错误是在验证指标开始下降时立刻停止,其实那可能只是随机波动,过早停掉会错过后续更好的结果。我的经验是 patience 至少设 3 轮,同时把每次 epoch 的指标曲线都画出来,眼见为实再判断。
3.4 第四阶段:部署与监控
模型训练结束后,我把 PyTorch 模型导出为 ONNX 格式,再用 ONNX Runtime 做推理。这样做的一大好处是摆脱了对 PyTorch 运行时的依赖,容器镜像可以瘦身将近一半;另外 ONNX Runtime 的图优化让推理延迟比原始 PyTorch 低了不少。导出过程有个关键点:必须固定输入维度或用动态维度配置,否则带上批量维度导出后,线上单条请求进入时就容易维度报错。
部署服务我写了一个不到 80 行的 FastAPI 应用,核心只有三个接口:/healthz健康检查、/predict推理接口、/metrics暴露监控指标。健康检查不只是返回 "OK",而是真正加载模型跑一次小样本推理,确认模型文件没有损坏;监控指标则记录了 QPS、P99 延迟、错误率和标签分布,Prometheus 可以直接抓取。
监控告警我设置了三个阈值:P99 延迟超过 300ms、连续 5 分钟错误率高于 1%、预测标签分布和训练分布样本量差异显著。这三个阈值不是说拍脑袋定的,而是我从踩坑教训里总结出来的:延迟异常通常是资源瓶颈或上游依赖抖动;错误率抬头大概率是代码改动或数据格式变化;分布偏移则是数据漂移的信号。三者分别对应稳定性、正确性和数据质量,缺一个都会让排查陷入盲区。
4. 常见问题与排查技巧实录
4.1 环境与依赖管理的高频事故
这个领域的经典灾难是 CUDA、cuDNN、PyTorch 三者版本对不上。某个版本 PyTorch 要求 CUDA 11.8,机器上驱动只支持 CUDA 11.0,于是你得到一堆莫名其妙的undefined symbol报错,或者 GPU 设备就是加载不出来。我的解决思路是:不直接在宿主机上装深度学习框架,而是用官方 PyTorch Docker 镜像作为基础,镜像内部已经锁定了匹配的 CUDA 版本,宿主机只需要提供 NVIDIA 驱动和容器运行时。
另一个高频问题是不同项目对同一种包的要求互相冲突。A 项目要pandas==1.3,B 项目要pandas>=2.0,装一个把另一个弄坏。所以每个项目都应该有独立虚拟环境,conda 或 venv 都行,关键是不要偷懒图省事把所有项目塞在同一个环境里。我现在的规矩是:新项目先在本地创建虚拟环境,再安装依赖并生成 lock 文件,没有例外。
4.2 训练过程的 Bug 与排查思路
Loss 不降是最让人慌的场景。不要立刻去调学习率,先做这几件事:检查数据预处理后的字符串是不是全变成了空值,标签是否从 0 开始连续编码,模型输出层和损失函数是否匹配。排查的手法也很简单,在训练循环前单独打印几个 batch 的输入输出,亲眼确认数据和张量形状,比看堆栈快得多。
过拟合则是另一个常见病。我碰到过训练集准确率 0.99、验证集 0.76 的情况,第一反应不是加正则化,而是检查验证集的数据分布有没有和训练集严重偏移,以及数据切分时是不是发生了泄漏。确认切分没问题后,再依次试增加 dropout、降低模型容量、做数据增强。不要一上来就同时改五个超参数,这样永远不会知道自己到底靠哪一步救回来。
GPU 利用率上不去很多新人不知道如何下手。最常见的原因是数据加载太慢,GPU 在空等 CPU 喂数据。排查方式很简单:跑训练时另开一个终端执行nvidia-smi,如果 GPU 利用率经常掉到 50% 以下而 CPU 占用很高,就把DataLoader的num_workers调大、prefetch_factor设成 2 或 4,并确保pin_memory=True。我在这个项目里把num_workers从默认 0 调到 4 后,训练耗时直接缩短了三分之一。
4.3 服务化部署与性能瓶颈
部署阶段的第一坑是端口冲突。同一个开发机上跑多个服务,端口占用会导致模型服务起不来,报错信息有时候还很隐晦。我后来给所有服务统一用环境变量配置端口,并且启动脚本先检查端口是否被占用,避免反复怀疑代码写错了。
模型冷启动延迟也是性能痛点。一个几百 MB 的模型文件,首次推理可能要花几秒钟加载权重,这个延迟对于线上请求完全不可接受。解决思路就两个字:预热。服务启动时执行一次空推理,让权重真正进入显存或内存,之后的请求延迟就能降到正常水平。还有一个小技巧是给 FastAPI 开一个线程池跑推理,因为 PyTorch 推理是同步的,放在协程里并不会自动变快,多个请求同时进来时反而可能互相阻塞。我实测同一个推理函数,从协程直接调用改成线程池执行后,P99 延迟下降了约 30%。
4.4 团队协作与流程规范问题
多人协作最容易翻车的是 Notebook。两个同事同时改同一个.ipynb,Git 冲突永远处理不干净。我现在的原则是:Notebook 只做探索性分析,最终训练脚本、特征逻辑、服务代码全部用.py文件维护,Notebook 提交前先清理输出。这个习惯让代码评审变得可行,毕竟 code review 一个.py的 diff 比 review 一个充满 JSON 的 notebook 容易太多。
模型文件放进 Git 也是常见事故。一个 500MB 的.pth文件用 Git LFS 管理可以缓解,但如果团队没约定好配额,仓库体积依然会爆炸。更干净的方案是 DVC 管理模型和数据,Git 只保留小体积的元信息。模型文件的问题,我在项目里统一走 MLflow Model Registry,某次老板想"回退到上周那版效果好的模型",我只需要在注册表里选版本号,而不是去 Git 历史里翻二进制文件。
5. 实操心得与长期习惯
5.1 我踩过最深的一次坑:特征不一致
前面提到过的特征不一致问题,我在这里再展开讲讲。当时训练脚本里有个函数会去掉文本中的连续空格,推理服务里复制了一份,后来又有人手贱给推理端加了一句text[:200]截断,因为"觉得长文本没用"。结果新模型上线后线上效果直线下降,排查了一整天,最后逐行对比两端预处理逻辑才发现差异。
这个坑给我的教训非常深刻:训练和推理共用同一份特征代码,是 AI 工程的底线之一。现在我把特征工程代码抽成独立 Python 库,训练脚本和 FastAPI 服务都通过 pip 安装同一个库并固定版本。任何想改动特征逻辑的人必须先发新版特征库,再部署推理服务,训练脚本也要同步升级,整个流程有 trace 可查。
5.2 新手最容易低估的部分:数据质量与评估体系
大多数新人会把精力全放在模型结构和超参数选择上,但说实话,从工程视角看,决定项目成败的往往是数据质量和评估体系。我在做这个项目时建立了 golden dataset,人工标注了 500 条高置信度样本,每次模型改动后先跑这一组样本做回归测试。只要有一条原本预测正确的样本被改错,就会弹窗提醒,让我立刻意识到改动可能引入了行为偏移。
评估体系也远比单个准确率复杂。对于分类任务,我同时看每个类别的 precision 和 recall,而不是只盯着总准确率。之前有个二分类模型整体准确率 0.93,但少数类 recall 只有 0.4,业务方用起来满肚子意见。上线前就要把分层的指标矩阵分析清楚,否则指标好看和业务好用完全是两码事。
5.3 给从零开始的你一份行动路线
如果你正在筹备自己的 ai-engineering-from-scratch 项目,我的建议是别一上来就追求大模型,先挑一个小而具体的方向,比如垃圾评论识别、商品标题分类、日志异常检测。第一步把环境、依赖、目录结构搭好,确保别人 clone 项目后能一键跑通;第二步用一批有代表性的真实数据做端到端 baseline,哪怕准确率不高也没关系,关键是让整个链路完整跑起来;第三步再逐步替换模型的每一环,每一步都用实验记录和评估指标说话。
不要急着读一百篇论文,也不要三天两头换框架。AI 工程的本质是"让模型稳定地产生价值",这句话我花了很久才真正理解。你的第一个项目不需要惊艳,需要的是一套能不断迭代的骨架,以及你在这个过程中踩过的每一个坑沉淀下来的判断力。如果你也在从零开始走这条路,记住:别先钻研花哨算法,先把你手上的工程骨架打磨到搬上任何机器都能稳定运行,后面的一切都会顺很多。