
我第一次完整地跑通一套AI项目是从网上找的教程里复制代码开始的。跑完的那一刻确实很爽模型也出了个不错的准确率但第二天产品经理让我换一种数据增强策略时我盯着屏幕愣了半天——因为我不知道每一行代码到底在干什么。那之后我下定决心不依赖任何脚手架从最底层重建一套完整的AI工程链路。这就是我写“从零开始AI工程”的初衷不是再看一遍教程而是把数据、模型、训练、评估、部署整个闭环亲手搭一遍。这篇文章面向的读者是那些已经会调包、跑过几个notebook但始终觉得“没有真正掌控项目”的人。我会用一个完整的图像分类服务作为贯穿案例从环境准备到模型上线每个环节都给出可复现的方案也会把决定做某件事的逻辑和踩过的坑讲清楚。这套思路不止适用于图像换成文本、音频、推荐系统骨架都是一样的。1. 先搞清楚AI工程到底在解决什么问题1.1 从“会跑模型”到“能交付系统”差距在哪里很多人以为AI工程就是把模型训练出来其实那只是最前端的一小段。真正让一个AI项目从实验变成产品靠的是后面一大片看不见的水下部分数据怎么来、怎么保证数据质量、训练怎么复现、模型怎么评估、上线之后怎么监控和迭代。我自己见过太多这样的团队模型在笔记本上跑得挺漂亮但一到生产环境就崩——要么数据分布变了准确率暴跌要么推理延迟太高扛不住流量要么模型文件到了重启就丢。这些问题没有一个是在“训练循环”里能解决的它们全都属于工程问题。所以AI工程的核心不是说你能写出多复杂的网络结构而是你有能力把一条完整的流水线稳定地跑起来。从零搭建一遍的价值就在于此你被迫亲手处理那些被框架隐藏的细节才会真正理解每个环节存在的意义。1.2 AI工程的最小闭环包含哪些环节我习惯把一个完整的AI工程拆成五个环节这也是我这篇文章的主线数据处理收集、清洗、划分、增强构建可重复的数据管线模型构建定义网络结构或选择基线模型明确输入输出模型训练设计loss、优化器、学习率策略跑通反向传播模型评估用合适的指标衡量效果判断能不能上线模型部署将训练好的模型封装成服务处理推理优化与监控请注意这五个环节不是线段式的先后关系而是个闭环。上线之后发现badcase要回到数据处理环节去补样本、修正标注再重新训练。所以我一直强调AI工程的本质是一个持续迭代的系统工程而不是一个“训练完就交付”的单次任务。1.3 选一个贯穿全流程的案例项目为了不让你看一堆抽象概念我决定用一个具体项目把整个流程串起来从零构建并部署一个图像分类服务解决一个经典的10分类问题。数据集选用CIFAR-10因为它足够小、下载方便、类别清晰非常适合完整跑通链路。为什么选图像分类而不是更火的大模型微调因为图像分类的pipeline最直观数据是图片、模型是卷积网络、评估是准确率每一环都容易理解。把这条链路吃透之后再去做文本分类、目标检测甚至生成式应用你会发现骨架上只是在替换“数据格式”和“模型结构”这两个零件。注意如果你以后要做的项目是LLM应用核心逻辑依然绕不开数据清洗、提示词或微调迭代、效果评估、服务部署这几个环节只是工具链换了而已。2. 动手前的工程准备与设计2.1 技术选型为什么是PyTorch FastAPI技术选型这件事我一向主张“跟随生态”而不是“追逐热点”。当前AI工程领域PyTorch几乎是事实标准研究论文、开源模型、工具库都优先支持它。相比之下TensorFlow的Keras封装虽然上手快但在自定义训练循环、动态调试方面明显更绕。推理服务我选择FastAPI原因有三个一是基于Python和PyTorch同栈省去跨语言的序列化成本二是自带OpenAPI文档接口调试非常方便三是性能不差异步支持好足够应对中小规模推理场景。当然如果团队已有Java或Go的技术栈用对应的推理框架也完全合理。技术选型没有银弹关键是让你的整个流水线语言统一、调试路径短、部署成本低。2.2 环境准备与依赖清单我这里默认你有一台带NVIDIA GPU的Linux机器没有的话用CPU也能跑通流程只是训练时间会拉长不少。我推荐用conda或uv创建独立环境避免污染系统Python。# 创建虚拟环境 conda create -n aieng python3.11 -y conda activate aieng # 安装核心依赖 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install fastapi uvicorn pillow scikit-learn onnx onnxruntime上述依赖里torchvision用来加载数据集和提供标准数据增强scikit-learn用来画混淆矩阵和算指标onnx和onnxruntime用于后续部署时的模型加速。这些后面都会用到。提示如果你用的是Apple Silicon Mac可以把torch的安装命令替换成对应的MPS版本代码基本不用改。遇到显存不够或者训练过慢可以直接把batch size调小。2.3 项目目录结构一开始就按工程标准来从零搭建的最大好处就是你能按自己的标准来组织代码。我强烈建议不要把所有东西塞进一个Jupyter Notebook里而是从一开始就划分好模块。下面是我这个项目的目录结构你应该能复用到自己的项目里。ai-engineering-from-scratch/ ├── config.py # 全局配置路径、超参数 ├── data.py # 数据下载、预处理、加载 ├── model.py # 模型定义 ├── train.py # 训练与验证循环 ├── evaluate.py # 评估与指标输出 ├── export_onnx.py # 模型导出脚本 ├── serve.py # FastAPI推理服务 └── requirements.txt # 依赖清单每个文件对应一条清晰的职责边界出了问题能快速定位。比如训练效果差先查data.py的数据分布有没有问题再看train.py的训练参数而不是在一个几百行的文件里大海捞针。2.4 超参数不是拍脑袋要有依据超参数是我最早踩坑的地方。刚入门时习惯照抄别人的batch size和学习率结果自己的数据量、模型大小不一样训练直接发散。后来我总结出一套逻辑先根据显存定batch size再按batch size定学习率基准最后用一个小实验快速验证收敛性。以CIFAR-10和下面要定义的轻量CNN为例我的初始参数是参数取值选择依据batch size128一张24G显存卡能轻松放下且梯度比较稳定初始学习率0.001Adam优化器在这个量级通常表现稳健训练轮数30CIFAR-10小模型30轮足够收敛太长容易过拟合图像尺寸32x32CIFAR-10原生尺寸避免无谓缩放3. 核心实操从零搭建一个图像分类服务3.1 数据准备训练集测试集的划分不可马虎数据处理是整条链路里最不性感但最重要的一环。我见过太多项目死在“数据没有正确划分”上训练和测试有重叠、标签错位、类别不均衡这些问题到部署阶段才会爆发而且极难排查。CIFAR-10官方已经帮我们分好了训练集与测试集看起来不需要额外处理。但工程上我依然会再留一个验证集用来做早停和模型选择。标准做法是在训练集里切出10%作为验证集保证训练、验证、测试三方数据互不重叠。from torch.utils.data import Subset, random_split # 假设你已经通过torchvision加载了完整的训练集 train_len int(len(dataset) * 0.9) val_len len(dataset) - train_len train_subset, val_subset random_split(dataset, [train_len, val_len])这里为什么非要单独划验证集因为测试集是模拟“未来真实数据”的你只能看一眼最终结果不能拿它来调参数。如果反复用测试集验证效果你实际上是在对测试集过拟合上线之后面对真实数据的效果会比报告低不少。3.2 数据增强别让模型只记住像素CIFAR-10本身是规整的32x32小图如果不做增强模型很容易把训练集的背景噪声背下来。数据增强的本质是对样本做合理扰动让模型学到更鲁棒的特征而不是死记硬背。我的策略分两档训练集用随机裁剪、水平翻转和标准化验证集和测试集只用标准化。为什么验证集不做增强因为验证集要尽可能接近真实分布不能引入额外的随机性。# 训练集增强 train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) # 验证/测试集仅标准化 eval_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ])注意Normalize的均值和标准差是CIFAR-10数据集官方统计好的换成你自己的数据集时一定要重新统计直接用别人数据集的统计值会让输入分布错位。3.3 模型定义从零手写一个轻量CNNfrom-scratch强调的不仅是流程从零模型定义我也建议先自己手写一遍至少要知道每一层的输入输出形状是怎么变化的。这里我设计了一个轻量CNN参数约1.2M在CPU上也能跑适合做工程验证。import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32 - 16 nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 16 - 8 nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 8 - 4 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))为什么这么设计三个卷积块逐步把通道数从32扩到128同时把空间尺寸从32x32压到4x4这是在模拟“信息从细节到语义”的浓缩过程。最后的Dropout层是防止全连接层过拟合它只对训练生效推理时自动关闭我一度以为是自己代码写错了后来确认是框架的默认行为。3.4 训练循环自己写一遍才知道框架帮你做了什么下面是训练循环的核心代码我刻意没有用pytorch-lightning这类高层封装因为它就是为了让你看清每一步到底发生了什么。import torch import torch.optim as optim def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * inputs.size(0) correct (outputs.argmax(1) labels).sum().item() total inputs.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) total_loss loss.item() * inputs.size(0) correct (outputs.argmax(1) labels).sum().item() total inputs.size(0) return total_loss / total, correct / total这三个写进循环里的动作值得你牢牢记住optimizer.zero_grad()清空梯度、loss.backward()反向传播计算梯度、optimizer.step()更新参数。很多人训练效果漂浮不定就是漏了清空梯度导致梯度跨batch累积。优化器我用Adam初始学习率0.001。同时搭配一个余弦退火学习率调度器让训练后期学习率慢慢降下来帮助loss进入更稳的最优区域。scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)3.5 早停与模型保存别到最后一步才发现白训了训练30轮的过程中我每个epoch都会在验证集上跑一遍当验证准确率不再提升时触发早停。这个习惯帮我省下大量时间尤其是调参阶段我经常只跑到第15个epoch就知道这组超参数行不行。模型保存也有讲究不要只存最后一轮而是要存验证集表现最好的那一版。我的做法是每轮验证后判断是否刷新最优值如果刷新就覆盖保存为best_model.pt同时把对应的训练轮数记录下来方便回溯。best_acc 0.0 for epoch in range(epochs): train_loss, train_acc train_one_epoch(...) val_loss, val_acc validate(...) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)3.6 评估准确率之外还要看混淆矩阵准确率是最容易骗人的指标。当10个类别样本均衡时80%准确率看起来不错但如果某个类别几乎没被正确分类问题就大了。所以我每次训练完都会输出混淆矩阵具体到每个类别上的精确率和召回率。from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(all_labels, all_preds) report classification_report(all_labels, all_preds, digits4) print(report)CIFAR-10里最容易混淆的类别是猫和狗、鸟和鹿因为它们视觉特征确实相近。如果业务场景中恰好有这类易混类别你就要考虑增加对应样本、调整loss权重或者干脆把它们合并为一个大类。3.7 部署为APIFastAPI推理服务实战训练完模型只是第一步真正让项目“能交付”的是部署环节。我先在训练脚本里加载最优权重然后使用torch.no_grad()封掉梯度计算再包一层FastAPI服务。import io import torch from fastapi import FastAPI, UploadFile, File from PIL import Image import torchvision.transforms as transforms app FastAPI() model SimpleCNN(num_classes10) model.load_state_dict(torch.load(best_model.pt, map_locationcpu)) model.eval() classes [airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck] transform transforms.Compose([ transforms.Resize((32, 32)), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) app.post(/predict) async def predict(file: UploadFile File(...)): img Image.open(io.BytesIO(await file.read())).convert(RGB) x transform(img).unsqueeze(0) with torch.no_grad(): logits model(x) pred logits.argmax(1).item() prob torch.softmax(logits, dim1).max().item() return {label: classes[pred], confidence: round(prob, 4)}启动服务只需要一条命令uvicorn serve:app --host 0.0.0.0 --port 8000。之后访问http://localhost:8000/docs就能看到OpenAPI自带的调试页面往/predict传一张图片就能拿到分类结果和置信度整个服务就可以交付给前端联调了。注意torch.load一定要设置map_location否则在GPU上训练的模型在纯CPU机器上加载会报错。这也是上线最常见的报错之一。3.8 推理加速ONNX导出与半精度上线之后我发现一个问题PyTorch模型在CPU上用float32推理一张图要跑60毫秒左右并发一高CPU就吃紧。这个场景我决定导出成ONNX并用ONNX Runtime推理实测能提速30%到50%。import torch import onnx dummy_input torch.randn(1, 3, 32, 32) torch.onnx.export( model, dummy_input, model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version17 )dynamic_axes这个参数很关键它允许推理时传入可变batch大小。转换完成后用onnxruntime.InferenceSession加载推理接口的改动很小但性能和兼容性都提升了一截。如果GPU服务器内存足够我还会把模型换成半精度float16推理显存占用减半吞吐量明显上升。但半精度有个坑如果你的数据分布存在极端异常值半精度可能会放大误差所以上线前要在真实采样数据上做一轮AB测试。4. 实战避坑指南从0到1最容易踩的这些坑4.1 模型训练不收敛第一步查什么训练loss一直不降或直接变成NaN这是最常见也最让人头大的问题。我总结了一套排查顺序基本覆盖90%的原因排查项检查方法常见原因数据是否归一化打印输入x的均值方差输入像素未归一化到[0,1]或没做标准化标签是否对齐抽查batch里的image和label数据加载顺序与标签错位学习率是否过大看loss曲线是否震荡发散初始lr超出合理范围模型输出是否有NaN打印logits统计数值不稳定尝试降低lr或用梯度裁剪梯度是否正常打印每个参数的梯度范数梯度爆炸或消失检查和初始化我自己最常犯的错误是把图像转成Tensor后又做了一次除以255导致输入范围变成0到0.003左右梯度信号微弱训练几乎没进展。所以排查第一步永远是“确认输入的数据分布和你预期的一致”。4.2 显存溢出与训练中断显存溢出在训练大模型或增大batch时经常出现。我不建议直接盲目调小batch因为batch太小会导致梯度噪声大、收敛不稳定。更合理的方案是先用当前batch size跑一轮观察显存占用再按数据开销调整。如果batch size已经小到32还是溢出可以用梯度累积模拟更大的batch。例如每4个batch更新一次参数等效于batch size乘以4但显存占用不变。accumulation_steps 4 for i, (inputs, labels) in enumerate(loader): loss criterion(model(inputs), labels) loss loss / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()4.3 数据泄漏准确率虚高的真凶训练准确率98%测试准确率只有70%除了过拟合还要怀疑数据泄漏。一个常见的泄漏场景是数据增强在划分训练集和验证集之前执行导致同一张图的不同版本同时出现在两边验证集不再“干净”。另一个场景是标准化统计量提前用了全局数据。比如先对整个数据集算均值和方差再做标准化这本身就把测试集的信息泄漏给了训练过程。正确做法是只用训练集的统计量再应用到验证集和测试集。4.4 推理延迟高先别急着换模型很多人上线后发现推理慢第一反应是换更小的模型但这是成本最高的选择。我的建议是先做三步优化再动模型结构确认推理代码里没有残留梯度计算用torch.no_grad()包裹推理逻辑把模型导出为ONNX并用ONNX Runtime推理开启服务端批处理把多条请求合并成一个batch推理批处理的收益非常明显GPU或CPU在批量推理时的吞吐量远高于单条推理。FastAPI里可以用队列收集请求每秒钟处理一批我实测同样流量下CPU占用下降一半以上。4.5 上线后的模型监控与迭代闭环模型上线不是终点。我见过很多团队上线后只看业务指标从不看模型输入分布结果几个月后数据漂移导致效果下滑而不自知。所以部署完之后要做两件事一是把每次请求的输入和预测结果存日志二是定期对积累的新数据进行评估出现偏离时就触发重新训练。这一步听起来不是“技术活”但它和写模型代码一样决定项目成败。AI工程的可靠性本质上就是靠一轮轮监控、反馈、再训练堆出来的。5. 我的项目总结与进一步扩展方向到这里我已经把从零开始AI工程的完整链路走了一遍设计、数据、模型、训练、评估、部署、监控。如果你完整跟着做了一遍你会发现自己的收获不是一个能跑的项目而是以后遇到任何AI任务时都能清晰地知道每个环节要做什么、有什么风险、从哪里排查问题的“工程直觉”。就我个人经验而言这个过程中最容易被低估的其实是数据处理和评估设计。写模型代码是很开心的部分但真正让项目持续有效的是你对数据的理解、对指标的敬畏、对异常情况的监控。你可以先把这个CIFAR-10项目完整跑通然后把里面的数据加载部分替换成你自己的业务数据模型换成更合适的结构部署部分加上鉴权和日志这就已经是一个具备生产雏形的AI服务了。如果后续想深入我建议你按三条线扩展一是把训练封装成可配置的流水线支持多组超参数自动搜索二是用容器化方案把训练和推理隔离起来让部署环境完全可复现三是在评估环节加入置信度阈值和人工兜底机制让模型在低置信度时学会“拒绝回答”。每一条线都够你折腾一阵子但它们都是在同一个工程骨架上长出来的血肉。