☰
PyTorch转TFLite全流程:ONNX与TensorFlow三跳转换实战
2026/10/20 3:58:57 网站建设 项目流程

如果你和我一样,模型训练在一个框架,部署却常常要在另一个框架里完成,那"模型转换"这四个字一定让你五味杂陈。我最近做的边缘端图像分类项目就是这样:训练阶段全程在 PyTorch 里跑得舒舒服服,线上服务那边要 ONNX Runtime 的东西,而移动端同学开口就要 TFLite。于是我把这条路完整走了一遍:PyTorch 模型转 ONNX,ONNX 转 TensorFlow,TensorFlow 再转 TFLite。

这里面的每一跳都有它自己的脾气:第一跳考算子映射,第二跳考图结构还原,第三跳考量化和算子裁剪。这篇文章就是我反复跑这条链路后的完整记录,既有每一步的关键配置,也有我在实际部署中踩过、后来查了很久才弄明白的坑。如果你也准备把 PyTorch 模型送到移动端或嵌入式设备上,这篇文章应该能帮你少走不少弯路。

1. 为什么要绕这么大一圈,而不是直接一把梭

1.1 三种格式各管一段,谁也不是谁的子集

先说清楚这三种格式是怎么分工的。ONNX 本质上是一种中立的"交换格式",它的目标不是跑得快,而是让不同框架之间能互相看懂计算图。TensorFlow 的 SavedModel 则是完整保留训练到推理语义的一种图格式,不但有结构、有权重,还带一套 signature,能告诉外部调用者输入输出长什么样。TFLite 就更特殊了,它是专门给手机、树莓派这类资源受限设备准备的轻量级推理格式,算子集合被压缩过一遍,运行时有专门的分发机制,启动快、体积小。

可以这么理解:ONNX 像一份跨平台的交换文档,SavedModel 像排版完整的原图,TFLite 是最后压缩出来的离线阅读包。三者的侧重点完全不同,谁也没法完美替代谁。

1.2 PyTorch 直接出 TFLite 的尴尬

很多人问过我,为什么不能直接用某个 PyTorch 转换器一把梭到 TFLite?我的回答是:官方路径它就是走不通。TFLite Converter 的入口是 Keras 模型、SavedModel 或者 ConcreteFunction,你给它一个 PyTorch 的state_dict,它根本不知道该怎么解析。第三方脚本虽然存在,但只要模型里出现小众算子,你就得在自建算子和手工改图之间来回折腾,到最后往往还是回到 ONNX 中转。

所以"PyTorch → ONNX → TensorFlow → TFLite"这条链并不是多余,它只是把一个大问题拆成了三个可以通过成熟工具处理的小问题。每个环节都有成熟的检查手段,出了问题能精准定位,这是直接转换方案给不了的。

1.3 决定这条链路成败的关键:计算图语义一致

整条链路本质上做三件事:图结构翻译、权重搬运、算子重新映射。权重搬运一般不容易出问题,真正的风险在图结构的语义丢失。同一段计算,PyTorch 里怎么组织张量流,ONNX 里有没有对应的图表达,TensorFlow 里的某个算子行为是不是和 ONNX 里的完全一致,但凡有一处对不上,最后转出来的模型可能能跑,但算出来的结果已经不对了。

我在实操里最重要的一条经验是:每一跳都要做一致性验证,不要等到最后拿着 TFLite 在手机上跑出错误结果才开始排查。后面我所有步骤都会围绕"转换完立即验证"来展开。

2. 第一跳:PyTorch 导出 ONNX 时的四个关键配置

2.1 导出前的模型状态:eval 模式和 no_grad 一点不能省

先把这一步最容易被忽略的前提说清楚。导出 ONNX 之前,模型必须切换到eval()模式,并且要在torch.no_grad()环境下执行导出。原因很简单:eval()状态会直接影响 BatchNorm 的 running_mean 和 running_var 是否被更新,Dropout 是否被保留;如果忘了切,导出的图里可能带着训练态的行为,部署时表现会和离线评测差一大截。而no_grad()则避免导出过程中误建计算图,节省内存不说,也防止对参数产生梯度。

