☰
从零搭建AI工程闭环:图像分类服务实战指南
2026/10/3 5:58:26 网站建设 项目流程

我第一次完整地跑通一套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 python=3.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, padding=4), 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 模型定义:从零手写一个轻量CNN

from-scratch强调的不仅是流程从零,模型定义我也建议先自己手写一遍,至少要知道每一层的输入输出形状是怎么变化的。这里我设计了一个轻量CNN,参数约1.2M,在CPU上也能跑,适合做工程验证。

import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 32 -> 16 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 16 -> 8 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 8 -> 4 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplace=True), 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_max=30)

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, digits=4) print(report)

CIFAR-10里最容易混淆的类别是猫和狗、鸟和鹿,因为它们视觉特征确实相近。如果业务场景中恰好有这类易混类别,你就要考虑增加对应样本、调整loss权重,或者干脆把它们合并为一个大类。

3.7 部署为API:FastAPI推理服务实战

训练完模型只是第一步,真正让项目“能交付”的是部署环节。我先在训练脚本里加载最优权重,然后使用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_classes=10) model.load_state_dict(torch.load("best_model.pt", map_location="cpu")) 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, dim=1).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_version=17 )

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 推理延迟高,先别急着换模型

很多人上线后发现推理慢,第一反应是换更小的模型,但这是成本最高的选择。我的建议是先做三步优化再动模型结构:

  1. 确认推理代码里没有残留梯度计算,用torch.no_grad()包裹推理逻辑
  2. 把模型导出为ONNX并用ONNX Runtime推理
  3. 开启服务端批处理,把多条请求合并成一个batch推理

批处理的收益非常明显,GPU或CPU在批量推理时的吞吐量远高于单条推理。FastAPI里可以用队列收集请求,每秒钟处理一批,我实测同样流量下CPU占用下降一半以上。

4.5 上线后的模型监控与迭代闭环

模型上线不是终点。我见过很多团队上线后只看业务指标,从不看模型输入分布,结果几个月后数据漂移导致效果下滑而不自知。所以部署完之后要做两件事:一是把每次请求的输入和预测结果存日志,二是定期对积累的新数据进行评估,出现偏离时就触发重新训练。

这一步听起来不是“技术活”,但它和写模型代码一样决定项目成败。AI工程的可靠性,本质上就是靠一轮轮监控、反馈、再训练堆出来的。

5. 我的项目总结与进一步扩展方向

到这里,我已经把从零开始AI工程的完整链路走了一遍:设计、数据、模型、训练、评估、部署、监控。如果你完整跟着做了一遍,你会发现自己的收获不是一个能跑的项目,而是以后遇到任何AI任务时,都能清晰地知道每个环节要做什么、有什么风险、从哪里排查问题的“工程直觉”。

就我个人经验而言,这个过程中最容易被低估的其实是数据处理和评估设计。写模型代码是很开心的部分,但真正让项目持续有效的,是你对数据的理解、对指标的敬畏、对异常情况的监控。你可以先把这个CIFAR-10项目完整跑通,然后把里面的数据加载部分替换成你自己的业务数据,模型换成更合适的结构,部署部分加上鉴权和日志,这就已经是一个具备生产雏形的AI服务了。

如果后续想深入,我建议你按三条线扩展:一是把训练封装成可配置的流水线,支持多组超参数自动搜索;二是用容器化方案把训练和推理隔离起来,让部署环境完全可复现;三是在评估环节加入置信度阈值和人工兜底机制,让模型在低置信度时学会“拒绝回答”。每一条线都够你折腾一阵子,但它们都是在同一个工程骨架上长出来的血肉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询