去年我在一次内部技术分享会上问了个问题:在场自认为在做AI工程的人有多少?举手的人不少。我又追问了一句:项目里你们花时间最多的是哪个环节?回答五花八门,最多的是“调prompt”,少数人说是“处理数据”,几乎没人提到“监控线上效果”。这个场景我印象很深,因为它戳中了一个普遍误区——很多人对AI工程的认知,还停留在“训练或调用一个模型”的层面。
我自己也经历过这个阶段。刚开始做相关项目时,以为能用现成框架跑通一个开源模型,就算入门AI工程了。后来踩了无数坑才明白:从“模型能跑”到“系统能稳定交付价值”,中间隔着数据、评估、部署、监控、迭代一整条完整链路。这篇文章想把这几年从零开始做AI工程的经验整理成一份可参考的路线图,给正在入门或准备转型AI工程方向的朋友,帮大家绕开那些我走过的弯路。
1. 你以为的AI工程和实际做的AI工程,差距比想象中更大
先给“AI工程”一个更准确的定位。很多人第一反应是“训练模型”,这其实只是其中一环。我更倾向于把它定义为:以模型为核心、以系统交付为目标的软件工程实践。换句话说,模型只是这个系统里的一个组件,真正的难点在于让整个系统在真实环境中稳定、可控、可持续地运转。
这里有一个经常被忽略的对比。做学术实验时,数据集是固定的,目标是刷高离线指标;而生产环境下的AI工程,数据会不断变化,模型要持续迭代,服务要保证稳定性,任何一个环节出问题,整个系统都可能不可用。我见过不少效果优秀的模型,上了线就歇菜,核心原因恰恰是只关注了模型本身,没关注链路里的其他环节。
1.1 一个AI工程项目的典型生命周期
一个真实落地的AI工程项目,基本绕不开这几个阶段:需求定义、数据工程、模型选型与训练、评估验证、部署上线、监控回归、持续迭代。
需求定义容易被忽略,但它决定了后面所有工作的方向。比如业务方说“帮我做一个工单自动分类”,你得先搞清楚:分类到几级类目?错误成本有多高?对延迟的容忍度是多少?这些不明确,后续所有技术选型都可能跑偏。数据工程是很多项目的隐形主力,我在实际项目里见过的最极端情况是,整个团队80%的时间都花在数据清洗和标注上。模型训练反而是那个“看起来最忙、其实周期最短”的环节。
拿一个文本分类项目举例。需求是“自动判断用户反馈是抱怨还是表扬”,听起来简单,但数据里掺杂大量广告、无意义字符、中英文混杂,清洗规则可能比模型结构还复杂。这种时候你就明白,AI工程考验的不是模型技巧,而是系统工程能力。
1.2 数据环节为什么是AI工程的重灾区
数据质量决定了一个模型的上限,这句话不是鸡汤,是客观规律。模型的结构只是逼近这个上限的手段。我做过一个通俗的类比:你把数据想象成学生用的教材,模型是学生,教材错误百出,学生再聪明也学不到正确答案。
生产环境的数据问题通常是这样的:数据源不止一个,字段格式不统一,有空值有异常值;历史数据和实时数据分布不一致,同名实异或者同实异名;标签的标注标准模糊,不同人标的结果对不上。每一个问题都会直接传导到模型效果和线上稳定性上。很多团队在模型上反复调参,却不愿意花时间把数据管线做扎实,这是典型的抓错重点。
1.3 服务环节:模型只是系统的一部分
到了部署环节,问题就更多了。推理延迟、吞吐量、成本、弹性伸缩、灰度发布、版本回滚,每一件都跟算法能力无关,但每一件都会决定模型能不能真正被业务用起来。
我见过一个案例:模型精度很好,但单次推理要800毫秒,业务方说超过300毫秒用户就会流失,于是整个方案只能推倒重来。懂一点服务端知识、懂一点部署流程、懂一点性能分析,在AI工程里不是加分项,而是必修课。这也是为什么我坚持认为,AI工程本质上是系统工程,模型能力只是其中的一个必要不充分条件。
2. 为什么“from scratch”值得做,以及哪些模块真的需要手写
说回“from scratch”这件事。很多人会问:现在开源工具这么成熟,为什么还要从零开始?我最早接触预训练模型时也用现成的Trainer,几行代码就跑通了微调,当时还觉得挺轻松。但后来一遇到问题就傻眼:日志报错看不懂,训练过程没法自定义,想加个特殊的采样逻辑都不知道从哪里下手。
后来我下决心把训练循环、数据加载、评估脚本都自己写了一遍,整个人的状态完全变了。不是说现成工具不好,而是你必须先“手写一次”来理解机制,之后用现成工具时才真正知道它在帮你干什么,出了问题也知道去哪排查。
2.1 手写一次,到底能换来什么
我想把手写的价值总结成三点。
第一是全局理解。当你自己写过一遍训练循环,你就知道loss.backward()做了什么,梯度在哪个环节更新,学习率调度器是怎么影响参数更新的。这些细节在调参或排查问题时非常关键。第二是可排查性。别人的代码始终是个黑盒,你自己的代码虽然简陋,但每一行都清楚,压测、debug、加日志都更顺手。第三是可定制性。业务需求千奇百怪,总有一些现成框架覆盖不到的场景,这时候你能改底层,就能比别人多一条路。
2.2 值得手写的模块清单
根据我自己的经验,这几个模块值得至少完整手写一次:
| 模块 | 建议做法 | 手写理由 |
|---|---|---|
| 数据清洗与预处理管线 | 用纯Python/pandas手写完整流程 | 理解每个字段处理的意义,方便排查线上特征不一致 |
| 训练循环 | 用PyTorch手写forward/backward/optimizer step | 掌握底层机制,后续换框架或自定义操作时不慌 |
| Tokenizer构建 | 自己写一次词表构建和编码过程 | 理解文本变成ID的过程,避免被封装工具坑 |
| 推理API服务 | 用FastAPI/Flask手写一个模型服务 | 理解部署链路,知道模型的输入输出如何暴露成接口 |
| 评估脚本 | 自己写指标计算和错误分析 | 避免用错指标,知道每个指标到底在度量什么 |
2.3 哪些东西不该自己造轮子
“from scratch”不等于排斥一切现成工具。分布式训练框架、高性能推理引擎、向量数据库、监控可视化系统,这些没必要自己造,直接站在巨人的肩膀上就好。
我自己的判断标准很简单:凡是半衰期短、跟业务强相关的部分,值得手写;凡是半衰期长、通用性极强的部分,直接用现成的。比如数据预处理跟业务强相关,几乎每个项目都不一样,手写才有针对性;而分布式训练框架是通用基础设施,你花三个月写出来的大概率不如DeepSpeed这类成熟方案稳定。把精力花在刀刃上,这才是“from scratch”的真正意义。
3. 一条真实可复制的AI工程链路:从数据到上线的四段推进
前面讲了不少理念,接下来给一条可以落地的实操链路。我用“短文本工单自动打标”这个场景来贯穿整个流程,因为它的数据形态和模型结构都比较简单,方便理解全链路,又不至于被领域知识干扰。
3.1 数据准备:把原始数据变成训练集
工单系统里导出的原始数据通常长这样:一条JSON或者一行CSV,包含用户ID、提交时间、工单内容、人工处理结果等字段。首先做字段筛选,把真正需要的文本和标签字段拿出来,然后去重。工单场景里同一用户会重复提交相似内容,按文本内容的哈希值去重是最常见的方式。
import pandas as pd def build_train_data(raw_path: str) -> pd.DataFrame: df = pd.read_json(raw_path, lines=True) df = df.drop_duplicates(subset=["content_hash"]) # 文本去重 df = df.dropna(subset=["text", "label"]) # 去除缺失值 df = df[df["text"].str.strip().str.len() >= 2] # 去掉无信息短内容 return df[["text", "label"]]清洗完数据之后,紧接着是标注环节。标注规范非常关键,拿“其他”这个默认类别来说,不定义清楚就会成为垃圾箱,模型学到的全是“不确定就分到其他”。我给团队定的规矩是:每条数据至少两个人标,标签不一致的交给仲裁人,最后统计标注一致性。数据最终要保存版本信息,包括数据来源、清洗规则、标注规范、生成时间,方便后面回溯。这些看起来繁琐,但是排查线上问题时的救命稻草。
3.2 训练与微调:从baseline到可用模型
数据准备好,开始训练。我的习惯是先选一个中等规模的预训练模型当baseline,比如中文场景用bert-base-chinese,不要一上来就上大模型。小模型训练快,方便快速验证数据是否存在问题,等数据链路跑通了再考虑换更大模型提升效果。
训练脚本的关键结构是这样几个部分:自定义Dataset负责加载和编码数据,模型用预训练权重初始化,优化器用AdamW,学习率调度器用warmup加线性衰减,保存策略是每轮验证后保留最优checkpoint。
from torch.utils.data import Dataset, DataLoader from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=len(label_set) ) for epoch in range(num_epochs): preds, labels = [], [] for batch in train_loader: outputs = model(**batch) loss = outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()几个实际经验供参考:batch size受显存限制,可以先从8开始,能加就加到32,一般不要一开始就追求大batch,除非配合梯度累积;学习率用2e-5到5e-5之间的范围,迭代次数2到3个epoch基本够了,再多就容易过拟合。训练过程中不能只看loss曲线,我还会定期取一批bad case看,验证模型是在“理解”数据还是在“硬背”答案。
3.3 评估体系:不能只盯着准确率
工单分类场景里,类别往往不均衡,“网络问题”和“账户问题”可能各占40%,剩下的20%分散在五六个类别里。这种情况下只看准确率没有意义——模型把所有样本都预测成“网络问题”,准确率也能有40%。所以要分开看precision、recall和F1,特别是每个类别单独算。
我见过很多团队在评估环节犯一个严重错误:数据划分太随意,没有按时间线或按用户维度拆分,导致训练集和测试集之间存在信息交叉,评估分数虚高。这一点我后面会专门讲踩坑过程,这里先给结论:划分数据集时,至少要保持一个原则——训练数据里出现过的用户,不应该出现在测试集里,否则模型在“见人下菜碟”。
评估环节还必须包含bad case分析。每次跑完验证集,我会挑出二三十个预测错的样本,逐个看是标注错、文本歧义,还是模型理解不到位。这一步对改进数据和模型结构的指导价值,比调任何超参数都大。
3.4 部署:把模型包装成一个稳定服务
模型训练完,到了部署环节。最轻量的方案是FastAPI把模型包装成HTTP服务,加载预训练权重到内存,接收文本输入,返回预测结果。
from fastapi import FastAPI from pydantic import BaseModel class RequestItem(BaseModel): text: str app = FastAPI() @app.post("/predict") def predict(item: RequestItem): inputs = tokenizer(item.text, truncation=True, max_length=128, return_tensors="pt") logits = model(**inputs).logits label = int(logits.argmax(-1)) return {"label": label, "prob": float(logits.softmax(-1)[0][label])}服务上线前要做的几件事:加健康检查接口,给容器设置合理的内存和显存上限,用压测确认并发能力,把模型输出和输入文本的日志打全。发布时先灰度给10%流量,观察一段时间,稳定了再全量放开。这些事没有一个是算法层面的难度,但每一个都直接影响线上可靠性。
4. 三个我栽过跟头的地方,每个都值得写进避坑手册
这部分我想讲几个自己真实踩过的坑,重点不是结果,而是当时的排查思路和修复过程。希望读者以后遇到类似问题能快速定位,不用绕我走过的弯。
4.1 数据泄漏:评估分数很美,上线就露馅
第一个坑发生在一个评论审核模型上。离线评估F1刷到0.95,所有人都挺乐观,结果上线后线上效果差得离谱,误杀率比预期高好几倍。
当时的排查链路是这样的:先怀疑预处理不一致,把训练和线上代码对比了一遍,没发现问题;再怀疑模型过拟合,但验证集的视觉效果又确实好;最后逐条抽查验证集样本,发现测试集里混了很多标注噪声很重的数据,而且这些数据在构造时是从同一个时间段的语料里随机切分的,同一个用户的多条评论被同时分到了训练集和测试集。模型等于见过“同一个人的说话习惯”,自然能猜到答案。
修复方案是重写数据划分逻辑,按用户ID维度切分,确保同一个用户的全部数据只出现在训练集或只出现在测试集。更保险的做法是加入时间维度,用前面时间段的数据训练,用后面时间段的数据评估,模拟真实上线后的分布变化。这次之后我养成一个习惯:每次构造数据集时做一次泄漏自检,确认训练集和测试集之间没有用户、文本哈希、以及目标字段本身的交叉。
4.2 显存OOM:batch size不是越大越好
第二个坑是训练启动时直接爆显存。当时给模型加了一组CRF头做序列标注,满心欢喜跑起来,结果一行日志没看到就OOM了。
排查过程比较简单,但值得记录。我先把batch size降到1跑一遍,发现能运行,说明模型本身不算太占显存。然后按2、4、8倍增batch size测试,同时用nvidia-smi实时观察显存占用,找到临界点。最后发现不是模型太大,而是backward时保存的中间激活值占了大量显存,序列长度稍微长一点就直接溢出。
我当时用的是“梯度累积+混合精度”的组合方案。梯度累积的意思是,模拟大的batch size但不增大单次反向传播的显存压力——攒几个小batch的梯度再更新一次参数。混合精度则是用FP16做大部分运算,减少显存占用。简单列一下当时的处理逻辑:先确定单次可承受的最大batch size,比如8;想使用等效的32的batch效果,就设梯度累积步数为4;同时开启AMP混合精度,显存压力明显下降。
这里想多说一句:不要盲目调大batch size,我曾经以为模型效果差是batch太小,后来发现,过大的batch有时反而让模型更不容易跳出局部最优,收敛效果未必更好。选择batch size的核心标准是“这个batch下loss下降正常、梯度更新稳定”。
4.3 训练时和线上推理的预处理不一致
第三个坑的特征是“离线效果正常,线上服务返回结果却莫名其妙”。当时做一个文本分类服务,训练时的预处理流程是先统一转小写、再过滤URL和邮箱、最后做分词;线上服务这边,因为复用了另一段代码,里面省略了URL过滤这一步。模型训练时压根没见过带URL的文本,线上一遇到就乱预测。
排查链路很有意思。我先在线上随机抽取了几十条推理日志,把输入文本和模型实际接收的编码结果一起打印出来,和训练样本的编码结果做对比,发现同一句话经过线上和训练两个流程后,得到的token序列有明显差异。
修复方案是把预处理逻辑抽成一个公共函数库,训练和线上服务都从同一个包里导入,不能让两边各写各的。更绝的是我后来加了一个自动化检查:每次上线前,随机选100条真实日志样本,分别跑训练预处理和线上预处理,对比编码结果是否一致,不一致就拦截发布。这个思路后来帮我拦下了至少两次低级的发布事故。
5. 给同样想从零开始的人:学习路线与我的个人体会
最后这部分,写给那些真的打算从零开始走AI工程这条路的人。我可以给出我自己验证过的路线和一些工具选型建议,用不着面面俱到,但一定保证你走下来不会慌。
5.1 建议的学习路线
按我现在的经验看,比较顺畅的顺序应该是这样:先打牢Python和数据处理基础,然后手写一个简单的MLP在MNIST上跑通分类,理解梯度下降到底在做什么。接着学习Transformer结构,不用读太多源码,但要把attention的计算过程自己推导一遍。之后再接触预训练模型微调,这时你对底层已经有感觉,用huggingface的封装工具会非常顺手。
之后学实验管理,用MLflow或wandb把每次实验的记录留下来。接着就是部署,先把FastAPI的模型服务跑通,再学容器化和基本压测。最后可以进阶到性能优化,比如推理加速、量化、分布式训练。这条路线不追求快,但每一步都稳,形成的能力不会因为工具换代而清零。
5.2 我的常用工具清单
| 场景 | 工具 | 说明 |
|---|---|---|
| 数据处理 | pandas、SQL | 快速清洗和探查,别一上来就上大数据框架 |
| 实验记录 | MLflow | 记录参数、指标、模型产物,简单易上手 |
| 训练框架 | PyTorch | 生态成熟,资料多,遇到问题容易搜到答案 |
| 预训练模型 | transformers | 加载和微调预训练模型的标准工具 |
| 模型服务 | FastAPI | 写接口轻量,性能足够大多数场景 |
| 监控与日志 | Prometheus + Grafana | 虚拟机指标和简单业务指标足够用 |
| 容器部署 | Docker | 统一运行环境,方便迁移和隔离依赖 |
5.3 我的一些实在话
最后分享几点个人体会。第一,不要等到所有东西都准备好了才开始,AI工程接触的链路很长,边做边补是常态。第二,一定要养成记录的习惯,每次实验的参数、数据集版本、踩过的坑都记下来,三个月后你会感谢自己。第三,遇到问题先怀疑数据,再怀疑代码,最后才怀疑模型——按这个顺序排查,大概率能省下半天时间。
我自己也就着“from scratch”的方式把很多模块重新实现了一遍,整个过程谈不上轻松,但带来的收益非常直接:现在无论是看开源项目源码,还是排查线上问题,都更有底气。希望这篇内容也能给你一些参考,让你在从零开始的道路上少踩一些无谓的坑。