导出一个标准卷积分类模型的代码大概是这样的:

import torch import torch.nn as nn # 以一个简单的 CNN 为例 model = SimpleCNN(num_classes=10) state_dict = torch.load("checkpoint.pth", map_location="cpu") model.load_state_dict(state_dict) model.eval() dummy_input = torch.randn(1, 3, 224, 224) with torch.no_grad(): torch.onnx.export( model, dummy_input, "simple_cnn.onnx", opset_version=13, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )

这里dummy_input的 shape 非常关键。PyTorch 导出 ONNX 走的是 JIT 追踪机制,对于大多数常规模型来说,输入张量的维度会被直接记录进图里。如果你在服务端需要动态 batch,就得靠dynamic_axes把对应维度打开;如果模型下一步要转 TFLite,我一般强烈建议先固定成[1, 3, 224, 224],后面章节我会解释原因。

2.2 torch.onnx.export 那几个参数,背后都有讲究

实际导出时,最常打交道的是这几个参数:opset_version、input_names、output_names、dynamic_axes。先说opset_version。ONNX 的算子集版本迭代非常快,新版本会补充新算子,也会修正旧算子的语义。PyTorch 不同版本的默认值也不一样,老版本默认 9,新版本默认可能到 14 或 17。我踩过的坑是:有些高级算子(比如部分 ROI 类、部分 gather 变体)用低版本 opset 根本导出不了。

但这里有个反向陷阱:opset 开太高,后面 ONNX 转 TensorFlow 的时候,翻译器未必能接得住新语义。我自己的经验是,给这条链路中转的模型,尤其是图像分类这种标准结构,用opset_version=12或13是最稳妥的区间。遇到确实需要新算子的场景,再针对性地升高,而不是一刀切。

input_names和output_names不是可有可无的装饰。它们决定了 ONNX 图里输入输出的名字,而这些名字会一路传导到 SavedModel 的 signature 里。如果你自己写推理脚本,前后保持同一套命名会减少很多麻烦。我见过一些人把输入叫input,到了后面又改名,最后对不上号,纯粹浪费时间。

2.3 dynamic_axes 到底该不该开,要看下一步去哪

dynamic_axes解决的问题是让模型接受可变大小的输入。对于服务端推理,开着它是刚需,因为线上请求的 batch 不可能永远等于 1。但对于要转 TFLite 的模型,我的建议非常明确:不要开。

道理不复杂。TFLite 的 runtime 对动态维度支持很有限,它在转换时通常需要确定输入张量的具体 shape,转换出来之后,输入就是固定死的静态形状。你在 ONNX 阶段开了动态维度,TensorFlow 阶段的 SavedModel 里那个维度就会是弹性占位符,等 TFLite Converter 处理时很容易在 shape 推导上栽跟头。与其最后绕一圈发现这个维度还是被固定,还不如一开始就明确batch=1,从源头上省掉麻烦。

如果某个模型确实需要在多个尺寸下用,那我的实践方案是准备两套导出配置:一套给服务端用,保留动态 batch;一套给移动端用,固定成部署时最常用的分辨率。虽然产物多了一个,但每一份都少很多隐患。

2.4 导出之后,先别急着走,三步自查必不可少

我几乎每次导出完都会做同样三件事:用onnx.checker检查模型结构,用onnxruntime对比一次 PyTorch 原始输出,再用onnxsim做一次精简。

第一步是onnx.checker.check_model,它会检查图的拓扑、节点属性这些基础合法性,能拦住相当一部分低级错误。第二步是最重要的一致性验证,代码很简单:

import onnxruntime as ort import numpy as np import onnx onnx_model = onnx.load("simple_cnn.onnx") onnx.checker.check_model(onnx_model) sess = ort.InferenceSession("simple_cnn.onnx") x = np.random.randn(1, 3, 224, 224).astype(np.float32) ort_out = sess.run(None, {"input": x})[0] with torch.no_grad(): torch_out = model(torch.from_numpy(x)).numpy() print("max diff:", np.max(np.abs(torch_out - ort_out)))

