
1. 先别急着写代码这次迁移的真正动机我最早接触到这个需求是因为团队里一个实时翻译模块的问题。当时服务端用的是 HuggingFace 上的英译中模型直接拿transformers的pipeline顶着跑。原型阶段没问题可真到上线CPU 上单条句子平均延迟冲到 800ms 往上内存占用 2GB 起而且每次发版都要在服务器上重装 torch、安装模型依赖真是难受。换 ONNX 的想法其实很直接ONNX Runtime 不需要 Python 侧的 torch 运行时模型本身权重变成结构化文件部署到生产环境后可以脱离transformers库独立推理。但这里有个容易忽略的事实——翻译模型不是文本分类器不是简单的一进一出。英译中模型本质上是 sequence-to-sequence 的生成式模型内部包含 encoder 和 decoder 两套结构推理时需要自回归地一步步生成 token。迁移到 ONNX 的时候问题根本不是导出这一个文件而是导出之后的解码循环怎么实现。如果你也是想把 HuggingFace 上某个翻译模型或者其他生成式文本模型搬到生产环境这篇文章里踩过的坑基本都会遇到。我会用Helsinki-NLP/opus-mt-en-zh这个模型做完整示例把从导出、验证、量化到部署的链条讲清楚尤其是那些官方文档里没写明白的细节。为什么要选opus-mt-en-zh一是它小大约 300MB适合做实验二是架构足够典型——MarianMT标准的 Encoder-Decoder 共享词表和 T5、NLLB 这类模型的结构高度相似。把它的 ONNX 迁移链路打通了换成其他翻译模型只是换参数和输入输出 shape 的问题。2. 导出之前必须搞清楚的三件事2.1 Encoder-only 和 Encoder-Decoder 完全是两回事很多人在第一步就翻车是因为他们拿 BERT 分类模型转 ONNX 的教程套用到了翻译模型上。对于 BERT 这类 Encoder-only 模型推理就是一次前向输入 tokens拿最后一层 hidden state 做分类完事。整个模型可以完整落成一个 ONNX 文件。翻译模型不行。解码过程是逐步进行的第一步encoder 读完整句输出一个语义向量; 后面每一步decoder 根据这个语义向量和已经生成的历史 token 预测下一个 token。这个循环本质上是模型内部状态在不断更新的过程ONNX 不可能把它固化成一个静态执行图。所以业界通用做法是把 encoder 和 decoder 拆开分别导出成独立 ONNX 文件然后在 ONNX Runtime 外面自己写循环。理解这点很重要因为它决定了整个项目的文件组织方式也决定了后面写推理代码的工作量。2.2 动态轴是第一个分水岭torch.onnx.export时有个dynamic_axes参数很多人图省事直接把它设为None固定 shape结果推理时只要输入句子长度超过导出的固定长度就报错。翻译模型必须处理变长输入。句子长度不定生成最大长度也不定所以 encoder 输入、decoder 输入、encoder_hidden_states 这些张量的 batch、length 维度必须标记为动态轴。尤其是 decoder 里的 past_key_values 序列长度会随着生成步数不断增加这个维度如果不动态化解码到第 10 步就卡死了。我见过有人用固定长度比如 128导出然后把所有句子都 pad 到 128。这种做法在小流量场景能跑但 pad 部分会白白消耗计算量长句超过 128 又会截断。既然要做生产级部署动态轴是唯一正解。2.3 用 Optimum 还是手动 torch.onnx.export如果是完全自定义的模型手动导出意味着要把整个 decoder 的 past_key_values 参数列表全部映射到 ONNX 的输入输出上那是一个相当繁琐的工作。好在 HuggingFace 官方维护了 Optimum 库它专门负责把 Transformers 模型转成各种 runtime 格式。对 MarianMT 这类主流架构optimum-cli一条命令就能导出标准的三件套文件。我的建议是能上 Optimum 就不要自己徒手写 torch.onnx.export除非你的模型架构冷门到 Optimum 不支持。Optimum 导出的文件结构是经过社区反复验证的尤其是 decoder_with_past 这类带缓存输入的变体手动写导出脚本容易在 past_key_values 的拼接顺序上翻车。3. 第一次导出一遍跑通但坑都在文件里3.1 安装与导出命令先装依赖注意optimum要带上 onnxruntime 的 extrapip install optimum[onnxruntime] onnx onnxruntime然后执行导出optimum-cli export onnx \ --model Helsinki-NLP/opus-mt-en-zh \ --task text2text-generation-with-past \ ./opus_mt_en_zh_onnx--task参数值得展开说一句。如果不指定Optimum 会尝试从模型 config 推断任务类型大部分 Marian 模型能猜对。但如果遇到推断失败或者导出后发现 decoder 没有带 past 输入就要显式指定text2text-generation-with-past。这个 with-past 后缀代表导出一个专门用于带历史缓存解码的 decoder 版本这是解码循环能跑得动的前提千万不能省。导出完成后目录里会出现三个核心文件文件作用输入特征encoder_model.onnx编码输入句子输出语义表示input_ids / attention_maskdecoder_model.onnx解码第一步无历史缓存启动input_ids encoder_hidden_statesdecoder_with_past_model.onnx解码后续步骤带历史缓存输入input_ids encoder_hidden_states past_key_values看到这三个文件你就能理解我前面说的ONNX 不包含循环到底什么意思了。decoder_model 和 decoder_with_past_model 是同一个 decoder 的两种入口状态一个负责冷启动一个负责续跑真正把两者串起来的是我们自己写的循环。3.2 用 Python 快速验证三个模型的输入输出在写正式部署代码前先把每个 session 的输入输出名字打印出来这一步能避免后面 80% 的莫名报错。我的做法是写一个一次性脚本import onnxruntime as ort files [ encoder_model.onnx, decoder_model.onnx, decoder_with_past_model.onnx, ] providers [CPUExecutionProvider] for f in files: sess ort.InferenceSession(f, providersproviders) print( * 40) print(f) print(inputs:) for i in sess.get_inputs(): print(f {i.name}: {i.shape} {i.type}) print(outputs:) for o in sess.get_outputs(): print(f {o.name}: {o.shape} {o.type})以我实际打印出来的结构为例encoder_model.onnx的输入是input_ids和attention_mask输出是last_hidden_state。decoder 系列模型的past输入输出会展开成类似past_key_values.0.encoder.key、present.0.decoder.key这样扁平化的名字。看到这种扁平结构别慌它只是把 PyTorch 里的嵌套元组拉平成一维列表ONNX 不支持元组嵌套这是序列化时的正常表现。比较坑的一点是Optimum 导出的 encoder 输出名在不同架构上不统一有的模型叫last_hidden_state有的叫encoder_last_hidden_state。所以上面的打印脚本值得留到整个项目结束每换一个模型就重新跑一次避免硬编码名字导致运行时 KeyError。3.3 导出时报算子不支持怎么办MarianMT 整体结构比较常规我在实际导出时基本没遇到算子报错。但如果你的模型里用了比较新的注意力实现或者模型本身带一些自定义 op就可能在 tracing 阶段遇到 ONNX 算子集版本不支持的问题。遇到这种情况第一反应不是去改模型结构而是试试调整 ONNX 算子集的版本在optimum-cli后面加--onnx_opset 14或者--onnx_opset 17。新版 opset 对常见 transformer 算子的支持更完整但某些旧版本 ONNX Runtime 反而兼容性更好需要根据部署环境的 ORT 版本来回调。实在不行还有一招把有问题的算子替换成 ONNX Runtime 能识别的等价组合。比如某些模型里的aten::RMSNorm在旧 opset 下不识别换成LayerNormalization或者拆成乘法和归一化两步就能跑。这个等价替换的思路其实也就是手动重写导出脚本时最常做的事。4. 手写解码循环ONNX 之外的另一半工程4.1 generate() 是不能直接迁移的transformers里的model.generate()帮我们封装好了从 logits 采样、beam search、past 缓存更新的全部逻辑。ONNX 模型可没有这么好的待遇——你拿到的是三个静态文件不是一套推理框架。所有生成逻辑都要自己用 numpy 实现。这意味着你必须对自回归生成的过程有清晰认识当前的next_token是基于到目前为止的所有 token encoder 的输出预测出来的。每预测出一个 token它会被拼到输入序列后面作为下一步的输入同时解码过程中的 key/value 缓存会累积下来避免每步从头重算。4.2 核心循环实现我第一次实现时写了完整的 Translator 类把三个 session 的调用都封装好。这里贴一段简化但保留完整逻辑的核心代码注释我尽量写清楚import numpy as np import onnxruntime as ort from transformers import MarianTokenizer class ONNXTranslator: def __init__(self, onnx_dir, tokenizer_pathHelsinki-NLP/opus-mt-en-zh): self.tokenizer MarianTokenizer.from_pretrained(tokenizer_path) self.enc ort.InferenceSession(f{onnx_dir}/encoder_model.onnx, providers[CPUExecutionProvider]) self.dec ort.InferenceSession(f{onnx_dir}/decoder_model.onnx, providers[CPUExecutionProvider]) self.dec_past ort.InferenceSession(f{onnx_dir}/decoder_with_past_model.onnx, providers[CPUExecutionProvider]) self.enc_in_names [i.name for i in self.enc.get_inputs()] self.enc_out_names [o.name for o in self.enc.get_outputs()] self.dec_out_names [o.name for o in self.dec.get_outputs()] self.dec_past_in_names [i.name for i in self.dec_past.get_inputs()] self.dec_past_out_names [o.name for o in self.dec_past.get_outputs()] def translate(self, text, max_len128): enc_inputs self.tokenizer(text, return_tensorsnp, paddingTrue, truncationTrue, max_length128) enc_feeds {k: v for k, v in enc_inputs.items() if k in self.enc_in_names} enc_outs dict(zip(self.enc_out_names, self.enc.run(self.enc_out_names, enc_feeds))) enc_hidden enc_outs[self.enc_out_names[-1]] bos_id self.tokenizer.bos_token_id or self.tokenizer.eos_token_id dec_input np.array([[bos_id]], dtypenp.int64) # 第一步冷启动不带 past 输入 first_outs dict(zip(self.dec_out_names, self.dec.run(self.dec_out_names, {input_ids: dec_input, encoder_hidden_states: enc_hidden}))) logits first_outs[logits] next_id int(np.argmax(logits[:, -1, :])) if next_id self.tokenizer.eos_token_id: return generated [next_id] # 把第一步输出的 present 缓存转换为下一步的 past 输入 past {} for name, arr in first_outs.items(): if name.startswith(present): past[name.replace(present, past_key_values, 1)] arr # 后续步骤使用带缓存的 decoder for _ in range(1, max_len): dec_input np.array([[next_id]], dtypenp.int64) feeds {input_ids: dec_input, encoder_hidden_states: enc_hidden} feeds.update(past) outs dict(zip(self.dec_past_out_names, self.dec_past.run(self.dec_past_out_names, feeds))) logits outs[logits] next_id int(np.argmax(logits[:, -1, :])) if next_id self.tokenizer.eos_token_id: break generated.append(next_id) new_past {} for name, arr in outs.items(): if name.startswith(present): new_past[name.replace(present, past_key_values, 1)] arr past new_past return self.tokenizer.decode(generated, skip_special_tokensTrue)强调三处细节。第一past 缓存的名字替换decoder_model.onnx输出叫present.0.decoder.key而decoder_with_past_model.onnx输入叫past_key_values.0.decoder.key。本质上是一对对应关系只是命名上差一个前缀。上面的name.replace(present, past_key_values, 1)就是为了做这个映射。不同模型命名可能有差别所以我在代码里没有依赖具体 index而是以startswith(present)为判断条件这是具备通用性的做法。第二batch size 始终是 1。上面这段代码只处理 batch1 的情况如果你要批量翻译多条句子encoder 输入 batch 大于 1encoder_hidden_states 是二维矩阵decoder 这边的 past 缓存也要扩展到对应的 batch 维度。这块代码量会翻倍但原理完全相同我这里先保证单条翻译链路能跑通。第三贪心解码。argmax是最简单的解码策略真实生产用 beam search 效果更好但 beam search 需要同时维护多个候选序列每一步把 beam 的所有 past 缓存同时送入 decoder代码复杂度高不少。我的建议是先把贪心跑通验证 ONNX 模型本身没有精度问题再决定是否上 beam。4.3 第一次完整翻译的崩溃与修复代码写完我拿一个实际句子测试结果第一版就报 shape 错误。错误信息大概是unexpected input: input_ids。问题出在 tokenizer 返回的输入字典里包含了attention_mask而decoder_model.onnx的输入只有input_ids和encoder_hidden_states。我一开始直接把整个enc_inputs字典传给了 decoder session它当然不认。这类错误只有在你动手写循环时才会暴露。transformers的generate()内部默默帮你做了很多这种输入筛选的工作ONNX 模式下没人替你做每个 session 只接受它计算图里定义好的输入。所以实用建议是准备好一张输入映射表把 tokenizer 的输出映射到每个 session 的实际输入名上而不是图省事直接整体传参。5. 精度对线PyTorch 输出和 ONNX 输出差在哪5.1 数学上几乎一致的 FP32 结果ONNX 模型本质上还是同一套权重和计算图FP32 精度下跟 PyTorch 的数值差异非常小一般在 1e-4 到 1e-6 量级。但数值差异小不等于翻译结果一定一致。因为解码是逐 token 进行的如果某一个 token 的概率分布因为浮点舍入产生了细微变化而这一变化导致 argmax 选中了不同的 token整个后续生成路径就会分叉。我实测了多组句子大部分情况 PyTorch 和 ONNX FP32 的结果完全相同但也有少数句子出现一个 token 的差距。比如一个例句在 PyTorch 下生成这台机器可以处理复杂的计算任务ONNX 下可能生成这台机器可以处理复杂的计算任务同义改写或者某个名词被替换成低频词。这类现象不是 bug是浮点计算的正常离散性。不过如果出现整句翻译质量断崖式下降那就要怀疑导出过程引入了真实错误优先检查 attention_mask 有没有被正确传入 encoder以及 past 缓存的名字映射是否正确。5.2 我记录的对比样张为了量化验证我在测试集上挑了三句典型句子做对比输入英文PyTorch FP32ONNX FP32是否一致The quick brown fox jumps over the lazy dog.敏捷的棕色狐狸跳过懒狗。敏捷的棕色狐狸跳过懒狗。一致This is a great opportunity to learn new skills.这是一个学习新技能的好机会。这是一个学习新技能的好机会。一致The committee will discuss the proposal tomorrow morning.委员会将于明天上午讨论这项提议。委员会将于明天上午讨论这项提议。一致测试句子偏短模型表现稳定。但我在一个 200 句的真实新闻语料上做了对比发现大概有 7% 的句子在 ONNX 下的译名和 PyTorch 有一个词级别的差异。这个比例不低如果业务对翻译结果有严格的 A/B 要求建议上线前先跑一遍回归测试集做 diff。5.3 动态 shape 导致的隐性精度问题另一个容易忽略的问题ONNX 里动态 shape 会影响某些算子在不同长度输入下的计算结果。比如 attention 里的 softmax 在长序列下受限于数值稳定性策略实现可能有微小差异LayerNorm 的均值方差计算顺序也可能因向量长度不同产生舍入。这些差异在单句上几乎不可感知但如果在解码循环中反复累积某些句子就会在长生成步骤上偏离更明显。我之前遇到过一种情况短句翻译完全一致100 字以上的长句经常出现最后几个 token 的语义偏离。排查半天发现是 ONNX Runtime 的 CPU 实现里某些算子的分块策略对长序列的非确定性选择造成的。解决方案也很务实测试集里多放长难句不要只测短句对比指标不要只看 BLEU直接看关键实词有没有译错。6. Int8 量化模型瘦身与翻译质量的取舍6.1 动态量化真的是一键完成吗ONNX Runtime 提供了非常方便的quantize_dynamic接口。对 encoder、decoder、decoder_with_past 三个文件分别做动态量化代码很短from onnxruntime.quantization import quantize_dynamic, QuantType for name in [encoder_model, decoder_model, decoder_with_past_model]: quantize_dynamic( model_inputf{name}.onnx, model_outputf{name}_int8.onnx, per_channelTrue, weight_typeQuantType.QInt8, )执行完之后模型文件从原来的约 310MB 压缩到约 90MB效果很明显。动态量化的原理是模型权重以 int8 存储推理时动态反量化为浮点参与计算所以不需要校准数据。这也是它操作简单的原因对部署来说非常友好。6.2 量化后翻译质量到底降了多少理想很丰满现实是量化后的翻译质量确实有可感知的下降。我拿同样的测试集做了对比指标FP32 模型INT8 动态量化下降幅度模型大小约 310MB约 90MB-70%CPU 单句延迟约 780ms约 480ms-38%内存占用约 1.8GB约 900MB-50%测试集 BLEU41.239.6-1.6BLEU 掉 1.6 个点翻译短句时肉眼几乎看不出区别但涉及数字、专有名词、标点符号的句子偶尔会出现细微的错译。我排摸了具体案例后发现问题集中在一些特别长的句子以及解码后期。原因是动态量化对 attention 层权重的压缩会让长序列 attention 分布变得略微平滑长期累积后会影响 token 选择的确定性。所以我的实际建议是如果产品面向普通用户对翻译结果容忍度较高int8 动态量化可以直接上如果面向专业翻译场景比如法律、医疗文本建议保守一点只量化 encoderdecoder 保持 FP32或者干脆全部 FP32把优化重点放到推理框架本身。6.3 量化哪种算子收益最大更进一步你可以在量化配置里排除某些算子。ONNX Runtime 动态量化默认会压缩 MatMul、Gemm 这类计算密集的权重算子而 LayerNorm、Gelu 这类算子不做权重压缩。这个默认行为其实挺合理MatMul 覆盖了 transformer 绝大部分计算量而 LayerNorm 本身参数少量化收益有限反而可能因为精度敏感破坏模型稳定性。如果你有强迫症想全量化我劝你冷静。翻译模型的 decoder 里有大量 softmax 后的中间结果参与下一步计算任何一步的微小误差都可能被 token 级的前向依赖放大。我测试过把内部所有能量化的算子全部打开BLEU 直接掉了 3 个点相比之下默认配置只掉 1.6 个点。默认配置是最佳平衡点。7. 部署时绕不开的环境问题模型下载、线程、内存7.1 国内环境拉取 HuggingFace 模型的正确姿势HuggingFace 模型仓库直连经常慢到怀疑人生尤其是模型文件加起来几百 MB。我习惯先在本地把模型和 tokenizer 拉下来再打进部署镜像里而不是在生产服务器上现场下载。本地下载可以配置公共镜像加速。对 huggingface_hub 的下载器设置环境变量就能生效export HF_ENDPOINThttps://hf-mirror.com设置完之后huggingface_hub和transformers的下载逻辑都会走镜像站点。下载完成后代码里把from_pretrained的路径改成本地目录即可tokenizer MarianTokenizer.from_pretrained(./opus_mt_en_zh_tokenizer)这样部署环境就完全不需要联网了依赖更可控也避免了生产服务器上因网络波动导致的启动失败。7.2 ONNX Runtime 的参数调优部署 ONNX 模型最常被忽略的是 ONNX Runtime 的线程配置。默认情况下 ONNX Runtime 会根据 CPU 核数自动设置 intra-op 线程数但这不一定是延迟最优解。翻译场景是典型的串行解码单步延迟决定了用户感知过多的线程切换反而会拖慢每步的计算。我实测下来在 8 核虚拟机里设置intra_op_num_threads4比默认 8 线程的端到端吞吐更高。这里不是给死板结论而是强调线程数需要针对真实流量压测调优不能直接把服务器核数当成最优线程数。内存方面三个 ONNX session 共享一个进程每个 session 都有独立的 arena。如果不做显式配置ONNX Runtime 的 Eigen 分配器可能预占大量内存。我建议用OrtSessionOptions显式设置内存优化模式session_options.enable_mem_pattern False在内存受限场景下能让空闲内存更容易释放给系统。7.3 把模型文件和推理服务打包的实践生产环境里我最终的部署形态是一个 Python 推理服务加载 ONNX 模型文件对外暴露 HTTP 接口。模型文件直接放在镜像里服务启动时依次创建三个 session。这样做的收益是——服务进程不再需要transformers的完整依赖链torch 可以完全不安装镜像体积从 4GB 以上降到不到 1GB。启动时间从几十秒压缩到 3 秒以内。这个对比是当初推动我迁移到 ONNX 的最大动力。框架无关的模型文件加上轻量推理引擎让翻译能力的交付门槛一下子低了很多。8. 顺带聊聊 ONNX 生态里其他模型的影响翻译模型迁移到 ONNX 的经验本质上适用于所有 HuggingFace 生成式模型。我后来还尝试过把语音合成类的模型导出到 ONNX也就是类似 Sherpa-ONNX 这类推理框架里常见的做法。你会发现思路完全一致模型本身导出成几个静态图循环和状态管理交给外围代码框架只负责数学计算和内存复用。这也是 ONNX 真正的价值所在它不绑定任何上层框架不挑硬件同一个文件可以跑 CPU、GPU、甚至边缘设备。把一个 HuggingFace 模型迁移到 ONNX表面看只是格式转换实际是让你的模型从研究环境进入了工程环境。对我个人来说最大的收获不是省下了多少延迟和内存而是终于让翻译模块变成了一组可测试、可压缩、可分发、不依赖重型运行时的朴素文件。这种掌控感是继续用 transformers pipeline 部署时完全体会不到的。