从零开始做 AI 工程,听起来像是一条又长又卷的路。我入行这几年,见过太多人把“跑通一个 Jupyter Notebook”当成“搞定了 AI”,结果一上生产环境就翻车:模型推理慢到超时、数据分布一变精度就崩、显卡 OOM 却不知道日志在哪看。这篇东西,就是给那些真正想把 AI 工程落地、而不是停在 demo 阶段的人写的。我会从一个端到端的视角,把环境搭建、数据处理、模型训练、推理部署到监控排查的每个环节拆开讲透,全程用一套可复现的实操流程串起来。不管你是刚转行想做 AI 工程,还是已经在做算法想补齐工程短板,这篇都应该能让你少踩几个坑。
1. 为什么“从零开始”反而最快——AI 工程的整体设计思路
1.1 AI 工程远不止是训练模型
很多人对 AI 工程的第一个误解,就是以为它等于训练模型。实际做下来你会发现,模型训练在整个项目周期里可能只占两到三成的时间。真正吃时间的是数据清洗、特征工程、模型部署、监控调优这些“脏活累活”。如果只盯着训练那一环,很容易出现一种尴尬的局面:模型在离线评测集上分数很高,一上线就拉胯。
传统软件工程和 AI 工程最大的区别,在于 AI 系统是“数据驱动”的,系统的行为由数据和训练过程共同决定,而不只是由代码逻辑决定。这句话翻译成人话就是:传统开发是你写死逻辑,AI 开发是你喂数据让系统自己学逻辑。因此你在做 AI 工程时,面对的调试对象不只是代码,还有数据分布、模型权重、推理延迟这些更不确定的东西。
我从零带过好几个项目,每一次都把时间分配在四个板块上:数据工程、模型实验、部署上线、持续监控。这四个板块缺一不可,而且顺序往往不能乱。你跳过数据直接跑模型,后续返工成本极高;你不管监控就上线,出了问题只能靠用户投诉来发现。
1.2 方案选型:先选轨道,再踩油门
AI 工程里最忌讳的事情,是在需求还没理清的时候就去追最新的模型和框架。技术选型不是越新越好,而是越“匹配”越好。我一般会先问三个问题:业务的数据量有多大?对推理延迟的容忍度是多少?团队能维护到什么复杂度?这三个问题的答案,直接决定技术栈。
举个例子,如果业务只是做中文短文本分类,数据量在几十万条这个量级,那完全没必要上一套分布式训练框架,单卡微调一个几百 M 的预训练模型就绰绰有余。反过来,如果业务是面向 C 端的实时推荐,那模型再准,推理延迟超过几百毫秒也是不可接受的,这时候就要把精力放在推理加速和缓存设计上。
技术栈上我的选择很固定:Python 做胶水语言,PyTorch 做训练框架,FastAPI 做推理服务,Docker 做环境封装。这个组合的好处是社区生态最成熟、遇到问题最容易找到解决方案。曾经试过为了追求性能换了其他的推理框架,结果踩了一堆环境兼容的坑,后来还是老老实实回到了 PyTorch 生态,用 TorchScript 或者 ONNX 做导出优化,性能和稳定性都能兼顾。
1.3 训练与推理的“两段式”思维
初做 AI 工程的人还容易犯一个错误:把训练和推理当成一回事。实际上训练阶段拼的是吞吐量,推理阶段拼的是延迟。这两个目标有时候是相互矛盾的。
训练时我们想尽办法把 GPU 用满,batch size 能大就大,这没问题。但推理时如果也想着把 GPU 用满,就可能引入过大的 batch,导致单个请求的等待时间被拉长。这里的核心矛盾在于,训练可以忍受几秒钟甚至几分钟的延迟,推理却往往要求在几十毫秒内返回结果。
所以我习惯在架构上把训练环境和推理环境完全分开:训练用 GPU 集群,推理用独立的 GPU 服务,两者不共享资源也不共享代码仓库里的同一套依赖。这样做还有一个隐藏好处:训练环境里可以随意升级依赖做实验,推理环境则锁定稳定版本,互不污染。很多线上事故,追溯起来都是因为在推理环境里改了某个依赖版本,结果模型行为发生了微妙变化。
2. 从零到一:数据、模型与训练策略的核心细节
2.1 数据的真实形态:比你想的脏得多
我在带新人的时候,经常让他们做的第一件事不是跑模型,而是去“看数据”。因为真实场景里的数据,永远比教科书里的 benchmark 数据集脏。以文本分类为例,原始数据里可能混杂着 HTML 标签、emoji、重复样本、标注不一致,甚至有些样本根本不属于任何预设类别。
一个合格的数据清洗流程,至少包含这样几步:去重、去噪、归一化、标签修正。听起来简单,做起来全是坑。去重不只是去掉完全相同的样本,还要考虑近似重复——两句话表达的意思一样,但用词略有不同,这种样本不处理,模型就容易学会“死记硬背”。去噪要小心,不是所有特殊符号都该删,比如一些业务场景里“#”号本身就是特征。
更关键的是标签质量。我踩过最深的一个坑,就是用了标注工具导出的数据直接训练,结果发现有些标签和内容根本不匹配。后来养成了一个习惯:每次拿到一批数据,先抽 100 条人工核对标签,估算一下标签准确率。如果低于 95%,我会先返回去修数据,而不是直接拿去做训练,因为数据质量问题会在训练后被无限放大。
数据版本管理这件事,很多人一开始不重视,觉得“文件放在那不就得了”。但模型上线后一旦效果变差,你想复盘的时候,如果不知道当前模型是用哪份数据、哪个参数训练出来的,排查起来就像大海捞针。我现在每个项目都会在训练前把数据文件打上 hash 标识,连同训练参数一起记录在模型配置里,这是强烈建议从第一个项目就养成的习惯。
2.2 模型选型的取舍原则:先求稳,再求新
模型选型没有银弹,但有一条通用的原则:先用成熟的 baseline 跑通全链路,再考虑要不要换更复杂的模型。这里的 baseline 不一定要效果最好,但必须是文档齐全、坑最少、生态最成熟的方案。把整条链路跑通之后,你对数据形态、推理性能、部署难度都有了感知,再去迭代模型,效率会高得多。
拿中文文本分类举例,我通常会用中文预训练模型作为起点。这类模型在中文 NLP 任务上效果稳定,而且加载方式特别简单,一行from_pretrained就能搞定。先把全量微调跑通,拿到一个靠谱的精度基线,记录训练时长和显存占用,这些数据后面做模型选型对比时非常有用。
在“全量微调”和“参数高效微调”之间怎么选,也是新手容易纠结的问题。我的经验是看两个条件:一是算力够不够,二是任务和预训练任务的差距大不大。算力充裕、数据量也够,全量微调通常效果上限更高;算力紧张或者下游任务和预训练任务差异不大,LoRA 这类方法能帮你省下大量显存,效果也不会差太多。
这里有一个很重要的细节:不同随机种子跑出来的模型效果可能差不少,尤其是数据量不够大的时候。所以我做实验对比时,固定跑 3 个种子取均值,而不是只跑一次就下结论,这个习惯帮我避免了很多“虚假的优越性”。
2.3 训练策略里最容易忽视的四个细节
训练策略听起来抽象,但其实可以拆成具体的选择:优化器怎么选、学习率怎么设、batch size 多大、训练多少个 epoch。每一个选择都有讲究,我不打算复制一遍论文里的公式,我只说你上手就能用的经验。
第一,优化器首选 AdamW,并且设 weight decay。预训练模型微调场景下,AdamW 是实践验证过的稳妥选择。weight decay 我一般设在 0.01,这个值已经在大量实验里被证明是又好又稳的起点。第二,学习率不要拍脑袋。微调预训练模型,我通常从 2e-5 到 5e-5 这个区间起步,配合学习率预热和线性衰减。直接从头训一个大模型,学习率可以更高,但那是另一个故事。第三,batch size 的选择要看显存。显存够就多用一点,但不建议牺牲梯度稳定性去迁就一个特别大的 batch。第四,epoch 数要配合早停。在验证集上监控 loss 或指标,连续几个 epoch 不降就停,防止过拟合。
这几个细节不解决大问题,但能帮你训练过程更稳定。我自己曾经因为忘了设随机种子,同一个配置连续跑两次结果差了好几个点,排查半天才发现是种子的问题。从此以后,固定种子被写进了我的实验启动脚本里,每次训练前自动设置好。
3. 从环境到上线:一个完整实操流程
3.1 环境准备:版本锁定是第一优先级
AI 工程里最让人头大的问题之一,不是模型效果不好,而是“我昨天还能跑,今天怎么就跑不起来了”。原因十有八九是环境依赖变了。所以我从一开始就强调版本锁定,把环境当作代码的一部分来管理。
我习惯用 conda 创建隔离的 Python 环境,然后配合 requirements.txt 固定依赖版本。以下是我常用的一套版本组合,稳定性经过多轮项目验证:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.11 | 兼容性和性能均衡 |
| CUDA | 12.1 | 与显卡驱动匹配即可 |
| PyTorch | 2.1.2 | 稳定版,生态兼容最好 |
| transformers | 4.36.0 | 模型加载与微调工具 |
| FastAPI | 0.109.0 | 推理服务框架 |
| Docker | 24.0 | 环境镜像封装 |
创建环境的命令非常简单,但很多人会栽在 CUDA 和 PyTorch 版本不匹配上。安装 PyTorch 时一定要看清楚官方命令里的 CUDA 版本,别装成了 CPU 版本,不然训练慢到怀疑人生。装完之后用torch.cuda.is_available()验证一下,返回 True 再继续。
requirements.txt 里不但要写库名,还要把版本号写死。如果有人后来说“我升级一下某个库”,除非你确认当前版本真的有严重 bug,否则我建议一律拒绝。生产环境的稳定性,往往就靠“不变应万变”。
3.2 数据流水线:把清洗逻辑变成可复用的代码
数据处理不应该是一次性脚本,而应该是一套可复用、可测试的流水线。以文本分类为例,我会把清洗逻辑拆成几个独立函数,再串起来形成主线流程。
最基本的清洗函数包括:文本去空白和特殊符号、全角转半角、URL 和邮箱归一化、表情符号处理。这些函数每个都很简单,但组合起来能让原始数据的噪声大幅下降。清洗逻辑写完后,一定要在少量样本上做可视化检查,看看清洗前后的差异,避免误删有效内容。
数据划分也是一个常被忽视的环节。训练集、验证集、测试集要按业务分布来切,而不是简单随机切。我遇到过这样的问题:模型在随机划分的验证集上精度很高,上线后面对真实分布的表现差了很多。原因就是训练集和测试集来自不同时间段,分布发生了漂移。所以划分数据时,最好保证各个集合的时间段和业务分布一致,有代表性的样本都要覆盖到。
标签映射看似简单,但容易出现错位。我的做法是把标签映射表单独存成一个 JSON 文件,训练和推理都从同一个文件读取,从源头避免“训练用了一套标签,推理用了另一套”这种低级但致命的错误。
3.3 训练过程:参数不只是抄,要知其所以然
训练脚本的核心是配置训练参数。我用的TrainingArguments参数配置如下,供你参考:
from transformers import TrainingArguments training_args = TrainingArguments( output_dir="./checkpoints", num_train_epochs=5, per_device_train_batch_size=32, per_device_eval_batch_size=64, warmup_ratio=0.1, learning_rate=3e-5, weight_decay=0.01, logging_steps=50, evaluation_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, fp16=True, )这些参数里面,我想单独说说学习率 3e-5 和 batch size 32 背后的考量。3e-5 是微调预训练模型的一个经典量级:太高容易破坏预训练学到的知识,太低则收敛太慢。batch size 32 是精度和显存占用之间的折中,8G 显存能跑得动,梯度估计也比较稳。fp16 开启后显存占用大概能省三分之一,训练速度也能提升,但要注意某些算子在半精度下可能有精度问题,需要观察训练曲线是否异常。
训练过程的监控,强烈建议看实时 loss 曲线。正常情况下 loss 应该平滑下降,如果出现剧烈震荡,可能是学习率太大或者数据有问题。如果 loss 下降异常缓慢,可能要考虑学习率是否太小。不要等到训练完再看结果,那太晚了。
动态 padding 是一个容易被忽略但能显著提升训练效率的技巧。默认情况下,一个 batch 里的样本都会 padding 到最长句子的长度,但这个长度可能被少数超长样本拉得很高,白白浪费显存。动态 padding 则是在每个 batch 内部,只 padding 到这个 batch 的最长长度,可以节省不少计算量。
3.4 推理服务与部署:把模型变成产品
训练出好模型只是第一步,把它变成一个稳定、高效的线上服务才是 AI 工程真正的考验。我的标准做法是用 FastAPI 封装推理接口,再用 Docker 打包部署。
推理服务的核心诉求是低延迟和高吞吐。我做了两件事来满足这个诉求:第一,模型加载后用torch.no_grad()和model.eval()锁定推理模式;第二,开启半精度推理(fp16),单张 8G 显存卡可以同时服务好几个并发请求。还有一个细节是推理请求的 batch 处理,可以把排队中的多个请求动态合并成一个 batch 去推理,吞吐量能翻几倍,但实现上需要小心处理返回顺序。
from fastapi import FastAPI from pydantic import BaseModel import torch app = FastAPI() text_model = None class PredictRequest(BaseModel): text: str @app.on_event("startup") def load_model(): global text_model text_model = TextClassifier.from_pretrained("./checkpoints/best_model") @app.post("/predict") def predict(request: PredictRequest): inputs = tokenize(request.text) with torch.no_grad(): logits = text_model(inputs) pred = logits.argmax(dim=-1).item() return {"label": id2label[pred], "score": logits.softmax(dim=-1).max().item()}部署之后,监控必须跟上。我一般在服务里加三个指标:请求延迟的分布(p50、p95、p99)、请求错误率、推理 QPS。采集方式可以直接用 Prometheus 客户端库打点,再用 Grafana 拉图表。监控不是摆设,它能在用户发现问题之前先帮你发现问题。
注意:加载模型放在
startup事件里做,不要在接口函数里每次请求都加载,不然第一个请求会等上几秒钟,后面也容易内存爆炸。
4. 实战中踩过的坑与排查技巧
| 现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| 训练时显存 OOM | batch size 过大或序列过长 | 调小 batch size;开启梯度累积;检查是否有动态 padding |
| 训练 loss 不降 | 学习率过大或数据 label 噪声高 | 调小学习率到 1e-5;随机抽数据人工核对标签 |
| 验证集效果好,线上效果差 | 数据划分未按业务分布 | 重新按时间段/来源划分数据;检查是否有数据泄漏 |
| 推理延迟忽高忽低 | 动态 batch 合并策略不稳定 | 设置最大 batch 等待时间;优化请求队列逻辑 |
| 模型迁移到新领域效果差 | 预训练模型与目标领域差异过大 | 考虑用领域语料先做一步继续预训练 |
| 推理服务内存持续增长 | 模型加载重复或缓存未控制 | 检查是否有全局变量被反复赋值;限制缓存大小 |
| 多线程推理结果不一致 | 某些操作引入了随机性 | 在推理入口设置torch.manual_seed();锁住随机源 |
这些坑说到底是两类问题:一类是数据问题,一类是环境或工程问题。我梳理排查思路时,永远先看数据和日志,再去看模型结构。尤其是日志,很多问题的答案都写在日志里,但大家习惯性地先怀疑模型参数,结果绕了一大圈才发现是环境变量没设置对。
我印象最深的一次排查经历:一个推理服务上线后 p95 延迟经常飙升,一开始以为是模型计算变慢了,后来加了详细耗时日志才发现,是某个渠道的请求文本特别长,导致单次推理时间暴涨。解决方案不是优化模型,而是给推理接口加了一个输入长度上限,超长的文本先走截断策略,p95 延迟立刻降了下来。
复盘这件事让我意识到,AI 工程里的很多问题都不是“算法问题”,而是“工程问题”。工程问题的解法往往比算法问题简单直接,难的是你愿不愿意一层层往下查,而不是停留在“再调调参吧”的层面。
提示:排查问题前先把当前的数据、代码、参数、模型版本打成一个快照记录,方便回溯。很多线上问题无法复现,就是因为没人记得当时到底跑的是哪一版。
从零开始做 AI 工程,最大的体会是:好模型只是成功的一半,另一半在数据和工程链路里。那些看起来“平平无奇”的细节——数据版本管理、标签一致性、环境锁定、监控告警——才是让一个 AI 系统稳定运转的根基。我做过的每个顺利落地的项目,复盘时都没有什么惊心动魄的调参奇迹,有的只是每一步都走得稳,每一步都有据可查。这套方法不一定最炫,但一定最不容易翻车。