正常情况下max diff应该在1e-6级别,最多到1e-5。如果差到1e-3以上,那基本可以断定哪里语义对不上了,必须回到 PyTorch forward 里查,而不是带着问题往下传。

第三步可以用一个命令简化图结构。ONNX 导出产物经常包含冗余的恒等运算、可折叠的常量节点,这些对后面的转换工具是不小的负担。我一般会顺手跑一下:

pip install onnxsim onnxsim simple_cnn.onnx simple_cnn_sim.onnx

精简之后的图通常更适合后一级的翻译器处理,而且体积也可能小一些。

3. 第二跳:ONNX 转 TensorFlow 的两种工具路线

3.1 onnx-tf:经典老牌,但不是所有算子都能接

ONNX 转 TensorFlow 最常见的老工具是onnx-tf,安装和调用都很简单:

pip install onnx-tf
import onnx from onnx_tf.backend import prepare onnx_model = onnx.load("simple_cnn.onnx") tf_rep = prepare(onnx_model) tf_rep.export_graph("simple_cnn_saved_model")

这个工具的原理是把 ONNX 算子逐一映射到 TensorFlow 算子,再导出成一个 TensorFlow 图。它的优点是逻辑清晰、API 简单,经典算子覆盖度不错。缺点是维护节奏比较慢,新版本的 ONNX 算子经常会接不住。如果你在模型里用了比较新的算子,或者 opset 开得比较高,它可能直接给你抛一个Unsupported Op的异常。

我自己的使用感受是:对于标准 CNN、常见分类结构,onnx-tf基本够用,而且轻量,不需要装一大堆额外依赖。但如果模型里带了 Transformer 模块、有些特殊注意力算子,这种老工具的项目损耗率会直线上升。

3.2 onnx2tf:面向现代模型、直接产出 SavedModel 的新选择

另一个路线是onnx2tf,它比onnx-tf要"现代"得多。这个工具会做不少 ONNX 图的预处理,能自动折叠常量、解析动态轴,最终产出的就是标准 SavedModel。安装和运行都非常直接:

pip install onnx2tf
onnx2tf -i simple_cnn.onnx -o simple_cnn_saved_model

onnx2tf的执行逻辑里已经内置了 onnxsim 那类精简步骤,所以你在前面手动做一次简化也不会有冲突。它还有一个很实用的参数-osd,当转换完的 SavedModel 里的 signature 不符合预期时,加上这个参数会强制覆盖 signature 的定义,能救回来不少形状怪异的模型。如果模型输入是被固定成 batch 的,也可以显式传入 batch 大小,减少后续判断。

这条路线最大的优点是,它在转换阶段就把很多 TFLite 转换时的前置问题处理掉了,最后产出的 SavedModel 对TFLiteConverter非常友好。缺点则是依赖更重、执行时间更久,但为了后面少折腾,我觉得值得。

3.3 转换之后先别欢呼,SavedModel 的签名和输出必须查一遍

不管用哪个工具,转换完成后我都会做同一个动作:检查 SavedModel 的 signature。因为在 ONNX 里叫input的节点,经过转换工具处理之后,名字未必还叫input。如果名字变了,后面喂数据时对应关系就得跟着变。

用两个方式验证。一是命令行直接看:

saved_model_cli show --dir simple_cnn_saved_model --all

这个命令会把整个图的 SignatureDef、输入输出名称全部打出来,一目了然。二是用 Python 加载并实际跑一遍前向:

import tensorflow as tf import numpy as np loaded = tf.saved_model.load("simple_cnn_saved_model") sig = loaded.signatures["serving_default"] tf_out = sig(tf.constant(x, dtype=tf.float32))["output"] print("max diff vs onnx:", np.max(np.abs(ort_out - tf_out.numpy())))

