1. 为什么训练完的模型,都要先过一道“ONNX”的手
先聊点实际的。我在这个项目里做到“Phase A · Step 3”的时候,模型已经在 PyTorch 里迭代得差不多了,指标说得过去,推理速度也还凑合。但真要把它扔到产品里去用,问题马上就来:推理端不可能装一个完整的 PyTorch 环境,显卡、CPU、边缘盒子、甚至 NPU 上跑的推理框架五花八门,你不可能指望每一条链路都原生支持 PyTorch 的state_dict。所以,把训练好的模型导出成 ONNX,就成了整个模型部署流程里最不起眼、但最绕不开的一环。
ONNX 这套东西,全称是 Open Neural Network Exchange,本质上是给深度学习模型定义了一个“中间表示层”。你可以把它理解成一个通用的翻译语言:模型在 PyTorch 里写,导出成 ONNX 之后,就能被 ONNX Runtime、TensorRT、OpenVINO、RKNN、TFLite 这些推理引擎识别。也就是说,ONNX 不是一个具体的推理框架,而是一座桥——桥这边是训练框架的百花齐放,桥那边是推理硬件的千奇百怪,ONNX 的任务就是让模型结构和推理计算图能在这头倒腾到那头,不至于被某个框架绑死。
很多新手第一次接触“模型导出”这个概念,会误以为这只是个“save”操作,把权重存下来就行。实际上,ONNX 导出做的是两件事:第一,把模型的计算图结构完整地固化下来,包括每一层的类型、连接关系、参数值;第二,把模型的动态行为(比如输入张量的 shape 变化、显式的控制流)转换成一张静态或半静态的计算图。这个转换过程才是最麻烦的地方,一旦模型里有一些 PyTorch 动态逻辑、Python 层面的 if/else、循环、自定义 autograd Function,导出时就很容易踩坑。
说到底,ONNX 导出不是一个可以“无脑调用”的 API,它是在“模型训练完成”和“部署推理开始”之间做的一次无损(或尽量有损小)的翻译。你需要清楚你模型的输入输出到底是什么、有哪些算子、在目标推理引擎上哪些算子是被支持的。把这些摸清楚了,导出这个动作本身就只是几行代码的事;摸不清楚,后面排查问题会排到你怀疑人生。
2. 导出 ONNX 的完整思路:先想清楚再动手
2.1 导出前必须确认的三件事
动手写导出脚本之前,我建议你先花十分钟确认三件事,这三件事能帮你避开一半以上的坑。
第一,确认模型所处的状态。PyTorch 模型在导出前必须置为eval()模式,并且建议用torch.no_grad()包起来。这不是个可有可无的建议,而是直接影响导出结果的硬性要求。eval()模式会把 BatchNorm、Dropout 这些层的行为切换到推理状态,否则 BatchNorm 会用当前 batch 的统计量去跑,导出的计算图里带着这些潜在的不确定性,后续推理结果稳定性就没法保证。同时,torch.no_grad()能阻止 autograd 跟踪图中的梯度信息,导出的计算图会更干净,内存占用也更小。
第二,确认输入输出的 shape 和 dtype。ONNX 本质上要求你给一次“样例输入”来跑一遍模型,然后根据这次前向传播的记录去构建计算图。所以你要明确你的输入张量是什么样的:是[1, 3, 640, 640]还是[N, 3, H, W]?是 float32 还是 float16?有没有动态变化的维度?这些信息不仅用于生成 dummy input,也直接决定你在导出时的dynamic_axes设置。很多项目导出后推理崩溃,最后查出来都是输入维度写死导致的。
第三,确认模型的输出结构。分类模型还好说,就是[N, num_classes];但像目标检测模型,输出可能是一个 dict、一个 tuple,甚至还有多个不同尺度的特征图。ONNX 导出时,如果你不对输出做整理,很容易导出成乱七八糟的节点。我的一般做法是在导出前用 PyTorch 把模型 pack 成一个统一的输出结构,比如把多个输出 concat 成一个张量,或者明确指定每个输出节点的名字,方便下游推理引擎做解析。
2.2 选择 ONNX 的 exporter:torch.onnx.export 的底层逻辑
PyTorch 官方提供的是torch.onnx.export,底层走的是 TorchScript → ONNX 这条路线。这中间有个关键点:导出不是按 Python 源码逐行翻译的,而是先把模型脚本化(script)成 TorchScript 的中间表示,再做算子映射到 ONNX。
用torch.onnx.export的时候实际上有两条路径:一条是 trace 模式,即直接给一个 dummy input,让模型真实跑一遍,记录下实际执行过的算子序列,按这个序列生成 ONNX 图;另一条是 script 模式,用torch.jit.script把模块完整编译成 TorchScript 图再导出,适合有数据流控制的模型。
我绝大多数场景下都用 trace 模式,因为它对模型的侵入性最小,也最简单直接。但 trace 模式有个天然缺陷:它只“看得到”实际执行过的路径,如果模型里有依赖 python 运行时数据的 if/else 分支,trace 模式只会记录当前走的那条分支,另一个分支直接消失。这也是为什么很多包含动态控制流的模型(比如循环次数取决于输入长度的模型)不能简单 trace 导出。
如果你需要处理这类带动态控制流的模型,我建议你用torch.jit.script先转成 ScriptModule,再导出。这样模型的控制流能被完整编译成静态图结构,ONNX 里会多出一些循环或条件执行节点。代价是你的模型代码要尽量兼容 TorchScript 的语法限制,写起来没那么自由。
2.3 核心参数详解:opset、input_names、dynamic_axes
torch.onnx.export的可配置项里,最常被拿出来研究的就是opset_version、input_names和dynamic_axes这三个。我一个个说。
opset_version,即 ONNX 算子集版本。ONNX 的算子定义是不断演进的,每个版本会新增或修改部分算子。选多高版本的 opset,取决于两个约束:一是 PyTorch 对高版本 opset 的支持程度,二是目标推理引擎支持的 opset 上限。比如 ONNX Runtime 一般opsets支持得比较新,而一些芯片厂商的 NPU 工具链往往只能支持到 opset 11 或 12,选太高版本反而会导致算子转换失败。我的经验是“就低不就高”:在目标引擎支持范围内,优先选 PyTorch 支持得最成熟、算子兼容性最稳定的版本。像 YOLO 系列导出,我经常用 opset 11,因为绝大多数框架对这个版本的算子覆盖已经足够完善,踩坑概率最小。
input_names和output_names是给模型输入输出节点起名字。别小看这一步,很多推理引擎加载 ONNX 时靠名字来定位输入输出,如果你不起名字,默认就是input.1、output.1这种丑名字。下游写推理代码时,你要反复去查这些索引对应哪个实际含义,很容易搞混。我习惯用有语义的名字,比如images、boxes、scores、labels,这样后续推理代码的可读性会好很多。
dynamic_axes控制哪些维度允许动态变化。例如:
dynamic_axes={ "images": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size", 1: "num_detections"} }这里的意思是:images的第 0 维、第 2 维、第 3 维都是可变的;output的第 0 维、第 1 维也是可变的。设置了dynamic_axes之后,ONNX 图里会把这些维度标记为 symbolic 维度,推理引擎就可以接收动态形状的输入。但天下没有免费的午餐:动态轴会导致部分算子走动态 shape 的分支,执行效率通常会比静态 shape 低一些,某些硬件后端甚至会直接不支持动态 shape。所以我的原则是:能静态就静态,确实需要动态(比如 NLP 里不定长的输入)再把相关维度打开。
3. 实操:从 PyTorch 到 ONNX 的完整导出流程
3.1 一个可复用的导出脚本模板
说得再多不如直接跑一遍。我把自己常用的导出脚本核心部分贴出来,你拿去改改就能用:
import torch import torch.nn as nn class DemoModel(nn.Module): def __init__(self): super().__init__() self.conv = nn.Conv2d(3, 16, kernel_size=3, padding=1) self.bn = nn.BatchNorm2d(16) self.fc = nn.Linear(16 * 16 * 16, 10) def forward(self, x): x = self.conv(x) x = self.bn(x) x = torch.relu(x) x = torch.flatten(x, 1) x = self.fc(x) return x model = DemoModel() model.load_state_dict(torch.load("demo_model.pth")) model.eval() dummy_input = torch.randn(1, 3, 16, 16) torch.onnx.export( model, dummy_input, "demo_model.onnx", export_params=True, opset_version=11, do_constant_folding=True, input_names=["images"], output_names=["logits"], dynamic_axes={ "images": {0: "batch_size"}, "logits": {0: "batch_size"} } )简单解释几个关键参数:
export_params=True:把模型权重也一起导出进 ONNX。如果你只想导计算图结构、之后再单独加载权重,可以设为 False。绝大多数场景都建议保留为 True,省事后面的麻烦。do_constant_folding=True:开启常量折叠。PyTorch 会把计算图里可以预先算好的常量计算折叠成常量节点,减小模型体积,提升推理效率。这个默认就是 True,但我遇到过一个 Bug——某些自定义算子开启常量折叠后结果不对,这时候可以改成 False 来排查。dummy_input的 shape 要和模型实际输入对齐。尺寸不对的话模型会直接跑错,报错信息会提示是哪一层尺寸 mismatch。
导出完成后,你会得到一个.onnx文件,它是一个 protobuf 格式的二进制文件。你可以用onnx.shape_inference.infer_shapes给它做一次形状推断,让每个中间节点的 shape 信息更完整,后续转其他格式时会少很多报错。
3.2 导出后的第一道验证:onnx.checker 和 onnxruntime
导出文件拿到手,别急着往推理引擎里塞,先用 ONNX 官方工具链做两道基础验证。
第一道是用onnx.checker.check_model检查这个 ONNX 文件的合规性:
import onnx model = onnx.load("demo_model.onnx") onnx.checker.check_model(model) print("check passed")它会检查模型结构是否合法、节点属性是否完整、图是否连通等。如果这步都过不去,说明导出的图本身就有问题,多半是 PyTorch 版本和 ONNX 导出器之间的兼容问题。
第二道是用 ONNX Runtime 跑一遍推理,和 PyTorch 的结果做对比:
import numpy as np import onnxruntime as ort import torch ort_session = ort.InferenceSession("demo_model.onnx", providers=["CPUExecutionProvider"]) x = torch.randn(1, 3, 16, 16) ort_out = ort_session.run( None, {"images": x.numpy()} )[0] with torch.no_grad(): torch_out = model(x).numpy() print("max abs diff:", np.abs(ort_out - torch_out).max())这个“最大绝对差值”如果小于1e-5级别,说明导出基本无损。如果差值偏大,我通常会先怀疑是 BatchNorm 或 Dropout 没有切到 eval 模式,其次是模型里有随机性操作(比如torch.rand、F.dropout在 eval 下仍产生随机数)。
建议你把这两步写成一个小脚本每次导出后固定跑一遍。我在项目里就是把它做成一条命令,导出完直接给结果,省得每次手动操作。这种“验证先行”的习惯,到后期模型多了以后尤其能省时间。
3.3 用 onnxsim 给导出模型“瘦身”
导出后的 ONNX 往往会带着一些冗余节点,比如多余的 Identity、Cast、Reshape 链。这些不会改变计算结果,但会影响后续转换效率和推理性能。我在落盘到部署链路之前,习惯用onnx-simplifier过一遍:
pip install onnx-simplifierpython -m onnxsim demo_model.onnx demo_model_sim.onnx这个工具做的工作主要是常数折叠、去掉无用的节点、简化冗余的子图结构。处理完成后模型体积通常会小一点,结构图干净很多。市面上很多“导出前避坑指南”都会第一句就推荐onnxsim,不是没道理的——它是你排查“推理引擎不认识某个算子”时的第一块挡箭牌。很多在推理引擎里报错的节点,其实就是 PyTorch 导出时一些冗余节点包裹住了真正的算子,用 onnxsim 清理后,问题常常会自己消失。
onnxsim也不是万能灵药。如果你的模型里有自定义算子、动态控制流比较多的结构,simplify 之后反而可能把关键信息“简化”掉。我一般会把原始导出和简化的结果都保留,直到确认目标推理引擎吃哪一版,再决定后续用哪个。
4. 不同场景下的导出:YOLO、TTS、量化与嵌入式 NPU
4.1 目标检测模型(YOLO 系列)导出时的那几个隐形地雷
YOLO 系列可以说是普通用户接触 ONNX 导出最多的模型了,但恰恰是它,最容易一脚踩进坑里。网上的热词“yolo导出onnx模型”能成为搜索趋势,足以说明这题确实没那么省心。
先说 YOLOv5,它官方仓库自带export.py脚本,理论上直接能用。但这个脚本导出的是它自家的“完整检测模型”,输出是一个包含所有检测结果的 tensor,其中还内置了 NMS 等多后处理逻辑。这个模型结构完整是完整,但推理引擎不一定吃得下。我在 rknn、TensorRT 这些后端的转换过程中,反而建议你用 YOLOv5 官方仓库里的yolo.py里那个只保留 backbone + head 的模型,只输出原始检测特征图。输出结构从 1 个 tensor 变成 3 个不同尺度的特征图 tensor,后处理留给推理端自己写。这样虽然你在推理侧要多写一点解码代码,但模型的部署兼容性会好很多。
再说 YOLOv8,它的检测头输出是一个[batch, 4 + num_classes, num_anchors]的大张量,导出直接转成 ONNX 是可以的。但如果你在导出前没有注意它某些模块的内部实现,也会出问题。比如C2f模块里用了torch.chunk后接 concat,不少旧版本 ONNX 导出器对 chunk 的输出处理不干净,导致后续推理引擎算出来的结果跟 PyTorch 不一致。遇到这种问题,最粗暴有效的处理方式是把这些算子改写一下:把 chunk 拆成两个切片操作,切片结果直接 concat。切片和 concat 在全平台算子集合里都是最基础、最不容易出事的两个操作。
还有 NMS。ONNX 官方算子集合里其实有NonMaxSuppression算子,一些框架也支持导出 NMS 到 ONNX 图里。但我的经验是:能不在模型里带 NMS 就尽量别带。原因是很多边缘部署工具对 NMS 算子的支持并不完善,有些甚至需要特殊的插件或自定义算子的方式去绕过去,反而给你增加对接成本。后处理在推理端用 NumPy 或 C++ 手写,清晰且可控,排查问题也直观。
如果你跑的是“模型 == 视觉模型”这个通用场景,我还有个额外建议:导出前先确认你的输入是否做了归一化。如果你的训练代码里对输入有x / 255.0这种操作,导出时这个操作到底是写进模型里,还是留给预处理端做,会直接影响你后续的部署代码。我个人偏好是把这个归一化操作留在预处理端,模型里只接收[0, 1]的浮点输入,这样模型结构更“纯粹”,在量化时也比较友好。
4.2 语音合成(TTS)类模型的 ONNX 导出:以 sherpa-onnx 为例
最近“sherpa onnx tts engine安装使用教程”搜得比较多,这也说明了 TTS 模型导出,已经不只是 PyTorch 转 ONNX 这么一道坎了,还包括 ONNX 模型如何被推理引擎加载、如何对接语音前端和后端。
sherpa-onnx 是一个专注于离线语音识别和语音合成的开源推理工具,它本身不提供训练能力,而是专门负责加载 ONNX 格式的模型,提供多平台推理能力。它在 TTS 场景里加载的模型大多是 VITS 这一类端到端语音合成模型。VITS 这类模型有一个典型特点:输入是文本 token,输出是线性谱,中间还涉及到随机采样、VAE 的先验后验分布、Flow 模块等复杂结构。如果直接把完整的 PyTorch VITS 模型拿去 trace 导出,很容易因为随机采样部分被固化而导致导出后的模型“锁死”了乱数生成器,每次推理结果都一样。
我在自己导出 TTS 模型时的做法是:把 VITS 的推理路径拆成两个子模型来处理。第一个子模型处理“从文本到隐变量”的部分,第二个子模型处理“从隐变量到音频波形”的部分。这样在导出时就能避开随机采样这个动态点,后面的部署也好控制。如果你的模型用的是 ONNX 官方准出的 VITS 结构,注意在导出前设置好torch.manual_seed并显式把随机操作排除在 trace 范围之外,否则你会在“同一段文本每次合成出来的声音都不一样,但同一次导出的模型在某次推理时又总是一样的”这种诡异现象里浪费大量时间。
sherpa-onnx 本身的使用上,核心就是把导出的 ONNX 模型路径、tokens 词典、模型元数据信息正确填到配置结构里。它的安装和配置没太多玄学,但一个常见的痛点是把 PyTorch 版本产出的 ONNX 模型交给 sherpa-onnx 后提示“unsupported operator”或“unexpected model type”。这种时候 90% 是导出时的 opset 或者动态轴设置与实际模型需求不一致导致的。我的建议是先按模型自带的导出配置来,不要自己顺手改 opset 和 dynamic axes,等跑通了再按自己的需求去微调。
4.3 ONNX 模型的 INT8 量化:性能和精度之间的博弈
热词里有 “.onnx量化int8”,这块值得多说几句。量化是一个典型的“通则不痛、痛则不通”的话题——从流程上看,把 ONNX 模型转成 INT8 版本只是调几个 API 的事,但从结果上看,不是精度暴跌,就是推理引擎根本不买账。
目前 ONNX 生态里最主流的量化手段是 QDQ(QuantizeLinear/DequantizeLinear)模式。做的事情就是往计算图里插入“量化-反量化”节点对,让模型在推理时能把浮点中间结果缩放到 int8 域计算。这个方案的一个好处是模型结构仍然保留在 ONNX 图里,你可以很直观地看到每个算子前后被插入的量化节点,排查起来相对容易。
做 INT8 量化的第一步是准备校准数据集。校准数据集不要求带标签,但要求分布尽量贴近真实推理场景的输入分布。你需要跑一部分真实数据过一遍浮点模型,统计每一层激活值的 min/max 或者直方图分布,然后根据统计结果确定量化 scale 和 zero point。onnxruntime的quantize_static接口里,你可以传入CalibrationDataReader,它负责一个 batch 一个 batch 地把数据喂给模型做统计。一个反直觉的细节是:校准数据量并不是越多越好。我用下来觉得 200~500 张典型图片通常就够了,再多反而可能把统计分布拉偏,尤其当数据集中出现少量极端亮度或噪声样本时,min/max 统计会被这些离群点带偏,直接拉低整体量化精度。
量化后的精度验证,我建议你主要看两类指标:一是全量测试集上的 mAP/accuracy 对比,二是抽样对比单张输入的输出张量差值。前者告诉你“总体效果还行不行”,后者帮你看“哪个环节差值拉大、有没有可能通过混合量化修复”。如果你发现某个量化后精度掉点严重的模型,通常可以做的补救手段有三个:第一,把敏感层保留为浮点运算,做混合精度量化;第二,把校准算法从 min/max 换成 percentile(比如 99.99%),舍弃极端离群点;第三,换成逐通道量化(per-channel)来量化权重,减少权重值动态范围差异造成的精度损失。
4.4 “.onnx转.kmodel”:嵌入式 NPU 部署的最后一公里
瑞芯微(Rockchip)系列边缘盒子在安防、工业视觉场景里用得非常广,它的模型格式是.kmodel,官方提供的转换工具链是rknn-toolkit2。这个转换过程中间也卡着一个 ONNX——你先把 PyTorch 模型导成 ONNX,再把 ONNX 喂给 rknn-toolkit2 转成 kmodel。
.onnx转.kmodel有太多人踩坑,是因为这里的“转”不止是格式转换,还包了一层“硬件适配”。RKNN 工具链会分析你的 ONNX 模型结构,把算子映射到它自研的 NPU 指令上。如果你的 ONNX 图里有它不认识的算子,工具链会直接报错或者让该层落到 CPU 上执行,后者尤其坑——模型能跑,但某个关键卷积跑到 CPU 上去了,速度直接掉一个量级,而你从日志里还不一定能一眼发现。
给 RKNN 用的 ONNX,我一般会专门做一些“预加工”。首先确保导出时 batch 维设为 1,很多 NPU 工具链对动态 batch 的支持非常差。其次对模型里的切片、concat、reshape、transpose 等算子,尽可能用 opset 11 的经典形式,避免一些新版本算子的语义变化。还有个比较隐蔽的点:RKNN 工具链对nn.MaxPool2d中ceil_mode=True时的处理经常和 PyTorch 不一致,如果模型里有这个配置,建议在导出前改成等价的卷积 + padding 组合。
另外,如果你在导出前就知道最终要转 kmodel,我建议你在 PyTorch 导出阶段就把后处理尽量剥离出去。还是那句话,模型里只保留最纯粹的卷积 / 激活 / 池化结构。后处理放到 NPU 外面做,哪怕用 CPU 做,都会比把复杂算子塞进 NPU 模型里更省心。
5. 常见问题排查与避坑技巧实录
5.1 一张表看懂高频报错和解决思路
| 问题现象 | 常见原因 | 处理思路 |
|---|---|---|
ONNX 导出报错Failed to export an op | PyTorch 版本与该算子 opset 映射不完整 | 升级 PyTorch 或降低opset_version,或改写该层实现 |
| 导出成功,但 ONNX Runtime 推理结果和 PyTorch 不一致 | 模型未切 eval、存在随机操作、BatchNorm 状态异常 | model.eval()并在no_grad下导出,固定随机种子排查 |
| 推理引擎不支持某个算子 | opset 版本太高或算子太新 | 尝试onnxsim简化、降低 opset,或改写对应模块实现 |
| RKNN/其他 NPU 工具链转换失败 | ONNX 里有不支持的算子或动态 shape | 导出时固定 shape,剥离后处理,替换不兼容算子 |
| 模型导出后体积异常偏大 | 常量折叠未生效、有多余结构 | 开启do_constant_folding,执行onnxsim做精简 |
| 量化后精度大幅下降 | 校准数据分布偏移、敏感层被量化 | 替换校准集、用 percentile 校准、敏感层保留浮点 |
这张表是浓缩过的高频问题,但真正排障的时候,我一般建议大家按这个顺序来:先跑onnx.checker确认模型本体,再跑 onnxruntime 对比一份随机输入,然后上onnxsim简化,最后才进目标推理引擎的报错堆栈里去翻。每一步都能帮你缩小排查范围。
5.2 三个深度技巧,普通教程里不会写
第一个技巧:把 OpSet 的兼容性当成“方言”问题来理解。不同版本的 ONNX 算子像方言,同一个意思在不同 opset 里可能有不同表达方式。比如Resize算子在 opset 10 和 opset 11 之间的坐标变换参数差异很容易让你栽跟头。遇到图像尺寸相关的问题时,不要只在 PyTorch 层面找原因,去对比一下不同 opset 版本的算子定义,往往能秒定位。
第二个技巧:导出前先“压缩”模型的输入输出空间。PyTorch 模型里经常有大量的if self.training分支、辅助输出等。导出时这些结构不会直接报错,但会在计算图里留下大量死节点。我每次导出前会尽量把模型 forward 函数里跟训练相关的分支全部摘掉,只留推理主干。这一步对后续所有部署环节都是正向收益。
第三个技巧:把一个“验证集脚本”沉淀成团队资产。因为导出是个反复迭代的过程——模型改版、环境升级、目标后端切换,任何一环变动都可能影响导出结果。手上有一份固定的“导出—校验—转后端—跑测试集”脚本,你就能在每次变动后快速做回归对比。这个习惯比任何技巧都更值得养成。
6. 写在项目落地的边缘:我对模型导出的几个认知
做这个项目到 Phase A 第 3 步的时候,我越来越觉得“模型导出”这个环节,本质是在训练的自由和部署的严苛之间寻找一条可行路径。训练时你可以随意用 Python 的灵活语法、自定义模块、动态分支,但部署端需要的是确定、稳定、快速。ONNX 作为一个通用中间带,能承接住这种跨框架、跨硬件、跨语言的迁移诉求,但它并不会替你把所有问题都扛下来。
我个人的经验是,把导出模型这件事做好,主要靠两点:一是对模型结构的“洁癖”,也就是从一开始就尽量避免在 forward 里堆叠过于花哨的 Python 逻辑,让模型保持学习到的计算主干的纯粹性;二是对“导出—验证—部署”这个闭环的敬畏,每一次导出都不跳过任何一步,都做对比验证,绝不抱有侥幸心理。
最后再分享一个小技巧:导出 ONNX 后,把模型拓扑图导出来看一眼。onnx库有onnx.tools.net_drawer可以生成图结构,或者你用 Netron 打开模型文件。这一步能让你非常直观地看到模型里有没有奇怪的孤岛节点、切分点、多余的 reshape。很多隐性问题在这一眼之下就藏不住了。
这一步做完,你的模型才算真正具备了离开训练环境、去往各种推理硬件闯荡的资格。接下来无论是量化、转 kmodel、还是嵌入式部署,都会顺畅许多。