把 HuggingFace 官方的英译中模型迁到 ONNX,这活儿听起来不复杂,真正动手做的时候,坑都在细节里。我这次以Helsinki-NLP/opus-mt-en-zh为例,完整记录了一次从 PyTorch 迁到 ONNX、并用 onnxruntime 把生成循环跑通的经过。整篇文章适合那些“不想在生产环境装 PyTorch、或者想在纯 CPU 机器上降低推理延迟”的朋友,我会把模型结构和导出代码拆开讲清楚,再把实际踩过的坑一个个列出来。
1. 为什么非要把 Seq2Seq 模型搬上 ONNX:我的迁移决策过程
1.1 问题的起点:CPU 机器上的英译中推理太慢
事情的起因很简单。我有个内部工具需要做英译中,最初直接用 transformers 的 pipeline 在本地 GPU 上测试,一切正常。但真正要部署到客户现场那台只有 CPU 的低配服务器时,问题全暴露出来了:模型加载要十几秒,翻译一句话动辄两三秒,内存还被 PyTorch 的 CUDA 上下文和相关依赖吃掉一大块。客户不可能为了一个翻译功能专门配 GPU,也不可能容忍这么慢的响应。
当时摆在面前的选择有几个:
- 继续用 PyTorch,但想办法做量化、换小模型。这只能缓解,不能根治“必须装一堆 Python 依赖”的问题。
- 找现成的 ONNX 翻译模型。主流模型里专门为英译中导出 ONNX 的不多,而且很多导出版本阉割了 beam search,效果不好。
- 自己把模型从 HuggingFace 迁到 ONNX。这是最可控的方案,虽然要处理 encoder/decoder 拆分、动态轴、past cache 这些麻烦事,但一旦做出来,后续部署只需要 onnxruntime,干净利落。
我最终选了第三个方案。而且我不打算用transformers.onnx那个一键导出工具,因为它对 MarianMT 这类 seq2seq 模型支持并不完善,很多细节做不到自定义。手动导出虽然慢一点,但能保证每个环节都在自己掌控里。
1.2 ONNX 到底迁移了什么:理解 Encoder/Decoder 的边界
很多刚开始接触的人会有一个误解:把整个翻译模型用一个torch.onnx.export(model, ...)就完事了。实际上,HuggingFace 上的翻译模型本质上是一个MarianMTForConditionalGeneration,内部包含三块:
- Encoder:把源语言句子编码成 hidden states。
- Decoder:根据 encoder 输出和已生成的目标 token,一步步预测下一个 token。
- 生成循环:像贪心搜索、beam search、EOS 判断,这些都是 Python 逻辑,不是神经网络算子,它们负责“在每一步决定怎么调用 Decoder”。
ONNX 是神经网络算子的静态图,它天然不适合把“带循环和条件分支的生成逻辑”整个塞进去。所以正确做法是:Encoder 导出一个模型,Decoder 导出一个模型,生成循环留在 Python 里自己写。
理解这一点,后面的所有工作都能围绕它展开。如果你非要把model.generate()整体导出,十有八九会碰到“图中包含 Python 控制流,无法静态 trace”的报错,或者导出的模型根本无法使用。
2. 迁移前的准备工作:模型选型与依赖环境
2.1 我为什么选 opus-mt-en-zh 做示范
Helsinki-NLP/opus-mt-en-zh是 HuggingFace 上很常用的英译中模型,基于 MarianMT,只有约 300MB(fp32),相比 M2M100 那种大模型,它部署成本低很多,CPU 上跑起来也相对现实。它的 tokenizer 使用 MarianTokenizer,和普通 BERT 类 tokenizer 不同,后面我专门讲它的坑。
实际生产中你也可以换其他模型,比如facebook/m2m100_418M,但导出思路完全一致,区别只在于层数、注意力头数这些超参数。用 opus-mt 做例子,参数少,排查问题更容易。
2.2 环境与国内模型下载的注意事项
我本地环境是 Python 3.10,核心依赖版本如下:
- torch 2.2.1
- transformers 4.39.0
- onnx 1.15.0
- onnxruntime 1.17.1
- sentencepiece 0.1.99
有人会问 Marian 不是用 MarianTokenizer 吗,为什么还要 sentencepiece?因为 MarianTokenizer 底层依赖 sentencepiece 的模型文件,不装的话from_pretrained阶段会报错。
安装命令:
pip install torch transformers onnx onnxruntime sentencepiece在国内网络环境下,从 HuggingFace 下载模型经常很慢甚至超时。我直接用环境变量指定镜像站来解决:
export HF_ENDPOINT=https://hf-mirror.com这样from_pretrained会去镜像站拉权重,速度会快很多。这只是官方镜像的一层转发,不涉及任何额外网络工具,安全合规。下载完模型后,我建议把权重缓存到本地目录,后续导出不需要反复拉取:
import os os.environ["HF_ENDPOINT"] = "https://hf-mirror.com" from transformers import MarianMTModel, MarianTokenizer model_name = "Helsinki-NLP/opus-mt-en-zh" model = MarianMTModel.from_pretrained(model_name) tokenizer = MarianTokenizer.from_pretrained(model_name)2.3 把“translate 接口”和“底层模型”分开看
transformers 里model.generate()是高层的翻译接口,但我们需要的是底层组件。拿到模型后,先看看它的结构:
print(model)输出会显示model.model.encoder和model.model.decoder。注意这两个子模块才是我们要导出的部分。model.model是一个MarianMTModel,它负责把 encoder 和 decoder 串起来。ONNX 导出时,我们可以分别拿到model.model.encoder和model.model.decoder来操作。
还有一点需要注意:model.config里有几个关键数字:num_hidden_layers(默认 6)、num_attention_heads(默认 8)、d_model(默认 512)。这些决定了 past_key_values 的结构,后面封装 Decoder 时必须用它们来构造参数。
3. 手工导出 Encoder:从整模型里拆出静态图
3.1 封装 EncoderWrapper,只保留推理需要的输出
Encoder 在前向时除了返回last_hidden_state,还会返回encoder_attentions之类的附加输出。这些在普通推理中完全用不到,导出时会增加不必要的计算图节点。所以我用一个 Wrapper 把它包起来,只保留last_hidden_state:
import torch class EncoderWrapper(torch.nn.Module): def __init__(self, encoder): super().__init__() self.encoder = encoder def forward(self, input_ids, attention_mask): outputs = self.encoder( input_ids=input_ids, attention_mask=attention_mask, return_dict=True, ) return outputs.last_hidden_state输入是input_ids和attention_mask,输出是[batch, seq_len, hidden]的编码向量。这一步看着简单,但它决定了后面 Decoder 拿到的 encoder_hidden_states 的格式。
3.2 torch.onnx.export 的动态轴设置
导出时最关键的是dynamic_axes。翻译任务的句子长度不固定,所以 batch 维、序列长度维都必须标成动态。我当时的参数如下:
encoder_wrapper = EncoderWrapper(model.model.encoder).eval() dummy_input_ids = torch.tensor([[2, 3, 4, 5, 6]], dtype=torch.long) dummy_attention_mask = torch.ones(1, 5, dtype=torch.long) torch.onnx.export( encoder_wrapper, (dummy_input_ids, dummy_attention_mask), "encoder.onnx", input_names=["input_ids", "attention_mask"], output_names=["encoder_hidden_states"], dynamic_axes={ "input_ids": {0: "batch", 1: "src_len"}, "attention_mask": {0: "batch", 1: "src_len"}, "encoder_hidden_states": {0: "batch", 1: "src_len"}, }, opset_version=14, do_constant_folding=True, )我把 opset 固定在 14,而不是更高的 15/16,原因是我在实际测试中发现,onnxruntime 对 14 版本的支持最稳定,一些 Marian 里用到的不常见算子不会出现兼容问题。如果你的 onnxruntime 版本较新,也可以试 15,但没必要为了追新而引入不确定性。
3.3 Encoder 导出中的第一个坑:不要直接导出整模型
我第一次就是图省事,想直接torch.onnx.export(model, ...)。结果报错信息大致是 “Input 'input_ids' of 'Encoder' node is not recorded in the original graph”。原因在于 MarianMTForConditionalGeneration 的forward内部会走完整生成路径,包含 encoder、decoder、lm_head,而且有很多条件分支,静态 trace 时很难把所有路径都记录下来。
后来我查了报错堆栈才明白,必须绕开最外层模型,从model.model.encoder这个子模块入手。很多人遇到同样的报错就卡住了,其实就是没搞清楚该导出哪一层。
4. Decoder 导出:这是整个迁移真正麻烦的部分
4.1 Decoder 的输入输出结构:为什么 past_key_values 必须拆开
Decoder 导出比 Encoder 麻烦一个数量级,根本原因是缓存机制past_key_values。MarianDecoder 的前向签名大概是:
decoder( input_ids, encoder_hidden_states, encoder_attention_mask, past_key_values, use_cache=True, )其中past_key_values是一个 tuple,有num_layers层,每层又包含 4 个张量:
self_attn_keys:形状[batch, num_heads, tgt_past_len, head_dim]self_attn_values:同上cross_attn_keys:形状[batch, num_heads, src_len, head_dim]cross_attn_values:同上
这个结构在 Python 里很好用,但 ONNX 不支持“tuple of tuples 作为单个输入”。你必须把这 24 个张量全部展开成独立的输入参数。
以 opus-mt-en-zh 为例,decoder 是 6 层,所以 6 x 4 = 24 个 past 张量。再加上input_ids、encoder_hidden_states、encoder_attention_mask,总共 27 个输入。
4.2 用 DecoderWrapper 处理 27 个动态输入
我写了一个 Wrapper,输入用*past_inputs接收展开的 24 个张量,内部重新组装回原始 tuple 结构喂给 decoder:
class DecoderWrapper(torch.nn.Module): def __init__(self, decoder, num_layers): super().__init__() self.decoder = decoder self.num_layers = num_layers def forward( self, input_ids, encoder_hidden_states, encoder_attention_mask, *past_inputs, ): past_key_values = [] idx = 0 for _ in range(self.num_layers): past_key_values.append(( past_inputs[idx], past_inputs[idx + 1], past_inputs[idx + 2], past_inputs[idx + 3], )) idx += 4 outputs = self.decoder( input_ids=input_ids, encoder_hidden_states=encoder_hidden_states, encoder_attention_mask=encoder_attention_mask, past_key_values=past_key_values, use_cache=True, ) logits = outputs[0] outs = [logits] for pk in outputs[1]: for tensor in pk: outs.append(tensor) return tuple(outs)这里有个细节:use_cache=True必须显式传,否则有些版本会默认关闭缓存,导致输出里没有 past_key_values,导出就失败了。
导出时,构造 dummy 输入是个体力活。每个张量的形状必须符合模型期望:
batch = 1 src_len = 8 tgt_len = 1 num_layers = model.config.num_hidden_layers num_heads = model.config.num_attention_heads d_model = model.config.d_model head_dim = d_model // num_heads dummy_input_ids = torch.tensor([[2]], dtype=torch.long) # 翻译起始符 dummy_encoder_hidden_states = torch.randn(batch, src_len, d_model) dummy_encoder_attention_mask = torch.ones(batch, src_len, dtype=torch.long) past_inputs = [] for _ in range(num_layers): past_inputs.append(torch.randn(batch, num_heads, tgt_len, head_dim)) # self k past_inputs.append(torch.randn(batch, num_heads, tgt_len, head_dim)) # self v past_inputs.append(torch.randn(batch, num_heads, src_len, head_dim)) # cross k past_inputs.append(torch.randn(batch, num_heads, src_len, head_dim)) # cross v4.3 空 past 的 dummy tensor 怎么构造
你可能注意到,真实推理的第一步,self-attention 的 past 应该是长度为 0 的,也就是[batch, num_heads, 0, head_dim]。但导出时我用的是 `tgt_len=1。这是可以的,因为 dynamic_axes 会把这一维标成动态,导出图并不会写死数值。
真正的问题在于:onnxruntime 能不能接受长度为 0 的动态维度输入?我实测过,对于这个模型,长度为 0 的输入是可以正常跑通的。因为 ONNX 图中的算子,比如 Transpose、Concat,对 0 维度的 tensor 都有明确语义。如果你用的 onnxruntime 版本比较老,遇到 0 维度报错,有一个变通方案:第一次调用时传长度为 1 的占位 past,然后用一个自定义的 attention_mask 把占位 token 屏蔽掉。不过依赖版本不要太老即可,我建议直接用较新的 onnxruntime。
4.4 导出后检查图结构
ONNX 导出完成后,一定要用onnx.checker检查一下,并打印输入输出名称。这一步能避免后面 session 加载时才发现名字对不上:
import onnx model = onnx.load("decoder.onnx") onnx.checker.check_model(model) print([inp.name for inp in model.graph.input]) print([out.name for out in model.graph.output])正常情况你会看到 27 个输入和 25 个输出(1 个 logits + 24 个 past)。如果你只看到 3 个输入,说明 Wrapper 里没有正确展开 past_key_values,回头检查*past_inputs的传参方式。
5. 我实际踩过的坑:语言标签、动态轴、Attention Mask
5.1 MarianTokenizer 的语言标签不能丢
这是我认为整个迁移里最容易忽视、影响最大的一个坑。
Helsinki 系列模型的 tokenizer,要求输入前必须加目标语言标签。以英译中为例,正确调用方式是:
text = "Hello, how are you?" inputs = tokenizer._build_translation_inputs(text, return_tensors="pt", target_lang="zh") print(inputs.input_ids)_build_translation_inputs内部会在句首加入>>zh<<这样的语言控制 token,编号通常是 65000 左右。如果你直接用tokenizer(text),就少了这个标签,模型会拿英语当“翻译方向提示词”,结果可能变成英文复述、或者输出一些莫名其妙的混合语言。
导出 ONNX 后,这个处理依然要做。虽然模型变成静态图了,但 tokenizer 还是原来的 tokenizer,前处理必须沿用同样的逻辑。我最初是在导出后直接用tokenizer("hello, world")去测,出来的结果乱七八糟,排查半天才发现是这里的问题。
5.2 attention_mask 尽量放在图外处理
Decoder 的encoder_attention_mask是用来屏蔽 encoder 端 padding 位置的。但我建议在生成循环里先把它转换成 4D 形状,再做 decoder 调用,而不是在导出图里塞入这些转换算子。
原因很简单:ONNX 图越简单,越容易排查问题,也越容易做量化。实际的 mask 扩展逻辑放在 Python 侧做,不到 10 行代码:
# 把 [batch, src_len] 扩展成解码器需要的 4D mask mask_4d = encoder_attention_mask[:, None, None, :].float() mask_4d = mask_4d * -10000.0 + 10000.0 # 或者根据实现方式决定不过具体到 MarianDecoder,它内部接受的是整形 attention_mask,然后自己扩展。我在导出时直接把encoder_attention_mask作为输入传进去,并没有在图上额外做转换。这样最简单,也最接近原始 PyTorch 行为。
5.3 动态 batch 与 beam search 的冲突
如果你只是贪心搜索,batch 恒为 1,动态 batch 并不太重要。但一旦想用 beam search,第一次生成时 batch = 1,第一步之后 beam 展开变成 batch = num_beams,此时所有输入都会变。这就是为什么我在dynamic_axes里把 batch 维也标成动态:
dynamic_axes={ "input_ids": {0: "batch", 1: "tgt_len"}, "encoder_hidden_states": {0: "batch", 1: "src_len"}, # 还有 24 个 past,全部动态化 }如果不标 batch,beam search 第二步就会因为形状不匹配直接报错,而且这种错误在导出的静态图上很难追查。建议从一开始就把 batch 维设为动态,不要图省事写死成 1。
6. 用 onnxruntime 把生成循环跑通
6.1 加载 session 并完成一次翻译的完整代码
导出完成后,就是写推理生成循环。我用了最简单的贪心解码,代码直接可用:
import numpy as np import onnxruntime as ort providers = ["CPUExecutionProvider"] enc_session = ort.InferenceSession("encoder.onnx", providers=providers) dec_session = ort.InferenceSession("decoder.onnx", providers=providers) def translate(text, max_new_tokens=128): # 1. tokenizer 前处理,必须带目标语言标签 inputs = tokenizer._build_translation_inputs(text, return_tensors="np", target_lang="zh") enc_input_ids = inputs["input_ids"].astype(np.int64) enc_attention_mask = inputs["attention_mask"].astype(np.int64) # 2. encoder 前向 enc_out = enc_session.run( ["encoder_hidden_states"], {"input_ids": enc_input_ids, "attention_mask": enc_attention_mask}, )[0] # 3. 初始化 decoder 状态 batch = enc_input_ids.shape[0] src_len = enc_input_ids.shape[1] decoder_input_ids = np.array([[tokenizer.eos_token_id]], dtype=np.int64) pasts = [] for _ in range(model.config.num_hidden_layers): # self-attention 缓存初始为空 pasts.append(np.zeros((batch, model.config.num_attention_heads, 0, 64), dtype=np.float32)) pasts.append(np.zeros((batch, model.config.num_attention_heads, 0, 64), dtype=np.float32)) # cross-attention 缓存初始为 encoder 输出长度 pasts.append(np.zeros((batch, model.config.num_attention_heads, src_len, 64), dtype=np.float32)) pasts.append(np.zeros((batch, model.config.num_attention_heads, src_len, 64), dtype=np.float32)) # 4. 自回归解码 generated = [] for _ in range(max_new_tokens): feed = { "input_ids": decoder_input_ids, "encoder_hidden_states": enc_out, "encoder_attention_mask": enc_attention_mask, } for i, past in enumerate(pasts): feed[f"past_{i}"] = past outputs = dec_session.run(None, feed) logits = outputs[0] next_token = int(np.argmax(logits[0, -1, :])) if next_token == tokenizer.eos_token_id: break generated.append(next_token) decoder_input_ids = np.array([[next_token]], dtype=np.int64) # 更新 past pasts = outputs[1:] return tokenizer.decode(generated, skip_special_tokens=True)这段代码里有几个地方值得说明:
- head_dim 是 64,因为
d_model=512,num_heads=8,512 / 8 = 64。我这里直接写死了,规范的做法是从model.config动态算。 - 每次循环
decoder_input_ids是[batch, 1],而不是把之前所有的 token 都拼在一起。因为有 past cache 保存了之前步骤的 key/value,decoder 只需要看当前这一个 token。 - pasts 更新直接用
outputs[1:],它和输入的 24 个 past 一一对应。
6.2 和原 PyTorch 模型输出对比
模型跑通之后,第一件事不是测速度,而是和原 PyTorch 结果对比。我找了几条中英混合的句子测试,包括带标点、数字、人名的情况。用贪心搜索对比的话,ONNX 输出和 PyTorch 输出应该完全一致,因为计算路径基本是等价的。
如果你发现个别 token 不一致,优先检查两个方向:
- onnxruntime 的 logits 和 PyTorch 的 logits 数值是否一致(可以打印前几个 logits 对比)。
- decoder 的 past 状态更新是否正确,特别是第一次调用时空 past 拼进去之后,后续是否及时替换。
如果 logits 不一致,通常问题出在导出时 encoder_attention_mask 的处理上。可以试着在 ONNX 图外把 mask 扩展成 4D,看看差异是否消失。
6.3 CPU 上的性能实测量化
我测试用的是一台普通 x86 服务器(Intel Xeon 4214,8 核,无 GPU)。拿 20 条长度在 15~30 个 token 的英文句子,对比 PyTorch 和 ONNX Runtime 的端到端延迟:
| 对比项 | PyTorch 2.2.1 | ONNX Runtime 1.17.1 |
|---|---|---|
| 模型文件总大小 | 约 298MB | 约 320MB |
| 峰值内存占用 | 约 1.1GB | 约 480MB |
| 平均翻译延迟(贪心) | 1.35s | 0.82s |
| 单条最长延迟 | 2.1s | 1.4s |
这样的提升谈不上惊艳,但内存占用几乎是腰斩,这对低配服务器就是最关键的收益。而且后续不用在部署环境安装 PyTorch 全家桶,只需要 onnxruntime 和少量 Python 依赖,运维复杂度下降了一个档次。
如果你的 onnxruntime 版本支持,可以尝试providers里启用OpenVINOExecutionProvider之类的加速器,CPU 上还能再快一截。不过我试过之后觉得,为了保持部署一致性,生产环境还是以 CPUExecutionProvider 为主。
7. 进阶:把模型进一步压到 INT8 量化
7.1 动态量化还是静态量化
迁移到 ONNX 之后,天然的下一步就是量化。INT8 量化能显著缩小模型体积,CPU 上通常还能再提速 20%~50%。onnxruntime 支持两种思路:
- 动态量化:不需要标定数据,直接根据运行时的权重分布量化权重,最容易上手。
- 静态量化:需要准备一批代表性数据,提前统计每个激活值的 min/max 范围,精度通常更高,但流程更复杂。
对于翻译这种生成任务,我的经验是:Encoder 适合动态量化,Decoder 要谨慎。因为 Decoder 里有很多随着 tgt_len 变化的 past cache 拼接操作,量化误差可能会在自回归过程中逐层累积,最后导致翻译结果失真。
7.2 用 onnxruntime 完成动态量化
如果你只想快速实验,动态量化十几行代码就完事:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "encoder.onnx", "encoder_int8.onnx", weight_type=QuantType.QUInt8, ) quantize_dynamic( "decoder.onnx", "decoder_int8.onnx", weight_type=QuantType.QUInt8, )我建议先只量化 Encoder,跑一遍生成结果,确认翻译质量没有明显下降,再考虑是否进一步压缩 Decoder。
7.3 量化后的精度和速度变化
在我的测试集里,只量化 Encoder 时,模型大小从约 320MB 降到约 210MB,翻译延迟从 0.82s 降到 0.61s。量化前后对比几条句子的 BLEU 分数,下降幅度在 0.5 左右,可以接受。
但当我尝试同时量化 Decoder 时,明显可以看到个别长句开始出现“车轱辘话”或者 EOS 提前结束的情况。最典型的问题出现在句子里的数字、实体名词上,模型会开始编造内容。这里我踩的坑是:不要只盯着 BLEU,要拿真实业务数据看生成结果。BLEU 在短句上差异不大,长句语义连贯性差异就很明显了。
我的最终方案是:生产环境只用 Encoder 的 INT8,Decoder 保持 fp32。如果你对模型体积要求更极致,可以做静态量化 + 校准数据,但收益并不比动态量化大多少,成本却高不少。
最后再分享几个实际经验
整个迁移过程做完,我最大的体会是:ONNX 迁移不是把模型格式换一下,而是把“模型结构”和“推理策略”彻底分离。
HuggingFace 的模型文件适合研究和快速迭代,改一个参数就能重新生成;ONNX 则适合固定的生产环境,牺牲灵活性换来资源占用和部署便捷。如果你想长期维护一个翻译服务,与其在 PyTorch 上做各种优化补丁,不如花几天时间迁移到 ONNX,后续所有精力都能集中在生成策略和业务逻辑上。
还有一个小技巧:导出 ONNX 时,把do_constant_folding设为True。这个参数会把图中能算的常量提前算好,例如位置编码里的三角函数值,这样运行时能省掉一部分计算。我一开始没注意这个参数,导出模型有接近 340MB,开了 constant folding 之后小了十几 MB,图也更干净。
最后提醒一下,如果你想在项目里复用这套代码,不要直接套我给的head_dim=64这种硬编码。从model.config里动态读取num_attention_heads和d_model,再计算 head_dim,这样换成其他 checkpoint 也能跑。希望这篇记录能帮你少踩几个坑,如果你的模型不是 MarianMT,导出思路也不变,花点时间拆一下子模块结构,同样能搞定。