这一步的浮点结果会有一些差异波动,但通常应该控制在1e-4量级以内。如果差太多,我在这个阶段就会停下来查,而不会拖到 TFLite 阶段。

4. 第三跳:TensorFlow 转 TFLite 时可以做的三件取舍

4.1 入口选 SavedModel,还是选 Keras H5?

TFLite Converter 支持从 Keras H5 模型和 SavedModel 两个主流入口转换。我的建议是优先从 SavedModel 转,尤其当这个 SavedModel 是上一跳工具刚生成的时候。

原因在于,那一类转换工具产出的模型虽然能加载,但未必带有足够的 Keras metadata。如果你非得把它导出成 H5 再转 TFLite,中间可能因为部分层无法被识别而触发奇怪的告警。而from_saved_model的路子更直接,它读的就是 SavedModel 里的标准图结构和变量,兼容性最好。

而且 SavedModel 的目录结构本身就值得了解:saved_model.pb存放计算图和 signature 定义,variables目录存放权重文件,assets放辅助资源。看到这些目录,你就能判断这个 SavedModel 结构是不是完整的。

import tensorflow as tf converter = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_saved_model") tflite_model = converter.convert() with open("simple_cnn_fp32.tflite", "wb") as f: f.write(tflite_model)

这样直接得到的是 FP32 的 TFLite 模型,默认输入输出和 SavedModel 保持一致。

4.2 做不做量化、做哪种量化,先想清楚部署目标

这是整条链路上最需要提前规划的一步。TFLite 本质上是冲着移动端和嵌入式去的,量化与否直接决定你的模型能不能真正跑起来。我一般按三种情况区分:

量化方案配置方法体积变化精度风险适用场景
纯 FP32什么都不加基本不变无PC 端或算力充足的设备
动态范围量化只加 DEFAULT 优化约为原来的 1/4通常很小以 CPU 为主的中端设备
INT8 全量化DEFAULT + 代表性数据集 + INT8 算子约为原来的 1/4需要验证低功耗、低存储设备

动态范围量化的代码很短:

converter = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_saved_model") converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert()

这种方式会把权重压缩成 int8,但输入输出仍是浮点,兼容性好,精度损失通常也很小。如果你的模型在部署端只是嫌体积大,这是性价比最高的一步。INT8 全量化更极端,输入输出也可以显式转成 int8,需要在低功耗设备上压榨性能时用。

4.3 代表性数据集怎么写,直接决定 INT8 量化的质量

做 INT8 全量化时,representative_dataset的作用不是训练,而是帮 TensorFlow 统计每一层激活值的动态范围,从而算出合理的 scale 和 zero_point。很多人在这里栽过跟头,最典型的问题是只放一张全黑图进去,结果激活范围统计偏差巨大,量化完精度崩掉。

我的常用写法是这样:

def representative_data_gen(): for i in range(100): # val_images 为预处理好的验证集,数值范围 0~1,shape 为 (3,224,224) img = val_images[i] yield [img[None, ...].astype(np.float32)] converter = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_saved_model") converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_data_gen converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.float32 tflite_int8 = converter.convert() with open("simple_cnn_int8.tflite", "wb") as f: f.write(tflite_int8)

关于数据集的几个细节我特别强调一下。数量上,100 到 500 张通常就足够稳定;关键是覆盖模型真实面对的数据分布,别只拿一个类别。每张样本都要做和训练时一致的预处理,比如同样的均值方差归一化,别转换前顺手把图片转成 0 到 255 再扔进去,那样统计出的激活范围会整体偏移。另外yield出来的数组维度,一定要和模型输入的 shape 对应,少一维多一维都会让转换中止。

4.4 转完不是终点,用 interpreter 做一次推理一致性检查

拿到simple_cnn_int8.tflite之后,我至少会做一次 interpreter 推理,和转换前的 TF 输出对比。流程不复杂:

