我见过太多人把"AI工程"理解成"调个模型、跑个推理、部署个API",结果真到了简历上写"AI工程师",却在面试时被一句"你的模型是怎么上线的?监控怎么做?数据漂移怎么发现?"问得哑口无言。这个ai-engineering-from-scratch的项目标题,恰好戳中了这个痛点——它不是教你背几个Transformer公式,也不是让你跑通一个LeNet demo就算完事,而是一条从零开始、把一个AI系统真正落地为可交付产品的完整路线。我最初接触这个项目时以为它只是又一份"深度学习入门清单",实际走完一遍才发现,它把AI工程师和算法工程师之间那条模糊的边界画得清清楚楚:数据、训练、部署、监控、迭代,每一环都不可偏废。
这篇文章就是我自己从零实践这套体系后的完整复盘。我会先拆解"AI工程"这个名词背后到底覆盖了哪些能力栈,再按实操顺序带你走一遍环境搭建、数据管线、模型训练、服务化部署和线上监控的完整流程,最后把项目里埋的那些"坑"——也就是普通教程不会告诉你的部分——全部摊开。无论你是刚转行的新人,还是被Notebook困住、想让模型真正跑到生产环境里的算法工程师,这篇文章都能给你一张可以照着执行的地图。
1. 核心拆解:AI工程与"调包"之间隔着多少个环节
1.1 为什么"从零开始"这件事本身最难
很多人以为难在数学、难在模型结构,但我把这个项目跟下来后最大的感受是:AI工程里的难点,根本不在"模型"本身,而在模型周围那套看不见的基础设施。一个模型从论文里走出来,到它能在线上稳定服务千万级请求,中间隔的是数据版本管理、特征一致性校验、训练成本控制、推理延迟优化、模型灰度发布和回滚机制——这些环节每一个单拎出来都能写一本书,但它们通常不会出现在课程大纲里。
这个项目叫"from scratch",它的巧妙之处就在于:你不是在一个已经配好环境、灌好数据的kaggle比赛里做题,而是要从一台裸机开始,自己动手解决"数据从哪来、代码怎么组织、模型怎么训练、服务怎么部署、指标怎么观测"这一整条链路。这条链路上的每一个决策,都会在后面某个时刻以"线上故障"或"莫名其妙的离线指标崩了"的形式回来找你。所以我说,AI工程的核心能力不是调参,而是做决策的能力——在资源有限的情况下,知道哪一环该精雕细琢,哪一环先跑通再说。
1.2 AI工程师、算法工程师与数据科学家的分工边界
我在做这个项目时给自己画了一张能力对照表,想搞清楚AI工程师到底在哪个位置。很多人把这三个角色混在一起,但实际项目里分工差异很大,直接影响了你要练什么。
| 角色 | 核心交付物 | 主要时间花在哪 | 关键工具链 |
|---|---|---|---|
| 数据科学家 | 分析报告、离线实验结论 | 数据探查、特征工程、模型调参 | Pandas、Notebook、Sklearn |
| 算法工程师 | 模型精度、论文复现 | 模型结构设计、训练策略、SOTA指标 | PyTorch、CUDA、分布式训练 |
| AI工程师 | 稳定可靠的AI系统 | 数据管线、推理服务、监控告警、CI/CD | Docker、K8s、MLflow、Triton |
这个表不是绝对的,但它解释了为什么很多"算法很强"的人一到了生产环境就手足无措。当前这个项目要培养的,恰好是第三列和第五列之间的那部分能力——你不但要让模型在离线测试集上达到95%的准确率,还要让它在线上真实分布里保持全程稳定,甚至当数据分布发生变化时,你能靠监控指标第一时间察觉。
1.3 项目背后的底层思维:可复制、可回滚、可观测
"From scratch"并不意味着从零实现一个BP算法。我的理解是:它强调的是一种不依赖任何黑盒平台、一切自己可控的工程习惯。比如训练脚本必须能一条命令复现,数据增强必须具备随机种子可回溯性,模型产出必须自动记录超参数和评估指标,部署必须能一键回滚到上一个版本。这三个"可"——可复制、可回滚、可观测——几乎贯穿了整个项目所有章节。
我后来在真实业务里吃过亏才明白:没有可复制性,你连"为什么昨天线上效果不错今天就不行"都答不上来;没有可回滚性,你敢不敢深夜一个人发布模型版本都得犹豫半天;没有可观测性,模型静默退化个把月你都不会知道。所以这个项目里反复强调这些,不是形式主义,而是生产环境的基本生存法则。
2. 环境与基础设施搭建:工程地基的每一块砖
2.1 开发环境:别在第一周就把自己锁死在Notebook里
项目开始后的第一个任务不是装PyTorch,而是建立一个可复现的开发环境。我的做法是:用uv替代pip和conda做Python版本和依赖管理,用Docker把环境固化成镜像,用Makefile把训练、测试、部署这些常用操作封装成短命令。
为什么是这三件套?uv的速度比pip快一个数量级,而且它的lock文件机制可以精确锁定每个传递依赖的版本——这在AI工程里特别重要,因为深度学习框架的版本兼容问题几乎是日常事故的源头。比如torch==2.1.0在某次小版本升级后,transformers的某个API行为完全变了,这种问题在非锁环境下基本无解。
Docker则解决了"我这台机器跑得好好的,为什么到服务器上就崩了"这个经典问题。我会把CUDA驱动、cudnn版本、Python解释器、依赖包全部写进Dockerfile,然后用docker build生成镜像,推到私有仓库。每一轮实验都对应一个镜像标签,比如train:20250115-e2e1f4,这样顶级的可复现性就实现了。
Makefile的价值容易被新手低估。它本质上是一个"命令说明书",让团队里的任何人都可以用make train、make deploy、make monitor-up这种极简指令完成操作,避免在命令行里敲一长串带各种参数的python脚本。我自己的Makefile大概长这样:
train: uv run python src/train.py --config configs/exp001.yaml test: uv run pytest tests/ -x -q serve: uv run uvicorn src.api:app --host 0.0.0.0 --port 8000 deploy: uv run python scripts/build_and_push.py uv run python scripts/trigger_deploy.py不要小看这个过程,它决定了你后续所有工作的幸福指数。环境如果是脏的,实验就谈不上对比,模型A比模型B好了0.5个点,你根本不敢确定这0.5是模型结构带来的还是依赖升级带来的。
2.2 项目目录结构:按"关注点分离"组织代码
这个项目里给出的目录结构值得直接抄走。一个健壮的AI工程代码仓库,应该按数据、模型、训练、评估、部署、配置分离,而不是把所有东西塞进一个main.py。我实践下来常用的结构是:
project_root/ ├── data/ # 数据文件(一般被.gitignore) ├── configs/ # 所有实验的yaml配置 │ └── exp001.yaml ├── data_pipeline/ # 数据下载、清洗、特征工程 │ ├── download.py │ ├── clean.py │ └── features.py ├── models/ # 模型定义 │ ├── base.py │ └── classifier.py ├── train/ # 训练入口与训练循环 │ ├── trainer.py │ └── evaluate.py ├── deploy/ # 部署相关:Dockerfile、推理脚本 │ ├── api.py │ └── Dockerfile ├── tests/ # 单元测试与数据校验测试 ├── scripts/ # 运维脚本:构建镜像、发布、回滚 └── docs/ # 文档这种结构的核心逻辑是"关注点分离"——模型定义只关心网络结构,训练循环只关心forward/backward,数据管线只关心从原始数据到张量的转换。任何一环修改了,其他环节都能通过接口契约(比如Dataset的输出格式)保持稳定,不会因为改了一个特征处理逻辑就必须重写整个训练代码。这个项目里反复强调的"low coupling, high cohesion",我认为在AI工程里的价值被严重低估了。
2.3 依赖锁定:从源头上避免"版本地狱"
很多从零开始的项目死在依赖冲突上。我这里说的不只是pip install时的报错,而是那种隐藏的版本漂移——比如你上周训练模型时numpy的API行为是X,这周装了个新包自动升级了numpy,结果同样代码产出的模型指标变了。为了彻底杜绝这个问题,我在这个项目的每个实验目录下都保留了一份uv.lock,并且在Docker构建时强制从lock文件安装,而不是从requirements.txt的宽松约束安装。
# 每次改依赖后执行 uv lock # 永远从lock文件构建镜像 docker build --build-arg LOCKFILE=uv.lock -t train:${GIT_SHA} .这个习惯在前期略显繁琐,但当项目复杂度上来后,它省掉的排查时间远超付出的维护成本。版本锁定这件事,越早做越省心,等出问题再补就是灾难现场了。
3. 数据集与特征管线的工程化:真正的隐形工作量
3.1 数据获取与版本管理:用 DVC 告别 "final_v3_really_2024.csv"
"从零开始"的项目里,数据往往是第一个真正的挑战。你要么需要从公开来源下载原始数据集,要么需要对已有的CSV、图片、文本做清洗整理。一个常见的错误是把数据直接塞进git仓库,或者用data_20231215_v2_clean_final.parquet这种文件名管理版本。这个项目教我的正确做法是用DVC(Data Version Control),让数据和代码走同样的版本管理逻辑。
DVC的思路很简单:数据文件放在对象存储或NAS里,DVC只记录文件的哈希和路径映射关系。当数据发生变化时,DVC会生成新的哈希版本。这样你在训练脚本里只需要写dvc pull就能把某个明确版本的数据拉到本地,而实验日志里记录的dvc.yaml的hash,就等同于记录了"这个实验用的到底是哪一份数据"。
# 初始化DVC dvc init # 添加远程存储(S3/NAS/本地路径) dvc remote add -d storage /mnt/data_dvc # 跟踪数据文件 dvc add data/raw/train.csv # 触发训练时同时记录数据版本 dvc repro train在真实项目中,数据版本混乱带来的后果往往比代码回滚更可怕。模型代码可以随时回滚,但数据一旦被错误覆盖或混用,你训练出来的整个模型都是无效的。所以我在这个项目里学到的第一课就是:数据必须像代码一样被严肃对待,有版本、有来源、有校验。
3.2 数据校验:在训练开始前就拦住脏数据
很多从零开始的教程会直接跳过数据校验这一步。但一旦你处理真实数据,就会发现:CSV里混入了一行字符串、某个类别只剩一个样本、缺失值的默认填充方式在最新批次里发生了变化——这些问题如果不提前拦截,模型会在训练中途崩溃,或者更隐蔽的是,模型成功训练完了,但线上效果崩了。
较轻量且好用的方案是Great Expectations或pandera。我在项目里用pandera定义了一个数据schema,对每个字段指定了类型、范围、非空约束,并在数据管线的每一层都插入校验节点:
import pandera as pa schema = pa.DataFrameSchema({ "user_id": pa.Column(str, unique=True), "age": pa.Column(int, pa.Check.in_range(0, 120), nullable=True), "label": pa.Column(int, pa.Check.isin({0, 1})), "score": pa.Column(float, pa.Check.gt(0)), }) validated_df = schema.validate(raw_df, lazy=True)这里值得注意的细节是lazy=True参数。如果设为lazy=False(默认),遇到第一个错误就会停住并抛出异常;设成lazy=True后,pandera会一次性告诉你所有违反约束的记录详情,这对定位数据质量问题的效率提升非常明显。实际排查时,我遇到过最典型的问题是"数据源在某个时间点后longitude字段从经纬度换成了Web Mercator投影坐标",这种肉眼完全看不出来的变化,就是靠schema校验在训练启动前拦截下来的。
3.3 DataLoader设计:别让I/O成为训练瓶颈
数据处理完成后,训练效率的下一个瓶颈往往不在GPU算力,而在于数据喂给GPU的速度。我在项目第一个模型训练时就用错了姿势:直接在__getitem__里读图片、做解码、做各种数据增强,结果GPU利用率长期在30%以下。后来把管线改成了标准的"三阶段流水线"设计:
- 阶段一(数据加载进程):用
torch.utils.data.DataLoader的num_workers参数开多个子进程并行读取原始样本; - 阶段二(预处理与增强):在子进程内部完成解码、裁剪、归一化、增强,并把处理结果放到共享内存队列;
- 阶段三(GPU侧):主进程通过
prefetch_factor做批量预取,确保GPU算完当前batch后能立刻拿到下一个batch。
一个更实用的法则是用异步数据管线替代同步数据管线。比如用albumentations库做图像增强时,它的Compose本身就支持多线程加速;而webdataset这种格式可以把大批量样本打包成tar文件,减少Inode数量和小文件随机I/O,在超过百万样本的规模下效果差距非常明显。我实测的一个案例里,仅仅把数据读取从"每次随机访问文件"改为"预读并使用内存缓存",训练时长直接缩短了40%。数据管线的每一毫秒优化,都会乘上训练步数变成小时级收益。
3.4 特征工程的可复现性:训练/推理必须共用同一套代码
AI工程里一个极具隐蔽性的坑是:训练时做的特征处理和推理时做的特征处理不一致。比如训练时你对数值型特征做了Z-score归一化,用的均值和标准差是训练集的;但如果推理代码里写死了某个常数,或者上线时用了不同顺序的log变换,那线上效果必然退化。这个项目里反复强调的一点是:特征处理代码必须放在公共模块里,训练和推理都从同一个模块导入,并且归一化参数一定要持久化到文件或数据库。
# features.py:训练和推理共用的特征变换类 class FeatureTransformer: def __init__(self, mean_path, std_path): self.mean = np.load(mean_path) self.std = np.load(std_path) def transform(self, raw: pd.DataFrame) -> np.ndarray: # 必须保证训练和推理走完全一致的路径 numeric = (raw[self.numerical_cols] - self.mean) / (self.std + 1e-8) categorical = self.onehot(raw[self.cat_cols]) return np.hstack([numeric, categorical])我的原则很简单:凡是训练代码里做过的事情,推理代码里必须一字不差地再做一遍,而且要由同一段代码完成。这个细节在实际生产项目的模型上线中,是我见过的最常见"离线98分上线砍一半"的原因,没有之一。
4. 模型训练与实验管理的工程实践
4.1 训练脚本的"一条命令复现"设计
在这个"from scratch"项目里,训练脚本的设计目标不是"能跑",而是**"任何人在任何机器上一条命令复现出完全一样的结果"**。这就要求训练逻辑与配置彻底分离。我在每个实验目录下放一个YAML配置文件,训练入口是同一个train.py,实验差异只体现在config文件里,效果如下:
# configs/exp001.yaml data: dataset_path: data/processed/train.parquet batch_size: 512 model_config: model_type: "LightGBM" num_leaves: 63 learning_rate: 0.03 train: epochs: 100 early_stopping_rounds: 20 random_seed: 42 eval_metric: "auc"训练的时候执行make train EXP=configs/exp001.yaml。每一次实验结束后,我会把exp001.yaml连同loss曲线、指标曲线、模型权重、日志文件一起归档到以exp001_20250115_143025命名的目录里。这个习惯是我从MLflow的实践中学到的,但即使不用任何框架,纯手工目录归档也能发挥80%的效力。
4.2 优化器与学习率调度的选型
模型训练过程中最容易被忽视但影响巨大的选择是优化器搭配学习率scheduler。这个项目里我用SGD的momentum版本配ReduceLROnPlateau试过一组CV任务,换成AdamW配CosineAnnealingLR后,同样的epoch数,验证集指标提升了约1.5个百分点。
我个人的经验法则是:
- 表格类/中小型数据集:LightGBM/XGBoost搭配原生调参,不需要深度学习优化器;
- CV/NLP大规模模型:首选AdamW,初始学习率设为3e-4左右(batch_size=256基准),并配合Warmup+Cosine退火;
- 训练图像分类小模型做实验:SGD+momentum+ReduceLROnPlateau往往比Adam家族更稳,不容易过拟合,但需要更宽松的初始学习率(比如0.1)。
在学习率这一部分,还特别建议记录最后一个epoch的学习率实际值。很多复现失败的案例,问题就出在scheduler状态没有正确保存和恢复——比如你在训练中断后从checkpoint恢复,但只恢复了模型参数,没恢复optimizer和scheduler的状态,那么后续的学习率完全是乱掉的。我在checkpoint里会同时打包这三样东西:
checkpoint = { "model_state": model.state_dict(), "optimizer_state": optimizer.state_dict(), "lr_scheduler_state": scheduler.state_dict(), "epoch": epoch, "best_metric": best_val_metric, } torch.save(checkpoint, "checkpoints/exp001_epoch42.pt")4.3 实验追踪:用MLflow管理模型演进的每一个脚印
项目进行到第20次实验左右,手动记录实验的笔记方式已经完全不可控了——哪个实验用了哪份数据、哪个配置跑出过最优分数、哪个模型是当前线上服务的版本,全凭记忆一定会出错。这个阶段我用MLflow做了统一管理,并把它的集成点嵌入训练脚本:
import mlflow mlflow.set_experiment("ai-engineering-from-scratch") with mlflow.start_run(run_name="exp001"): # 记录超参数 mlflow.log_params(params) # 记录指标 mlflow.log_metrics({"train_loss": loss, "val_auc": val_auc}) # 记录模型 artifacts mlflow.pytorch.log_model(model, "model") # 记录数据与代码版本 mlflow.log_param("data_version", dvc_latest_hash) mlflow.log_param("git_sha", git_sha)MLflow最大的价值,不只是给你一个曲线面板,而是它支持模型注册表(Model Registry)。你可以把某个实验的产物标记为Staging,线上验证通过后再转为Production,这样"当前线上模型是哪个版本"就从一个模糊的聊天记录,变成了一个系统内的明确状态。配合我在后面章节要讲的推理服务,模型版本和代码版本一一对应,上线和回滚都只需要改一个标签。
4.4 分布式训练:单卡跑不动的信号,而不是默认选项
从scratch项目里很容易犯的另一个错误是过早引入分布式训练。我的建议是:先单卡跑到极限,再考虑多卡。当你的单卡训练时间超过24小时,或者单卡显存已经无法容纳必要batch size时,才需要考虑DistributedDataParallel或DeepSpeed。分布式训练带来的代码复杂度是数量级的提升,还伴随通信开销、梯度同步策略、更频繁的内存OOM等问题。
如果确实需要多卡,我建议直接选PyTorch的accelerate库,它可以把多机多卡训练的脚本改动控制在一个很小的范围内:
from accelerate import Accelerator accelerator = Accelerator() model, optimizer, dataloader = accelerator.prepare(model, optimizer, dataloader) ... loss.backward() accelerator.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()用accelerate最大的好处是,你的训练代码可以在单卡、多卡、TPU之间无缝切换,几乎不需要重写逻辑。这个项目里我用它跑过一个8卡训练任务,代码改动量只有不到20行,这是标准的分布式训练方式很难做到的。
5. 模型部署与推理服务化:从离线实验到线上服务的最后一公里
5.1 推理服务的形态选择:Batch、在线API还是流式计算
模型训练完不代表项目结束,真正验证"AI工程"成色的是部署环节。我在这套体系里把推理服务分了三种形态,对应不同的业务需求:
| 服务形态 | 延迟要求 | 典型场景 | 技术栈 |
|---|---|---|---|
| 离线批量推理 | 分钟级~小时级 | 日报、报表、离线推荐打分 | Spark / python脚本+调度 |
| 在线API推理 | 毫秒级~秒级 | 搜索、推荐、反作弊实时识别 | FastAPI + Triton / TorchServe |
| 流式/增量推理 | 秒级~分钟级 | 实时特征更新、动态风控 | Flink / Kafka + 模型服务 |
这个项目里主攻的是第二类在线API推理,因为它的复杂度最高、最考验工程能力。标准做法是:训练产物导出为ONNX格式,用ONNX Runtime或Triton Inference Server提供推理,前面的HTTP层用FastAPI封装。为什么优先ONNX而不是直接部署PyTorch模型?一方面ONNX Runtime做了图优化和算子融合,推理速度往往比PyTorch原生的eager模式快;另一方面ONNX格式与训练框架解耦,后续如果想换推理服务框架,模型文件不需要重新转换。
5.2 自建推理服务与成熟推理框架的权衡
如果你问我自建一个FastAPI推理接口和用Triton这类框架哪个好,我的答案是:业务原型期用FastAPI自建,追求吞吐和稳定性时换Triton。FastAPI的好处是开发效率极高,几行代码就能把模型封装成HTTP接口,方便联调和接业务;但它的瓶颈也很明显——Python的GIL、缺少动态batch、GPU显存管理不够精细,在高并发下吞吐能力会受限。
Triton Inference Server则提供了动态批处理、并发模型实例、多模型管理、GPU显存池化等能力。我在一个文本分类场景里做过对比:同样的模型、同样的GPU,直接FastAPI推理的峰值QPS大约在320左右,而Triton开启dynamic batching后能跑到1050以上,延迟反而更低了。代价是Triton的配置和部署复杂度更高,需要config.pbtxt来处理输入输出格式、实例数量和调度策略,学习曲线比FastAPI陡不少。
所以我的决策准则很简单:如果QPS预期低于100,或者只是给别人做Demo,FastAPI就够了;如果模型要承载线上真实流量,或者一个服务需要挂载多个模型,直接上Triton,别在中间地带反复折腾。
5.3 Docker化与Kubernetes部署:不可变基础设施
模型服务化之后,部署动作必须变成可重复的自动化过程。这个项目的最后一公里就是用Docker容器打包整个推理服务,再推送到Kubernetes集群。我构建推理镜像的Dockerfile通常是这样设计的:
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04 # 安装Python和依赖 RUN apt-get update && apt-get install -y python3.10 python3-pip && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 拷贝推理代码和模型文件 COPY deploy/ /app/deploy/ COPY models/artifacts/exp001.onnx /app/model/exp001.onnx EXPOSE 8000 CMD ["uvicorn", "deploy.api:app", "--host", "0.0.0.0", "--port", "8000"]Kubernetes上的部署我倾向于使用标准的Deployment + Service + HorizontalPodAutoscaler组合。这里最容易被忽略的是资源限制设置。我在一次部署中因为没有设置requests和limits,导致一个推理Pod把节点上所有CPU都占满,影响了同节点其他业务。后来规范化配置如下:
resources: requests: cpu: 1 memory: 2Gi limits: cpu: 2 memory: 4Gi另一个经验是务必开启Pod的就绪探针和存活探针。因为模型加载需要时间,Pod刚启动时如果直接接流量,会出现大量超时错误。就绪探针可以延迟到模型完全加载后才打上Ready状态,存活探针则负责在服务内部死锁时自动重启,这套机制让我的线上服务稳定性提升了非常多。
5.4 线上监控:模型效果不是"上线后就不用管了"
模型部署上线后,"AI工程"的工作并没有结束——最容易被忽略也最要命的部分来了:线上监控。我在项目里把监控分成了两个层面。第一层是系统监控:CPU、GPU利用率、推理延迟、错误率、Pod重启次数,这些用Prometheus + Grafana就能搞定。第二层是模型效果监控:线上面临的数据分布如果与训练集明显不一致,模型精度会默默下降,但这种下降有时候要几周后才能从业务指标中发现。
模型效果监控里最核心的就是数据漂移检测。我会对线上请求的输入特征做实时统计,并与训练集的分布做对比,使用的就是Evidently这类库。它可以输出PSI(Population Stability Index)值,简单说就是衡量两个分布之间的差异大小:
from evidently.report import Report from evidently.metric_preset import DataDriftPreset report = Report(metrics=[DataDriftPreset()]) report.run(reference_data=training_data, current_data=online_data) report.save_html("drift_report.html")当PSI超过0.2时,我会设一个告警规则——不是马上自动重训模型,而是先把问题抛给人工:是特征取值突变?是产品策略导致样本分布变化?还是数据管道出了故障?AI工程中,监控的目的不是消灭一切漂移,而是在漂移发生时能快速定位原因并给出应对策略,这个认知是我踩过几次坑之后才真正建立起来的。
6. 从零到一的路线图:按周拆解可执行里程碑
6.1 十二周实践路线图
既然项目名是"ai-engineering-from-scratch",我就分享一下我实际执行的十二周分解路线,这个表已经被我拆成了可以每天照着打勾的任务粒度:
| 周数 | 里程碑 | 主要交付物 | 通过标准 |
|---|---|---|---|
| 第1-2周 | 开发环境与项目骨架 | Docker镜像、Makefile、规范化目录 | 任意机器一条命令复现训练环境 |
| 第3-4周 | 数据获取与清洗 | 数据版本化、校验schema、特征工程模块 | 离线评估脚本可直接读取最新数据 |
| 第5-6周 | 第一个完整训练实验 | 可复现的训练脚本、MLflow实验记录 | 对比实验只需改config,无需改代码 |
| 第7-8周 | 模型调优与指标追踪 | 达到预设指标的模型产物 | 实验历史可追溯、最优模型可定位 |
| 第9-10周 | 推理服务化与部署 | FastAPI/Triton服务、K8s部署脚本 | 服务可一键部署、回滚 |
| 第11-12周 | 监控与迭代闭环 | 漂移检测、告警规则、线上效果看板 | 模型退化能够被自动发现 |
这个路线图最重要的一个设计原则是:每一周交付物都是可运行的,而不是"理论学习完毕"。太多人学AI死在"我还没准备好,再看两章吧"这个循环里。其实最好的前进方式就是尽早让代码跑起来,哪怕第一版极其粗糙,也远胜于停留在笔记里。
6.2 每个阶段的验收标准与自测方法
每一阶段我都给自己设计了明确的"完成定义"(Definition of Done),而不是笼统的"做完"。比如第5-6周的验收标准是"训练脚本在全新容器里从零运行,20分钟内出结果,实验记录可在MLflow中完整复现";第9-10周的标准是"关掉训练入口,只用API文档+部署脚本就能让服务上线,并支持回滚到上一版本"。
这个自查环节非常重要,因为很多人在完成阶段任务时只做到了"代码能跑",而没做到"交付物能被人复现"。在AI工程语境下,不可复现的成果等于没有成果。我建议你在每个阶段结束时,找一个不了解你代码细节的人,让Ta严格按照你的README或Makefile命令走一遍。只有陌生人能走通,你的交付才算真正达到标准。
6.3 项目选题建议:不要一上来就做ImageNet
从零开始的项目,最忌讳的就是选题过大。如果你选了一个需要8卡A100训练一周的任务,大概率半个月后因为资源和调试成本半途而废。我建议的选题有以下几个梯队,按难度递增:
- 梯度一(文本分类):如情感分析、垃圾评论识别。数据好找、模型训练快、部署简单,非常适合打通全流程;
- 梯度二(结构化数据预测):如信贷违约预测、广告点击率预估。特征工程空间大,能锻炼数据管线和特征一致性的能力;
- 梯度三(多模态检索):如图文匹配、视频标签推荐。工程复杂度提高,涉及向量索引和混合检索;
- 梯度四(生成式应用):如基于微调模型的摘要或对话系统。需要处理LLM显存、推理速度和成本控制的问题。
我的建议是老老实实从梯度一或梯度二开始,把端到端管线跑通两三遍之后,再上难度更高的梯度。把一个小题目做到生产可用,比把一个高难度题目做到只能离线演示,对你的能力提升大得多。
7. 常见问题与排查技巧实录
7.1 环境与依赖的故障速查
这个项目实践过程中,我和环境问题搏斗了不短的时间,下面是我整理的高频故障表:
| 症状 | 根因 | 处置方法 |
|---|---|---|
torch.cuda.is_available()返回False | CUDA驱动和PyTorch版本不匹配 | 使用nvidia-smi确认驱动版本,重新安装匹配的torch cu版本 |
| 同一份代码在同事机器上跑出不同指标 | 依赖版本漂移 | 固定uv.lock或requirements.txt精确版本,并用Docker固化环境 |
| 训练到一半显存OOM | batch size过大或GPU显存被其他进程占用 | 检查GPU占用(nvidia-smi),调小batch size或开启梯度累积 |
import torch时报GLIBC版本错误 | 系统编译器版本过低 | 升级Docker基础镜像到更高版本的Ubuntu镜像即可 |
排查环境问题有个原则:先查版本,再查代码。大多数环境问题都能在版本矩阵里找到答案,PyTorch官方文档有完整的CUDA版本兼容表,对着表检查往往十分钟就能定位。
7.2 训练不收敛或指标异常的排查顺序
模型训练时"loss不下降""指标波动巨大""train好val差"这类现象,我有一套标准排查顺序,从最廉价到最昂贵:
- 检查数据泄露:训练集和验证集是否混入了相同样本。这个问题我试过很多次,往往是切分时没有按用户ID分组导致。
- 检查训练/验证指标计算逻辑:验证集是否做了与训练集完全相同的预处理?特别是归一化参数是否来自验证集(泄露)。
- 检查优化器和学习率:学习率过大会导致loss震荡不收敛,过小会收敛缓慢,先用3e-4(Adam)或0.1(SGD)作为基线。
- 检查数据顺序:DataLoader是否shuffle了,batch内样本是否高度相似(比如全是同一个类别的)。这种情况我在样本不平衡任务里遇到过很多次。
- 检查模型初始化:某些激活函数(如Sigmoid)在特定初始化下会导致梯度消失,可以临时换用ReLU或GELU验证曲线变化。
记住一个原则:先怀疑数据和逻辑,再怀疑模型结构。模型结构有问题时,通常你从一开始就会看到明显的异常(loss完全不变、NaN),而不是"效果不理想但稳定下降"。
7.3 推理服务性能与稳定性排查
线上推理服务出现性能问题时,我的排查路径也有固定顺序:先看延迟分布和QPS,再看GPU利用率和显存,最后看数据加载和预处理链路。以下是我整理的最容易踩的性能坑:
- 动态batch未开启:Triton里没有配置
max_batch_size和dynamic_batching,导致每次请求都单独过GPU,GPU利用率极低。开启后吞吐量能提升3~5倍。 - 预处理在请求线程中执行:每来一个请求就在Python线程里做一次特征变换,CPU密集和I/O密集耦合,GPU全在等数据。把预处理放到独立的Prefetch线程或直接用Triton的Preprocess模型处理。
- 模型文件放在本地磁盘但未预热:初次请求时模型冷加载耗时极高,解决方法是部署时增加
warmup请求或使用model_repository的initialized状态预热。 - 缺失监控导致延迟恶化不可见:如果你没有按p99而不是平均值来监控延迟,某个慢请求的抖动会被平均值掩盖,直到用户开始投诉。
7.4 数据漂移与线上效果退化的应对
线上模型效果退化时,我的第一步永远是检查数据漂移报告,而不是冲上去重训模型。我会分别看几个维度:输入特征的PSI值、预测分数分布的偏移、以及业务率指标的同期对比。根据漂移的根因,应对方案完全不同:
| 漂移现象 | 可能原因 | 处理方案 |
|---|---|---|
| 输入特征分布整体偏移 | 产品策略变化、用户群体变化 | 重新切分训练集,补充最近数据重训 |
| 少量特征取值不连续 | 埋点或采集逻辑变更 | 修复数据管道,不需要马上重训模型 |
| 预测分数整体升高 | 类别先验变化或业务方调整规则 | 校准分数阈值,或增加正则重训 |
| 没有任何指标异常但业务效果变差 | 模型本身没有错,是业务假设变了 | 和业务方对齐目标,可能不是模型侧问题 |
想特别强调一点:不是所有线上效果退化都必须立刻重训模型。经常是数据管道的一个bug导致的,重训模型只是白花钱且延长故障时间。先定位、再决策,这是AI工程的基本素养。
8. 写在最后:从"会训练模型"到"会做AI工程"
这个项目给我最大的收获,不是某个具体的模型指标,而是一套关于AI系统的思维框架。从前我以为AI工程的核心是算法和数学,走到最后发现真正决定项目成败的,是数据治理、版本管理、部署运维和监控迭代这些"不太性感"的工程能力。如果只会训练模型,你只能被称为算法研究员;能把模型稳定地带到线上并持续产生价值,才配得上AI工程师这个头衔。
我踩过最印象深刻的坑,是在一次模型上线后,离线指标非常漂亮,但线上效果一周不如一周。我一开始怀疑是模型过拟合,花了两天做了大量无用功去调模型结构。后来才意识到,问题的根源是数据管道中的时间字段解析在新数据源里变成了字符串格式,导致特征工程输入完全错乱,模型输入分布发生了严重漂移。如果我在数据管线上就加入schema校验和漂移监控,这个故障本可以在几小时内被拦截,而不是白白浪费两天。
如果你也正在这条"from scratch"的路上,我的建议只有一句话:尽早跑通端到端,然后再回头补细节。第一次跑通时,代码可能丑、部署可能糙、监控可能缺,但你已经拥有了一个可以迭代的真实系统。所有后期学到的优化手段,都需要这样一个系统作为依附点。先完成,再完美——这比任何详尽的理论学习都更接近AI工程的真谛。