
搞机器学习的都知道训练出一个模型只是迈出了第一步真正让人头大的是怎么把模型平移到真实场景里让它稳定跑起来、被人调用、还能扛住压力。这个项目是我从零做的一个目标检测小项目从数据采集、标注、训练到导出、量化最后同时部署成服务端接口和端侧设备整条链路都走了一遍。这篇文章就把这次“机器学习小项目之旅”完整复盘一下重点聊聊模型训练之外的部署环节以及那些只有踩过坑才明白的取舍逻辑。如果你正打算走一遍“训练到部署”的完整闭环或者已经训练完模型但不知道怎么把它用起来这篇内容应该能给你省下不少周末时间。我会把技术选型思路、关键步骤、参数设置和排查记录都摊开来讲尽量让不同基础的人都能拿去参考。1. 项目整体设计与思路拆解1.1 项目到底要做什么我给自己定的目标很明确做一个基于视觉的“安全区域人员检测”小系统。说白了就是在一个固定摄像头画面里检测有没有人进入危险区域并且把结果通过接口暴露出来方便其他系统调用。选这个方向不是因为多新颖而是它把机器学习项目的几个关键环节全都覆盖住了数据标注、模型训练、模型导出、服务化部署、端侧推理。很多初学者容易犯一个错误就是只盯着模型训练那一环训练完准确率上去了就觉得项目完成了。但实际上模型训练只是整条链路的一部分。做过真实项目的人都知道数据质量、推理速度、部署环境的兼容性往往比模型本身的涨点更影响最终体验。所以我这个项目从一开始就不是“训练一个模型”而是“交付一个能跑的推理服务”。确定目标之后我把整个项目拆成了几个阶段数据准备与标注、模型训练与评估、模型导出与格式转换、服务端部署、端侧部署、性能验证。每个阶段都有明确产出物后面我会逐个展开讲。1.2 技术路线怎么选技术选型这部分我纠结过一阵子最后定下来的方案是用 YOLOv5s 做训练导出成 ONNX 作为中间格式服务端用 FastAPI Docker 部署端侧在树莓派 5 上用 ONNX Runtime 跑推理。选 YOLOv5s 的原因很实际社区活跃文档齐全遇到问题基本都能搜到答案。YOLOv5s 属于轻量级版本模型体积小训练速度快对硬件要求也低很适合做个人项目。相比 YOLOv8 和 YOLOv9YOLOv5 在部署生态上更成熟很多嵌入式平台和部署工具都优先适配它。部署端我刻意避开了 TensorRT 作为主力方案原因是我这次的目标设备混杂既有带 NVIDIA GPU 的服务器也有纯 CPU 的树莓派。TensorRT 虽然性能好但只在 NVIDIA 平台上能用跨平台能力太差。ONNX Runtime 成了折中的选择它支持 CPU、GPU、甚至部分 NPU还能跟 TensorRT、OpenVINO 做后端对接。这有点像把 ONNX 当成一个“通用接口”让模型在不同设备上都能找到合适的执行引擎。1.3 整体链路拆成三段整个项目链路我习惯性拆成三段训练侧、转换侧、部署侧。训练侧管的是数据和模型权重产出物是.pt文件或best.pt权重。转换侧负责把训练框架的权重转成部署框架能读的格式也就是.pt转.onnx必要时再转.engine或.rknn这类平台专用格式。部署侧则是把转换后的模型塞进业务系统里做成 HTTP 接口或者嵌入端侧程序。这个三段式拆法最大的好处是每一段的错误不会互相污染。如果部署后效果不对我可以判断问题是出在转换参数上还是部署环境少装了依赖不用从训练环境一路排查到线上。这也是我建议所有做部署项目的人先想清楚的架构问题。2. 数据准备与模型训练2.1 数据集和标注比想象中更花时间这个项目我用了公开数据集加自采数据混合的方式。自采数据就是拿着手机在厂区门口类似的场景拍了大概一千多张照片涵盖了不同角度、不同光照、不同距离的情况。公开数据选的是 COCO 里 person 类别的子集再筛掉一些跟场景差异太大的图最后全部统一重标一遍。标注工具我用了 LabelImg界面虽然老旧但分类、画框、导出 YOLO 格式都是一条龙很顺手。标注过程中有一个非常容易忽略的细节标注框的紧致程度。有些人标框喜欢把整个人的轮廓全包进去留很多白边有些人只贴身体贴得很紧这会导致同一个类别在不同样本里表现不一致模型训练时容易困惑。我后来统一规范成“贴着人体边缘最多留 2~3 个像素的余量”效果立刻稳定不少。数据质量检查这一步我建议别省。我写了一个简单脚本把所有标注框画回原图生成一张带框的大图网格用肉眼看一圈。这招虽然土但能很直观地发现标注错位、类别打错、框超出图像边界这类问题。我前后检查了两轮发现并修正了几十张图的标注问题。2.2 训练环境搭建最大的坑在 CUDA 版本训练环境我直接用了一台带 RTX 3060 的机器12GB 显存跑了不到三个小时就完成了训练。这里我详细说一下环境搭建因为这一步对新手来说最容易卡住。我用的工具链是 Python 3.8 PyTorch 1.13 CUDA 11.7。安装 PyTorch 的时候有一个超关键的细节不要图省事直接pip install torch那样装的是 CPU 版本得去 PyTorch 官网用对应的 CUDA 版本命令安装。比如 CUDA 11.7 对应的是pip install torch1.13.1cu117 torchvision0.14.1cu117 --extra-index-url https://download.pytorch.org/whl/cu117装完之后一定要验证一下 GPU 是否真的被识别别等到训练时才发现用的是 CPU。验证命令很简单python -c import torch; print(torch.cuda.is_available())打印True才算环境没问题。如果打印False多半是 PyTorch 版本和显卡驱动不匹配。NVIDIA 驱动是向下兼容的驱动版本太老会导致新版本 PyTorch 检测不到 GPU这时候用nvidia-smi看一下驱动版本和 CUDA 版本再回头选匹配的 PyTorch 版本就行。关于 YOLOv5 的依赖安装其实很简单我直接克隆了官方仓库然后安装依赖git clone https://github.com/ultralytics/yolov5 cd yolov5 pip install -r requirements.txt2.3 训练参数与效果评估训练时我参考了 YOLOv5 官方给的默认参数然后根据显存做了调整。关键参数如下img-size: 640YOLOv5 默认尺寸速度与精度均衡点batch-size: 1612GB 显存能承受的上限epochs: 100对小数据集来说足够收敛workers: 4数据加载线程数optimizer: SGD默认优化器比 Adam 更容易得到稳定的训练曲线训练命令我放在这里python train.py --img 640 --batch 16 --epochs 100 --data mydata.yaml --weights yolov5s.pt --cache训练过程中我重点盯着两个东西loss曲线和mAP指标。YOLOv5 在训练日志里会输出box_loss、obj_loss、cls_loss以及mAP0.5、mAP0.5:0.95。如果发现 loss 已经不怎么下降但 mAP 还在涨说明模型还在受益于训练可以继续跑。如果两个指标都平了再跑也是浪费资源。我还额外跑了一次可视化验证启动tensorboard --logdir runs/train把 loss 曲线拉出来看。有一个值得注意的细节是训练集的 loss 和验证集的 loss 要一起看训练集 loss 降了但验证集 loss 涨了就是典型的过拟合信号这时候就应该停止训练而不是继续增加 epoch。最终我训练得到的结果是mAP0.5大约 0.93mAP0.5:0.95大约 0.71。对一个人形检测的小项目来说这个水平已经够用。3. 模型导出与格式转换3.1 为什么要做格式转换训练完成后你手里拿到的是一份 PyTorch 权重文件也就是.pt格式。这个格式有一个明显的问题它强依赖 PyTorch 环境生产环境里还得装一整套深度学习框架才能跑。而且 PyTorch 的序列化格式在不同版本之间可能不兼容部署到别的机器上稍不注意就翻车。所以标准的做法是转换成 ONNX 格式。ONNX 是一个开放的模型表示标准相当于把模型“翻译”成一种跟框架无关的通用语言。这种设计思路非常好理解就像一篇 Word 文档直接发给别人对方机器上没装 Word 就打开不了但如果你转成 PDF任何人用任意设备都能正常查看。YOLOv5 官方仓库提供了导出脚本一条命令就能转python export.py --weights runs/train/exp/weights/best.pt --include onnx --opset 12导出成功后会生成一个best.onnx文件。但这仅仅是第一步后面还有坑等着填。3.2 ONNX 导出和验证这几个坑是必踩的ONNX 导出完成后我强烈建议先做一次“推理一致性验证”。也就是说用一张测试图分别走 PyTorch 模型和 ONNX 模型看输出的检测框和置信度是否一致。YOLOv5 自带的detect.py可以验证 PyTorch 模型ONNX 模型可以写一个小 Python 脚本用 ONNX Runtime 跑。验证时我会关注两个维度检测结果是否一致以及推理耗时是否合理。常见的坑之一是 opset 版本过低。如果导出的 ONNX 模型里包含一些高版本算子比如某些注意力机制低版本的 opset 不支持加载就会报错。解决办法是调高 opset 版本比如--opset 12或--opset 13。但也不是越高越好部署端 ONNX Runtime 的版本如果偏老对高版本 opset 的支持可能也有问题所以一般我会先试opset 12不行再升。另一个坑是动态轴设置。YOLOv5 的默认导出会把输入尺寸固定为 640x640如果你希望部署时能接收不同尺寸的图片就得开动态轴python export.py --weights best.pt --include onnx --opset 12 --dynamic但动态轴开了之后部署端的输入预处理也要跟着变不能再用固定尺寸的预处理逻辑否则会导致检测框位置偏差。我这次项目里摄像头画面是固定分辨率所以直接用了静态尺寸省掉一堆麻烦。第三个坑是 NMS 算子的处理。YOLOv5 导出的 ONNX 模型默认只包含模型前向推理部分也就是输出原始的预测框坐标和置信度不包含非极大值抑制NMS步骤。这意味着你在部署时必须自己在后处理里实现 NMS。如果你希望 ONNX 模型里直接包含 NMS可以导出时加--nms参数但这样会引入额外的自定义算子部分推理引擎不一定支持。我这次选择了不在 ONNX 里带 NMS后处理写在业务代码里反而更灵活。导出之后我习惯用onnxruntime快速跑一遍验证import onnxruntime as ort import numpy as np sess ort.InferenceSession(best.onnx) input_name sess.get_inputs()[0].name input_shape sess.get_inputs()[0].shape print(fInput: {input_name}, shape: {input_shape}) x np.random.randn(1, 3, 640, 640).astype(np.float32) outputs sess.run(None, {input_name: x}) print([o.shape for o in outputs])这一步能确认 ONNX 模型文件没有损坏输入输出结构符合预期。3.3 量化和精度验证收益和代价都要算清楚导出.pt 到 ONNX 之后模型体积大约是 14MB 左右在服务器上跑完全没问题但放到树莓派这类端侧设备上就显得有点重了。这时候就要考虑模型量化。量化最简单的理解就是降低模型参数和计算精度的位宽比如从 FP3232 位浮点降到 FP1616 位浮点甚至 INT88 位整数从而换取更小的模型体积和更快的推理速度。代价是精度会看着下降通常 FP16 基本不掉点INT8 会掉几个点但具体掉多少取决于数据和量化方式。我用 ONNX Runtime 做 FP16 量化操作很简单from onnxruntime.quantization import quantize_float16 quantize_float16(best.onnx, best_fp16.onnx)实测下来FP16 版模型体积从 14MB 降到 7MB树莓派上的推理速度提升了大约 30%而 mAP 只下降了不到 1 个百分点完全在可接受范围内。INT8 量化收益更诱人但坑也多。ONNX Runtime 默认的 INT8 量化和类支持有一定限制需要动态量化或者静态量化数据校准。如果量化后模型精度下降太多先别急着骂工具先看看是不是自己的测试数据分布和训练数据分布差异太大。量化本质上是根据数据的数值范围来压缩信息如果模型从来没见过的数据分布突然出现量化误差自然就大。4. 部署上的两种落地方式4.1 服务化部署FastAPI Docker出门在外也能跑服务化部署这块我选择用 FastAPI 把 ONNX 模型包一层 HTTP 接口。FastAPI 的优势是轻量、异步支持好、自带交互式 API 文档而且它对 Python 类型注解的原生支持让写接口变得非常顺手。接口逻辑很简单接收一张上传的图片预处理成 640x640送进 ONNX Runtime 推理后处理完 NMS 后返回检测框坐标和置信度。核心代码长这样from fastapi import FastAPI, UploadFile import onnxruntime as ort import numpy as np import cv2 app FastAPI() sess ort.InferenceSession(best_fp16.onnx) def preprocess(image_bytes): img cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR) img cv2.resize(img, (640, 640)) img img[:, :, ::-1].transpose(2, 0, 1) # BGR to RGB, HWC to CHW img np.ascontiguousarray(img, dtypenp.float32) img / 255.0 return img[None, ...] app.post(/detect) async def detect(file: UploadFile): data await file.read() x preprocess(data) outputs sess.run(None, {images: x}) # 后处理 NMS 逻辑返回检测结果 return {success: True, predictions: []}这里有一个特别容易踩的坑图像预处理必须和训练时完全一致。我在训练时是 BGR 转 RGB、除以 255、归一化到 0~1部署时也必须做同样的事差一步都不行。很多部署后检测不出来或者检测结果异常十有八九就是预处理不一致导致的。接口写好后我用 Docker 做容器化。Dockerfile 很简洁核心就是把 Python 环境、ONNX Runtime、FastAPI 和模型文件打个包FROM python:3.8-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8000]这里有个细节值得注意容器里用的是python:3.8-slim也就是精简版镜像。如果用完整版python:3.8镜像体积可能多出几百 MB拉取和启动都慢。但精简版镜像缺少一些编译工具如果依赖里有需要编译的库可能装不上所以需要根据实际情况选。4.2 端侧部署树莓派 5 上的推理优化树莓派 5 算力有限部署的时候需要精打细算。我跑通了两种方案一种是直接装 ONNX Runtime另一种是转成 OpenVINO 格式。这里给一个我实测的数据参考。直接跑 ONNX Runtime CPU 版树莓派 5 上推理一帧 640x640 图像大约需要 600~800 毫秒明显太慢达不到实时。后来我优化了三个点第一个优化是启用 ONNX Runtime 的线程数配置。默认线程数可能没吃满 CPU我用SessionOptions显式设置了线程数import onnxruntime as ort opts ort.SessionOptions() opts.intra_op_num_threads 4 sess ort.InferenceSession(best_fp16.onnx, opts)第二个优化是使用 FP16 模型前面提到的best_fp16.onnx。FP16 在 CPU 上有些平台会回退到 FP32但我实测树莓派 5 的 CPU 对 FP16 有一定加速效果。第三个优化是裁剪输入尺寸。从 640 降到 480虽然精度掉了一点但速度提升非常明显大概能降到 400 毫秒左右。说实话树莓派这种设备跑这个级别的检测模型离“实时”还是有距离。如果项目真的要做实时视频流检测建议换 NPU 或者 GPU 设备或者用更轻量级的模型架构比如 YOLOv5n 或者 MobileNet 系列。树莓派更适合低频触发、精度优先的场景。4.3 性能评估和上线检查别等用户发现卡顿部署上线之前我做了几轮性能压测。用简单的 Python 并发脚本模拟多客户端同时请求 FastAPI 接口。结果如下在服务器上RTX 3060 ONNX Runtime GPU 版单帧推理耗时约 20~30 毫秒100 并发请求下接口平均响应时间 100 毫秒左右比较稳定。在树莓派上CPU 推理单帧耗时约 600 毫秒只能支持低频调用不适合实时视频流。性能压测阶段的另一个重点是内存占用。ONNX Runtime 加载模型后内存占用大概在 200~400MB 之间。树莓派的内存是 8GB 版本绰绰有余但如果换成 2GB 内存的旧设备这个问题就要优先考虑了。我在上线检查时还加了一个简单的监控逻辑每次请求打印推理耗时、结果数量和异常信息方便后续排查问题。5. 常见问题与排查技巧实录5.1 问题速查表直接按图索骥这一节我整理了这次项目中实际遇到过的典型问题做成了一个速查表方便你直接排查。问题现象常见原因排查与解决思路模型部署到生产环境后检测结果和本地完全不同图像预处理不一致、归一化参数不同、通道顺序搞错对比本地和部署环境的预处理代码用同一张图逐步验证ONNX 模型加载报错“Unknown op”或者算子不支持opset 版本太高或太低、自定义算子未注册查看报错中的算子名调整导出时的 opset 参数必要时用较新版本的 ONNX Runtime训练时 GPU 利用率很高但 loss 不降学习率设置不合适、数据标注质量差、数据增强强度过高降低学习率、检查标注框、关掉部分增强看是否有改善模型量化后精度骤降校准数据集与真实数据分布差异大、量化粒度太大用真实数据做校准集尝试不同量化方式必要时回退到 FP16Docker 容器里运行报“libgomp.so.1: cannot open shared object file”默认镜像缺少部分系统库在 Dockerfile 里安装libgomp1、libgl1等系统依赖树莓派部署后推理速度太慢模型太大、未启用多线程、输入尺寸过大换更小的模型、配置 ONNX Runtime 线程数、降低输入分辨率后处理的 NMS 没写对检测框大量重叠没有实现或写错了非极大值抑制逻辑检查置信度阈值和 IoU 阈值设置确认输出张量维度含义这些排查思路也不是我一次性总结出来的大多是查文档、看源码、反复试验一点点磨出来的。真正的经验教训集中在下面几个问题上单独展开说说。5.2 几个值得展开的记录第一个值得说的是CUDA 环境一致性问题。有一次我在服务器上重新部署把 PyTorch 升级到了 2.x结果 ONNX 导出的算子结构跟之前完全不一样某些自定义模块直接导出失败。后来我强制在虚拟机里锁定了与训练环境一致的 PyTorch 版本才把问题解决。部署环境的版本控制能锁死的尽量锁死我不只在 requirements.txt 里锁版本还在 Dockerfile 里备注了基准镜像的版本号。第二个是数据泄露的问题。我在做数据划分的时候第一次不小心把同一场景的连续帧图片分别分到了训练集和验证集导致验证集精度虚高。换了一批像素级不重叠的图片后mAP 直接从 0.95 跌到 0.88。这说明如果验证集和训练集有太多重复内容模型几乎是在“背书”而不是“泛化”。数据划分时一定要考虑场景重叠建议对连续帧做时间维度的采样间隔至少保证同一个镜头里的帧都进同一边。第三个是树莓派部署的散热问题。跑长时间推理时树莓派 CPU 温度很容易飙到 80 度以上这会触发降频推理速度急剧变慢。后来我给树莓派加了一个小风扇并且把推理任务改成了批量处理每次间隔几秒温度降下来之后推理时间也从 700 毫秒降回到 500 毫秒左右。端侧设备的硬件限制很多时候比软件算法的影响更直接。第四个是模型跟业务解耦的问题。最开始我把 NMS 后处理逻辑写在了 FastAPI 接口里后来发现业务方需要直接拿到原始预测框做进一步处理不得不重新改接口。后来我把“模型推理”和“业务后处理”拆成了两个独立模块NMS 只是后处理的一种策略通过参数控制是否启用。这样一来模型部署和业务逻辑就不互相绑死了后面迭代模型时也不用动业务代码。最后分享一点我的实际体会整个项目走完我最深刻的感受是训练模型只占整个项目大约 30% 的时间剩下 70% 的时间都花在数据清洗、格式转换、环境适配和性能调优上。很多人觉得机器学习最难的环节是算法但我认为对大多数实际项目来说真正拉开差距的是工程化的细心程度。你会不会检查数据泄露、会不会做推理一致性验证、会不会排查算子兼容性问题这些细节决定了模型能不能从实验曲线真正走到线上环境。如果让我再做一遍这个项目我会把部署约束提前到训练阶段就开始考虑。比如先在目标设备上试跑一遍推理确认模型大小和推理速度是否达标再回头调整模型选型和输入尺寸。这种“以终为始”的思路能帮你省掉不少返工的时间。希望这篇分享能帮你在自己的机器学习项目之旅里少踩几个坑早日把模型真正用起来。