import numpy as np import tensorflow as tf interpreter = tf.lite.Interpreter(model_path="simple_cnn_int8.tflite") interpreter.allocate_tensors() inp = interpreter.get_input_details()[0] out = interpreter.get_output_details()[0] input_scale, input_zp = inp["quantization"] x_int8 = np.clip(np.round(x / input_scale) + input_zp, -128, 127).astype(np.int8) interpreter.set_tensor(inp["index"], x_int8) interpreter.invoke() quant_out = interpreter.get_tensor(out["index"])

这里有个非常常见的坑:如果inference_input_type设成了tf.int8,那么你传入的数据就必须是 int8,并且要根据get_input_details()[0]["quantization"]里的 scale 和 zero_point 做一次缩放偏移。很多人拿着 float32 数据直接塞进去,出来的结果自然是错的。

如果转换时保持浮点输入,那这段预处理就不需要,直接喂 float32 即可。所以提前想好输入类型,能省掉大量调试时间。

5. 用一个图像分类 Demo 实测完整链路

5.1 Demo 的初始形态

为了把上面这些步骤串起来,我拿一个模拟项目中的图像分类 Demo 做了完整的转换测试。模型是一个常规 CNN,输入是 3×224×224 的图片,输出 10 类置信度;主体结构为卷积加 BatchNorm 加 ReLU,最后接全局平均池化和全连接层,总参数大约在 100 万左右。权重用 PyTorch 训练后保存为checkpoint.pth。

整个转换链路按顺序执行:先 PyTorch 导出 ONNX,接着 onnxsim 精简,然后 onnx2tf 转 SavedModel,再用 TFLiteConverter 产出 FP32 和 INT8 两个 TFLite 文件。

5.2 每个阶段的具体命令和关键产物

PyTorch 导出和 onnxsim 简化:

python export_onnx.py onnxsim simple_cnn.onnx simple_cnn_sim.onnx

ONNX 转 SavedModel:

onnx2tf -i simple_cnn_sim.onnx -o simple_cnn_saved_model

TFLite 转换:

import tensorflow as tf converter = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_saved_model") fp32_model = converter.convert() with open("simple_cnn_fp32.tflite", "wb") as f: f.write(fp32_model) # INT8 量化版本 converter2 = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_saved_model") converter2.optimizations = [tf.lite.Optimize.DEFAULT] converter2.representative_dataset = representative_data_gen converter2.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter2.inference_input_type = tf.int8 converter2.inference_output_type = tf.float32 int8_model = converter2.convert() with open("simple_cnn_int8.tflite", "wb") as f: f.write(int8_model)

所有命令跑完后,我拿到了四份中间和最终产物,体积对比大致如下:

产物格式体积
checkpoint.pthPyTorch 权重4.2 MB
simple_cnn.onnxONNX 原始导出4.0 MB
simple_cnn_saved_modelTensorFlow SavedModel4.1 MB
simple_cnn_fp32.tflite浮点 TFLite4.0 MB
simple_cnn_int8.tfliteINT8 量化 TFLite1.0 MB

5.3 逐跳精度对比

我还记录了每一跳的精度差异。PyTorch 输出和 ONNX Runtime 输出之间的最大绝对差在1e-6级别;ONNX Runtime 和 TensorFlow SavedModel 之间的最大绝对差在5e-4级别;FP32 TFLite 和 TensorFlow 输出差在1e-6级别,基本可以忽略。INT8 量化后的 logits 和 FP32 的偏差稍微大一些,最大绝对差在0.05左右,模型在验证集上的准确率从 92.3% 掉到 91.8%,这个幅度在这个任务里完全可接受。

这个结果印证了我一直强调的那个观点:转换本身的损失很小,真正明显的损失几乎全部来自量化。所以当你发现最终模型精度不对,第一嫌疑永远是量化策略和代表性数据集,而不是前面的格式转换。

6. 三跳链路里最容易被卡住的地方,和我的排查顺序

6.1 第一跳:ONNX 导出时的 Warning 不能一声不吭地放过去

