我对“AI工程从零开始”这个选题一直有执念,因为市面上太多教程都在教你怎么调库,却很少有人讲清楚一套代码从能跑到能用的完整链路。这个项目我反复推倒重来了三版,最后沉淀下来的内容,不只是教你跑通一个模型,而是带你走一遍数据、训练、调优、部署的完整旅程。如果你是个想入行AI工程却总在环境配置和抽象概念里打转的人,这篇文章就是为你准备的。
1. 为什么“从零开始”比直接套框架更有价值
1.1 先谈动机:我见过太多“只会调用、不懂拆解”的工程师
过去几年面试过不少候选人,简历上写着熟练使用PyTorch、TensorFlow,但一问到“你的模型为什么选这个损失函数”“数据增强怎么设计才能不破坏标签分布”“推理延迟为什么是200毫秒而不是50毫秒”,很多人就卡住了。这不能怪他们,因为多数教程的路径是“装个环境 → 下载预训练模型 → 跑个demo”,这条路径最快,但会留下认知盲区。
这个项目我取名ai-engineering-from-scratch,就是为了补上这块短板。它不追求用最快的速度跑出一个SOTA模型,而追求把工程链路中的每一个环节都拆开给读者看。从机器上怎么装Python环境开始,到亲手实现一个数据管线、训练一个可用的视觉模型、把它封装成API、最后监控线上推理的延迟和漂移,全程没有黑盒。
如果你是以下三类人,这个项目会特别对胃口:
- 刚转行AI的工程师,有编程基础但没做过完整项目;
- 做算法研究但工程能力偏弱,想补上部署和监控这课;
- 被各种库的抽象层绕晕了,想知道底层到底发生了什么。
1.2 这个项目和普通“教程仓库”的差异在哪
先说结论:我刻意避开了“一键式”脚本。很多仓库会把所有东西封装成一个run.py,跑完就结束,观察不到中间状态。ai-engineering-from-scratch则按阶段拆成独立模块:
00-environment:环境安装和验证脚本,含CUDA版本与PyTorch的匹配检查;01-data:原始数据下载、采样、清洗、转换全流程;02-model:从零实现一个轻量模型结构,不依赖预训练权重;03-training:完整的训练循环、日志记录、Checkpoint管理;04-evaluation:精度、混淆矩阵、单类性能、推理延迟统计;05-deployment:为模型写一个HTTP服务接口,并做压测与监控。
每一层都留有手工操作的余地。比如在数据模块里,我不会直接给你一个处理好的.npy文件,而是让你亲手经历“下载原始图片 → 发现类别不平衡 → 决定采样策略 → 重新组织目录结构”这个过程。这些决策点才是工程经验的体现。
提示:初学者往往觉得“能跑起来”就是胜利,但工程思维的核心是“能复现、能定位、能改进”。前者靠运气,后者靠结构。
2. 环境基建:从一台“干净”的机器到可复现的训练环境
2.1 硬件与操作系统的现实选择
先说硬件。我做这个项目时用的是一张6GB显存的显卡,这是为了故意模拟“大多数人手头只有一台普通游戏本”的情况。如果显存不够,很多模型结构你必须重新设计——这恰恰是好事,因为生产环境里的资源约束永远比实验室里更苛刻。
操作系统方面,主流程是Linux环境(Ubuntu 20.04/22.04),但我也在macOS和Windows WSL2上跑通过。如果你用Windows,我强烈建议不要直接在原生Windows上装CUDA驱动再配环境,太容易出岔子。用WSL2会省心很多,文件系统、GPU透传、网络配置都比较成熟。
2.2 Python环境管理的坑:anaconda、uv、pyenv该选谁
Python环境管理是一个“看起来不重要,踩坑后很痛苦”的主题。我推荐用uv,因为它的解析速度快、依赖锁文件可复现,而且对虚拟环境的隔离做得干净。命令行如下所示:
uv venv ai-eng --python 3.11 source ai-eng/bin/activate uv pip install torch --index-url https://download.pytorch.org/whl/cu121这里有个关键点:不要用pip install torch默认源安装,它很可能会装上CPU版。必须到PyTorch官网根据你的CUDA版本选择对应的安装命令。如果你用的是NVIDIA显卡,先运行nvidia-smi看驱动支持的CUDA版本,再决定安装哪个wheel包。
2.3 验证CUDA可用性的最小步骤
装完之后别急着开始写代码,先跑三段验证:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果第二行是False,大概率是驱动和PyTorch版本不匹配,或者装了CPU版。如果第三行报错,检查nvcc -V和nvidia-smi显示的版本差异,以实际驱动为准。
我最初在这个环节耗时三小时,原因是没意识到“驱动支持的CUDA版本”和“PyTorch要求的CUDA版本”是两码事。比如驱动支持CUDA 12.4,你装cu121的PyTorch也能跑,反之则不行。理解了这层关系,环境问题就解决了一大半。
2.4 固定依赖版本:复现是所有工程的地基
环境搭好后,第一时间导出锁文件:
uv pip freeze > requirements.lock不要只用requirements.txt记录顶层依赖,要把全部传递依赖固定住。这样半年后回来还能复现出完全一样的环境。在AI工程里,“当初明明能跑,现在跑不了”是这个领域最常见的灾难,锁文件是唯一的解药。
3. 数据工程:拥抱原始数据、组织标签与写Dataset类
3.1 别用“网上有人传好的清理版数据”,自己走一遍处理流程
数据环节最大的误区是拿来主义。很多开源数据集已经被组织成漂亮的目录结构,直接torchvision.datasets.ImageFolder加载就能训练。这会让你错过最关键的工程环节:如何从“原始状态”变成“模型能吃的样子”。
我在项目里选了一个公开的中小规模图片分类数据集,但故意保留了它的原始压缩包形态。那是一个目录里塞了数万张图片、文件命名混乱、部分图片损坏、标签通过单独的CSV文件给出的状态。第一步就是把这些东西整理成稳定的三板斧结构:
data/ train/ class_a/ class_b/ val/ class_a/ class_b/ test/这里必须强调一点:训练集、验证集、测试集的划分要在“清洗之前”还是“清洗之后”?我的经验是:先按原始唯一ID做划分,再做清洗和增强,避免同一张图片的不同增强版本同时出现在训练集和验证集里造成“数据泄漏”。这种泄漏会让验证分数虚高,上线后才发现真实效果差一大截。
3.2 一个更接近生产环境的Dataset类
PyTorch的Dataset类看似简单,但很多人对它“返回什么”理解得不够灵活。我写的Dataset不直接返回(image_tensor, label),而是返回字典结构:
class ProductDataset(Dataset): def __init__(self, samples, image_dir, transform=None, return_path=False): self.samples = samples self.image_dir = image_dir self.transform = transform self.return_path = return_path def __len__(self): return len(self.samples) def __getitem__(self, idx): image_id, label = self.samples[idx] image_path = self.image_dir / f"{image_id}.jpg" # 显式处理缺图情况 if not image_path.exists(): return self.__getitem__((idx + 1) % len(self.samples)) image = Image.open(image_path).convert("RGB") if self.transform: image = self.transform(image) return { "image": image, "label": label, "path": str(image_path), }返回path的好处你平时可能感受不到,但当你发现某个样本loss异常高、需要回溯检查原始图片时,这个字段能省下大量排查时间。生产环境的数据管线永远要留“追溯能力”,这是数据工程思维和数据算法思维的最大区别。
3.3 数据增强:怎么“增”才不改变语义
数据增强是初学者最容易玩过火的地方。很多人直接把RandomResizedCrop、RandomHorizontalFlip、ColorJitter全堆上去,结果模型在验证集上表现不错,上线后却对真实场景毫无泛化能力。问题就出在增强策略偏离了目标域的语义。
我在项目里对增强策略做了“域一致性”约束,例如:原始场景是商品拍摄图,那翻转就要慎重——很多商品文字会因翻转变成反字;颜色扰动幅度也要小,否则会影响品牌色的特征。用一个中心思想来指导:增强后的样本必须仍然被人类无歧义地识别为原类别。违反这条原则的增强,直接舍弃。
3.4 标签不平衡问题:不是“加权”那么简单的
实际数据里类别不平衡是常态,而不是例外。我在项目里统计后发现其中一类样本数量只有最丰富类的1/25,最开始试了WeightedRandomSampler,但发现它只解决了“采样频率”问题,没解决“特征多样性不足”的问题。
最终方案是靠“过采样+适度的针对性增强”。比如稀少类别不做大幅翻转,但做局部缩放和轻微平移,迫使模型学到更鲁棒的特征。同时,在损失函数里调整pos_weight时,不是拍脑袋给一个大数字,而是按“目标覆盖度/当前类别召回率”动态调整。这些细节在论文里往往只有一句话,但工程上每一步都需要实证。
4. 模型与训练:亲手实现一个轻量CNN并跑出可用精度
4.1 模型结构设计:为什么我不用预训练模型
虽然用了迁移学习可以快速达到不错的效果,但那个过程的“工程含量”很低。所以主体训练我用了自己实现的轻量卷积结构,这样每一层的输入输出尺寸、参数数量、感受野变化都是可计算、可追踪的。模型结构控制在约三百万参数,设计要点是三层卷积块加一个全局平均池化加分类头,所有卷积层的stride/padding都要算清楚。
下面的代码是这个项目里我自己实现的核心模块,它比较土,但胜在每一行改动都能感受到效果:
import torch.nn as nn class SimpleBlock(nn.Module): def __init__(self, in_c, out_c, stride=1, use_bn=True): super().__init__() self.conv = nn.Conv2d(in_c, out_c, kernel_size=3, stride=stride, padding=1, bias=False) self.bn = nn.BatchNorm2d(out_c) if use_bn else nn.Identity() self.act = nn.ReLU(inplace=True) def forward(self, x): return self.act(self.bn(self.conv(x))) class TinyNet(nn.Module): def __init__(self, num_classes=10, width=32): super().__init__() self.features = nn.Sequential( SimpleBlock(3, width, stride=1), SimpleBlock(width, width*2, stride=2), SimpleBlock(width*2, width*4, stride=2), SimpleBlock(width*4, width*8, stride=2), ) self.global_pool = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Linear(width*8, num_classes) def forward(self, x): x = self.features(x) x = self.global_pool(x) x = torch.flatten(x, 1) return self.classifier(x)为什么不用残差连接?为了让学生更容易观察“梯度消失”和“层数加深后的精度退化”现象。这个选择是故意为之的。以后你上手ResNet时才会理解加法分支的价值。同样地,BatchNorm在推理阶段的行为和训练阶段不一样,这也是一个必须通过手写代码才能理解的坎。
4.2 训练循环里的那些“隐藏逻辑”
训练循环看起来简单,但里面藏着工程级别的门道。我按下面的顺序组织训练代码:
- 每个epoch开头打乱数据,设置
num_workers和pin_memory; - 前向计算loss后先
optimizer.zero_grad(),防止梯度累加; loss.item()之后再backward(),避免释放计算图时报错;- 每隔N步打印损失,不是print到控制台而是写入结构化日志(JSON Lines);
- 每个epoch结束跑验证集,记录Top-1、Top-5、每类别的recall和precision。
关于model.train()和model.eval(),很多刚入门的人会忘记切换模式。eval()模式下,BatchNorm会使用累计的running mean和running var,而Dropout会失效。如果漏了这步,验证效果会非常不稳定。
4.3 优化器、学习率和Batch Size的联动关系
我在项目里对比了SGD和AdamW。在这个小型任务里,SGD配合余弦退火能达到更好的泛化性能,但需要手工调学习率。AdamW几乎不用调参就能收敛,但最终精度略低,而且更容易过拟合。究其原因,是自适应学习率方法对每个参数做了归一化,影响了泛化界。
我给出了一个经验法则:batch_size翻倍时,学习率最好也相应调整(线性缩放法则)。比如batch size 32时学习率0.01,batch size 128时学习率大概调到0.02~0.04,而不是原封不动。使用混合精度训练时,loss缩放也需要小心,我通常是先试torch.cuda.amp.GradScaler的默认配置,如果出现NaN,再配dynamic_loss_scale关闭动态缩放。
这里贴一段完整的训练循环,包含混合精度与梯度裁剪:
scaler = torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() for batch in dataloader: images = batch["image"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): logits = model(images) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update()梯度裁剪不是必须的,但在小模型大学习率场景下能有效防止个别样本把梯度带偏。上线模型时这种稳定性比极限精度更重要。
5. 排查链路:loss不降、显存溢出、过拟合的完整定位过程
5.1 loss不降:先怀疑代码,再怀疑模型,别急着调参
训练第一天最可能遇到的就是loss死活不降。这时候最忌讳的是马上去调学习率。我的排查顺序是:
- 用一个固定batch过一遍模型,确认前向反向能跑通;
- 把模型换成“常数模型”做对照:预测每个类别的先验概率,算一下base loss应该是多少;
- 如果训练loss高于base loss,一定是模型输出分布不对,检查最后的激活函数和损失函数是否匹配;
- 如果训练loss接近base loss但验证loss不降,再考虑模型容量和特征提取能力。
这个项目里有一类样本特别容易出现loss不降——图片尺寸被resize得太小,五官细节都丢了。这个问题不靠调参解决,靠检查预处理流程解决。很多“奇怪的不收敛”其实都是数据问题,提前看一眼增强后保存的样本图能救你半天时间。
5.2 显存溢出:不只有OOM这一种解法
我还特意在项目里留了一个“显存溢出”的坑,让读者体会到两种类型:一次性申请过大显存和逐步累积泄漏。CUDA out of memory比较好办,把batch size降下来,或者临时把图片resize换小。但有一种显存溢出特别隐蔽——你在验证集中没有包torch.no_grad(),验证阶段也在构建计算图,每个epoch结束后显存占用会缓慢爬升,最后在第n个epoch突然崩掉。
排查方法:记录每个epoch的torch.cuda.memory_reserved()和torch.cuda.memory_allocated()。如果后一个数字每个epoch都在涨,多半是验证集没有关闭梯度或者代码里存了不该存的中间Tensor。此外,把不用的变量显式del并调用torch.cuda.empty_cache()在绝大多数场景里没必要,它反而会拖慢性能。
5.3 过拟合:训练集loss降到0.1但验证集很差
过拟合在视觉小模型里太常见了。我用一个专门章节展示如何系统对抗:
- 最优先做的是降低模型容量或接入Dropout,而不是急着上数据增强;
- 记录每一个epoch的训练loss和验证loss差值,差值拉大说明过拟合开始;
- 早停(Early Stopping)应该以验证loss连续N个epoch不改善为准则,而不是固定epoch;
- 如果数据少,
Label Smoothing会有奇效。比如让目标不是硬one-hot编码,而是1 - epsilon给真实类、epsilon/(num_classes - 1)分给其余类。这会防止模型对训练集过于自信,间接提升泛化。
另外,过拟合的模型往往会学“背景模式”而不是“前景对象”。如果你想验证,用Grad-CAM看激活区域,如果激活点在背景上,说明模型学偏了,需要调整数据裁剪策略,让前景占据更多像素比例。
5.4 验证集上的“幻觉指标”:单次验证波动太大怎么办
小数据集上验证集的指标波动很大,有可能这次验证acc是0.91,下次变成0.94。为了不让调参决策被随机性带偏,我做了一个朴素但实用的方案:用固定随机种子的验证加载顺序,同时对验证集做多次重复推理取平均。这不是标准的统计学方案,但在工程实践中很有效。
具体实现是:验证时在DataLoader里设置shuffle=False,固定worker_init_fn的随机种子。这样每次验证都在同一批图片上计算,变化只来自模型的权重。如果你的验证集足够大,单次评估就够了;如果不够大,就把验证过程重复三次,对logits求平均再做softmax。
6. 评估与部署:从验证指标到HTTP服务的关键一跳
6.1 不只看总体准确率:混淆矩阵和单类报告要打印出来
在项目进入部署阶段前,我要求所有参与者先打印一份完整的classification report。很多团队只看总准确率,这是上线后事故高发的根源。有的类别数据量少,即使总准确率有95%,该类的召回可能只有61%。做医疗、质检这类业务时,单类召回往往比总体准确率重要得多。
我在评估脚本里输出以下指标,并保存成JSON文件,方便后面追踪:
- 总体Top-1、Top-5准确率;
- 每个类别的Precision、Recall、F1;
- 混淆矩阵(保存成图片);
- 每个类别“最容易混淆成谁”的前三名;
- 样本级置信度分布。
6.2 模型导出:PyTorch模型不适合直接裸奔
训练完成的model.pt文件包含完整的计算图和参数,但直接暴露给Web服务有风险。生产环境标准做法是模型导出为TorchScript或ONNX。我做了两者对比:
| 导出格式 | 推理框架 | 优势 | 劣势 |
|---|---|---|---|
| TorchScript | LibTorch / PyTorch | 和PyTorch生态无缝兼容,动态处理方便 | 版本耦合,部署包偏大 |
| ONNX | ONNX Runtime / TensorRT | 跨语言、跨平台,能上设备端 | 部分算子转换需要踩坑 |
我在项目里导出了ONNX格式,然后用ONNX Runtime跑推理。转换过程中遇到的最大坑是AdaptiveAvgPool2d在动态输入尺寸下的算子转换问题,后来把输入固定为(3, 224, 224)并显式指定shape之后才解决。这也提醒我们:导出模型时最好固定输入尺寸,不但在转换时省事,在部署时也能用上TensorRT的静态优化。
6.3 封装一个适合生产起步的推理服务
部署服务的时候我用FastAPI而不是Flask,它有更好的异步支持、请求体验和可视化的/docs界面。服务端核心代码逻辑如下:
import onnxruntime as ort import numpy as np class InferenceService: def __init__(self, onnx_path, providers=["CPUExecutionProvider"]): self.session = ort.InferenceSession(onnx_path, providers=providers) self.input_name = self.session.get_inputs()[0].name def preprocess(self, image_bytes: bytes) -> np.ndarray: # 字节流转RGB数组、resize、归一化 ... def predict(self, image_bytes: bytes) -> dict: tensor = self.preprocess(image_bytes) logits = self.session.run(None, {self.input_name: tensor})[0] probs = softmax(logits) top_idx = int(np.argmax(probs)) return {"label": top_idx, "confidence": float(probs[top_idx])}这里要注意providers参数的书写,如果你装了GPU版ONNX Runtime,建议写成["CUDAExecutionProvider", "CPUExecutionProvider"]。CUDA在列表前方,这样在有GPU的机器上自动走GPU,没有就回退CPU。
部署容器直接开放8000端口,给一个简单的健康检查路径/health,返回模型版本信息。这个“版本信息”很重要,模型迭代后你要能在线上快速确认当前跑的是哪个版本。
6.4 压测和延迟监控:上线前必须知道它能扛多少并发
没有压测就上线的模型服务,遇到流量波峰一定会手忙脚乱。我的压测逻辑分三档:
- 单请求延迟:确认P50、P95、P99延迟;
- 并发10路:观察是否有排队,CPU/GPU占用率;
- 并发50路:找出系统开始出现错误或延迟陡增的临界点。
我推荐用locust或者简单的wrk做压测。在CPU部署、输入图片尺寸224的条件下,我这个三百万参数的模型单请求CPU推理约80到120毫秒,GPU推理约20到40毫秒。如果你的模型比这个大,却只有一台普通服务器,就要认真考虑模型蒸馏或TensorRT加速了。
另一个常被忽略的问题是监控数据漂移。我做了最简单的版本:把线上输入图片的灰度均值、方差、尺寸分布记录成日志,定期和训练集的统计值做对比。如果差异超过阈值,触发告警。这种方案不完美,但比完全不监控好很多,实现成本也很低。
6.5 回滚预案:新模型上线必须保留旧模型的服务入口
我经历过一次惨痛的教训:新模型验证集精度更高,但上线后发现某种光线条件下效果断崖式下跌。还好当时保留了上一版模型的容器镜像,用一套路由规则做灰度切换,一小时内就回滚了。现在的原则是:新模型先跑灰度比例5%,观察小时级错误率和平均置信度,再逐步放开。这个流程最简单,却最有用。
7. 下一步扩展:这套从零搭建的路子,能平移到语音和文本任务吗
7.1 把“数据、模型、评估、上线”这套脚手架搬到NLP场景
做完这个项目后你会发现,许多套路并不局限于图像。做文本分类时,数据集组织方式变成“文本文件 + 标签CSV”,模型从卷积换成Embedding+ 浅层编码器,但训练循环、检查点管理、服务封装这些代码几乎可以原样复用。
我也确实这么试过。从视觉切到文本任务时,只花了一个周末就搭出原型,原因就在于工程骨架是稳固的。数据清洗策略需要重新思考,但“怀疑先于调参”的排查思路完全一致。你掌握的真正通用的东西是这一整套工程方法,而不是某个框架的API。
7.2 需要注意的“边界”:任务不同,评估指标和数据策略差异很大
跨任务迁移不是自动成立的。视觉里实效显著的随机翻转,在文本领域就不存在对应操作;NLP里常用的label smoothing和temperature scaling虽然好使,但青春期的“预处理/增强策略”需要完全重新设计。文本任务要特别小心“标签泄漏”:比如拿全文做关键词过滤时,过滤规则可能间接把标签信息泄露进输入。
在图像任务里,类别不平衡用重采样就能解决大半;在信息抽取任务里,实体类别不平衡则需要结合损失函数和阈值调整,单纯重采样效果有限。所以这套从零搭建的方法论是可迁移的,但细节必须回到数据本身去重新推演。
7.3 下一步:自动化实验管理与模型注册
当你开始做大量消融实验时,实验记录会变得混乱。“这个模型效果不错”和“为什么效果不错”是完全两回事。用MLflow或W&B记录每个实验的配置、指标、代码版本、数据集版本是迟早要做的事。我在项目的进阶分支里加了MLflow集成:
mlflow run . --env-file .env -P epochs=30 -P lr=0.001每个实验会生成独立运行ID,自动把参数、指标、模型产物归档。三个月后回头对比实验,不用靠脑子和Excel表。这是从“个人项目”走向“工程体系”的关键一步。
8. 最后留个工具箱:帮你抄作业的常用命令和几处心态建议
8.1 项目最常用的命令清单
如果你想复现这个项目,下面这些命令按顺序执行就行。需要注意的是,不同的环境里包版本差异可能很大,所以锁文件必须优先度最高。
# 环境创建 uv venv ai-eng --python 3.11 source ai-eng/bin/activate # 安装依赖 uv pip install -r requirements.lock # 下载并整理数据 python 01-data/download_data.py python 01-data/build_dataset.py # 训练模型 python 03-training/train.py --config configs/baseline.yaml # 评估模型 python 04-evaluation/evaluate.py --ckpt checkpoints/baseline/best.pt # 导出ONNX python 05-deployment/export_onnx.py --ckpt checkpoints/baseline/best.pt # 启动推理服务 uvicorn 05-deployment/server:app --host 0.0.0.0 --port 80008.2 我踩了十几遍的一个“低级”错误
训练脚本每次启动前,我建议先确认数据文件夹下的图片总数和CSV里的样本数对得上。我经常因为操作系统里的“文件同步未完成”或Windows的OneDrive同步引起缺图,导致DataLoader跳过样本,但总样本数变少后模型精度悄悄下降,你完全察觉不到。后来我在Dataset构造函数里加了一个总样本数的断言,低于预期就直接抛出异常。这类防御式编程在长期维护时非常香。
8.3 谈一点心态:从零开始做项目,最大的障碍不是技术
这个项目做到后期,我最深的感受是:工程能力的瓶颈从来不是某一个具体API不会用,而是你愿不愿意在一个地方卡住,然后自己动手翻源码、写探针脚本、逐步逼近问题的根源。
很多人学AI工程时,习惯是“看会了就等于会了”,但实际动手时才发现自己连transforms.Normalize的参数是怎么算出来的都解释不清。我强烈建议你把代码中每个“魔法数字”都当成敌人去追问:mean和std为什么是这个值?为什么学习率用1e-3而不是1e-1?为什么卷积层的padding是1?这些问题追到底,知识才会真正长在你自己身上。
如果你和我一样不想做只会调库的“炼丹师”,那就从今天开始把环境打碎重装一遍、亲手断言一下每个Bug、把服务压测到崩溃一次。这个过程很痛苦,但每一个坑都会在后面的工程里以“经验”的方式回报给你。