先说一个我这些年反复见到的现象:很多能把训练指标做到很漂亮的人,一到“把模型交出去给别人用”这个环节就卡住了。环境搭不起来、接口写不明白、上线没几天就崩,最后花在模型以外的时间,远远超过训练本身。这个把模型从数据到服务完整打通、让它稳定被真实用户使用的领域,就是ai-engineering,也就是AI工程。接下来这篇,我想分享的是从零开始建立AI工程能力的一条完整路径,以及那些看起来不起眼、但真正决定成败的环节。
1. AI工程不是调库,也不是训模型,它是从需求到稳定服务的一条完整链路
先说个最容易混淆的概念。很多人觉得“AI工程 = 用深度学习框架训练模型”,这其实把问题理解小了。训练模型在AI工程里确实重要,但它只是整条链路里最容易被注意到、却也最短的一环。
我举个真实例子。某次我们把一个文本分类模型交给合作方,离线指标没问题,训练流程也复现过三次。结果对方拿过去以后,光是在他们服务器上把Python环境和依赖装对,就折腾了两天。第三天模型总算跑起来了,但接口一压测就超时,没人知道问题是出在模型推理慢、服务框架配置错,还是他们服务器CPU太老。这种问题,就是典型的AI工程问题——它不发生在模型训练过程里,而发生在模型从“我的电脑”走向“别人的系统”的过程中。
用一句通俗的话来概括:训练模型像做一道拿手菜,AI工程则像开一家饭店。做饭只需要你对自己的厨房负责,开店要求你保证每一道菜稳定、可重复、出问题能追溯,换一个厨师也能接手。这个类比基本说出了AI工程的核心目标——把个人化的、实验性的能力,转化为组织化的、可靠的系统能力。
所以给AI工程下一个切实的定义,我会说它是:
从真实业务需求出发,把数据获取、数据处理、模型训练与评估、模型打包、服务化部署、线上监控、再迭代这一整条链路以工程化的方式串起来,并且保证每个环节可复现、可维护、可回滚。
那AI工程师和算法工程师到底有什么不一样?我一般用下面这张表跟新人解释:
| 角色 | 关注的核心问题 | 主要交付物 | 典型工具 |
|---|---|---|---|
| 算法工程师 | 模型指标还能不能更高 | 实验结果、模型权重、技术报告 | PyTorch、TensorFlow、Notebook |
| AI工程师 | 系统能不能稳定运行并持续迭代 | 线上服务、部署流程、监控告警 | Docker、接口框架、CI/CD、监控体系 |
| 数据工程师 | 数据是否及时、准确、完整 | 数据管道、数据仓库 | Airflow、Spark、Kafka |
这里不是要分高下,而是强调关注点不同。算法工程师可以只对实验结果负责,AI工程师必须对整条链路的结果负责。前者是某个点上的突破,后者是整条线的贯通。
对于整条链路,我习惯把它拆成四段来理解:
- 数据段:数据怎么来、怎么清洗、怎么切分、怎么保证训练数据的可复现性。
- 模型段:模型选型、训练、评估、实验记录。这一段大家最熟悉,但工程化程度往往也最低。
- 服务段:把模型文件变成一个可以对外提供能力的服务,涉及序列化、推理封装、接口设计、性能优化。
- 运营段:上线之后的事,包括监控、告警、版本管理、回滚、定期重训。
接下来的内容,基本就按照这四个段来展开。我会拿一个具体项目——电商评论情感分类——从零到上线完整走一遍,这样比空谈定义有用得多。
2. 第一天就要打好的地基:环境、依赖与可复现性
2.1 别再全局环境裸装Python包了
AI项目里最隐蔽的坑,就是环境。我见过太多人的习惯是:打开终端,pip install torch,装完就跑,跑了就忘。等到三个月后再打开电脑,发现什么都跑不动,因为全局环境里几十个包互相打架,谁也不知道是哪次安装把它们搞坏的。
从第一天起,我建议养成两个习惯。
第一,每个项目一个独立虚拟环境。现在Python生态里的选择很多,我推荐优先用uv或者conda。uv的特点是速度快,创建环境、装包都是一两秒的事;conda在管理CUDA相关依赖时更省心,适合GPU训练为主的场景。两个都行,关键是别混着在全局环境里乱装。
第二,锁死依赖版本。requirements.txt里不要写torch>=2.0这种范围版本,而要写torch==2.1.2这种精确版本。更彻底的做法是用pip-tools生成requirements.lock文件,把传递依赖也锁住。这样做的理由是:AI库的版本兼容性太脆弱了。比如PyTorch的小版本更新,就可能改变某些算子的默认行为;同样的模型代码,在torch 2.0.1和2.1.2下可能训练出略微不同的结果。不锁版本,就谈不上复现。
2.2 Docker:让“在我电脑上能跑”这句话消失
虚拟环境解决的是本机的隔离问题,但解决不了“你的电脑”和“别人的电脑”之间的差异。比如你可能用的是Mac,对方是Windows,再往下CPU指令集、GPU驱动、系统库版本都不一样。
Docker是AI工程里绕不开的基础设施。它把运行环境和代码一起打包,保证镜像在任何地方跑出来的行为一致。下面这份是我个人喜欢的最简Dockerfile,适合大多数单机模型服务:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]这里面有三个容易被忽略的细节:
- 基础镜像必须锁到小版本:用
python:3.10-slim而不是python:latest。否则半年后重建镜像,底层的Python小版本可能已经变了,很多包需要重新编译,甚至出现行为差异。 - 先拷贝依赖文件再装依赖,最后拷贝源码:这样当代码改动而依赖没变时,Docker能复用依赖安装的缓存层,构建速度会快很多。
- 用
--no-cache-dir:减小镜像体积,也避免pip缓存在镜像里留下冗余文件。
如果你要跑GPU训练,那基础镜像换成pytorch/pytorch:2.1.2-cuda12.1-cudnn8-runtime这类官方带好CUDA的镜像,会比自己在python镜像里配CUDA省心得多。
2.3 数据版本和实验记录:别等到要追溯的时候才开始后悔
这里是我最想提醒新手的一件事:模型代码要用Git做版本管理,数据同样也要有版本管理,实验记录更是要可视化。
数据版本这件事,很多人一开始会想“不就是个csv文件吗,我复制一份按日期命名不就行了?”短期看确实行,但时间一长你就知道什么叫灾难。常见场景:你三个月前训练了一个效果不错的模型,今天线上效果变差了,想查一下当时用的到底是清洗后的数据还是原始数据、切分比例是多少、哪一次代码提交产出的模型——如果全靠文件名和记忆,基本等于不可追溯。
我推荐从项目一开始就固定两件最简单的事:
- 所有数据文件计算一个哈希值(如SHA256),写进实验记录里。下次训练前对比哈希,就知道数据是否发生变化。
- 用一个实验记录文件(甚至一张Excel表)登记每次实验:时间、数据集哈希、Git提交号、配置文件路径、最终指标。
等项目做大了,可以再上MLflow或者W&B这类专门的实验管理工具,它们能自动记录指标、模型文件、超参数,还支持可视化对比。但对从零开始的人来说,重要的是先建立“记录”的意识,而不是一开始就纠结选多复杂的工具。
3. 从零跑通全流程:一个电商评论情感分类的最小项目
3.1 为什么选这个项目作为练手场景
AI工程入门,最怕选错项目。如果一上来就做大语言模型微调、多模态、推荐系统,光数据处理和模型训练的资源消耗,就足以让人劝退。我的强烈建议是:选一个足够小、但能覆盖完整链路的任务。
我给学生和新人推荐的一直是短文本二分类,尤其是电商评论的情感分类(正面/负面)。选择理由如下:
- 数据好找好造:可以直接拿电商平台的“好评”“差评”标签,或者自己构造几百条样本,一下午的阅读量就够写一个可用的数据集。
- 模型足够轻:用
distilbert-base-uncased或albert-base这类小模型,在CPU上几小时也能训完,有GPU更快,不需要申请几十GB显存的服务器。 - 部署极简:模型小,单机CPU加ONNX Runtime就能跑,不需要纠结GPU推理、分布式推理这些一下子上来的复杂度。
- 指标可解释:二分类看准确率、精确率、召回率、F1,谁都能理解,便于判断系统是否正常。
这个场景保证了你能完整地经历一遍AI工程的四个阶段,而不会被某一步的复杂度拖垮。
3.2 训练脚本的工程化:让训练可以被复现,而不是靠运气
不少人写训练代码的习惯是:在Notebook里一段段跑,调参后整个文件重跑一遍,最后只存一个模型权重。这种流程最大的问题在于,“结果好的那个版本”很可能是最后一次改动的产物,而“最后一次改动”改了什么没人记得。
从工程化的角度,一个能接受的训练脚本至少要有这几样东西:
- 合理的目录结构;
- 配置与代码分离,用配置文件控制实验,而不是改代码;
- 使用日志系统记录而不是print;
- 自动保存最优checkpoint;
- 随机种子固定,保证结果可复现。
一个可以参考的目录结构是这样:
sentiment/ ├── configs/ │ └── train.yaml ├── data/ │ └── reviews.csv ├── src/ │ ├── train.py │ └── predict.py ├── models/ └── experiments/相应的配置文件train.yaml:
data: path: data/reviews.csv val_ratio: 0.2 text_field: review label_field: sentiment model: name: distilbert-base-uncased max_length: 128 training: batch_size: 32 epochs: 3 learning_rate: 2e-5 seed: 42训练脚本入口写成从配置文件读取参数,而不是直接复制修改参数:
import argparse import yaml import logging import random import numpy as np import torch from transformers import (AutoTokenizer, AutoModelForSequenceClassification, Trainer, TrainingArguments) logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) def set_seed(seed: int): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def main(config_path: str): with open(config_path, "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) set_seed(cfg["training"]["seed"]) logger.info("Loaded config from %s: %s", config_path, cfg) # 此处省略数据加载与预处理 # 重点:使用 tokenizer 将文本转成 ids,并切分训练/验证集 training_args = TrainingArguments( output_dir="experiments/run_001", num_train_epochs=cfg["training"]["epochs"], per_device_train_batch_size=cfg["training"]["batch_size"], learning_rate=cfg["training"]["learning_rate"], logging_dir="experiments/run_001/logs", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="accuracy", ) trainer = Trainer( model=AutoModelForSequenceClassification.from_pretrained( cfg["model"]["name"], num_labels=2), args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, compute_metrics=compute_metrics, ) trainer.train() trainer.save_model("models/sentiment_best") tokenizer.save_pretrained("models/sentiment_best") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--config", default="configs/train.yaml") args = parser.parse_args() main(args.config)这段代码里的工程要点有三个:
- 配置驱动训练:要跑一次新实验,只需要复制一份yaml改参数,不用动代码。而yaml文件本身放进Git,就能追溯到每一次实验用了什么参数。
- 日志代替print:日志带时间戳和级别,长时间训练后想回查“某个epoch的loss是多少”,print是翻不过来的。
- 固定随机种子:GPU训练里的随机性没法完全消除,但至少要固定Python随机、NumPy随机和PyTorch随机。种子不一致,哪怕代码一样也复现不出同等指标。
3.3 模型导出与序列化:不要随手Pickle
训练完模型,下一步是把权重交给服务端。这里有一个常见的错误:直接把整个model对象用pickle序列化后扔给服务。这个做法的问题在于,序列化后的文件绑定了一大堆运行时信息,包括模型类的源码、Python版本、PyTorch版本。只要换一个环境,非常容易反序列化失败或者行为不一致。
我在线上项目里更推荐的做法是:
- 保存模型权重,使用框架自带的
save_pretrained(HF格式),同时会保存pytorch_model.bin和config.json; - 模型结构信息放进config.json:包含模型名称、词汇表大小、num_labels等结构信息;
- 一并保存tokenizer文件:推理时文本必须先进tokenizer,tokenizer的词汇文件如果不一起保存,换环境就找不到了。
如果你的服务环境对性能和跨语言有要求,一个更强的方案是导出为ONNX。ONNX相当于模型的“通用格式”,推理时不依赖PyTorch,只依赖ONNX Runtime,因此不同语言、不同环境都能跑,而且推理速度通常更快。对于distilbert这种小模型,transformers支持直接导出。
from transformers import AutoModelForSequenceClassification, AutoTokenizer import torch model = AutoModelForSequenceClassification.from_pretrained( "models/sentiment_best") model.eval() dummy_input = torch.randint(0, 1000, (1, 128)) torch.onnx.export( model, dummy_input, "models/sentiment_best.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch_size", 1: "seq_len"}, "attention_mask": {0: "batch_size", 1: "seq_len"}, "logits": {0: "batch_size"}, }, )这里dynamic_axes是可选项,但强烈建议加上,否则服务端只能输入写死的batch_size=1和seq_len=128,一旦想批处理就废了。
3.4 用FastAPI把模型包成在线服务,以及接口的正确写法
模型文件准备好,接下来是把它变成一个能对外提供能力的HTTP服务。我用FastAPI做这件事比较顺手,原因有三点:
- 自带Pydantic请求校验,请求字段不合法直接返回400,不用手写一大堆判断;
- 异步能力对I/O密集场景友好;
/docs自带Swagger交互文档,联调方便。
但这里有一个特别重要、又容易被新手忽视的点:模型和tokenizer一定要在应用启动时加载一次,不能放到请求处理函数里加载。否则每一个请求都会重新加载模型,慢到没法用。
下面是一个完整的、小而正确的服务示例:
from fastapi import FastAPI from pydantic import BaseModel from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch app = FastAPI(title="sentiment-service") model_path = "models/sentiment_best" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSequenceClassification.from_pretrained(model_path) model.eval() class ReviewIn(BaseModel): text: str class SentimentOut(BaseModel): label: str confidence: float @app.get("/health") def health(): return {"status": "ok"} @app.post("/predict", response_model=SentimentOut) def predict(item: ReviewIn): inputs = tokenizer( item.text, truncation=True, max_length=128, return_tensors="pt") with torch.no_grad(): logits = model(**inputs).logits probs = torch.softmax(logits, dim=-1)[0] label_id = int(torch.argmax(probs)) label = "positive" if label_id == 1 else "negative" confidence = float(probs[label_id]) return SentimentOut(label=label, confidence=confidence)部署时不要只用uvicorn app:app单进程硬扛,正式环境用多进程:
gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker --timeout 60-w 4表示用4个worker进程,能利用多核CPU;-k uvicorn.workers.UvicornWorker指定用Uvicorn的worker类,让FastAPI的异步能力正常生效。如果这时候还对性能不满意,再考虑批处理、动态batch、GPU推理这些进阶优化,不要第一步就冲重量级推理引擎。
4. 线上事故才是一堂课:我遇到过的三个典型问题
4.1 线上环境torch版本不一致,模型输出全部乱掉
这是我最想展开讲的一个案例。当时流程是:本地用PyTorch 2.1.2训练并导出了模型,线上服务部署在一个基础镜像里,那个镜像默认装的是PyTorch 2.0.1。刚上线时没人意识到会有问题,直到第二天业务方反馈“模型预测结果明显不对”。
排查过程:
- 先用同一批请求分别打线下复现服务和线上服务,确认不是数据问题;
- 检查线上日志,没有报错,但模型的confidence分布非常奇怪;
- 逐个对比依赖版本,发现
torch主版本号一致、小版本不一致; - 在线上容器里单独跑一个预测脚本,用相同的输入,确实复现出了错误结果。
根因是:我用的模型代码在2.1.x版本里依赖了新版本的API行为,2.0.1里虽然也能执行,但某个算子的实现细节不同,导致输出分布偏移。这种问题最麻烦的地方在于它不报错,只在输出里悄悄出错。
从那以后,我定的规矩是:
- 训练环境和线上环境的PyTorch版本必须完全一致;
- 依赖版本一律锁死,写入
requirements.txt并固定Docker镜像tag; - 一旦模型要跨环境迁移,优先导出为ONNX,把对框架版本的依赖降到最低。
4.2 单条推理250ms,并发一高就雪崩
第一次给模型做压测的时候,我以为单条推理快就行了,结果用并发压到几十个请求,服务直接大面积超时。复盘后发现了几个常被忽略的点:
- 没有预处理:每条请求都临时重新初始化tokenizer相关的状态,浪费在重复劳动上;
- 没有控制线程数:单worker跑CPU推理时,默认线程数可能和核数不匹配,导致资源争抢;
- 缺少超时和熔断:请求一多,队列全堆积,响应时间越来越长,直到雪崩。
优化动作很简单:
- 启动时预先加载所有资源(模型、tokenizer),请求里只做推理;
- PyTorch推理时手动配置
torch.set_num_threads(n),一般设为物理核数; - 加一个简单的批量策略:攒够8条请求或等待20毫秒再统一推理,吞吐会明显提升;
- 给所有下游调用设置超时,上游设置合理的并发限制。
我习惯在压测时看三个指标:p95延迟、QPS、错误率。只看平均延迟会掩盖最差一批请求的体验,p95才是用户真实感受的参考线。
4.3 模型版本管理:上线新模型后,怎么快速回到旧版本
做AI工程越久越会发现,模型本身是需要版本管理的对象,跟代码的地位一样高。有一次我们迭代了一版新模型,离线指标提升了不少,上线之后线上却没有变好——因为情境变了,离线数据的分布和上线后的真实输入还是有差异。
这时候最要紧的不是“继续调参”,而是先回滚,保证线上稳定。但要回滚得动,需要提前设计好机制。我现在的做法很朴素但有效:
- 模型文件命名带版本号或内容哈希,比如
sentiment_best_v3_sha256.pth; - 服务的Docker镜像tag绑定模型版本,发布记录里写明“镜像tag → 模型文件哈希 → 训练实验记录”三者之间的关系;
- 每次发布新模型前,旧模型文件不删除,至少保留最近两个版本;
- 一旦需要回滚,改环境变量里的模型路径并重新发布镜像,几分钟内就能完成。
这里的关键不是工具多高级,而是链路里的每一环都留下可追溯的标记。模型本身是由哪份数据、哪段代码、哪些参数产出的,这三者要能在需要的时候快速查到。
5. 三个月实践路线:从环境搭建到能扛住一个真实接口
5.1 分阶段安排
如果让我给一个零基础但有编程经验的人设计路线,我会建议按三个月来排:
| 时间 | 聚焦内容 | 完成标准 |
|---|---|---|
| 第1-4周 | Python工程化、Docker、Git、FastAPI | 能用Hugging Face现成pipeline包一个接口,Docker构建并在本机跑通 |
| 第5-8周 | 自己写训练脚本、完整链路 | 复现第3节的文本分类项目,自己改数据、自己训练、自己导出、自己部署 |
| 第9-12周 | 工程化打磨、故障演练 | 给自己的服务加监控和日志,做一次模型迭代,练习一次回滚 |
第1-4周的重点是“会用工具”,别一上来就啃模型论文。第5-8周的重点是“把链路走通”,哪怕结果不惊艳,过程完整最重要。第9-12周的重点是“处理意外”,监控告警、延迟优化、回滚机制,这些才是工程化的分水岭。
5.2 用四个问题检验自己是否真的入了门
每次有人跟我说“我觉得我差不多会AI工程了”,我都会让他们用四个问题自我检验:
- 给你一台全新的、干净的电脑,你能不能在三步以内复现出你上周的训练环境?
- 线上模型结果出问题了,你能不能说出当前线上模型是哪次实验、哪份数据、哪个代码版本产出的?
- 你的服务在20个并发请求下的p95延迟是多少,你手上有数据吗?
- 需求让你给模型增加一个新的输入字段,你需要改动多少处地方?改完之后会不会心里没底?
这四个问题,分别对应可复现性、可追溯性、可观测性、可维护性。如果你能答得上来,说明你具备了基础;如果答不上来,那就回到对应环节继续补。
我这些年带新人的经验里,真正拉开差距的,往往不是谁读过更多论文,而是谁能把一条最简单的链路做得滴水不漏。哪怕只是把“评论情感分类”这个接口稳定地跑上一个月,期间经历一次数据更新、一次模型升级、一次线上抖动并逐一处理好,你对AI工程的理解,就会超过多数长期停留在调参和跑Notebook阶段的人。从零开始这件事,最大的门槛从来不是知识本身,而是愿不愿意把那些“看起来不酷”的琐碎环节一件件做到位。