1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
“ai-engineering-from-scratch”这个标题,第一次看到的时候我愣了一下。不是因为陌生,恰恰相反,是因为它戳中了我这几年带团队、做项目时反复遇到的一个痛点:太多人想学AI工程,但路径全走歪了。
市面上大部分教程和课程,打开第一页就是pip install transformers,然后直接上预训练模型跑推理。跑通了,觉得自己会了;换个场景,数据格式一变,显存一炸,服务一上线就崩,立刻抓瞎。这不是AI工程,这是API调用练习。
所谓“from scratch”,我的理解不是让你手写CUDA核函数、从零实现反向传播——那是框架开发者的事。对于绝大多数AI工程师来说,从零构建AI工程能力,指的是:你能够独立完成从数据接入、特征处理、模型训练、评估调优,到服务封装、性能压测、线上监控的完整闭环,并且清楚每一个环节为什么这么做、出问题该从哪里下手。
这篇文章适合谁看?三类人。第一类,有编程基础但没系统做过AI项目的开发者,想补上工程化这一课;第二类,算法出身但工程能力偏弱的研究者,模型训得出来但服务部署总出问题;第三类,技术负责人或架构师,需要评估团队AI工程能力的真实水位,判断该招什么人、补什么课。
我会按照一个真实项目的推进节奏来拆,从整体设计思路,到核心环节的实操细节,再到踩过的坑和排查方法。不堆概念,不背八股,全是能直接抄作业的东西。
2. 整体设计与思路拆解:先想清楚边界,再动手写代码
2.1 为什么“从零”不等于“从底层造轮子”
很多人对“from scratch”有误解,觉得必须从矩阵乘法开始写。我明确说:没必要,也不划算。PyTorch、TensorFlow这些框架已经足够成熟,你重写一遍除了感动自己,没有任何工程价值。
真正的“从零”,是从问题定义开始的。我见过太多项目,一上来就讨论用什么模型、多大参数量,结果做到一半发现数据标注标准都没统一,标签噪声大得离谱,模型再好也白搭。
我的习惯是,任何AI项目启动前,先花半天时间写一份“工程边界文档”,回答四个问题:
- 输入是什么:数据从哪来,格式是什么,量级多大,更新频率如何,有没有脏数据、缺失值、类别不平衡。
- 输出是什么:是分类标签、回归数值,还是生成内容?输出给谁用,下游系统怎么消费?
- 约束是什么:延迟要求多少毫秒,吞吐量多少QPS,显存/内存上限多少,能不能上GPU,成本预算多少。
- 成功标准是什么:准确率、召回率、F1、AUC,还是业务指标如点击率、转化率?离线指标和线上指标怎么对齐?
这四个问题不回答清楚,后面所有工作都是空中楼阁。我吃过亏:一个文本分类项目,离线F1做到0.92,上线后业务方反馈“效果很差”。一查才发现,业务方关心的是少数类别的召回,而我们在训练时按整体准确率调的参,少数类被淹没了。这就是边界没对齐的代价。
2.2 技术选型的三个核心原则
选型这件事,没有绝对的对错,只有适不适合。我总结三个原则,按优先级排序。
第一,团队熟悉度优先于技术先进性。一个团队用惯了的、能快速定位问题的技术栈,比一个“业界领先”但没人懂的技术栈靠谱得多。我见过团队为了追新,上了某个小众推理框架,结果线上出问题连日志都看不懂,排查了三天。后来换回熟悉的方案,半天搞定。
第二,可观测性优先于极致性能。尤其是项目初期,你需要知道模型为什么预测这个结果、数据在哪个环节出了问题。一个性能稍差但日志完善、指标齐全的方案,远比一个黑盒高性能方案有价值。等业务稳定了,再针对性优化性能。
第三,渐进式复杂度。不要一上来就上分布式训练、模型并行、异构推理。先用单机单卡把流程跑通,验证数据质量和模型效果,再逐步加复杂度。我见过太多项目,基础设施搭了两个月,模型效果一塌糊涂,最后发现是数据清洗没做好。
基于这三个原则,我通常的选型是:数据处理用Pandas/Spark(看数据量),训练用PyTorch,服务用FastAPI+ONNX Runtime或TorchServe,监控用Prometheus+Grafana。这套组合不是最优的,但足够稳,社区资料多,出问题好查。
2.3 项目目录结构:别小看这件事
很多人不重视目录结构,觉得能跑就行。但一个清晰的目录结构,能帮你省下大量“找文件”的时间,也让协作更顺畅。我常用的结构是这样的:
project/ ├── configs/ # 配置文件,按环境分dev/staging/prod ├── data/ │ ├── raw/ # 原始数据,只读 │ ├── interim/ # 中间处理结果 │ └── processed/ # 最终训练数据 ├── src/ │ ├── data/ # 数据加载、清洗、特征工程 │ ├── models/ # 模型定义 │ ├── train/ # 训练脚本 │ ├── eval/ # 评估脚本 │ └── serve/ # 服务封装 ├── notebooks/ # 探索性分析,不进入生产 ├── tests/ # 单元测试和集成测试 ├── scripts/ # 运维脚本 └── requirements.txt关键点:data/raw只读,任何清洗结果写到interim或processed,保证原始数据可追溯。notebooks只用于探索,不参与生产流程,避免“notebook里跑通了,脚本里跑不通”的尴尬。
3. 核心细节解析与实操要点:数据、训练、评估三关
3.1 数据环节:80%的问题出在这里
我个人的经验,AI项目80%的坑在数据。模型结构、超参数这些,反而相对标准化。数据环节我重点关注四件事。
第一,数据一致性检查。训练集、验证集、测试集的分布必须一致。我见过一个项目,训练集是白天采集的,测试集是晚上采集的,光照条件不同,模型在测试集上表现差,但业务上线后效果反而好——因为线上也是白天为主。这就是数据集划分没考虑业务场景。
实操上,我会写一个data_profile.py脚本,对每个数据集输出:样本量、类别分布、缺失值比例、数值特征的分位数、文本长度分布。然后对比三个集合的分布,差异超过阈值就报警。
第二,标签质量审核。标签噪声是模型效果的天花板。我通常随机抽样200-500条,人工核对标签。如果错误率超过5%,就必须重新标注或清洗。这一步不能省,我见过标签错误率20%的项目,模型怎么调都上不去,最后发现是标注规范没写清楚。
第三,特征处理的一致性。训练时的特征处理逻辑,必须和服务时的逻辑完全一致。常见错误是训练时用了全局统计量(如均值、方差)做归一化,服务时用单条数据的统计量,导致分布偏移。正确做法是把训练集的统计量保存下来,服务时加载同一份。
第四,数据版本管理。每次训练用的数据,必须有版本号,能追溯到具体的采集时间、清洗脚本版本。我用DVC或简单的文件哈希来管理。没有版本管理,模型效果波动时你根本不知道是数据变了还是代码变了。
注意:数据清洗脚本一定要写单元测试。我踩过的坑是,清洗脚本改了一行,把某个类别的样本全过滤掉了,训练时没发现,上线后该类别的召回直接归零。
3.2 训练环节:从能跑到跑好
训练环节的核心是可复现和可对比。我要求团队做到三点:固定随机种子、记录完整配置、保存中间检查点。
固定随机种子不用多说,PyTorch里torch.manual_seed、numpy.random.seed、random.seed都要设。但要注意,即使设了种子,不同GPU型号、不同CUDA版本,结果也可能有微小差异。所以对比实验时,尽量在同一台机器上跑。
记录完整配置,我习惯用YAML文件管理所有超参数,训练脚本启动时把配置和Git commit hash一起写进日志。这样任何时候都能复现某次实验。
保存中间检查点,不只是保存最好的模型。我会保存每个epoch的模型和优化器状态,方便分析训练过程。如果发现某个epoch后验证集效果突然下降,可以回滚到之前的状态继续训练。
关于超参数调优,我的建议是:先粗后细,先少后多。先用少量数据、少量epoch,快速试几组学习率和batch size,找到大致范围。然后再用全量数据精细调。不要一上来就网格搜索,浪费时间。
学习率是最重要的超参数。我的经验值:Transformer类模型,学习率在1e-5到5e-5之间;CNN类模型,1e-4到1e-3之间。先用这个范围试,再根据loss曲线调整。如果loss震荡,降低学习率;如果loss下降太慢,提高学习率或加warmup。
3.3 评估环节:离线指标和线上效果的对齐
评估环节最容易自欺欺人。离线指标好看,线上效果差,这种情况太常见了。我的做法是:离线评估必须模拟线上场景。
具体来说,如果线上是实时推理,离线评估就不能用全量数据做batch预测,而要逐条预测,模拟真实延迟。如果线上有类别不平衡问题,离线评估就要用加权指标,而不是整体准确率。
我常用的评估流程:
- 在测试集上计算整体指标(准确率、F1、AUC等)。
- 按类别、按数据来源、按时间分段,分别计算指标,看是否有短板。
- 做错误分析,随机抽样预测错误的样本,人工看原因。
- 如果可能,做A/B测试或影子部署,对比线上真实效果。
错误分析这一步,很多人跳过,但它价值极高。我通过错误分析发现过:模型把“苹果公司”和“苹果水果”混淆,因为训练数据里科技新闻和农业新闻混在一起,没有领域标签。后来加了领域特征,效果提升明显。
提示:评估指标要和业务方对齐。业务方关心什么,你就重点看什么。不要只报一个整体准确率,要拆开讲。
4. 实操过程与核心环节实现:一个文本分类项目的完整走一遍
4.1 环境准备与依赖管理
环境这块,我强烈建议用虚拟环境,别在系统Python里乱装。venv或conda都行,我习惯用conda,因为能管理CUDA版本。
conda create -n ai-eng python=3.10 conda activate ai-eng pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install transformers datasets scikit-learn pandas numpy fastapi uvicorn onnxruntime依赖管理用requirements.txt,但要注意版本锁定。我见过因为transformers版本升级,API变了,训练脚本跑不通的情况。所以生产项目里,所有依赖都写死版本号。
torch==2.1.0 transformers==4.35.0 datasets==2.15.0 scikit-learn==1.3.2 fastapi==0.104.1 onnxruntime==1.16.34.2 数据加载与预处理脚本
假设我们做一个情感分类任务,数据是CSV格式,两列:text和label。我写一个data_loader.py:
import pandas as pd from sklearn.model_selection import train_test_split from transformers import AutoTokenizer def load_and_split(path, test_size=0.2, val_size=0.1, seed=42): df = pd.read_csv(path) # 基本清洗 df = df.dropna(subset=['text', 'label']) df['text'] = df['text'].str.strip() df = df[df['text'].str.len() > 0] # 划分 train_val, test = train_test_split(df, test_size=test_size, random_state=seed, stratify=df['label']) train, val = train_test_split(train_val, test_size=val_size/(1-test_size), random_state=seed, stratify=train_val['label']) return train, val, test def tokenize_data(df, tokenizer_name='bert-base-chinese', max_len=128): tokenizer = AutoTokenizer.from_pretrained(tokenizer_name) encodings = tokenizer( df['text'].tolist(), truncation=True, padding='max_length', max_length=max_len, return_tensors='pt' ) return encodings, df['label'].tolist()关键点:stratify保证划分后类别比例一致;max_len根据数据长度分布选,我通常看95分位数,避免截断太多。
4.3 模型训练脚本与关键参数
训练脚本我用PyTorch Lightning,省去很多样板代码。核心逻辑:
import pytorch_lightning as pl import torch from torch import nn from transformers import AutoModelForSequenceClassification class SentimentModel(pl.LightningModule): def __init__(self, model_name='bert-base-chinese', num_labels=2, lr=2e-5): super().__init__() self.save_hyperparameters() self.model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=num_labels) self.lr = lr def forward(self, input_ids, attention_mask, labels=None): return self.model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) def training_step(self, batch, batch_idx): outputs = self(**batch) self.log('train_loss', outputs.loss, prog_bar=True) return outputs.loss def validation_step(self, batch, batch_idx): outputs = self(**batch) preds = torch.argmax(outputs.logits, dim=-1) acc = (preds == batch['labels']).float().mean() self.log('val_acc', acc, prog_bar=True) return outputs.loss def configure_optimizers(self): return torch.optim.AdamW(self.parameters(), lr=self.lr)训练启动:
from pytorch_lightning import Trainer from pytorch_lightning.callbacks import ModelCheckpoint, EarlyStopping checkpoint = ModelCheckpoint(monitor='val_acc', mode='max', save_top_k=3) early_stop = EarlyStopping(monitor='val_acc', patience=3, mode='max') trainer = Trainer( max_epochs=10, accelerator='gpu', devices=1, callbacks=[checkpoint, early_stop], log_every_n_steps=10 ) trainer.fit(model, train_loader, val_loader)关键参数说明:lr=2e-5是BERT类模型的常用值;patience=3表示验证集指标3个epoch不提升就停,避免过拟合;save_top_k=3保存最好的3个检查点,方便对比。
4.4 模型导出与服务封装
训练完的模型,要导出成推理友好的格式。我用ONNX:
import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification model = SentimentModel.load_from_checkpoint('best.ckpt') model.eval() dummy_input = tokenizer('测试文本', return_tensors='pt') torch.onnx.export( model.model, (dummy_input['input_ids'], dummy_input['attention_mask']), 'model.onnx', input_names=['input_ids', 'attention_mask'], output_names=['logits'], dynamic_axes={'input_ids': {0: 'batch', 1: 'seq'}, 'attention_mask': {0: 'batch', 1: 'seq'}} )服务用FastAPI:
from fastapi import FastAPI import onnxruntime as ort import numpy as np app = FastAPI() session = ort.InferenceSession('model.onnx') tokenizer = AutoTokenizer.from_pretrained('bert-base-chinese') @app.post('/predict') def predict(text: str): inputs = tokenizer(text, return_tensors='np', truncation=True, padding='max_length', max_length=128) logits = session.run(None, { 'input_ids': inputs['input_ids'].astype(np.int64), 'attention_mask': inputs['attention_mask'].astype(np.int64) })[0] pred = int(np.argmax(logits, axis=-1)[0]) return {'label': pred, 'confidence': float(np.max(logits))}启动:uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4。workers数量根据CPU核数定,一般是核数的1-2倍。
4.5 性能压测与优化
服务上线前必须压测。我用locust或wrk。关键指标:P99延迟、QPS、错误率。
wrk -t4 -c100 -d30s -s post.lua http://localhost:8000/predict如果P99延迟超过要求,优化方向:减小max_len、用更小的模型(如DistilBERT)、开启动态量化、增加workers、用GPU推理。
我实测下来,BERT-base在CPU上单条推理约50-100ms,ONNX Runtime比原生PyTorch快30%左右。如果延迟要求高,考虑蒸馏或量化。
5. 常见问题与排查技巧实录:那些年我踩过的坑
5.1 训练不收敛或loss震荡
这是最常见的问题。排查顺序:
- 检查数据:标签是否从0开始连续?有没有NaN?我见过标签是1和2,但模型输出维度设成2,导致索引越界。
- 检查学习率:太大导致震荡,太小导致不收敛。先用1e-5试,再逐步调。
- 检查batch size:太小导致梯度噪声大,太大导致泛化差。BERT类模型常用16或32。
- 检查warmup:Transformer类模型需要warmup,通常占总步数的10%。
5.2 显存不足(OOM)
OOM的排查和解决:
| 原因 | 解决方法 |
|---|---|
| batch size太大 | 减小batch size,或用梯度累积 |
| max_len太长 | 减小max_len,或动态padding |
| 模型太大 | 用更小的模型,或混合精度训练 |
| 梯度累积未清零 | 检查optimizer.zero_grad() |
| 中间变量未释放 | 用del删除不用的变量,torch.cuda.empty_cache() |
混合精度训练能省30%-50%显存:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): outputs = model(**batch) loss = outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()5.3 离线指标好但线上效果差
这个问题的根因通常是数据分布不一致或特征处理不一致。排查方法:
- 对比线上请求数据和训练数据的分布,看是否有偏移。
- 检查服务时的特征处理逻辑,是否和训练时完全一致。
- 检查模型版本,是否加载了正确的检查点。
- 做影子部署,把线上流量复制一份到新模型,对比预测结果。
我遇到过一次,线上效果差是因为服务时用了padding='max_length',但训练时用了动态padding,导致attention mask分布不同。改成一致后,效果恢复。
5.4 服务延迟高
延迟高的优化清单:
- 减小
max_len,从128降到64,延迟能降30%。 - 用ONNX Runtime或TensorRT,比原生PyTorch快。
- 开启动态量化,INT8推理,延迟降50%左右,精度损失通常1%以内。
- 增加
workers,但注意CPU核数和内存限制。 - 用GPU推理,但要注意GPU的batch推理才有优势,单条推理可能不如CPU。
注意:量化后一定要重新评估精度,我见过量化后某些类别召回掉10%的情况。
5.5 常见问题速查表
| 问题 | 可能原因 | 快速排查 |
|---|---|---|
| loss为NaN | 学习率太大、数据有NaN、梯度爆炸 | 降低学习率、检查数据、加梯度裁剪 |
| 验证集指标不提升 | 过拟合、学习率太小、模型容量不够 | 加正则化、调学习率、换更大模型 |
| 预测结果全为同一类 | 类别不平衡、标签错误、模型未训练好 | 检查类别分布、检查标签、检查训练loss |
| 服务启动报错 | 依赖版本冲突、模型文件缺失 | 检查requirements、检查模型路径 |
| 推理结果和训练不一致 | 预处理不一致、模型未eval模式 | 对比预处理代码、加model.eval() |
6. 工程化收尾:监控、迭代与团队协作
6.1 线上监控指标
服务上线不是终点,而是起点。我必看的监控指标:
- 业务指标:预测分布、置信度分布、各类别占比。如果预测分布突然偏移,说明数据分布变了。
- 系统指标:QPS、P99延迟、错误率、CPU/内存/GPU使用率。
- 数据指标:输入文本长度分布、空值率、异常字符比例。
用Prometheus采集,Grafana展示。关键指标设告警,比如错误率超过1%就通知。
6.2 模型迭代流程
模型迭代不是重新训练一遍就完事。我的流程:
- 收集bad case:从线上日志里找预测错误的样本,人工标注。
- 分析原因:是数据问题、模型问题,还是业务规则问题。
- 补充数据:针对bad case,补充训练数据。
- 重新训练:用新数据训练,对比新旧模型。
- 影子部署:新模型先跑影子流量,对比效果。
- 灰度发布:逐步放量,观察指标。
- 全量发布:确认无误后全量。
6.3 团队协作规范
AI工程项目通常多人协作,规范很重要:
- 代码规范:用
black格式化,flake8检查,mypy做类型检查。 - 实验管理:用MLflow或WandB记录每次实验的配置、指标、模型。
- 代码评审:数据清洗、模型定义、服务封装这些核心代码,必须评审。
- 文档:每个模块写README,说明输入输出、依赖、运行方式。
我个人的体会是,AI工程和传统软件工程最大的区别在于不确定性。传统软件是确定性的,输入A必然输出B;AI模型是概率性的,同样的输入可能因为数据分布、模型版本、随机种子的不同而有差异。所以工程化的核心,是把不确定性控制在可管理的范围内——通过数据版本管理、实验记录、监控告警、灰度发布,让每一次变化都可追溯、可回滚。
最后分享一个小技巧:每次训练新模型前,先跑一个baseline,用最简单的模型(如逻辑回归或朴素贝叶斯)在同样的数据上评估。如果复杂模型比baseline提升不明显,说明数据质量或特征工程有问题,先别急着调模型。这个习惯帮我省下了大量无效调参的时间。