PyTorch 导出 ONNX 时经常会打警告。有些警告无关痛痒,比如提示某个维度是动态的;但有些警告非常致命,比如明确告诉你某个算子不在导出支持列表里,自动替换成了图外 fallback。我处理过一个类似grid_sample的场景:模型 forward 里用了高级采样算子,导出时 PyTorch 给出了 warning,我不管不顾继续转,结果 ONNX 图在 ORT 里跑起来就报算子缺失。

遇到这类问题,我的习惯路径是:先尝试升级opset_version再导出;还不行,就到 PyTorch 的 forward 里把高级算子手动拆成基础算子组合,比如把grid_sample拆成 affine 变换加 bilinear 插值。虽然 forward 改起来麻烦,但换来的是后面所有步骤的畅通。

6.2 第二跳:转换工具各有脾气,错就回上一跳解决

当onnx-tf或onnx2tf报出某个算子不支持时,第一直觉别是去改 TensorFlow 图。这个方向非常容易绕进死胡同。正确做法是记下算子名字,回到 ONNX 层看它是由什么组合出来的,然后决定是调整 opset 版本,还是直接在 PyTorch 里改实现方式。

有一次我在onnx2tf阶段遇到一个长相奇怪的算子,最后发现是我在 PyTorch 里用了一个自定义兼容类,导出的 ONNX 图里带上了非常规组合节点。改写 forward 里的实现之后,第二跳一次通过。这类问题八成是源头问题,不是翻译器问题。

6.3 第三跳:输入 shape 被锁死是常见结果,不是 bug

TFLite 模型最终拿到的输入 shape 绝大多数是静态的。如果你在get_input_details()里看到 shape 永远是[1, 3, 224, 224],不要惊讶,这是 TFLite 为效率做出的取舍。想在移动端支持动态尺寸,需要依赖运行时的 resize 接口,而且并不是每个平台都支持得那么顺滑。

所以我在前面的章节反复说:如果目标平台是移动端,从 ONNX 阶段就把输入固定下来。反过来,如果你在服务端用的 ONNX 动态 batch 已经生效,那就没必要强行转到 TFLite,保持 ONNX 格式反而更合适。工具链的选择要跟着部署目标走,而不是为了统一而统一。

6.4 我固定的排查顺序:每一跳都在本地对齐,不把问题往下传

最后把排查顺序完整列一遍,这是我最核心的实践方法论:

  1. onnx.checker.check_model检查结构合法性。
  2. ONNX Runtime 推理输出与 PyTorch 原始输出对比,差一点都不能往下走。
  3. onnxsim精简后再加载一次,确认输出没有变化。
  4. SavedModel 转换后,用saved_model_cli show确认 signature,再实际跑一次前向,与 ORT 输出对比。
  5. 转 FP32 TFLite 后,用 interpreter 与 SavedModel 输出对比。
  6. 转 INT8 TFLite 后,再做一次量化输出对比,评估精度损失在不在可接受范围内。

这个顺序的好处是,任何一个环节出了问题,我能在产生问题的那一跳直接抓住它,而不是等整个链路跑完再猜。我有过惨痛教训:某一次图省事,跳过第三步直接往下走,最终在手机上发现输出不对,来回查了两天才定位到是 ONNX 图里一个常量折叠被破坏导致的。那次之后,我宁可每一步多写几十行验证代码,也不再赌运气。

最后分享一个我个人的小习惯:每次转换出来的中间产物,我都会在文件名里带上版本号和转换日期,比如simple_cnn_opset13_v2.onnx、simple_cnn_int8_v2.tflite。这个习惯来源于某次我拿错了上一版量化文件,在设备上测了半天精度,最后才发现文件根本没更新。现在所有转换脚本都统一输出带版本标记的产物,至少不会再因为文件名混淆而白忙一场。模型转换本身不复杂,复杂的是把每个环节的细节都控制住,让每一步都可追溯、可验证,这样整条链路才能稳定可靠。

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

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

立即咨询