1. 先说清楚:AI工程和"训练一个模型"差距到底在哪
如果你曾经在网上搜索过"ai-engineering"这个词,大概率会得到一堆互相矛盾的信息。有人告诉你它等于搭神经网络,有人说是调参炼丹,还有人强调必须会部署才能算工程……我做AI落地项目这几年,最深的感受是:AI工程不是某个单点技术,而是一条把数据、模型、代码、部署串起来的完整流水线。这篇内容我说的"from-scratch",不是教你怎么手写一个Transformer,而是把一套真实可用的AI工程体系,从零开始一砖一瓦搭起来。
先说个扎心的现象:很多团队模型训练得不错,验证集指标也很漂亮,但一到交付就卡壳。要么换台机器就复现不了结果,要么数据一更新模型就崩,要么模型上线了但没人知道它什么时候开始退化。这些问题都不是算法问题,而是工程问题——而"AI工程"这个词,恰恰就是用来解决这些事的。
1.1 为什么算法能跑通,项目却总是交付不了
我见过太多"论文复现很快、项目落地很慢"的情况。根源在于算法研究追求的是"在理想条件下达到SOTA",而AI工程追求的是"在约束条件下稳定交付"。约束条件包括:数据会变、机器会换、依赖会升级、线上流量有峰值、业务方会提新需求。
举个最常见的例子:你在Jupyter Notebook里跑通了一个模型,一切正常。然后你要把它部署成服务,立刻会面对一堆新问题——模型文件怎么打包?推理请求怎么并发处理?GPU内存够不够?冷启动要多久?依赖版本冲突怎么办?这些问题没有一个是"模型本身"的问题,但每一个都能让项目延期。
所以做AI工程的第一课,是改变心态:模型只是一个组件,而不是项目的全部。这和盖房子一样,模型是那根承重梁,但你不能只扛着梁就住进去,还得有地基、有管线、有门窗。
1.2 从零开始的一个真实落地路径长什么样
以我最近做的一个项目为例,业务方需要一套基于图像识别的质检系统,要求是:能自动判断产品外观缺陷,准确率不低于95%,而且要在内部服务器上稳定运行,不能依赖公网API。
这个需求拆解之后,落地路径大致是这样的:
- 环境搭建:锁定Python版本、CUDA版本、深度学习框架版本。
- 数据管线:收集现场图片、清洗标注、划分训练验证集、做数据增强。
- 训练工程化:把训练脚本结构化,记录每次实验的参数和指标。
- 评估与选型:对比多个模型,选一个准确率达标且推理速度能接受的。
- 部署上线:封装成推理服务,做性能测试,加监控告警。
这五个环节,就是一套最简的AI工程闭环。后面几节我会把每一步的选型逻辑、具体操作和踩过的坑展开讲。如果你正在从零搭建自己的AI工程能力,完全可以按这条路径一步步来,每一步都有可以直接抄作业的配置和命令。
2. 第一步:把开发环境变成"可复现的工程"
在开始写任何模型代码之前,先把环境搞好。这一步最枯燥,但也是后期最省心的地方。很多项目死在"换台机器跑不起来",根子就是环境没有锁定好。
2.1 工具链选型:不要一上来就装深度学习框架
我给团队搭环境时,默认顺序永远是:先装Python版本管理器,再选依赖管理工具,然后是深度学习框架,最后才是各种辅助库。顺序反了,后面全是坑。
这里重点说一下依赖管理工具。传统做法是pip install加一个requirements.txt,但这种方式在真实项目里有一个很烦人的问题:传递依赖的版本不会被严格锁定,除非你用pip freeze把所有包钉死。问题是pip freeze会把一堆无关紧要的包也掺进来,requirements.txt又臭又长还不一定兼容。
我现在的做法是:新项目一律用Poetry或uv,老项目才保留requirements.txt。Poetry的pyproject.toml会把直接依赖和传递依赖分开管理,再配合poetry.lock锁文件,任何人拿到项目后一条命令就能装出完全相同的环境。uv是更快的替代品,用Rust写的,安装速度比pip快一个数量级,体验很好。
你可以直接这样初始化一个项目:
uv init my_ai_project cd my_ai_project uv add numpy pandas scikit-learn torch uv add --dev pytest black pre-commit好处是uv.lock文件一旦生成,所有依赖的版本就彻底固定了。之后同事克隆仓库,执行uv sync就能复现一模一样的环境。
2.2 项目目录与配置管理的组织方式
环境搞定之后,下一步是规划项目结构。很多初学者喜欢把脚本全扔在一个目录里——train.py、utils.py、data_processing.py,再放几个Jupyter Notebook,然后项目就失控了。
我推荐一个经过多次迭代后固定下来的目录结构:
my_ai_project/ ├── configs/ # 所有配置文件 │ ├── data.yaml # 数据路径、数据增强参数 │ ├── train.yaml # 训练超参、优化器设置 │ └── deploy.yaml # 推理服务配置 ├── data/ # 数据文件(Git里不放,用DVC管理) ├── src/ │ ├── data/ # 数据下载、清洗、预处理代码 │ ├── features/ # 特征工程代码 │ ├── models/ # 模型定义代码 │ ├── train.py # 训练入口 │ ├── evaluate.py # 评估入口 │ └── predict.py # 推理入口 ├── tests/ # 单元测试、数据校验测试 ├── scripts/ # 一次性脚本和运维脚本 ├── pyproject.toml └── README.md这里有个很关键的原则:配置和代码分离。训练超参、数据路径这种东西不要硬编码在代码里,而是放在配置文件里。这样每次实验改参数,你只需要改配置文件,不用去翻代码,也不会改坏逻辑。
我用的配置文件格式是YAML。理由很简单:支持注释、支持嵌套结构、人类可读性极强。比如train.yaml长这样:
model: name: resnet50 pretrained: true num_classes: 10 train: batch_size: 64 learning_rate: 0.001 epochs: 50 num_workers: 8 seed: 42 data: train_path: ./data/processed/train val_path: ./data/processed/val augmentation: light训练入口里只需要用一个简单的load_config函数读取这个文件,代码里不出现任何魔法数字。等实验多了你就能感受到这种做法的好处:翻实验记录时,只需要看配置文件就能还原一切。
2.3 用Docker锁定运行时:三个最实用的写法
依赖锁文件解决了Python包的问题,但还有系统层面的问题:不同机器的CUDA驱动不同、操作系统的glibc版本不同、有些底层库的编译环境不同。要彻底解决"换台机器就复现不了"的问题,必须上Docker。
一个典型的AI训练镜像Dockerfile长这样:
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 WORKDIR /app # 先拷贝依赖声明文件,利用Docker层缓存加速构建 COPY pyproject.toml uv.lock ./ RUN pip install uv && uv sync --frozen # 再拷贝源代码 COPY src/ ./src/ COPY configs/ ./configs/ CMD ["python", "src/train.py"]这里有两个实用技巧。第一个是把"依赖声明文件"和"源代码"分开拷贝,这样只要依赖没变,Docker构建时就会直接命中缓存层,重建只需要几秒钟。第二个是基础镜像尽量选用-runtime而不是-devel版本,推理阶段用runtime镜像能小一半体积,而且暴露的攻击面也少了。
另外提醒一句:如果你要跑的是纯CPU推理服务,别用nvidia/cuda镜像,直接基于python:3.11-slim做一个精简镜像就好。镜像体积从2GB降到400MB,部署速度天差地别。
3. 第二步:数据是工程的源头,先把它变成"受控资产"
任何一个AI工程师都必须明白:模型可以换、代码可以改,但数据的质量直接决定天花板的上下限。我在带新人时最爱问的一句话是:"这批数据的版本是几?"几乎没人答得上来。答不上来,说明数据还没有被当作"受控资产"来管理。
3.1 数据收集与清洗:把临时脚本变成可重跑的管道
很多人处理数据的模式是写个Python脚本跑一遍,把结果保存成CSV,完事。问题是:三个月后数据源更新了,你想重新跑一遍,发现自己完全看不懂当初写的是什么,甚至脚本早就被删了。
正确的做法是把数据处理拆成一条可重跑的管道,每一个环节都是独立的模块,且支持断点续跑。参考这个流水线设计:
原始数据(s3/本地/数据库) → 1_download.py # 下载或同步原始数据,记录时间戳 → 2_validate.py # 检查数据完整性、格式合法性 → 3_clean.py # 去重、去空值、修复格式 → 4_transform.py # 特征提取、归一化、划分数据集 → 5_save.py # 输出到统一格式(PQ/parquet)并记录版本每一步的输出都写到磁盘上的独立目录,文件名带上日期或版本号。这样即使某一步挂了,你只需要重跑那个环节,不用从头开始。这一步听起来简单,但在真实项目里救了我无数次——有一次训练到一半发现数据里有大量重复样本,修正数据后只重跑清洗环节,几分钟就搞定了,不耽误进度。
3.2 数据校验:在训练之前先拦住脏数据
"Garbage in, garbage out"这句话,做AI的人耳朵都听出茧子了,但真正落地数据校验的团队少之又少。原因很简单:校验逻辑写起来不性感,也没有KPI。
我用的工具是Great Expectations,它允许你用声明式的方式定义数据期望。举个例子,你希望"缺陷标注的类别必须在['划痕','凹陷','脏污','正常']这四个值里",可以这样写:
import great_expectations as gx context = gx.get_context() batch = context.get_batch_list_from_dataframe( df, expectation_suite_name="product_quality" )[0] results = batch.validate(expectation_set=[ gx.expect_columns_to_exist(columns=["image_path", "label", "confidence_score"]), gx.expect_column_values_to_be_in_set("label", ["划痕", "凹陷", "脏污", "正常"]), gx.expect_column_values_to_not_be_null("image_path"), gx.expect_column_values_to_be_between("confidence_score", 0, 1), ])如果在训练的前置环节发现校验不通过,整个管道就直接中止,绝不带着脏数据进入训练。这样虽然拦住了不少批次,但也倒逼数据标注团队提高质量,大概迭代两三个版本后,数据准确性有了明显提升。
3.3 数据版本化:让模型可以回溯到"喂过什么"
模型效果变好了,你能说出是哪个commit的代码、哪批数据、哪组超参一起跑出来的吗?如果答案是否定的,那你就不算真正做了AI工程。模型回溯需要同时锁定代码版本和数据版本,缺一不可。
数据版本化用DVC(Data Version Control)。它的设计思路是把大文件存在云端或局域网存储,然后在Git里只存data.dvc这种很小的指针文件,通过它引用大文件的哈希。
常用命令:
dvc add data/processed/train.parquet dvc push # 推送到远程存储(AWS S3/OSS/NAS) git add data/processed/train.parquet.dvc .gitignore git commit -m "Add training data v2, includes new defect samples"等需要切换数据版本时,只需要git checkout对应的提交,再执行dvc pull就能把大文件拉回来。这套方案的关键价值是:数据版本跟着Git走,一个模型实验记录天然包含了数据版本信息。后续排查问题、重新实验、审计合规,都非常方便。
这里有个注意点:DVC不适合存超大单文件,比如几十GB的JSONL,仓库操作会非常慢。这类文件建议切成分片或者用parquet列式存储压缩后再纳入版本管理。
4. 第三步:训练流程的工程化改造
训练环节是AI工程里最接近"传统算法"的部分,但即使在这里,工程化手段也能明显提升效率。核心思路是:让每一次实验都可追踪、可比较、可复现。
4.1 从Jupyter到脚本:训练代码的结构拆分
我不反对用Jupyter做探索性分析,但训练代码绝对不能长期躺在Notebook里。Notebook有一个天然缺陷:执行顺序和代码顺序可以不一致,很容易出现"看起来跑得通,换个环境就崩"的情况。
训练代码的结构拆分,我用的模式很简单:
src/ ├── models/ # 模型定义,和具体任务解耦 ├── data_modules/ # 数据加载和预处理逻辑 ├── trainer.py # 训练循环主体 ├── callbacks.py # 日志、checkpoint、早停等钩子函数 └── utils.py # 公共工具函数训练代码只管三件事:加载配置、加载数据、启动训练循环。所有细节逻辑放在独立的模块里。例如早停、学习率衰减、模型保存这些逻辑,用简单的回调和钩子函数实现,不直接在训练循环里堆代码。
这种拆分方式最大的好处是:当你想尝试一个新的训练技巧,比如换一种学习率调度策略,只需要修改对应的回调模块,不需要动整个训练流程。几个项目下来,你会攒出一套可以复用的训练脚手架,新项目只需要改数据和模型部分,启动时间大幅缩短。
4.2 实验追踪:记录每一次修改
训练实验一多,人脑的记忆就不靠谱了。你上周跑了一个模型,batch_size是32还是64?学习率是多少?当时用的数据是哪一版?如果这些问题还要翻聊天记录去查,实验效率会非常低。
我的方案是用MLflow做实验追踪。它最实用的功能有三个:
- 自动记录超参数和指标,每次运行生成一条独立的实验记录。
- 模型产物注册,可以给每个模型打标签或阶段标记,如
Staging、Production。 - 推理服务封装,可以把训练好的模型快速封装成一个REST API。
MLflow的使用相当简单,在训练代码里加几行就行:
import mlflow with mlflow.start_run(): mlflow.log_params(config["train"]) # 记录超参数 mlflow.log_metric("val_accuracy", accuracy) # 记录指标 mlflow.log_artifacts("./checkpoints", artifact_path="models") # 保存模型文件几行代码,换来的是所有实验记录集中可查,还能直接在MLflow的UI上对比不同实验的指标曲线。项目里一旦用了这个,你就再也回不去"靠脑子记实验"的日子了。
4.3 超参数搜索:别全靠感觉调参
传统的超参数调优是"手艺人"做法——先跑一版,然后靠经验改几个参数,再跑,中间还要处理"玄学波动"(同样的参数换个随机种子结果就差几个点)。省力一点的做法是用Optuna做自动化的超参数搜索。
Optuna支持定义搜索空间,自动尝试不同的参数组合,并配合修剪机制提前淘汰明显跑不出来的实验。一段典型的调参代码长这样:
import optuna def objective(trial): lr = trial.suggest_float("lr", 1e-5, 1e-2, log=True) batch_size = trial.suggest_categorical("batch_size", [16, 32, 64]) weight_decay = trial.suggest_float("weight_decay", 1e-6, 1e-2, log=True) # 根据以上参数训练模型,返回验证集指标 return train_and_evaluate(lr, batch_size, weight_decay) study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50)关键是设置合理的搜索空间。比如学习率用对数范围、batch_size用离散候选值,而不是什么都给一个默认值。我踩过的坑是搜索空间太宽泛,浪费了大量算力,后来用前10次粗搜定位大致区域,再缩小范围精搜,效率高不少。
5. 第四步:部署与上线,工程化价值的最终检验
前面的环境、数据、训练,本质上都是为了一个目的:让模型稳定地在生产环境提供服务。部署这一节,我再细分三步来讲。
5.1 模型服务的三种形态与选型
把模型部署成服务,根据业务需求不同,有三种常见形态:
| 形态 | 适用场景 | 典型技术 | 延迟 | 复杂度 |
|---|---|---|---|---|
| 在线REST API | 实时推理,比如质检、在线审核 | FastAPI + Uvicorn | 低 | 中 |
| 批处理任务 | 离线大批量预测,比如定时报表 | Spark / TensorFlow Batch | 高 | 中 |
| 流式处理 | 实时数据流,比如日志异常检测 | Kafka + Flink | 极低 | 高 |
我80%的项目用的是第一种,批量预测用第二种。这里重点说FastAPI,因为它是把深度学习模型封装成服务的最快路径。一个典型的推理服务只有几十行代码:
from fastapi import FastAPI, UploadFile from model_loader import load_inference_model import cv2 import numpy as np app = FastAPI() model = load_inference_model() # 启动时只加载一次 @app.post("/predict") async def predict(image: UploadFile): image_bytes = await image.read() arr = np.frombuffer(image_bytes, np.uint8) img = cv2.imdecode(arr, cv2.IMREAD_COLOR) result = model.predict(img) return {"label": result.label, "score": result.score}几个关键细节:第一,模型要在服务启动时加载一次,放进全局变量,不要在每次请求时重复加载;第二,要做输入校验,防止用户传一个非图片文件导致整个进程崩溃;第三,同步IO操作会阻塞FastAPI的事件循环,如果你用的是免GPU的纯CPU推理,可以加def predict(不加async),让FastAPI自动放到线程池里处理。
5.2 推理性能优化:从GPU利用率说起
模型服务上线之后的第一个性能瓶颈,往往不在模型本身,而在数据传输和并发调度。我遇到过一个小项目,模型推理只要3毫秒,但端到端延迟有200毫秒——查了半天发现时间全花在图片的base64解码和重复的预处理上。
优化的顺序是这样的:
- 预处理前置:把图像解码、resize、归一化这些操作从模型推理路径里拆出来,能并行就并行。
- 显存复用:如果多个服务实例共用一张GPU,需要显式设置
CUDA_VISIBLE_DEVICES,并使用共享显存的推理框架如TensorRT,避免重复申请显存。 - 批量推理:在线推理也可以做动态batching——多个请求攒到一起,一次推理处理多张图。对于Transformer类模型收益极大,推理吞吐量能提升3到5倍。
- 模型量化:如果精度允许,把FP32换成FP16甚至INT8,显存占用直接减半。
关于量化,我的经验是:先做小规模验证再全量上。有些模型对量化很敏感,精度会掉一个点以上;而有些模型几乎无损。所以一定要在你的验证集上量化前后对比指标,不能想当然。
5.3 线上监控与数据漂移的兜底方案
模型上线不是结束,而是运营的开始。一个常见的场景:模型上线一个月后,业务方反馈"最近结果不准了"。你不一定是模型被改坏了,很可能是因为线上数据的分布变了。这就是数据漂移。
应对手段是加一层监控。轻量方案是在推理服务里记录每个请求的输入特征分布,定期和训练集分布做对比。开源工具里,Evidently可以很直观地做数据漂移检测,它的报告能显示哪些特征的分布发生了显著变化。
还要加传统运维里的业务指标监控:请求量、延迟、错误率。这些都是AI工程容易被忽视的部分,但恰恰是业务方真正关心的。我的建议是部署Prometheus + Grafana,把推理延迟、请求错误率、数据漂移指标全部画成面板。看到指标曲线异常,再回溯到对应的模型版本和数据版本,定位问题就快得多。
6. 从零到一的最短路径和学习顺序
讲了这么多,如果你是想从零开始构建AI工程能力的新手,可能会觉得信息量太大,不知从何下手。我把学习路径浓缩成六个阶段,每个阶段都配了核心目标和练习建议。
- 环境搭建:目标是把Python开发环境、Docker、Git彻底搞定。练习标准是:能在新机器上十分钟内复现任意一个旧项目的环境。
- 数据处理:目标是用pandas处理真实表格数据,用DVC管理数据版本。找一份Kaggle数据,完整地走一遍下载、清洗、划分版本。
- 训练脚本化:目标是能脱离Jupyter,用Python脚本完成一次完整的模型训练和评估,并用MLflow记录全部实验。
- 模型服务化:目标是用FastAPI封装一个推理接口,并在本地通过HTTP请求调用成功。延迟、并发、错误处理都要考虑。
- 工程链集成:目标是把上面四步串起来,形成一条"数据入→DVC→训练→MLflow→部署→监控"的完整流水线,并写好README让别人能复现。
- 性能优化:目标是在已有链路上做推理加速、模型量化、并发优化。这个时候你已经可以独立承接一个小型AI落地项目了。
这整个阶段里,比较容易被忽视的是第1和第2阶段,很多人觉得不够刺激,一上来就想去训练SOTA模型。我的建议恰恰相反:前两步打得越扎实,后面五个阶段的推进速度越快。等到部署环节出了问题,你回头补环境知识和数据管理知识,代价会大得多。
最后分享一个我自己带项目的切身体会:AI工程从零开始,最快的路不是看一堆课程然后做一堆不完整的Demo。想真正建立工程能力,必须完整地跑通一个端到端项目,哪怕只是一个很小的图像分类任务,也要认真走完环境锁定、数据版本化、实验记录、模型部署、监控告警这全部环节。这个过程里踩到的每一个坑——无论是依赖冲突、数据格式错误还是服务器上的CUDA版本不匹配,都是宝贵的实战经验。跑通一个,再跑第二个你会明显感觉到,工程化大幅降低了不确定性,剩下的事情都在掌控之中。