资讯动态

模型导出ONNX实战:从torch.onnx.export到部署落地全解析

发布时间:2026/10/17 5:21:49 来源:尧图企业网站定制
训练脚本最后一行输出完 mAP你以为 Phase A 就算完成了别急着庆祝还差一个经常被人低估的节点——模型导出 ONNX。在很多项目的工程排期里这一步都被放在第三位名字就叫 “Phase A · Step 3”但我见过太多团队在训练上花两个月结果倒在这个步骤上花了两周。原因无它导出根本不是“一键另存为”中间涉及算子兼容、动态形状、精度验证、二次转换任何一个环节松了部署阶段都会连环爆雷。这篇文章就把模型导出 ONNX 这整件事完整拆开讲一遍覆盖 torch.onnx.export 参数、dynamic_axes、一致性验证、报错排查思路以及从原始 ONNX 到最终可部署产物的简化量化链路。适合正在从训练转向部署、被各种导出报错困扰的工程师直接参考。1. 模型导出 ONNX 在项目流程里的真实定位1.1 训练框架与推理引擎之间的“中间语言”ONNX 的全称是 Open Neural Network Exchange开放神经网络交换格式。它不是训练框架也不负责推理执行而是定义了一套统一的图结构描述标准每个节点是什么算子、输入输出张量长什么样、权重怎么存。你可以把它理解成神经网络模型的“标准换接口”。PyTorch 训练出来的模型导出成 ONNX 之后既能在 ONNX Runtime 上跑也能丢给 TensorRT、OpenVINO、RKNN、NCNN 这些引擎继续加工。这种跨框架能力在工程上非常值钱。为什么训练框架不适合直接部署因为 PyTorch 里的模型对象依赖大量运行时上下文autograd 图、动态调度、Python 对象引用。服务端打包推理服务时塞进整个 PyTorch runtime 不仅体积大版本兼容问题也多边缘设备上更是装不下。所以实际部署链路里绝大多数项目都会先把模型导出成一种中间格式再由目标平台的工具链继续转换。ONNX 不是因为这格式完美而是因为生态兼容性最好sherpa-onnx、TensorRT、RKNN 工具链、OpenVINO 都认它。真要硬核绕过 ONNX 直接写各平台专用格式也不是不行但意味着要为每个平台单独写加载器、单独维护算子子集成本极高普通项目根本消耗不起。1.2 常见的部署链路为什么大多要经过 ONNX我把这几年实际落地的部署链路整理成了一张表你会发现横在中间的永远有 ONNX部署目标平台常用推理引擎典型转换路径服务器 CPU / GPUONNX RuntimePyTorch → ONNX → ORTNVIDIA GPUTensorRTPyTorch → ONNX → TensorRT engineIntel CPU / GPUOpenVINOPyTorch → ONNX → IR边缘 NPU瑞芯微等RKNNPyTorch → ONNX → Kmodel手机端NCNN / MNNPyTorch → ONNX → NCNN / MNN这不是巧合。ONNX 作为中间格式各家引擎都愿意主动适配因为它把“训练框架”和“推理引擎”彻底解耦了。训练侧的迭代随心所欲部署侧的性能优化也随心所欲两边中间只隔一个 well-defined 的 ONNX 文件。你项目里如果后期可能要换硬件或者换推理引擎这个解耦价值会体现得更明显——训练代码完全不用动只需要重新走一遍 ONNX 到新平台工具的转换。1.3 这一步真正要交付的不只是一个文件认清一件事导出阶段要交付的不是一个 .onnx 文件而已。我习惯把“Step 3 完成”的标准定义成三件事全部满足目标引擎能顺利加载这个 ONNX 文件加载后推理结果与训练框架数值一致导出参数opset、动态轴、验证误差被记进流程记录。三个条件缺任何一个后面都会炸。好消息是这三个条件都能通过具体动作来控制现在就逐个展开。2. torch.onnx.export 核心参数逐项拆解2.1 最小可用代码示例与其行为先给一个最常用的导出代码这个模板我基本每个项目都在用import torch model MyModel() model.load_state_dict(torch.load(checkpoints/epoch_10.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, checkpoints/model.onnx, opset_version17, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, )其中 model.eval() 不是走过场。模型里如果还有 Dropout、BatchNormeval 之后它们的行为才会固定下来。Dropout 在训练时随机丢弃特征BatchNorm 默认用当前 batch 的统计量做归一化这些行为一旦被导出成静态图推理结果要么带随机性要么完全不对。所以导出前必须把模型全面切到评估模式最好再用 torch.no_grad() 包一层推理避免任何不必要的梯度链参与导出。dummy_input 的作用也很关键。torch.onnx.export 默认走的是跟踪执行拿这个 dummy_input 真实跑一遍模型 forward把经过的算子逐个记录成图节点。所以 dummy_input 的 shape、dtype、device 都会直接影响导出结果。上线时输入尺寸固定这里就应该填真实尺寸而不是随便一个什么形状。2.2 输入输出命名与 tracing 的边界input_names / output_names 不是给文件起装饰性名字它们决定了部署时用什么样的 key 去绑定数据。比如在 onnxruntime 里你调用 session.run 时传的 dict key 就得和这里一致ort_out ort_session.run(None, {input: sample.numpy()})如果后续要把 ONNX 转成 TensorRT 或 RKNN这个命名还会一路带过去。我吃过不少亏都是因为临时改了输入名下游工具链的配置全部要跟着改。所以项目一开始就固定命名不要今天叫 input 明天叫 data。另外一个很容易忽略的边界是 tracing 不擅长处理“数据依赖的控制流”class MyModel(torch.nn.Module): def forward(self, x): if x.shape[0] 4: # 依赖输入值的控制流 x self.head_a(x) else: x self.head_b(x) return x这种逻辑在 tracing 时只会按当前 dummy_input 走进其中一个分支不会把两个分支都记录下来。结果就是部署时换了输入实际行为依然按导出时走过的那一次执行。遇到这种情况优先把 Python 控制流从网络主干里挪出去或者用 torch.jit.script 先脚本化再导出或者把 if/else 改写成 torch.where、masked_fill 这类张量运算。ONNX 导出对老老实实的张量计算图最友好歧义越少越省事。2.3 opset 版本的选择策略opset_version 即 ONNX 算子集版本很多报错和它直接相关。我整理了一份经验对照表opset 版本关键变化我的使用建议opset 11动态 shape 支持明显改善Resize 等算子重构老设备工具链的下限opset 13一批算子签名统一TopK 等导出更稳兼容性优先时常用opset 17PyTorch 2.x 默认级别算子集合较完整服务端部署默认选它opset 18控制流和复合算子更细粒度目标引擎明确支持才用否则慎选服务端项目我默认用 17因为 ONNX Runtime 和 TensorRT 都覆盖得好。边缘 NPU 项目要先查厂商工具链文档很多只支持到 11 或 13导出去之后再降级会更痛苦。一句话导出前先确认目标引擎支持的 opset 上限别闭着眼睛选。3. 动态轴导出时最容易用错的功能3.1 dynamic_axes 到底在设置什么很多人第一次看到 dynamic_axes{input: {0: batch}} 都会疑惑这个 “batch” 是从哪来的。它只是给第 0 维起的名字真正表达的意思是运行时这个维度可以变化。一份 ONNX 文件导出去之后同一份文件可以接受 batch1、batch8、batch32 的输入不需要重新导出。服务端你永远不知道用户一次会发多少条数据这就是动态轴存在的意义。再给一个更复杂的例子。我在做 TTS 类模型时会这样配dynamic_axes { text_input: {0: batch, 1: seq_len}, text_len: {0: batch}, audio_output: {0: audio_len}, }文本序列每次长度都不一样模型输出的音频长度也不是预先能确定的所以 seq_len 和 audio_len 都必须动态。如果这里配错导出的模型只能吃固定 20 个字、输出固定 20000 采样点的音频上线直接是废品。TTS 导出 ONNX 这个场景动态轴几乎是生死线。3.2 哪些维该动哪些维千万别动动态轴不是越狠越好。给出一个决策表场景建议动态的维度建议保持静态的维度图像分类服务batch宽、高、通道YOLO 检测batch输入宽高固定到训练尺寸TTS 语音合成batch、seq_len、audio_len采样率、mel 通道等结构参数ASR 语音识别batch、时间步特征维度理由在于动态维度会直接削弱推理引擎的静态 shape 优化能力。ONNX Runtime 和 TensorRT 对已知形状的图可以做内存规划、kernel 融合形状一旦变成运行时才知道优化就没法提前做。所以很多图像检测项目我会刻意把输入宽高固定在部署端先做 resize 再喂给模型而不是把宽高都设成动态。这个习惯能省掉一整套和 shape 推断相关的麻烦。3.3 动态轴在二次转换时的高频翻车点动态轴在 ONNX 图里的表现是允许空的 dim 参数但如果模型内部某个算子对形状有隐含假设ONNX → TensorRT 或 ONNX → RKNN 时经常会报 shape inference failed。这也是为什么我在前面强调“只动真正要动的维度”。动态轴越多下游工具的 shape 推断压力越大报错越不可控。先把必然要动态的维度设好其余全部静态这是最稳的导出姿态。4. 导出成功不等于能用一致性验证4.1 一份可以直接抄的验证脚本ONNX 导出时没有报错只代表图结构串起来了不代表算子语义完全一致。同一个模型PyTorch 里是一套实现ONNX Runtime 里是另一套 kernel实现细节不同就会产生数值偏差。所以必须拿同一份输入分别在两边推理再用脚本对比输出。下面这段代码就是我的标准验证姿势import numpy as np import onnxruntime as ort import torch model.eval() sample torch.randn(4, 3, 224, 224) with torch.no_grad(): torch_out model(sample).numpy() ort_session ort.InferenceSession(model.onnx, providers[CPUExecutionProvider]) ort_out ort_session.run(None, {input: sample.numpy()})[0] print(torch output:, torch_out.shape, torch_out.dtype) print(onnx output:, ort_out.shape, ort_out.dtype) print(max diff:, np.max(np.abs(torch_out - ort_out)))注意这里我故意用 batch4 而不是导出时的 batch1目的就是顺带验证动态轴是否生效。如果 batch 维度没有真正动态这个输入根本跑不起来。4.2 差异量级怎么看不同模型类型对误差的容忍度不同我一般按下表判断最大绝对差异判断低于 1e-5正常通常只是尾数舍入差异1e-5 ~ 1e-3算子实现有精度差异结合业务容错评估1e-3 ~ 1强烈怀疑导出语义不一致需要排查NaN / Inf基本是数值溢出或算子逻辑错误如果模型本身就是 FP32 导出正常情况下最大差异应该在 1e-5 量级。看到 1e-2 这种别急着调部署端回去检查导出链路里哪个算子在捣鬼。4.3 不同类型模型的验证重点分类模型直接对比整张输出图就够了。但检测类和语音类模型要小心它们导出后的输出往往不是最终业务结果。YOLO 导出 ONNX 时通常只导出主干网络输出的三个尺度预测张量NMS 是在后处理里做的。验证时要把每个输出节点逐个对比顺序错了部署端拿到的就是张冠李戴。TTS / ASR 模型导出后经常是中间特征比如 mel 谱后面还跟着 vocoder 做最终合成。光对比最终音频意义不大每个中间输出节点都要单独看。我在项目里养成了“分节点验证”的习惯它在部署排查时能救很多次命。5. 算子和 opset 兼容问题从报错到定位的排查链路5.1 一次真实的 topk 导出翻车讲一个我实际踩过的坑。项目里做检索模型attention 层用 torch.topk 取 logits 最大的 k 个位置。开发环境是 PyTorch 1.10导出时跑出一长串堆栈核心提示就是 “Could not export operator ::topk” 或者类似的 ONNX export failed 段落。那次我差点就去改模型结构了但冷静下来按下面的链路做先看堆栈里ONNX export failed之后抓到的具体算子名确认是 topk。查 ONNX 官方算子文档确认 TopK 在 opset 11 之后就存在说明问题不是“没有算子”而是 PyTorch 的符号表在低 opset 下没正确注册。试着把 opset_version 从默认值提到 13再导出。导出通过后立刻跑第 4 章的一致性验证确认数值没变。最后问题就这么解决了模型结构一行没动。这件事给我的教训很深很多“导不出来”的报错其实是符号绑定和 opset 版本不匹配导致的。报错信息里面其实埋着答案先读信息再考虑动模型。5.2 导出时没报错但推理结果错得离谱这种情况比直接报错更让人头疼。常见原因有三个第一个也是最经典的忘了 model.eval()。导出时模型还带着 Dropout随机性被固化到图里结果每次推理结果都不一样或者 BatchNorm 用的还是训练统计量输出整体漂移。第二个自定义 python 函数把 tensor 里的值变成了 python 标量tracing 会把这个标量当成固定常量写进图里。比如有人在 forward 里写 int(x.max())、x.item() 这种逻辑输入一变固化下来的常量却不变结果自然错。第三个自动插入的 Cast。有些算子在不同 opset 下会自动插类型转换模型里 float32 / float64 混用的话导出后某些节点精度变化可能很大。排查这类问题最有效的方法就是把所有非 tensor 运算从 forward 主干里清理出去让整条 forward 变成纯张量计算。这样 tracing 的记录结果才是最真实的网络行为。5.3 算子真的没有 ONNX 对应物时怎么办如果检查完算子支持表确认确实没有对应物我还有三个预案按顺序尝试换算子。比如 topk 导出有问题改成 sort 切片有时候一样能解决问题。注册自定义符号函数把自定义 Python 操作映射到一个可导出的 ONNX 算子大概长这样def my_op_symbolic(g, self, k): return g.op(TopK, self, k, outputs2) torch.onnx.register_custom_op_symbolic(::my_op, my_op_symbolic, opset_version13)把该层从图里摘除放到部署端后处理里去实现。这套思路的适用范围很广核心原则是先确认是不是真的缺算子再决定是用常规算子兜底还是自定义映射最后才是动模型结构。6. 从原始 ONNX 到可部署产物简化、量化与二次转换6.1 先用 onnxsim 把图的冗余清掉刚导出的 ONNX 文件通常带着不少冗余常量节点重复出现、某些 transpose 可以合并、形状推理带来的无用 reshape。这些在训练框架里无伤大雅但会拖慢推理引擎的加载速度也会提高二次转换的失败率。我的习惯是拿到原始 ONNX 后先做一次简化python -m onnxsim model.onnx model_sim.onnx如果模型里本来就是动态 shape不要顺手加 --input-shape 覆盖否则动态轴会被去掉。onnxsim 做的事本质上是常量折叠和形状推断。它对大多数模型都能显著减小文件体积TensorRT 和 RKNN 转换的成功率也会明显提升。6.2 INT8 量化的常用路径很多部署场景里模型太大或者延迟要求严格INT8 量化就是常用手段。ONNX Runtime 提供了一个很简单的入口动态量化不需要校准集from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_sim.onnx, model_int8.onnx, weight_typeQuantType.QInt8, )如果想要更好的精度表现就要用静态量化也就是 quantize_static。它需要一批校准数据来统计每层激活的量化范围流程更复杂但效果通常更稳。有一点必须说透量化不是白拿的午餐模型体积能压到四分之一左右推理延迟会下降但精度一定要重新测。分类任务看 top-1 掉多少检测任务看 mAP语音合成直接听合成音质。业务指标对比不过关量化方案就得再调。6.3 面向目标平台的二次转换ONNX 只是中间产物最后总要落到具体引擎上。几个常用转换我列在这里NVIDIA GPU 用 TensorRT常见命令行是trtexec --onnxmodel_sim.onnx --saveEnginemodel.engineIntel 平台转换 OpenVINO IR 时用模型优化器跑一次mo --input_model model_sim.onnx --output_dir ir_model瑞芯微这类边缘 NPU 平台在 RKNN Toolkit 里 load_onnx再做 build 导出 Kmodel整个流程就是热词里常被搜到的 “.onnx 转 .kmodel”。不同平台对 ONNX 算子集合的容忍度不一样对动态 shape 的处理能力也不一样。这也是我不断强调“导出阶段尽量干净、尽量静态、只动必要动态轴”的原因——你给下游工具链越少的意外它越容易把模型翻译成高效的本地算子。6.4 sherpa-onnx 这类引擎对 ONNX 输入特性的直接依赖sherpa-onnx 在实际项目里也经常遇到尤其是 TTS 场景。它加载 ONNX 直接跑合成对输入 tokens 的长度、输出的音频长度都有明确要求。如果你的 ONNX 文件里动态轴没标对或者输入名字跟引擎预设不一致加载就会失败。我的经验是先用官方示例模型跑通看清楚它的 ONNX 输入输出是怎么命名的、哪些维度设成了动态再照着复刻自己模型的导出参数。这套流程能省掉大量和不同引擎接口搏斗的时间。7. 我导出一两百个模型后固定下来的检查习惯7.1 版本锁定与导出记录模型导出的可复现性非常重要。torch、onnx、onnxruntime 都是快速迭代的库相同模型代码在不同版本下导出的 ONNX 图可能不一样推理引擎的解析行为也可能不一样。我在每个项目目录里都会固定依赖版本并且在导出成功时记录一份导出说明表字段示例值模型标识resnet18_baseline_v2torch 版本2.1.2onnx 版本1.15.0onnxruntime 版本1.17.0opset17动态轴batchonnxsim 是否执行是验证最大误差4.2e-6二次转换目标TensorRT / RKNN三个月后线上行为不对这张表能帮你快速定位是不是库升级引入的变化。7.2 先用小模型跑通链路再导正式模型大模型导出一次动辄几分钟导完发现 onnxruntime 都加载不了来回试错很浪费时间。我的节奏是先用一个简化版本比如减少通道数或只取几层先导出一个 mini ONNX用 onnxruntime 加载并做最小验证链路通了再导完整模型。看起来多了一步但实际省下的时间远大于多花的这一步。尤其做 TTS 这类包含 vits 结构的模型时小规模试导出能把输入输出错位的问题提前暴露。7.3 把导出和验证合到同一条命令里最后一步是把流程自动化。我会把导出脚本和验证脚本放进同一个 Python 文件让“导出验证”成为一条命令Makefile 里暴露一个入口make export它依次做五件事加载权重 → 模型 eval → torch.onnx.export → onnxruntime 加载 → 对比多组输入输出 → 输出一份简短报告。报告里包含导出参数、动态轴配置、最大误差、模型文件大小。跑完这一步Phase A 的 Step 3 才算真正结束。不建议手动一步步执行因为人有惰性步骤一多就漏。说回我自己的体会。模型导出 ONNX 这件事看着只是流程里的一个中间步骤但它卡住的次数远比想象中多。我在项目里被它绊倒过不止一次最典型的教训就是验证时省略了分节点对比结果 TensorRT 里跑出来的检测框位置偏了老半天最后才发现是 ONNX 输出节点顺序和部署端对不上。所以不管项目多赶我都会把这一步的价值钉死在流程里导出之后强制跑一致性验证、强制写记录再进入二次转换。你如果正把手里的模型从训练环境拿出来部署这份流程可以直接抄先把 Step 3 做扎实后面的路会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