两年前我第一次看到ai-engineering-from-scratch这个仓库名时,第一反应是:这大概又是个把论文公式抄一遍的学习笔记。真按着里面的路线完整走了一遍之后,我才发现自己完全想错了——它解决的压根不是“理论不会”,而是那个更折磨人的问题:“理论好像会了,却不知道从哪下手把整个系统跑起来”。
“AI工程”这个词已经被讲烂了,但真正从零开始把一个模型送进生产环境、稳定运行几个月的人,其实没那么多。绝大多数人卡在中间某个环节:环境装不好、数据管不住、训练结果复现不了、部署出来接口一压就挂。这篇内容就是围绕这套从零开始的AI工程路线,记录我自己一路踩坑、拆解、填坑的完整过程,特别适合那些算法基础有一点、工程经验几乎为零的人参考。文章里讲到的所有结构和操作,都可以直接抄进你自己的项目里。
1. 先想清楚:这个标题里的from scratch到底指什么
1.1 不是从神经网络起源讲起
很多人一看到from scratch,第一反应是“从感知机、反向传播推导开始”。但这套路线图的切入点完全不同:它默认你已经具备基本的机器学习概念,知道什么是有监督学习、损失函数、梯度下降,但接下来就不知道该干什么了。
岗位名称“AI工程师”听起来很光鲜,实际工作内容却很杂:数据要自己清洗、训练脚本要自己写、模型要自己封API、线上指标掉了要自己排查。任何一环薄弱,整个系统都会在线上暴露问题。所以这套路线把AI工程定义成一句话:把一个模型从实验环境搬到生产环境并持续维护下去的全套能力。它不是某一项技能,而是环境、数据、模型、部署、监控五件事的组合。
1.2 算法能力与工程能力是两套思维
算法同学关注的是“模型精度还能不能往上提一点”,而AI工程关注的是“整个系统能不能稳定复现、快速迭代、长期运行”。这两者的思维方式差异非常大,不提前意识到会吃大亏。
举个例子:算法阶段做实验,用Jupyter Notebook跑完一个单元格,看到准确率变高就开心地继续调下一个超参数。但工程路线要求的是,同一个实验必须能被别人(或三个月后的你)一键复现:固定随机种子、锁定依赖版本、记录数据标识、保存训练配置。这些工作不像调参那样有即时反馈,但恰恰是系统稳定性的基石。我在项目里专门把“可复现性”放在了环境阶段的第一位,原因很简单:不可复现的实验等于没做。
1.3 这个项目仓库的目录结构怎么排
我按“环境→数据→模型→部署→监控”五个阶段来组织整个项目,每个阶段配一个最小可运行示例,而不是长篇大论的文档。目录结构大概长这样:
ai-engineering-from-scratch/ ├── 01-environment/ │ ├── environment.yml │ └── Dockerfile ├── 02-data/ │ ├── download_data.py │ └── validate_data.py ├── 03-training/ │ ├── train.py │ └── evaluate.py ├── 04-serving/ │ ├── app.py │ └── test_request.py └── 05-monitoring/ ├── drift_detection.py └── alert_rules.md为什么这样分?因为每个阶段都有独立的知识体系和排错方法。如果混在一起,你遇到一个报错都分不清该查环境、查代码还是查数据。先划清边界,每个阶段单独打通,最后再串完整链路,这个顺序对初学者是最友好的。
2. 第一关:把运行环境从“玄学”变成“可复现”
2.1 Python、CUDA、PyTorch三方版本匹配
AI工程与普通后端开发最大的差异在于GPU依赖。普通后端服务你只要pip install一下依赖,代码基本能跑;但涉及深度学习框架,Python版本、CUDA驱动、PyTorch版本、cuDNN版本必须精确匹配,任何一个对不上,轻则Warning,重则直接段错误崩溃。
我这里直接给你一个经过大量项目验证的最小稳定组合:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Python | 3.10 | 生态兼容性最好,第三方库几乎都有预编译包 |
| CUDA Toolkit | 11.8 | PyTorch官方预编译轮子支持最稳的版本 |
| PyTorch | 2.1.x | 训练推理都稳定,API设计也合理 |
| 显卡驱动 | >= 525 | 驱动版本决定CUDA运行时能用哪个等级 |
环境创建建议用conda锁一层,pip再锁一层。我用的是这样的操作流程:
conda create -n ai-scratch python=3.10 -y conda activate ai-scratch conda install cudatoolkit=11.8 -c conda-forge -y pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118提示:先执行
nvidia-smi确认驱动版本。如果显卡驱动太老,即使conda环境完全正确,程序也会在torch.cuda.is_available()返回False时静默改用CPU跑,训练速度瞬间慢几十倍。
2.2 容器化是对未来的一种投资
很多初学者觉得Docker学习成本高,先跳过。这个想法在自娱自乐的小项目里没问题,但一旦项目要交接给同事、部署到服务器、或者换一台新电脑重新跑,你立刻会体会到处处是坑。容器化解决的不是“跑起来”,而是“在任何地方跑起来”的问题。
Dockerfile写得极简,也能达到目的:
FROM nvidia/cuda:11.8.0-cudnn8-devel-ubuntu20.04 ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 RUN apt-get update && apt-get install -y python3.10 python3-pip WORKDIR /workspace COPY requirements.txt . RUN pip install -r requirements.txt COPY . .关键点是:基础镜像选带CUDA和cuDNN的,这样就不用再从零装一遍GPU栈。写清楚PYTHONUNBUFFERED=1是为了让日志实时输出,后面排查线上问题时救命。
2.3 最小可复现骨架的必备文件
一个环境阶段合格的项目,至少要包含三个文件。第一是requirements.txt,必须用pip freeze生成精确版本号,而不是手动写“torch>=2.0”这种宽泛约束。你看一次“昨天还能跑今天跑不了”的惨案,就会明白精确锁版本有多重要。
第二是environment.yml,记录conda层面的通道和包。第三是README.md,但里面不能只写“安装依赖”,要写清楚复现步骤、预期结果、常见报错的解决方式。
我在这套环境阶段里栽过最大的跟头是:换了台新机器从零搭环境,CUDA装了12.0,结果项目依赖的某个旧库直接编译失败。后来全部改成在项目根目录放一个setup.sh脚本,一键完成“创建虚拟环境+装依赖+跑通测试”,才彻底摆脱每次迁移环境都要和报错搏斗半天的局面。
3. 第二关:数据工程,被严重低估的第一生产力
3.1 真实项目里数据工作的真实比重
外界对AI的想象是“写模型”,实际项目里数据工作往往占到一半以上时间。模型结构可以复用开源成果,但数据必须自己处理。我在这个项目的数据阶段整理了三个高频动作:采集、清洗、校验。
清洗不是把空值删掉那么简单。数值型特征有空值,要决定是填中位数还是用模型预测填充;类别型特征有非法取值,要统一归一化到合法集合;文本特征有编码混乱,要先检测再修复。每一个决定都会影响模型表现。更麻烦的是,这些决定如果不记录,几周后根本想不起来当时为什么这么处理。
所以我强烈建议从一开始就养成习惯:每个清洗步骤都写成独立函数,并且配上一个验证函数。比如“删除缺失率超过80%的列”,就要写一个断言来确认这个规则真的被应用了。
3.2 数据版本化:给数据打上可追溯的标签
代码可以用Git管理,数据却不适合塞进Git——大文件会让仓库膨胀到没法用。业界一般用DVC这类数据版本管理工具,操作逻辑其实和Git很像。
我第一次完整跑通数据版本化,是在一个图像分类项目里用了DVC。流程非常简单:
dvc init dvc remote add -d myremote s3://my-bucket/dvc-store dvc add data/raw_images/ git add data/raw_images.dvc .dvc/config git commit -m "add raw images v1"后续任何人想复现这个模型,只需要dvc pull就能把完全一致的数据拉下来。数据漂移导致模型指标下降时,你还能精准对回到某一天的某份数据,而不是凭记忆猜测“当时用的好像是8月份那份”。
3.3 数据集划分:别让数据泄漏毁掉整个评估
数据集划分看着简单,里面藏着大坑。最常见的是时间序列数据没有按时间切分,而是在全体数据上随机划分。这样训练集里混着未来信息,验证集指标会异常好看,一上线就被现实毒打。
正确的做法是:数据一旦有时间顺序,划分时要用前者训练、后者验证。比如交易数据、日志数据、股票数据,都必须严格按时间截断。
train_df = df[df['timestamp'] < cutoff_date] valid_df = df[(df['timestamp'] >= cutoff_date) & (df['timestamp'] < cutoff_date2)] test_df = df[df['timestamp'] >= cutoff_date2]这套路线里我一直反复强调“先验证集再训练集”的反直觉习惯:模型心里只认训练数据,当验证集表达的含义与训练集差异过大时,说明你的采样方式或数据预处理有严重问题,要先解决这个再谈调参。
4. 第三关:训练与实验管理,把调参从玄学变成科学
4.1 一个最小可用的训练循环
训练代码是AI工程中最容易写、也最容易写烂的部分。最小可用的PyTorch训练循环长这样:
import torch def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 total_samples = 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() batch_size = x.size(0) total_loss += loss.item() * batch_size total_samples += batch_size return total_loss / total_samples这段代码有几个容易忽略的细节:一是model.train()和model.eval()的切换,影响Dropout和BatchNorm的行为,很多新手忘了最后会得到完全不同的推理结果;二是optimizer.zero_grad()必须在每个batch迭代前调用,否则梯度会累积;三是loss要按样本数加权平均,而不是简单算所有batch的平均,因为最后一个batch可能很小且分布不均。
真正线上规模比较大的训练脚本,还会加这几样东西:Early Stopping只保存验证集损失最低的checkpoint、学习率调度器在loss平台期自动降学习率、训练日志记录每个epoch的耗时和GPU显存占用。我在项目里都提供了对应模板,照着填就行。
4.2 实验追踪工具:别让实验记录靠脑子
训练不只写循环,还要管实验结果。10次实验之后人脑就废了,根本记不住哪次用了什么超参、哪次用了哪份数据。我一开始用手写CSV记录,跑到第50次实验彻底放弃,转用MLflow。
MLflow最核心的用法就三行:
import mlflow mlflow.set_experiment("my-project") with mlflow.start_run(): mlflow.log_params({"lr": 0.001, "batch_size": 32}) mlflow.log_metric("val_loss", 0.42) mlflow.log_artifact("best_model.pt")这样每次实验的参数、指标、模型文件自动关联,浏览UI就能看到哪组超参表现最好。对比一下手动记录和实验管理工具,差别非常直观:
| 能力 | 手动CSV记录 | 实验管理工具(MLflow/W&B) |
|---|---|---|
| 参数与指标自动关联 | 易错 | 自动 |
| 复现同一实验 | 难 | 一键 |
| 多人协作 | 混乱 | 按实验隔离 |
| 可视化对比 | 自己做图表 | 内置 |
对个人项目,我推荐你先用MLflow的本地模式,不引入服务器。等团队协作需要共享实验记录,再上中心化部署。
4.3 过拟合与欠拟合的诊断顺序
初学者一上来就谈防过拟合(加正则、加Dropout、数据增强),但很多时候模型压根是欠拟合的。正确的诊断顺序一定是:先看训练集损失,再看验证集损失。
如果训练集损失就已经很高,说明模型容量不够或优化不充分,这时候把所有正则手段全加上只会雪上加霜。如果训练集损失很低、验证集损失很高,这才是过拟合,才需要上Dropout、权重衰减、数据增强。
我现在遇到模型效果差,第一反应永远是画两条曲线:训练损失曲线和验证损失曲线。曲线形态直接告诉你下一步该往哪个方向使劲,比瞎改超参高效太多。
5. 第四关:部署推理,从notebook到生产服务的最后一公里
5.1 模型封装成API,注意这几个容易翻车的点
部署阶段的第一件事,是把模型完整封装成一个HTTP服务。我在项目里用FastAPI做了一套最小模板,因为它在性能和易用性之间取得了最舒适的平衡点。
from fastapi import FastAPI import torch from pydantic import BaseModel app = FastAPI() model = torch.load("model.pt", map_location="cpu") model.eval() class Payload(BaseModel): features: list[float] @app.post("/predict") def predict(payload: Payload): with torch.no_grad(): tensor = torch.tensor(payload.features).unsqueeze(0) prob = torch.sigmoid(model(tensor)).item() return {"probability": prob}这段代码里最坑的地方是.item()。如果你忘记调用它,直接返回一个GPU上的Tensor对象,FastAPI会抛出序列化错误。此外模型加载应该放在模块导入阶段而不是请求内部,否则每个请求都要重新载入大模型,延迟直接翻几十倍。
注意:模型服务接口返回的一定是Python原生类型(float、int、list、dict),不要直接返回Tensor、numpy数组这类对象。这是新手在部署阶段最容易犯的错误之一。
5.2 推理性能优化:批处理与量化
模型上线后最大的性能瓶颈通常不是网络,而是单个请求的推理耗时。优化优先级的排序非常重要:先做批处理,再做半精度推理,最后才考虑量化。
批处理的意思不是让调用方传多个样本,而是把并发请求攒起来一次性输入模型。比如服务期间来了10个请求,每份都是单条数据,你可以把这10条拼成一个batch喂给GPU,吞吐量能提升数倍。实现方案用FastAPI的并发队列或者Redis队列都可以,核心思路是“攒批再推理”。
半精度推理几乎零成本:
model = torch.load("model.pt", map_location="cuda") model.half()显存占用减半、推理略快、精度损失在大部分任务上可忽略。量化效果好但工程复杂度高,一般项目可以放到后面再考虑。
5.3 线上稳定性:超时、重试、并发、降级
模型服务的线上问题大多是工程问题,而不是模型精度问题。我在项目里专门列了一个检查清单:
| 问题 | 典型表现 | 解决方法 |
|---|---|---|
| 单请求推理超时 | 客户端一直转圈 | 设置合理的请求超时,比如2秒 |
| 请求积压 | 服务内存不断上涨 | 控制并发数,排队超过上限直接拒绝 |
| GPU显存溢出 | CUDA out of memory | 批处理大小设上限,定期释放缓存 |
| 模型返回异常 | 后端500一片 | 接口加异常捕获,返回统一错误结构 |
| 上游依赖故障 | 调用外部API超时 | 熔断降级,返回备用结果 |
我踩过最深的坑是:模型服务没有设置超时保护,客户端100个并发打进来,GPU显存瞬间爆掉,进程直接崩溃,恢复还要手动重启。后来老老实实加了并发限制、请求超时、HealthCheck探活,才让服务真正能无人值守地跑过夜。
6. 第五关:模型监控与持续迭代,系统稳定跑一个月才算开始
6.1 数据漂移检测:模型变差的预警系统
模型精度不可能永远不变。你训练时用的数据分布,和线上真实的数据分布之间会有缓慢的漂移。今天是95%的准确率,三个月后可能悄无声息掉到85%,而且没有人发现。
我在项目里写的第一个漂移检测脚本,用的指标是Population Stability Index(PSI)。它的核心思路是:把训练集的特征分布和最近一个月线上数据的特征分布拉到同一个区间,算每个区间占比差异。PSI大于某个阈值就发出警告。
import numpy as np def compute_psi(expected, actual, bins=10): expected_percent, _ = np.histogram(expected, bins=bins, range=(0, 1)) actual_percent, _ = np.histogram(actual, bins=bins, range=(0, 1)) expected_ratio = expected_percent / expected_percent.sum() actual_ratio = actual_percent / actual_percent.sum() actual_ratio = np.where(actual_ratio == 0, 1e-6, actual_ratio) psi = ((actual_ratio - expected_ratio) * np.log(actual_ratio / expected_ratio)).sum() return psi简化的实现里,PSI小于0.1表示分布稳定,0.1到0.25表示轻度漂移,大于0.25就需要考虑重新训练模型。这个脚本不需要额外部署,每天定时任务跑一遍、把结果推到告警系统就行。
6.2 自动重训练:不要无脑每天重训
“监控发现漂移就自动重训”听起来很智能,实际操作里会踩大雷。最典型的坑是:数据量太小、标签质量太差、重训后模型反而比旧版还差。
所以我在项目里采用的策略是“阈值触发+人工确认”:
- 漂移指标超过轻度阈值,先发告警;
- 系统自动准备最新数据和检验集;
- 训练离线评估,对比新旧模型的验证集指标;
- 新模型有明显提升才进入灰度发布流程。
自动重训听起来是AI工程兜底能力,但本质上它也是工程问题,需要先做好回滚机制再谈自动化。
6.3 回滚与灰度发布
模型上线不是“一把梭”把所有流量切过去就完事。我亲身经历过一次:新模型验证集上表现正常,上线后线上数据分布跟验证集完全不一样,A/B测试指标直接崩了。如果当时没有灰度发布机制,整个线上服务都会遭殃。
标准做法是保留最近的N个模型版本,存在带版本号的文件或模型仓库里:
models/ ├── model_20240101.pt ├── model_20240115.pt └── model_latest.pt服务端启动时加载model_latest.pt,日常运维时先加载新模型跑一小部分流量(10%)观察指标,确认稳定后再逐步调高到50%、100%。如果有任何异常,立马切回上一个稳定版本,全程5分钟以内完成。
这套机制不需要复杂的架构,只要在API服务里加一个读取配置文件和模型文件版本号的步骤,就能实现。别等线上出了问题才想起回滚机制存在。
7. 一趟走下来,比技能更重要的三个习惯
整套路线跑完,专业技能确实提升了很多,但真正让项目变稳的是三个很朴素的工作习惯。
第一,每次遇到诡异报错,先花时间写一个最小复现脚本。这个问题看似“浪费”半小时,实际上能大幅缩短排查时间。我那个环境问题、部署问题,凡是能快速定位的,都是先写了10行以内的最小脚本确认问题边界,而不是在几千行代码里大海捞针。
第二,所有操作命令、踩坑结论、处理思路,全部记到项目的docs/troubleshooting.md里。这个文档现在已经有几十条记录了,常见问题打开就能查到解决方案。知识只存在于自己脑子里是很容易丢失的,写下来才有复用价值。
第三,所有实验必须可复现。不能复现的结果,不管当时看起来多好,都是无效的。这个习惯一开始觉得麻烦甚至多余,但经历过项目交接和回滚事故之后,我才意识到“可复现性”才是工程世界里报销一切的凭证。
从零开始做AI工程,说难很难,难点全在坑多且散;说容易也容易,因为每个坑都有明确解法和前人总结。这套路线我走了两遍,第一遍摸出一道道的坑,第二遍把所有坑都标成上了路标的绕行方案。照着这份经验走,我相信你会比我当年快得多。