
1. 先从“from scratch”说起为什么我放弃了一站式AI平台两年前我接到一个需求要在一个ToB项目里做图像识别质检用来代替人工检视产线上的一类缺陷。当时团队里所有人第一反应都是“找个现成的AI平台上传样本、训练、导出模型不就行了”。我也试了一圈发现问题不是出在“能不能跑通”而是出在“除了那一步跑通其他什么都不可控”。平台隐藏了太多细节数据的存储格式和划分逻辑不透明超参数被封装成黑盒训练完导出的模型在本地推理时遇到版本冲突没人能解释清楚最要命的是——模型一旦上线你无法向客户解释“为什么这个缺陷会被漏检”因为整个链路在你手里根本没有可追踪的中间产物。客户不关心你用了多先进的平台他们只关心你有没有办法对结果负责。所以当我把“ai-engineering-from-scratch”当作一个完整的、从零构建的工程路线来做时我定的核心原则就一句话不依赖任何端到端黑盒平台所有中间环节——数据清洗、特征提取、训练、评估、导出、部署、自动重训——全部用自己的代码亲手搭一遍。这不是为了炫技而是为了获得三项能力可解释性每一个环节都有输入、输出、日志和中间产物出了问题能逐段排查。可控性换模型架构、改训练策略、调整数据分布任何变量都可以单独验证。可扩展性后续上新的AI能力时只需复用已有的工程框架不用重新被平台绑架。这篇内容不是什么“从入门到放弃”的鸡汤而是一条我实测跑通的完整路径。我以“图像缺陷分类”为例从数据、训练、推理、部署、运维五个维度把整条生态链拆给你看。全程用的都是开源工具和普通配置的设备你不用有GPU集群一台带8GB显存的消费级显卡就够。2. 整体设计一条最小可用、但五脏俱全的AI工程闭环2.1 先给“AI工程”画个边界它不是单纯写模型很多人以为AI工程就是“训练一个模型”这个理解至少缺失了百分之七十的工作量。实际进入生产环境的AI系统必须包含数据管线、实验追踪、推理服务、监控告警、自动重训五个模块。少了任何一个模型都只是实验室里的玩具。我按这个边界定义项目范围模块作用说明数据管线把原始图片变成可训练的干净样本清洗、标注、增强、划分实验追踪记录每一次训练的参数和结果复现实验、比较模型训练与评估训练出基线模型并计算真实性能指标精度、召回、误检率推理服务把模型封装成可被业务调用的API处理单图、批量图监控与自动重训检测线上数据漂移并自动触发新一轮训练保证模型不过时这条闭环的好处是你可以在任何一个环节“停下来”检查中间产物而不是只有在最终结果上等一个遥遥无期的指标。我把这条闭环跑完整只用了三个核心依赖PyTorch做训练和推理FastAPI做服务接口PostgreSQL记录所有元数据。没有上什么重型分布式平台因为起步阶段需要的不是规模而是理解。2.2 技术选型的底层逻辑不追新只求稳定复现工具选型这件事我踩过最大的坑是“盲目追新”。一开始我用了当时最新的深度学习框架版本结果第三方库还没有适配白白花了两个晚上解决环境冲突。后来我改了一个原则所有核心依赖锁定版本并且每次实验前记录完整的运行环境快照。具体到这套项目我的选型如下Python 3.10生态兼容性最好Torch、ONNX Runtime、OpenCV都有完整预编译包。PyTorch 2.x训练和部署一体原生支持动态图调试转ONNX也方便。FastAPI异步性能好轻量自带OpenAPI文档写推理服务比Flask顺手得多。PostgreSQL用来存实验记录、模型版本、线上推理日志一个库解决全部元数据管理。这套组合不是追求最潮而是追求出了问题我能在一小时之内定位到原因。我见过太多项目把重心放在换个新框架上结果换来的不是效率而是深渊。稳定的技术栈加上严格的版本管理才是“从零搭建”最该有的态度。2.3 硬件要求没有A100一张消费级显卡足够起步很多人看到“AI工程”四个字第一反应是“需要很大的算力”。其实绝大多数真实业务场景里的模型用不上大集群。我这个项目全程在一块NVIDIA RTX 30608GB显存上完成图像尺寸压到224x224Batch Size控制在16。一个很关键的计算经验放这里你的显存总量决定了一次前向和反向传播能塞下多少样本。以ResNet18为例每批次16张224x224的RGB图片前向反向的显存占用大约在2.8GB到3.5GB之间8GB显存绰绰有余。如果是ResNet50同样批次显存会飙到5.5GB左右这时候就要小心了。训练时先把Batch Size调小逐步往上加直到显存使用率接近90%为止。内存方面普通32GB机器完全够用因为真正的“大块头”是硬盘上的数据集而不是内存里的临时张量。存储建议至少留出50GB一方面要存原始图片另一方面要存训练过程中的checkpoint和日志。3. 数据层模型效果的上限在这里被决定3.1 数据收集与清洗宁可少不要脏说实话整个AI工程链路里最“不性感”但最重要的就是数据准备。我见过太多人花80%的时间调模型结构却对数据的质量视而不见。结果就是训练集上漂亮得很一到线上全是漏检和误检。以我做的图像缺陷检测为例原始数据来源有三块产线摄像头抓拍的图片、历史缺陷归档图、以及人工用手机补拍的现场照片。这三类数据本身存在两个大问题一是分辨率不统一二是光照和角度差异极大。清洗数据时我做了三件事去重用感知哈希算法pHash把所有图片两两比较重复度超过阈值比如汉明距离小于10的直接删除。这一步处理掉了约12%的重复样本。过滤无效图图片亮度方差过低的全黑或全白图、尺寸小于100x100的、文件格式异常的全部剔除。统一归一化策略所有图片统一缩放到224x224不做拉伸而是用等比缩放加灰色填充避免物体形变。这三步做完原始数据从大约2.4万张降到1.9万张。数量少了但质量高了后续模型训练省了很多事。3.2 标注策略用“多级投票”对抗标注噪声缺陷检测的标注核心难点是“边界模糊”。一个缺陷到一定程度才算缺陷那“一定程度”由谁定如果只交给一个人标准很容易漂移——人嘛上午和下午的判断都不一定一致。我采用的办法是多级投票归纳法每个样本随机分给三个标注员独立标注最终标签按少数服从多数决定。如果三个人给出三种不同结论就进入人工仲裁队列。这套机制成本高但换来的是训练标签的稳定性。我还额外留了5%的样本作“置信度采样”每两周抽一次让标注员之间比对标定防止标准随时间和疲劳漂移。3.3 数据增强给模型“制造难题”而不是让它背答案数据增强是提高泛化能力的最廉价手段。别小看这一步一个好的增强策略可以让同样结构的模型在验证集上直接涨2到3个点。我的增强管线如下import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.Resize(224, 224), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.3), A.HueSaturationValue(hue_shift_limit10, sat_shift_limit20, val_shift_limit20, p0.3), A.CoarseDropout(max_holes8, max_height16, max_width16, fill_value0, p0.2), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])有一个细节值得注意增强在训练时随机生效但在验证和推理阶段只做Resize和Normalize不加入任何随机操作。随机增强的目的是制造样本多样性而不是让验证指标也变得随机。另外我强烈建议用Albumentations而不是TorchVision自带的Transforms实测下来速度更快、增强种类更多尤其是CoarseDropout这种对局部遮挡敏感的增强方式对工业缺陷检测非常有效。它让模型学会“就算缺陷被部分遮挡也能识别出来”而不是死记图像像素模式。3.4 数据集划分别让“未来数据”混进训练集数据集划分有一个经常被忽视的点**如果一批图片是同一时间段同一相机拍的它们之间的相似度往往很高直接随机划分会导致验证集“泄漏”训练集的信息。**我用的是按“批次批次”划分把同一批次采集的图片全部放到同一侧训练、验证或测试而不是按单张随机分。划分比例我习惯是7:1.5:1.5如果是小样本场景建议加大验证集和测试集的比例比如6:2:2。划分完一定要存一份划分清单CSV里记录每张图的路径、标签、所属集合这是复现实验的基石。4. 训练环节从构建模型到监控训练状态4.1 模型架构选择从ResNet起步而不是从Transformer起步我见过不少新手一上来就上Vision TransformerViT或Swin Transformer。也不是说不行但前提是你有足够的数据至少百万级和足够的调参耐心。在这类千级、万级样本的工业场景里ResNet系列依然是稳定、高效、可解释的首选。在“From Scratch”的项目里我选择ResNet18作为基线模型。原因有三参数量小约1120万、在图像分类任务上表现稳定、推理速度快CPU上单张预测低于30毫秒。如果后续需要更高精度最平滑的升级路径是换成ResNet50或RegNetY-4GF不用改动太多代码。迁移学习不是不能用我的建议是先用ImageNet预训练权重做初始化把最后一层全连接换成自己的输出维度然后冻结前面的卷积层只训练分类头跑5个epoch观察效果。如果结果已经不错再渐进式解冻部分层做微调。这一招能大幅缩短训练时间又能避免从头训练时收敛过慢的问题。4.2 训练脚本把实验配置与代码逻辑严格分离写训练脚本最容易掉进“所有参数写死在一个文件里”的陷阱。一旦你开始跑多组实验你会发现改一个学习率都要翻半天代码。我的做法是用Dataclass定义全量配置用命令行参数覆盖默认值。from dataclasses import dataclass, field from typing import Tuple dataclass class TrainConfig: data_root: str ./data/images train_csv: str ./data/train.csv val_csv: str ./data/val.csv model_name: str resnet18 image_size: int 224 batch_size: int 16 epochs: int 30 lr: float 3e-4 weight_decay: float 1e-4 warmup_epochs: int 2 num_workers: int 4 seed: int 42 output_dir: str ./runs device: str cuda这样设计的好处是每一组实验都可以用一行命令生成独立目录目录里自动保存配置文件、日志、checkpoint、评估报告。后面比较实验结果的时候只需要看目录名和配置快照完全不会混淆。另外一个非常实用的小习惯固定随机种子包括PyTorch、NumPy、Python内置random甚至把DataLoader的worker随机种子也固定。否则同一份代码跑两次结果可能差零点几个点你会分不清是模型变化还是随机波动。import random import numpy as np import torch def set_seed(seed: int 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False4.3 训练过程中的监控损失值曲线是“体检报告”训练的时候不要只看最终精度一定要盯着每个epoch的训练损失和验证损失。这两种损失的曲线关系能直接告诉你模型的状态两者都在下降说明训练健康。训练损失降、验证损失不降或上升典型的过拟合马上停或加正则。两者都不降学习率可能过大或过小先调学习率。验证损失出现锯齿状剧烈波动Batch Size太小或数据里存在噪声标签。我在训练脚本里每5个epoch输出一张损失曲线图自动保存到实验目录。这样即使人不在电脑前回头打开图片也能一眼看出整个训练过程是否健康。不要傻傻只盯着terminal里滚动的数字图表化的趋势才是真正的信号。5. 评估与分析模型“看起来准”和“真正可用”是两码事5.1 别只用Accuracy你的业务场景需要更细的尺子在缺陷检测这类正负样本严重不平衡的场景里Accuracy是个“骗子指标”。比如99%的样本都是正常品模型什么都不学直接全预测正常品Accuracy也有99%看起来完美实际上什么都干不了。我同时计算Precision查准率、Recall查全率和F1-Score并且额外关注漏检率。在质检场景里漏掉一个缺陷的代价远大于多查几次正常品所以我对Recall的要求是尽量逼近99%即使Precision会相应降低。你需要在业务侧权衡这个平衡点而不是看一个平均指标就草草上线。from sklearn.metrics import classification_report, confusion_matrix report classification_report(y_true, y_pred, target_namesclass_names, digits4) print(report) print(confusion_matrix(y_true, y_pred))classification_report的好处是每个类别的Precision、Recall、F1分别输出你能一眼看到哪个类是最弱的。我实测下来最容易互相混淆的缺陷类别是“划痕”和“细微裂纹”这两个类的特征在224x224分辨率下高度重叠后来我把这两类图片单独提出来先用超分模型增强分辨率再重新训练F1直接从0.82涨到0.91。5.2 错误分析不要只盯着错误率数字去看错误图长什么样评估报告完成后我把预测错误的图片按“真实类别预测类别”分成不同组合每类抽20张拼成网格图人眼直接审。这个动作看似原始但价值极大。我曾经发现大量被误判为“正常”的缺陷图其实是图片边缘存在极小的暗角阴影肉眼都很难发现。这个信息直接指导我用Canny边缘检测做预处理在送入模型前把边缘区域的低对比度伪影剔除整体漏检率下降了40%。我的建议是每一次评估都固化生成一份“错误分析报告”包括错误样本路径、预测概率、真实标签、人工备注。这些记录不仅帮你发现模型的问题也是后续和客户解释模型行为时的最重要素材。6. 推理与部署把模型从一个文件变成一个服务6.1 模型导出PyTorch模型不能直接上生产环境训练完成得到的是.pth或.pt权重文件它依赖PyTorch的运行时直接放到线上推理环境一是速度不够理想二是版本兼容性问题太多。我统一用ONNX Runtime做推理引擎把训练好的模型导出为ONNX格式。导出ONNX这一步有几个关键参数import torch dummy_input torch.randn(1, 3, 224, 224, devicecuda) model.eval() torch.onnx.export( model, dummy_input, model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, opset_version17, )dynamic_axes允许batch维度可变这样同一个模型既能单张推理也能批量推理。opset_version不是越新越好要看你所用的ONNX Runtime版本支持范围选17是保险选择。导出后一定做一次精度一致性校验用同样的输入分别跑PyTorch模型和ONNX模型预测结果误差不得超过1e-4否则就要排查算子兼容性。6.2 推理服务设计单图接口 批量接口一个都不能少我把推理服务设计成两个接口单图同步接口和批量异步接口。前者用于实时检测场景比如产线相机拍一张判断一张后者用于离线批量扫描比如历史图片全量重检。用FastAPI实现非常简洁这里给出核心骨架import time import numpy as np import onnxruntime as ort from fastapi import FastAPI, UploadFile, File from PIL import Image import io app FastAPI() session ort.InferenceSession(model.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) app.post(/predict) async def predict(file: UploadFile File(...)): image_bytes await file.read() image Image.open(io.BytesIO(image_bytes)).convert(RGB) image image.resize((224, 224)) input_tensor np.array(image).astype(np.float32) / 255.0 input_tensor (input_tensor - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) input_tensor np.transpose(input_tensor, (2, 0, 1))[None, ...] start time.perf_counter() outputs session.run([output], {input: input_tensor})[0] latency_ms (time.perf_counter() - start) * 1000 pred_idx int(np.argmax(outputs[0])) confidence float(np.max(outputs[0])) return { prediction: class_names[pred_idx], confidence: round(confidence, 4), latency_ms: round(latency_ms, 2), }别忘了在服务启动时加载模型并且设置好providers顺序。把CUDA放在前面如果没有GPU会自动回退到CPU这一点在混合部署环境里非常实用。6.3 并发与性能一次压测告诉我“单张快”不等于“并发快”我第一次上线这个接口时单张图片推理只要20毫秒心想这速度还不是轻轻松松。结果压测一上来50个并发直接把请求排队到几秒原因很简单GPU推理是串行的你一次只能推一个batch再加上Python GIL锁的影响同步接口在并发场景下直接成为瓶颈。解决办法分两步。第一步对推理请求做排队而不是用线程硬扛。FastAPI的async asyncio.Queue可以自然地把请求排队队列长度设个上限比如200超出直接返回“暂满稍后重试”。第二步把单张请求合并成batch。无论客户端怎么来服务端都先把请求放进缓冲区每隔10毫秒或者攒满8张图就触发一次真正的批量推理。这一步优化之后同样50并发下平均响应时间从几秒降到120毫秒。这个方案业界叫“动态批处理”用代码实现也就几十行但收益巨大。import asyncio from collections import deque request_buffer deque() buffer_lock asyncio.Lock() BATCH_SIZE 8 BATCH_TIMEOUT 0.01 # 10ms async def batch_worker(): while True: items [] async with buffer_lock: while len(request_buffer) 0 and len(items) BATCH_SIZE: items.append(request_buffer.popleft()) if items: inputs np.concatenate([item[input] for item in items], axis0) outputs session.run([output], {input: inputs})[0] for item, output in zip(items, outputs): item[future].set_result(output) await asyncio.sleep(BATCH_TIMEOUT)这个worker常驻后台只要有请求进来就往缓冲区放一旦满足批量条件就执行推理。如果一直没有新的请求10毫秒超时后也会把缓冲区内已有的请求处理掉不会无限等待。6.4 容器化部署让“在我机器上是好的”成为历史模型版本、Python依赖、CUDA环境、系统库……任何一个不一致都能让线上服务原地爆炸。我把推理服务做成了Docker镜像镜像内固定Python版本、ONNX Runtime版本、系统时区、工作目录实现真正的一次构建处处运行。Dockerfile核心部分如下FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04 RUN apt-get update apt-get install -y --no-install-recommends \ python3.10 python3-pip libgl1 libglib2.0-0 \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt COPY app/ ./app/ COPY model.onnx ./model.onnx EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 2]这里有两个细节值得单独说。一是libgl1 libglib2.0-0这是OpenCV和Pillow处理图像时的系统级依赖漏掉的话总会报一些莫名其妙的库缺失错误。二是--workers 2如果推理是CPU执行多进程能榨干多核但如果GPU推理且每进程都加载一份模型到显存显存不够就反而会OOM需要根据模型大小和显存容量权衡。7. 上线后的运维模型部署完工作才刚刚开始7.1 日志与监控要能看到每一次推理的“体检单”部署之后我把所有推理请求的元数据写入PostgreSQL每一条包含图片ID、预测类别、置信度、推理耗时、模型版本、输入图片的哈希值。这个看似啰嗦的日志表是后面一切排查的基础。除了业务日志我还额外采集三个系统指标GPU利用率、显存占用、推理队列长度。队列长度超过阈值说明并发超了预期GPU利用率长期低于30%说明动态批处理还没有发挥最优效果显存快满则要考虑换小模型或降batch。实时告警我用了简单的规则推理队列超过200持续10秒告警预测置信度整体均值连续下降告警单接口的P99耗时超过400毫秒告警。告警阈值不要拍脑袋前两周观察线上数据再定。7.2 数据漂移与自动重训让模型拥有“自我更新”能力模型上线一段时间后最隐蔽的敌人是数据漂移——产线换了新批次材料、摄像头角度微调、环境光照改变都会让线上数据的分布逐渐偏离训练集。如果模型不会自我更新精度会慢慢衰退而没人能确切说出哪天开始变差的。我的方案是每三天做一次线上数据和训练集的分布对比用简单的特征向量比如直方图均值和方差计算距离。当距离超过阈值时自动触发一次增量训练。增量训练不是从头跑而是在最近的历史数据上微调10个epoch然后把新模型送入A/B测试通道。这个“自愈”机制必须配合人工审核闸门自动训练出的模型必须先跑一遍离线测试集测试集上的关键指标比如漏检率必须不低于当前线上模型否则不发布。这就形成了一条完整的闭环监控发现漂移自动训练候选模型审核通过后自动切换。7.3 模型版本管理在AI工程里模型就是代码我一开始把模型文件当普通文件扔在服务器某个目录里谁要谁去拷。后来一次紧急回滚让我痛定思痛新版本模型上线后效果反而更差想切回旧版结果旧版权重被覆盖了只能重新训练。这种事故一次就够了。现在我的规则很简单每个模型文件和一个“模型版本记录”绑定存进PostgreSQL。记录内容包括模型名称、版本号、训练数据范围、训练时间、验证集指标、责任人、上线时间。每个版本模型在文件系统里单独建目录目录名就是版本号删除模型文件必须通过正式流程而不是“手动清理空间”。到这里从零搭建AI工程闭环的每个环节我都带你看了一遍。最后说点个人体会这套体系最大的价值不是“模型准确率多高”而是它让团队里每一个人都能在任何时候回答三个问题——“数据是怎么来的”“模型是怎么训的”“线上发生了什么”。做到这三点你手里的AI才真正从“写好玩的”变成了“能负责的”。