做 AI 工程这几年,我一直觉得“从零开始”这件事被严重低估了。市面上铺天盖地的教程都在教你“三分钟跑通一个模型”,但真正到了业务落地的时候,模型推理速度不够、数据质量拉胯、训练成本失控、上线后效果衰减——这些问题没有一件是“跑通 demo”能解决的。这个项目标题ai-engineering-from-scratch,说白了就是一条完整的从零构建 AI 工程能力的路线,不依赖现成的“全家桶方案”,而是把每一步都拆开揉碎,自己动手搭一遍。
它能做什么?它解决的是“我会调库,但不会做工程”的尴尬。适合两类人:一类是刚入行、想系统建立 AI 工程知识体系的开发者;另一类是已经在做算法、但总感觉自己在“面向论文编程”的工程师。这篇文章我会把整个项目的设计思路、核心模块、实操步骤、踩坑记录全部摊开来讲,包括为什么我要从数据管道写起、为什么模型选型要放在训练之后、还有线上部署那些文档里不会写的东西。内容不短,但每一条都是我在实际项目中验证过的方法。
1. 内容整体设计与思路拆解
1.1 为什么叫 “from scratch”,而不是“从调包开始”
很多人一听到 “from scratch”会觉得太极端,难道连线性代数都要自己实现一遍?不是的。这里的“scratch”指的是:不依赖某个完整封装好的 AI 平台或服务,而是从基础组件开始,手动搭建一套可运行的AI 工程链路。这就像学做饭,你可以买料理包加热,也可以从买菜、洗菜、切菜开始自己做。料理包很快,但你永远不会知道火候和调味之间的关系。
我当时定这个项目目标时,给自己列了几个硬性条件:
- 不用任何“一键训练”的云平台,所有代码本地跑通。
- 不用高度封装的 AutoML 工具,模型结构要能改。
- 数据、训练、评估、部署四个环节全部自己写一遍,哪怕写得丑。
- 每个环节都要有可观测的指标,不能“跑完就完了”。
这套约束的意义在于:它逼着你理解每一个环节的输入输出和瓶颈。比如数据增强,你可能觉得不就是翻转和裁剪吗,但当你自己写数据管道,发现 GPU 利用率只有 30%,瓶颈竟然在 CPU 的数据预处理时,你才真正理解为什么分布式训练框架要搞“数据加载器”这种东西。
1.2 目标拆解:从能力模型倒推项目模块
做工程和做研究最大的区别在于:研究先有问题再有方法,工程先有目标再反推步骤。我最终把整个项目拆成四个核心模块,每个模块对应一种关键工程能力。
| 模块 | 核心能力 | 交付物 |
|---|---|---|
| 数据工程 | 数据获取、清洗、增强、版本管理 | 可复现的数据管道 + 数据质量报告 |
| 模型训练 | 自定义模型结构、训练循环、分布式扩展 | 训练完成的模型权重 + 训练日志 |
| 评估体系 | 离线指标 + 错误分析 + 线上回流 | 评估报告 + 上线建议文档 |
| 部署运维 | 模型服务化、监控、告警、版本迭代 | 高可用的推理服务 + 监控面板 |
这四块不是孤立存在的,它们之间存在明显的依赖关系:数据工程决定模型上限,模型训练决定效果下限,评估体系决定能不能上线,部署运维决定上线后能活多久。很多团队把 90% 精力花在第二块,但真正影响业务结果的反而是第一块和第三块。
1.3 技术选型背后的思考:稳、轻、不过时
这次项目我选型的原则很简单:不追新,但求稳。
语言选择 Python 3.10+,生态最全,做 AI 工程没有理由不用 Python。模型框架选了 PyTorch 2.x,原因不是它比 TensorFlow 好多少,而是它的动态图机制在自定义模型结构时更直观,调试效率高。数据处理用的 Polars 而不是 Pandas,因为在数据量上来之后,Pandas 的内存占用和处理速度确实有点力不从心,Polars 的惰性计算和多线程处理能省掉很多不必要的优化工作。
训练管理这块我没有上 Kubeflow 或者 MLflow 全家桶,而是自己写了一套很轻量的实验记录工具。理由也很现实:团队里不是每个人都会用 Docker,工具太重反而会成为负担。自己写一个实验记录器,把超参数、指标、配置文件哈希值全部存成 JSON,配合 Git 分支管理,已经能覆盖大部分需求。
提示:选型不怕“土”,怕的是“乱”。你不需要一开始就上个“企业级 ML 平台”,从最简单的 JSON 记录实验开始,后面自然知道自己需要什么。
1.4 工程边界划分:哪些自己做,哪些用现成
“从零开始”也不意味着所有轮子都重新造。合理的边界划分能让你把精力用在刀刃上,我的划分标准是这样:
- 自己写:数据清洗逻辑、数据增强策略、训练循环、评估指标计算、模型服务化接口。这些是整个项目中业务耦合度最高、也是最需要定制能力的部分。
- 用现成库:PyTorch 的自动求导、TorchVision 的预训练权重、FastAPI 的 Web 框架、Prometheus 的监控采集。这些是通用组件,自己实现一遍并不会增加核心价值,反而容易引入新 bug。
这个边界的核心判断标准是:这个东西如果出了问题,我能不能快速定位并修改?如果答案是不能,那它应该用现成且成熟的实现。比如自动求导,你自己算梯度推导半天,结果一个数值稳定性问题就让模型发散,用 PyTorch 的自动求导既准确又高效,没必要在项目初期强上。
2. 核心细节解析与实操要点
2.1 数据管道的设计:别让你的模型吃“脏数据”
数据管道是整个 AI 工程里最不像“AI”的部分,但却是最决定成败的部分。我的经验是:数据工程师的工作量应该是算法工程师的 2 倍以上,否则后面所有工作都在浪费。
数据管道我分了三层,每一层都有明确的目标:
第一层是采集与校验。采集指的是把原始数据从业务库、日志文件或公开数据集中拉过来,统一格式。校验则包括:
- 字段完整性检查:是否有大量空值,空值比例超过阈值就告警。
- 类型一致性检查:同一字段在 A 表和 B 表中的类型是否一致。
- 分布合理性检查:数值型字段的均值、方差是否在预期范围,离散型字段的取值数量是否异常。
第一层解决的是“数据可不可信”的问题。我在实际项目里遇到过原始数据里 30% 的字段是错位的,表头和数据对不上。如果没有这一层校验,后面训练的模型再精致也没用。
第二层是清洗与增强。清洗不只是去掉空值,它包含一整套决策逻辑。比如缺失值是用均值填充、中位数填充、还是直接删掉这一行,取决于缺失原因和后续任务类型。数值型异常点也不能简单“3σ”一刀切,在用户行为数据里峰值往往是重要特征而不是噪声。
数据增强是让模型“见多识广”的关键手段。以图像数据为例,最基础但有效的是随机裁剪、水平翻转、颜色抖动和旋转。需要注意:
- 增强操作不能破坏语义:比如给一张医学影像做水平翻转,大概率没问题,但如果是文字识别任务,旋转 180 度之后人眼都认不出来,那模型也学不会。
- 增强强度要适中:过度增强会让模型把噪声当成模式,反而降低泛化能力。
第三层是版本管理与可追溯性。这一点在团队协作时尤为重要。我推荐的做法是:把数据集的元信息(来源表名、抽取时间、清洗规则版本号、增强参数)写进一个manifest.json,并对数据集本身计算一个哈希值。这样做的好处是:
- 模型训练后,你能准确说出它吃的是哪一版数据。
- 线上效果异常时,可以快速回溯到数据版本,判断是数据漂移还是模型退化。
2.2 模型选型:你不需要一开始就上大模型
现在一提起 AI 项目就是大模型、多模态,但在工程落地时,选型正确比选型新颖重要得多。
我的流程是:先用简单的模型跑通整个链路,再根据效果瓶颈决定是否升级模型。这听起来没什么技术含量,但它能有效避免一个常见局面:在复杂模型上调试了三个礼拜,最后发现是数据标签错了。
在本次项目中,我按任务类型做了一个选型清单:
| 任务类型 | 首选方案 | 备选方案 | 采用原因 |
|---|---|---|---|
| 图像分类 | ResNet-50 微调 | EfficientNet / 自研 CNN | ResNet 结构成熟,预训练权重丰富,收敛稳定 |
| 目标检测 | YOLO 系模型 | Faster R-CNN | YOLO 在工程部署中速度优势明显 |
| 文本分类 | fine-tune BERT-base | TF-IDF + LR 作为 baseline | BERT 效果上限高,但先跑基线做对比 |
| 序列预测 | LSTM / Transformer | 统计模型 ARIMA | 数据量小时统计模型不一定输 |
选模型时要关注的三个实际问题:
- 推理延迟。你选的模型精度再高,如果单次推理超过 200ms,很多线上场景就不可用了。MobileNet 和 ResNet-50 的精度差距可能只有 1%,但推理速度差 3 倍。
- 显存占用。很多开源模型假设你有 A100,但实际线上部署只有 T4 甚至 CPU。训练时就要把模型规模约束在部署环境的承受范围内。
- 可解释性要求。有些业务要求对模型的错误预测给出理由,那复杂的深度学习模型就不如树模型友好。在项目启动时就要想清楚这一点,否则后面会返工。
2.3 训练循环:自己写一遍,才能理解训练的本质
我坚持认为,每个做 AI 工程的人都应该自己写一遍完整的训练循环,哪怕只在一个小数据集上跑通。因为这能帮你理解那些封装好的 Trainer 到底做了什么。
一个完整的训练循环包含这几个核心步骤:
- 数据迭代:每个 epoch 开始前,用
DataLoader打乱数据顺序,并按 batch 取数据;为加快读取,设置num_workers,让子进程预取数据。 - 前向传播:把 batch 数据输入模型,得到预测结果。
- 计算损失:根据任务类型选定
CrossEntropyLoss或MSE等损失函数,把预测值和真实标签做对比。 - 反向传播:调用
loss.backward(),PyTorch 会按计算图自动计算每个参数的梯度。 - 梯度裁剪:当梯度范数过大时进行裁剪,防止训练发散。尤其对于文本模型和深层网络,这步几乎是必须的。
- 参数更新:优化器根据梯度和学习率更新模型参数。
- 学习率调度:按 epoch 或 step 调整学习率。常见的有
StepLR、CosineAnnealingLR,效果差异很大。 - 检查点保存:不仅要保存最后的模型,还要保存“验证集上效果最好”的模型,两者可能完全不同。
下面是我在实际项目中反复使用的一段训练循环核心代码,已经做了一些简化:
# 训练循环核心代码 def train_one_epoch(model, dataloader, optimizer, criterion, scaler, device): model.train() total_loss = 0 progress_bar = tqdm(dataloader) for batch_idx, (inputs, labels) in enumerate(progress_bar): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() with torch.amp.autocast(device_type="cuda", dtype=torch.float16): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() total_loss += loss.item() progress_bar.set_description( f"loss: {loss.item():.4f}, epoch: {current_epoch}" ) return total_loss / len(dataloader)注意这里用了torch.amp.autocast,也就是混合精度训练。它的原理是:前向和反向计算中一部分操作使用 FP16,一部分关键操作保持 FP32,这样显存占用减半、速度提升明显,但精度几乎不损失。在你模型参数超过 1 亿时,混合精度就不是可选项而是必选项。
2.4 评估体系:离线指标只是及格线,不是免死金牌
模型训练完,第一步是看离线指标,比如准确率、召回率、AUC。但我要强调一个容易忽视的事实:离线指标好,不代表线上效果好。原因有很多,包括数据分布不一致、线上特征缺失、延迟导致的超时无响应等等。
一个完整的评估体系应该是:离线指标 + 错误分析 + 线上小流量验证。
错误分析是我认为最有价值但最容易被忽略的一步。把验证集里预测错的样本全部列出来,逐一去查看原因。通常你会发现问题集中在几个小类上,比如:
- 遮挡目标检测不出来。
- 长尾类别样本太少,模型几乎没见过。
- 标注本身就有错误,模型学了一个“错误”的模式。
这时候你会有两个选择:增加数据或调整标签,而不是直接换模型。我见过一个项目,通过错误分析发现 20% 的“错误预测”实际上是标注错误,修正数据后,准确率直接提升了 3 个百分点——一分钱没花。
线上小流量验证是上生产前最后一道关。建议做法是:先让新模型服务 5% 的流量,和旧模型对比核心业务指标,比如点击率、转化率、响应时间。注意观察的不只是精度,还有 p99 延迟和超时率。小流量验证至少要跑 2 到 3 天,覆盖不同时段和用户群体,才能得出结论。
3. 实操过程与核心环节实现
3.1 从零搭建环境:版本锁死,避免“能跑但复现不了”
这个环节听起来最简单,但我在团队里见的坑最多。大多数人都会在半年后遇到一个问题:“这个代码我之前能跑,现在怎么不行了?”不用怀疑,大概率是依赖版本漂移了。
环境搭建的第一步是确定 Python 版本。建议直接使用 3.10 或 3.11,兼容性好,且很多新库已经放弃对 3.8 以下版本的支持。第二步是创建虚拟环境,使用venv或conda都可以。我个人偏好conda,因为在安装 CUDA 相关组件时不容易把系统环境搞乱。
第三步是安装深度学习框架。这里最容易踩的坑是 CUDA 和 PyTorch 版本不匹配。我的建议是:
- 先查看自己的 GPU 驱动支持的 CUDA 版本。
- 到 PyTorch 官网用它提供的命令安装对应版本,而不是
pip install torch默认装一个。 - 安装完成后跑一个小脚本验证 GPU 可用:
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"第四步是生成requirements.txt时不要用pip freeze直接导出,因为这样会把无关的传递依赖也导进去。更好的做法是手工维护一份顶层依赖和版本号,然后用pip-compile这类工具生成完整锁定文件。
我自己的项目环境是这么配置的:
python: 3.10.12 torch: 2.1.2+cu121 torchvision: 0.16.2+cu121 polars: 0.20.3 transformers: 4.36.2 fastapi: 0.109.0 uvicorn: 0.27.0 prometheus-client: 0.19.0注意:所有依赖的版本号必须完整记录,包括 CUDA 版本后缀。这决定了你的项目在三个月之后还能不能“一键复现”。
3.2 亲手搭建数据管道:从原始数据到干净数据集
我这次用的是公开的图片分类数据集,但把所有原始数据处理逻辑都写成了独立的脚本。这样做的目的是:你换任何数据集,都可以复用这套管道。
数据管道的第一步是目录规划。规范的项目目录长这样:
data/ raw/ # 原始数据,只读不修改 interim/ # 中间处理结果 processed/ # 最终训练/评估/测试数据 manifests/ # 数据版本记录文件每一层目录的职责不同:raw是上游数据的复制品,如果清洗脚本写错了,可以随时从原始数据重新跑;interim存放一些临时特征,比如图片尺寸统计、异常值标记;processed是模型实际读取的最终数据,格式统一为 TFRecord 或 Parquet。
第二步是缺失值处理。对图片数据来说,“缺失”通常表现为文件损坏、通道异常或尺寸问题。我的处理方式是写一个预扫描函数,把损坏文件列表输出到一个corrupted_files.txt,人工确认后再删除。
第三步是数据集划分。千万不要在清洗之前做划分,否则不同划分的数据分布会不一致。正确顺序是:先清洗,再划分,划分时固定随机种子,保证每次运行得到相同的训练集和验证集。
划分比例我常用 80% 训练、10% 验证、10% 测试。这里有个点:测试集一旦确定,在最终评估之前不能再碰。很多人会无意中拿测试集反复调参,这等于把测试集变成了验证集,模型在测试集上的成绩已经没有参考价值了。
第四步是数据增强策略固化。对训练集使用增强,对验证集和测试集只做 resize 和归一化。这不算什么新知识,但值得写成代码放在管道里,否则很容易在某个版本里忘记关闭增强,导致模型评估偏高。
# 数据增强配置示例 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ])归一化的 mean 和 std 用的是 ImageNet 预训练权重自带的默认值。如果你不是用预训练模型,而是从零训练,这三个数值应该根据你的数据集重新计算,否则模型收敛会变慢甚至不收敛。
3.3 训练过程中的关键决策:学习率、优化器与批次大小
训练不是把模型扔进去跑就完事了。整个训练过程有非常多的决策节点,每个决策都会直接影响最终模型效果。
优化器选择:最稳妥的选择是 AdamW。相比 Adam,AdamW 把权重衰减和梯度更新解耦了,泛化效果更好一些,这也是目前主流预训练模型的默认选择。当你的模型和训练数据比较稳定时,可以试试 SGD + Momentum,在有些任务上它比 AdamW 能获得更高精度,但需要更精细的学习率调节。
学习率策略:主流做法是 warmup + decay。为什么要 warmup?因为在训练初期,模型参数是随机初始化的,梯度的方差较大。如果一开始就用较大学习率,容易让模型跑到一个坏的局部最优点。先用小学习率“预热”若干个 epoch 或者几百个 step,让参数稳定下来,再调高学习率,然后按计划衰减。代码实现可以用 PyTorch 的LambdaLR或CosineAnnealingLR。
批次大小(batch size):它直接影响梯度估计的准确性。大批次让梯度更稳定,但也会让模型收敛到“尖锐极小值”,泛化性会差一些;小批次噪声更大,有时候反而能跳出局部最优点。经验值:在单卡 GPU 上,图像分类任务 batch size 选 32 或 64;如果显存不够,优先用梯度累积而不是粗暴减小 batch size。
梯度累积其实很简单:
# 梯度累积示例 accumulation_steps = 4 scaler = torch.cuda.amp.GradScaler() for step, (inputs, labels) in enumerate(dataloader): with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, labels) loss = loss / accumulation_steps scaler.scale(loss).backward() if (step + 1) % accumulation_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这段代码的本质是:把 4 个 batch 的梯度累积在一起,最后做一次参数更新,等效于 batch size 从 32 变成 128。显存占用没有变大,但模型的收敛表现接近真实的大 batch 训练。
训练监控指标:训练时至少要记录这几类数据:step 级别的 loss、每个 epoch 结束时的验证集指标、当前学习率、GPU 利用率、显存占用、数据加载耗时。尤其是“数据加载耗时”,很多时候你以为是模型训练慢,一看 nvidia-smi 的 GPU 利用率只有 40%,问题就出在 DataLoader 的num_workers太小,CPU 跟不上 GPU 的消费速度。
3.4 模型服务化:从本地推理到线上接口的完整路径
训练结束不等于项目结束。模型要真正产生价值,还得把它变成可以被业务调用的服务。这一步我选择了 FastAPI,它的性能足够、文档完善、类型校验清晰,是我目前用下来最舒服的服务化框架。
一个最小可用的模型服务和你想的没那么复杂:
# 模型服务化最小示例 from fastapi import FastAPI, UploadFile, File import torch from torchvision import transforms from PIL import Image app = FastAPI() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = load_model() model.to(device) model.eval() @app.post("/predict") async def predict(file: UploadFile = File(...)): image = Image.open(file.file).convert("RGB") tensor = preprocess(image).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1) label_id = torch.argmax(probs, dim=1).item() confidence = probs[0][label_id].item() return {"label_id": label_id, "confidence": round(confidence, 4)}但生产环境比这个要复杂得多,我觉得重点是这几条:
第一,模型权重不能每次请求都加载一遍。服务启动时加载一次到内存,之后所有请求复用同一个模型实例。不要图省事,在predict函数内部写torch.load,否则延迟会高到没法用。
第二,模型要设置model.eval()模式。这一步会关闭 Dropout 和 BatchNorm 的 training 分支,保证推理是确定性的。很多新手在上线后发现结果和离线对不上,十有八九是忘了这个。
第三,预处理和后处理也属于服务的一部分。一张图片从上传到返回结果,中间经历的解码、缩放、归一化、softmax 计算,全程都要稳定和可复现。任何一步和训练时不一致,都会导致线上效果下跌。
第四,加一层推理结果缓冲。如果你的业务允许一定的延迟,可以在 Redis 里缓存相同输入的预测结果。很多时候线上请求是高度重复的,缓存可以把 p99 延迟从 80ms 降到 5ms,而且不损失精度。
3.5 模型监控:不做监控,你就是在裸奔上线
模型上线不是终点,而是运维的起点。模型监控我把它拆成两块:系统监控和模型质量监控。
系统监控关注的是服务的健康状态,包括:
- QPS、响应延迟(均值、p50、p95、p99)。
- 错误率、超时率。
- GPU 利用率、显存占用。
模型质量监控更微妙。它关注的是“模型是不是已经不行了”。常用的手段是监控特征分布漂移和预测分布漂移:
- 特征漂移:线上接收到的输入特征分布,和训练集特征分布是否出现明显差异。比如训练时用户输入框平均文本长度是 20 个字符,线上突然变成 100 个字符,那模型大概率失效了。
- 预测漂移:模型输出的类别分布是否偏移。比如一个分类器训练时预测的正负比例是 1:10,线上变成 1:1,不用等准确率下降,你也能猜到模型已经不能用了。
监控手段不用很高级,Prometheus + Grafana 就能满足大多数场景。我在项目里会额外写一个“数据日志表”,每次线上请求的特征和预测结果都落一份日志,每天做一次离线统计,看分布有没有突变。这套方法成本低、效果直接。
4. 常见问题与排查技巧实录
4.1 训练 Loss 不下降的排查清单
这大概是所有 AI 工程新手遇到最多的一个问题。损失不下降的原因实在太多了,以至于我养成了一个习惯:先用极小数据集过拟合,再上全量数据。
如果极小数据集都无法过拟合,那问题多半出在建网或训练配置上。我会按以下顺序排查:
- 检查梯度是否正常:在第一个 batch 训练后打印梯度的范数。如果梯度过小或者为 0,检查模型各层之间是否有激活函数抑制了梯度(比如初始化时用了 sigmoid 但输入范围太大)。
- 检查学习率:学习率过小会导致 loss 下降极慢,过大则会导致 loss 震荡甚至直接变 NaN。在训练前几个 step 尝试
[1e-5, 3e-4, 1e-2]几个量级,观察 loss 变化速度。 - 检查标签是否正确:我在一个语义分割项目里发现 loss 卡在 0.69 不动,最后发现标签图里大部分类别像素值被标注成了 255(默认 ignore_index),模型实际上只在学一个类别,自然降不下去。
- 检查数据增强过强:增强过强会让模型无法从增强后的样本里学到稳定的模式。先关掉所有增强试一次,如果 loss 能降,就把增强一项项加回来定位问题。
4.2 显存不足(OOM)的处理方法
OOM 这个问题,很多人的第一反应是减小 batch size,这是对的,但不是最优解。按优先级从高到低,我的处理顺序是:
- 开启混合精度训练。显存占用直接减半,这是性价比最高的一步。
- 使用梯度累积。如上所述,它可以让你在不增加显存的情况下等效增大 batch size,解决“减小 batch 后效果变差”的问题。
- 检查是否有变量被意外保存。在 PyTorch 里,如果你想在反向传播后保留中间变量用于 debug,这些变量会一直占着显存。用
del删除不再需要的变量,再调torch.cuda.empty_cache()。 - 减小输入分辨率。如果任务允许,把图片从 224 降到 192 或 160,显存占用是按平方下降的,但精度损失往往很小。
- 使用梯度 checkpointing。这是最后的手段,它通过“在前向时不保存中间激活值,反向时重新计算”来省显存。代价是训练时间增加约 30%,但能把模型规模翻倍。
4.3 线上推理结果和离线不一致,怎么排查
这个问题一出现,先不要怀疑模型被“污染”了。按下面几个方向去查,大概率能定位:
- 数据预处理逻辑不一致。这是最常见的原因。训练时的缩放方式到底是双线性还是最近邻?归一化的 mean/std 有没有写死?颜色通道的顺序是不是 RGB 而不是 BGR?任何一处不同,结果都会有偏差。
- 模型是否处于 eval 模式。上面提过,不再赘述,但这是第二次出现在这个清单里,因为太常犯了。
- 推理是否被某些框架优化修改了行为。比如 TensorRT 的 INT8 量化可能导致精度下降。如果你上了加速优化,先跑一批离线测试,确认误差在可接受范围内。
- 随机性。有些模型结构在推理时仍然有随机行为(比如 Dropout 没关掉),或者某些算子在不同硬件上有不同的舍入方式。解决办法是固定随机种子并保持 eval 状态。
4.4 模型效果衰减:不是你模型坏了,是数据变了
很多团队会发现在线上运行 3 个月后,模型指标慢慢下降。第一反应是重新训练,但我建议先做数据对比:把最近 7 天的线上特征分布和训练集分布画在一起,看看有没有明显漂移。
如果是数据漂移,方向就不是“重新训练一个模型”,而是“重新采集和标注新数据,覆盖新的分布”。重新训练一个老分布上的模型,效果提升也是很有限的。
如果确认分布没有明显变化,再检查一下特征依赖关系:是不是某个上游特征接口改了字段含义?这种“隐性”变化最危险,因为它不会报错,只会让模型预测悄悄变差。我曾遇到过一个模型,效果下降 10%,查到最后是上游部门把“用户年龄”字段里的负数从“未知”改成了“0”,而模型在训练时从没见过 0 这个年龄。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Loss 为 NaN | 学习率过大、数值溢出、标签中含有 NaN | 看第一轮梯度,降低学习率,检查输入数据 |
| GPU 利用率低 | DataLoader num_workers 不足 | 调大 num_workers,开持久化工作进程 |
| 训练慢但 loss 正常 | 瓶颈在数据加载或数据增强 | 单独测 DataLoader 吞吐,考虑动态 cache |
| 验证集指标不稳定 | 数据集太小,或 batch size 过小 | 增大 batch size,使用 K 折验证取均值 |
| 推理时延高 | 使用了过多的前处理或后处理逻辑 | 对预处理做 profile,尽量向量化 |
| 模型效果不升反降 | 数据标签质量差 | 抽检 100 条标签,算标注一致性 |
5. 项目后续扩展方向
这个项目做到这里,已经是一条完整的最小闭环了。但如果继续往下走,我会从这几个方向做扩展,让整个体系更接近工业级:
第一个方向是分布式训练。单卡训练在数据量和模型规模变大之后一定会遇到瓶颈。可以先从 PyTorch 的DistributedDataParallel开始,在 2 台机器上做数据并行。理解init_process_group、rank、world_size这些概念之后,再往 FSDP 或者 DeepSpeed 走,会顺很多。
第二个方向是自动化超参数搜索。目前训练的超参数基本靠经验试,效率不高。可以基于 Optuna 搭一套简单的搜索框架,把学习率、batch size、weight decay 这些参数做成可搜索空间,自动跑若干组试验,然后选最优模型。注意:超参数搜索的每次试验都要在相同的验证集上评估,否则比较没有意义。
第三个方向是数据版本管理与 CI/CD 集成。当数据量变大、团队多人协作时,人工管理数据集会越来越吃力。建议引入 DVC 或 LakeFS 做数据版本管理,并把“训练-评估-打包镜像-部署”设置成一条自动化流水线,代码合并后自动触发。这条流水线跑完,会生成一份完整的报告,包含数据版本、代码 commit、训练指标、部署状态。这件事做完,整个团队的工作效率会再上一个台阶。
第四个方向是在线学习与模型更新。现在模型更新是定期重训,周期长、响应慢。如果业务对新鲜度有要求,比如推荐系统、广告排序,就要考虑引入在线学习框架。可以先用简单的定期“增量训练”方案过渡,再逐步演进到流式特征和在线参数更新。
6. 写在最后的个人体会
按惯例,最后还是想说点项目之外的东西。
做 AI 工程这几年,我最深的感受是:这个领域真正稀缺的不是“会用某个框架的人”,而是“能判断系统哪里会出问题的人”。框架更新很快,模型的 SOTA 每几个月就换一轮,但数据管道、训练调试、部署运维、监控告警这套工程方法论,是穿越周期的。
如果你准备照着这个项目路线走一遍,我的建议是:不要急着买很贵的 GPU,先用小数据集、小模型把链路跑通。跑通之后,再逐步扩大数据量和模型规模。底层的工程能力稳了,上层的东西只是时间问题。
还有一个细节想分享:每完成一个阶段,就把当时的想法、踩过的坑、试过的参数写进项目 README 或者自己的博客。你会发现,“写下来”本身就是在逼自己把模糊的经验变成清晰的逻辑。这个习惯,可能比学会某个模型结构更值钱。
这个项目我后续还会继续迭代,方向大概率会往“更强的数据管理能力”和“更可靠的模型监控”上走。如果你也在从零构建自己的 AI 工程体系,欢迎按自己的场景调整落地。体系不会是完美的,但它必须是你自己一步步验证过的,这才是 “from scratch” 真正的意义。