1. 从零搭建AI工程体系,为什么我劝你别一上来就调包
“ai-engineering-from-scratch”这个标题,第一次看到的时候我愣了一下。市面上讲AI的文章,十篇里有八篇在教你pip install之后怎么调API,剩下两篇在讲怎么改config里的超参。真正从零开始、把AI工程当成一门手艺来拆解的,少得可怜。我自己在这个坑里摸爬滚打了几年,从最早用numpy手写全连接层,到后来带团队做推理服务,踩过的坑能写满一个笔记本。所以看到这个标题,我特别有共鸣——它说的不是“从零学AI理论”,而是“从零搭AI工程”,这两件事差别巨大。
先把这个标题拆开看。AI工程这四个字,核心不在“AI”,在“工程”。模型是算法工程师的事,但把模型变成一个能扛住流量、能持续迭代、能被人维护的系统,是工程的事。From scratch也不是让你从晶体管开始造计算机,而是说你要理解每一层抽象下面到底发生了什么,而不是把model.fit()当成黑盒。这个项目适合谁?适合那些已经会调包、但一遇到线上问题就抓瞎的人;适合那些想搞清楚“为什么我的模型本地跑得好好的,一上线就崩”的人;也适合刚入行、想建立完整知识框架的新人。它解决的核心问题是:把AI从“实验室玩具”变成“生产系统”之间那条巨大的鸿沟。
我见过太多团队,模型指标刷得漂漂亮亮,一到部署就原形毕露。显存爆了、延迟飙了、并发上不去、版本对不上、数据漂移了没人发现。这些问题的根因,往往不是模型不行,而是工程没做扎实。所以这篇博文,我想按照一个真实的搭建路径,把AI工程从零到一的关键环节掰开揉碎讲一遍。不是理论综述,是我自己趟出来的路线图。
2. 整体设计思路:AI工程到底该分几层
2.1 为什么不能“一个脚本走天下”
刚入门的时候,我最喜欢写那种“一个train.py搞定一切”的脚本。读数据、预处理、建模型、训练、评估、保存,全塞在一个文件里。本地跑没问题,一旦要改点东西就痛苦了——换个数据集要改五处,换个模型要动十行,想加个日志得从头翻到尾。这就是典型的“没有工程分层”的后果。
AI工程的本质,是把一个复杂的、不确定的系统,拆成若干个职责清晰的模块,让每个模块可以独立开发、独立测试、独立替换。我后来总结出一个比较通用的分层思路,从下往上大致是:数据层、特征层、模型层、服务层、监控层。每一层只跟相邻的层打交道,层与层之间通过明确的接口通信。这样做的好处是,当你想把sklearn换成pytorch时,只需要动模型层;当你想把flask换成fastapi时,只需要动服务层。
提示:分层不是目的,解耦才是。如果你的项目只有一个人维护、生命周期不超过一个月,过度分层反而是负担。分层的前提是你预期这个系统会长期演进。
2.2 从零搭建的四个阶段
我把整个搭建过程分成四个阶段,每个阶段有明确的产出物和验收标准。这个划分方式是我带过几个项目之后沉淀下来的,比按技术栈划分更贴近实际推进节奏。
| 阶段 | 核心目标 | 关键产出 | 验收标准 |
|---|---|---|---|
| 阶段一:跑通闭环 | 数据到预测的完整链路 | 可复现的训练脚本 | 换台机器能跑出同样结果 |
| 阶段二:工程化改造 | 模块解耦、配置管理 | 分层代码结构 | 改配置不改代码 |
| 阶段三:服务化部署 | 对外提供稳定接口 | API服务+容器镜像 | 压测达标、能回滚 |
| 阶段四:可观测与迭代 | 监控、告警、持续训练 | 监控面板+自动化流水线 | 问题可定位、模型可更新 |
很多人卡在阶段一和阶段二之间。阶段一能跑通,就觉得“差不多了”,直接跳到部署,结果阶段三各种问题爆发。我的建议是,阶段二该花的功夫一定要花,它是后面所有工作的地基。
2.3 技术选型的取舍逻辑
选型这件事,没有银弹,只有权衡。我列几个我实际做过的决策,以及背后的理由。
框架选型:PyTorch还是TensorFlow?我的判断标准是看团队背景和部署环境。如果团队偏研究、需要快速实验,PyTorch的动态图更顺手;如果部署环境对推理性能要求极高、且需要成熟的移动端支持,TensorFlow的生态更完整。但说实话,到了2024年这个时间点,两者差距已经很小了,选哪个都能干活,关键是团队熟悉哪个。
服务框架:Flask、FastAPI还是Triton?小规模、低并发用Flask足够;需要异步、自动文档、类型校验用FastAPI;如果模型推理本身是瓶颈、需要批处理和张量并行,直接上Triton Inference Server。我一般先用FastAPI把业务逻辑跑通,等推理成为瓶颈再引入Triton。
配置管理:硬编码、argparse还是Hydra?实验阶段argparse够用,但一旦配置项超过二十个,Hydra的层级配置和命令行覆盖能力就体现出价值了。我踩过的坑是,早期用argparse,后来配置爆炸,改一个参数要翻半天代码。
3. 核心细节解析:每个环节的坑与解法
3.1 数据层:别让脏数据毁掉整个系统
数据层是AI工程里最容易被低估的部分。我见过太多项目,模型结构设计得精妙绝伦,结果因为训练数据和推理数据的预处理不一致,线上效果直接腰斩。这个问题的专业叫法是训练-服务偏差(Training-Serving Skew)。
解决这个问题的核心原则是:训练和推理必须共用同一套预处理逻辑。具体做法是把预处理代码抽成一个独立的模块,训练时调用它,推理时也调用它。千万不要训练时用pandas处理,推理时用numpy重写一遍,哪怕逻辑看起来一样,浮点精度、缺失值处理、类别编码顺序都可能不同。
# preprocessing.py —— 训练和推理共用的预处理模块 import numpy as np class Preprocessor: def __init__(self, mean, std, categories): self.mean = mean self.std = std self.categories = categories # 类别到索引的映射 def transform(self, raw): # 数值特征标准化 num = (raw["numeric"] - self.mean) / (self.std + 1e-8) # 类别特征编码,未知类别统一映射到 0 cat = np.array([self.categories.get(c, 0) for c in raw["category"]]) return np.concatenate([num, cat])这个类在训练时被fit出mean、std、categories,然后序列化保存;推理时直接加载,保证逻辑完全一致。我实测下来,光是这一条,就能消掉一大半“线上线下不一致”的诡异问题。
注意:标准化用的
mean和std必须来自训练集,绝对不能用全量数据计算,否则就是数据泄露。这个坑我在早期项目里踩过,线下指标虚高,线上一塌糊涂。
3.2 特征层:特征存储的必要性判断
特征工程是AI工程里最“脏”的活。早期我都是把特征计算写在训练脚本里,后来发现同一个特征在训练、评估、推理三个地方各算一遍,三份代码迟早会不一致。这时候就需要特征存储(Feature Store)。
但我要泼一盆冷水:不是所有项目都需要特征存储。特征存储解决的是“多团队共享特征”“训练推理特征一致性”“特征版本管理”这三个问题。如果你就一个模型、一个团队、特征不超过五十个,自己写个feature.py模块就够了,上特征存储是杀鸡用牛刀。
判断标准很简单:当你发现同一个特征被两个以上的模型使用,或者你需要回溯“上周三那个模型用的是哪个版本的特征”时,就该考虑特征存储了。轻量级的方案可以用Feast,重量级的可以用Tecton,但我的经验是,大部分中小团队自己用Redis加一张元数据表就能搞定。
3.3 模型层:版本管理与可复现性
模型版本管理是另一个血泪教训。我曾经遇到过线上模型效果突然下降,排查了两天才发现是有人重新训练了模型、覆盖了原来的文件,但没人记录这次训练用了什么数据、什么参数。从那以后,我强制要求每次训练必须产出三样东西:模型权重、训练配置、数据版本号。
# 训练产出的目录结构 runs/ 20240115_143022/ model.pt config.yaml data_version.txt # 记录数据集的哈希或版本标签 metrics.json train.log用时间戳做目录名,配合git commit hash,基本能保证任何一次训练都可追溯、可复现。如果团队规模再大一点,可以引入MLflow或Weights & Biases做实验追踪,但核心思想是一样的:没有记录的训练等于没训练。
3.4 服务层:延迟与吞吐的平衡术
服务层是AI工程最考验功力的地方。模型推理的延迟和吞吐是一对矛盾体:批处理能提高吞吐,但会增加单条请求的延迟;单条推理延迟低,但吞吐上不去。怎么平衡?
我的做法是动态批处理(Dynamic Batching)。服务端维护一个请求队列,攒够一定数量或者等待超过一定时间就触发一次推理。这个“一定数量”和“一定时间”就是需要调的参数。等待时间设得太短,批处理效果不明显;设得太长,用户等得着急。我一般从max_batch_size=32、max_wait_ms=10开始调,根据实际压测结果微调。
# 简化的动态批处理逻辑 import asyncio from collections import deque class BatchScheduler: def __init__(self, max_batch_size=32, max_wait_ms=10): self.max_batch_size = max_batch_size self.max_wait = max_wait_ms / 1000 self.queue = deque() async def add_request(self, data): future = asyncio.Future() self.queue.append((data, future)) if len(self.queue) >= self.max_batch_size: await self._flush() return await future async def _flush(self): batch = list(self.queue) self.queue.clear() # 调用模型推理,把结果 set 到各自的 future ...这段代码是简化版,真实场景还要考虑超时、异常、优先级等。但核心思路就是:用一点点延迟换吞吐,用队列做缓冲。
4. 实操过程:从空目录到可服务系统
4.1 环境准备与依赖锁定
从零开始的第一步,不是写代码,是搭环境。我见过太多“在我机器上能跑”的悲剧,根因都是依赖版本不一致。所以第一件事是锁定依赖。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # 安装依赖并锁定版本 pip install torch==2.1.0 fastapi==0.104.0 uvicorn==0.24.0 pip freeze > requirements.txtrequirements.txt里必须写死版本号,不能用torch>=2.0这种模糊写法。如果项目复杂,建议用poetry或pipenv管理,它们能处理依赖冲突。我个人的习惯是用poetry,因为它的lock文件能保证所有人装出来的环境完全一致。
提示:如果涉及GPU,还要记录CUDA版本和驱动版本。
torch.cuda.version和nvidia-smi的输出都要存档,否则换台机器可能就跑不起来了。
4.2 数据管道搭建
数据管道我一般用PyTorch的Dataset和DataLoader来搭,因为它们的抽象足够好,支持多进程加载、打乱、批处理。关键是要把数据读取和预处理分开,Dataset只负责“取一条原始数据”,预处理交给前面说的Preprocessor。
from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, file_paths, preprocessor): self.files = file_paths self.preprocessor = preprocessor def __len__(self): return len(self.files) def __getitem__(self, idx): raw = load_raw(self.files[idx]) # 只负责读取 features = self.preprocessor.transform(raw) # 预处理 label = raw["label"] return features, label # 使用 dataset = MyDataset(train_files, preprocessor) loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)num_workers这个参数很关键。设成0表示在主进程加载,会阻塞训练;设成4或8能并行加载,但设太大反而会因为进程切换开销降低效率。我的经验是设成CPU核心数的一半左右比较合适。
4.3 训练循环与检查点
训练循环看起来简单,但细节很多。我列几个必须处理的点:梯度裁剪防止梯度爆炸、学习率调度让训练后期更稳定、检查点保存防止训练中断白跑、早停防止过拟合。
import torch from torch.optim.lr_scheduler import CosineAnnealingLR def train(model, loader, epochs, lr, save_dir): optimizer = torch.optim.Adam(model.parameters(), lr=lr) scheduler = CosineAnnealingLR(optimizer, T_max=epochs) best_loss = float("inf") for epoch in range(epochs): model.train() total_loss = 0 for features, labels in loader: optimizer.zero_grad() outputs = model(features) loss = torch.nn.functional.cross_entropy(outputs, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() scheduler.step() avg_loss = total_loss / len(loader) # 保存最佳检查点 if avg_loss < best_loss: best_loss = avg_loss torch.save(model.state_dict(), f"{save_dir}/best.pt") # 每轮都保存,防止中断 torch.save(model.state_dict(), f"{save_dir}/last.pt") print(f"Epoch {epoch}, Loss {avg_loss:.4f}")clip_grad_norm_的max_norm设成1.0是个比较通用的起点,如果训练不稳定可以调小,如果收敛太慢可以调大。CosineAnnealingLR适合大多数场景,比固定学习率效果好。
4.4 服务封装与容器化
模型训练好之后,要封装成服务。我用FastAPI比较多,因为它自带文档、支持异步、类型校验方便。
from fastapi import FastAPI from pydantic import BaseModel import torch app = FastAPI() model = load_model("runs/best.pt") preprocessor = load_preprocessor("runs/preprocessor.pkl") class Request(BaseModel): numeric: list[float] category: list[str] @app.post("/predict") async def predict(req: Request): features = preprocessor.transform(req.dict()) with torch.no_grad(): logits = model(torch.tensor(features).unsqueeze(0)) prob = torch.softmax(logits, dim=-1) return {"probabilities": prob.tolist()}容器化用Docker,Dockerfile要基于nvidia/cuda镜像(如果用了GPU),并且把模型文件复制进去。注意镜像层缓存,把requirements.txt的安装放在复制代码之前,这样改代码不会触发依赖重装。
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]4.5 压测与性能调优
服务上线前必须压测。我用locust或wrk,模拟不同并发下的延迟和吞吐。关键指标是P99延迟和QPS。P99延迟比平均延迟重要得多,因为用户感知到的是最慢的那1%。
压测时我会重点观察三个地方:GPU利用率、CPU利用率、内存占用。如果GPU利用率低,说明瓶颈在数据预处理或网络传输;如果CPU利用率高,说明预处理太重,考虑用GPU做预处理或者优化代码;如果内存持续增长,可能有内存泄漏,检查是否有全局变量在累积。
5. 常见问题与排查技巧实录
5.1 线上线下效果不一致
这是最高频的问题。排查思路按顺序来:先查预处理是否一致,再查特征计算是否一致,最后查模型版本是否一致。我做过统计,八成的问题出在预处理。具体做法是拿一条线上请求的原始数据,分别走训练预处理和推理预处理,对比输出。如果不一样,问题就找到了。
5.2 推理延迟突然飙升
延迟飙升通常有三个原因:请求量突增导致排队、某个请求触发了慢路径、资源被其他进程抢占。排查时先看监控面板的QPS曲线,如果QPS没变但延迟涨了,大概率是慢路径;如果QPS涨了,是容量问题。慢路径常见于输入数据异常(比如超长文本、超大图片),需要在预处理阶段加长度限制。
5.3 模型更新后效果回退
新模型上线效果变差,先别急着回滚。检查新模型的训练数据分布是否和线上一致,检查评估指标是否选对了,检查A/B测试的流量分配是否均匀。我遇到过一次,新模型在离线评估上更好,但线上更差,最后发现是离线评估用了旧的数据划分,存在数据泄露。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 线上线下不一致 | 预处理逻辑不同 | 对比同一条数据的处理结果 | 抽取共用预处理模块 |
| 延迟飙升 | 请求排队/慢路径 | 看QPS和P99曲线 | 扩容/加输入限制 |
| 效果回退 | 数据分布漂移 | 对比训练和线上数据统计 | 重新训练/加监控 |
| 内存泄漏 | 全局变量累积 | 长时间压测看内存曲线 | 检查缓存和日志 |
| 训练不收敛 | 学习率过大/数据未归一化 | 看loss曲线 | 调小学习率/加标准化 |
5.5 几个我踩过的坑
第一个坑是日志打太多。早期我在推理服务里把每条请求的完整输入输出都打到日志里,结果磁盘两天就满了,而且日志IO拖慢了服务。后来改成只打关键字段和采样日志,问题解决。
第二个坑是模型文件太大导致容器启动慢。一个几百MB的模型,每次启动都要从镜像里加载,冷启动要几十秒。后来我把模型放到对象存储,启动时下载,配合本地缓存,冷启动降到几秒。
第三个坑是没有做优雅关闭。服务收到终止信号时直接退出,正在处理的请求全部失败。后来加了信号处理,等当前批次处理完再退出,用户体验好很多。
6. 监控与持续迭代:让系统自己会说话
6.1 必须监控的四个指标
AI系统的监控和普通后端系统不一样,除了CPU、内存、延迟这些常规指标,还要监控模型层面的指标。我一般盯四个:输入数据分布、预测结果分布、特征缺失率、模型置信度。
输入数据分布用PSI(Population Stability Index)衡量,超过0.2就说明分布漂移了。预测结果分布看类别比例,如果突然某个类别占比暴涨,可能是数据问题。特征缺失率能提前发现上游数据管道故障。模型置信度下降往往预示着模型需要重新训练了。
6.2 持续训练的触发条件
持续训练不是定时任务,而是事件驱动。我设置的触发条件有三个:PSI超过阈值、模型置信度连续下降、人工反馈的bad case累积到一定数量。满足任一条件就触发重新训练,训练完先影子部署(不接真实流量),对比新旧模型效果,达标再切流量。
6.3 回滚机制
回滚机制是保命的。我的做法是每次部署都保留上一个版本的镜像和模型文件,切流量用配置中心控制,出问题一键切回。回滚时间要控制在分钟级,超过五分钟的回滚等于没有回滚。
7. 一些个人体会
这套从零搭建的路径,我完整走过不止一遍。最大的感受是,AI工程的难点从来不在算法,而在一致性和可观测性。一致性保证训练和推理行为相同,可观测性保证出问题能快速定位。把这两件事做好,系统就稳了一大半。
另外,别追求一步到位。我见过太多团队一上来就想搭一套“完美”的MLOps平台,结果半年过去了还在搭平台,模型一个没上线。正确的做法是先跑通最小闭环,再逐步加工程化能力。每加一层都要问自己:这一层解决了什么具体问题?如果答不上来,就不该加。
最后分享一个小技巧:给每个模型训练任务起一个有意义的名字,包含日期、数据版本、关键参数,比如20240115_v2data_lr1e-3。这个习惯看起来不起眼,但当你三个月后需要回溯某个模型时,会感谢当时的自己。