1. 为什么要把翻译模型从原生框架搬到 ONNX
做过翻译服务部署的人大概都有过这种体验:模型在实验室里跑得好好的,一上生产环境就各种水土不服。我最早接触这个需求,是帮一个做跨境电商工具的朋友处理商品描述翻译。他们原本用的是某开源社区里一个英译中模型,推理框架直接跑原生权重,单条请求延迟在 800ms 上下,QPS 一上来 CPU 直接打满,服务器成本压不下来。后来把模型转成 ONNX 格式,配合 ONNX Runtime 做推理,同样的硬件条件下延迟降到了 200ms 出头,吞吐量翻了将近三倍。这个差距不是玄学,而是推理引擎针对计算图做了大量算子融合、内存复用和线程调度的优化。
ONNX 全称 Open Neural Network Exchange,本质上是一种开放的模型表示格式。你可以把它理解成模型界的“通用集装箱”——不管你的模型原本是用什么框架训练的,装进这个标准箱子里之后,任何支持该标准的“港口”(推理引擎)都能卸货运行。对于翻译模型来说,这意味着你不再被绑定在某个特定框架的运行时上,可以选择更适合部署环境的推理后端。
具体到英译中这个场景,迁移到 ONNX 能解决几个很实际的问题。第一是部署轻量化,原生框架的运行时往往带着大量训练相关的依赖,打包出来动辄几个 G,而 ONNX Runtime 的核心库可以控制在几十兆以内。第二是跨平台一致性,同一份 ONNX 模型文件,在服务器、边缘设备、甚至移动端上都能跑出相近的结果,不用为每个平台单独适配。第三是推理性能可调优,ONNX Runtime 提供了丰富的图优化选项和执行提供器配置,你可以根据实际硬件做针对性调优。
不过我得先说清楚,这个迁移过程不是一键转换就完事的。翻译模型通常包含编码器-解码器结构,涉及自注意力、交叉注意力、位置编码等复杂计算,转换过程中会遇到算子不支持、动态维度处理、解码循环优化等一系列问题。下面我会把整个流程拆开,从环境准备到最终部署,把每个环节的坑和技巧都讲透。
2. 迁移前的环境准备与模型选型
2.1 工具链版本搭配的讲究
环境准备这一步看似简单,实际上版本兼容性是最大的隐形杀手。我踩过的坑里,至少有三成是因为版本不匹配导致的诡异报错。核心工具链包括四个部分:模型导出工具、ONNX 本体、推理运行时、以及原始模型框架。
以目前主流的组合为例,我推荐这样搭配:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| 原始框架 | 2.x 稳定版 | 导出接口成熟,兼容性好 |
| 模型导出工具 | 与框架版本对应 | 版本必须严格匹配 |
| ONNX | 1.15 以上 | 支持较新的算子集 |
| ONNX Runtime | 1.17 以上 | 优化器更完善 |
这里有个经验:不要盲目追新。我有一次用了最新版的导出工具配合稍旧版本的运行时,结果模型能导出但加载时报算子集不兼容。后来查了半天才发现是新版默认用了更高的 opset 版本,而运行时还没支持。所以稳妥的做法是,先确定推理运行时的版本,再反推导出时应该用的 opset 版本。
安装命令本身不复杂,但要注意虚拟环境的隔离:
python -m venv onnx_env source onnx_env/bin/activate pip install torch --index-url https://download.pytorch.org/whl/cpu pip install onnx onnxruntime transformers注意:如果你打算用 GPU 推理,onnxruntime 要换成 onnxruntime-gpu,并且 CUDA 版本要和运行时编译时用的版本对齐,否则会静默回退到 CPU 执行,性能反而更差。
2.2 翻译模型的架构特点与导出难点
英译中模型绝大多数是基于 Transformer 的编码器-解码器架构。编码器负责理解英文源句,解码器负责逐词生成中文译文。这个结构在导出 ONNX 时有几个天然难点。
第一个难点是自回归解码。解码器不是一次性输出整个句子,而是每次生成一个词,然后把生成的词拼回输入再生成下一个。这个循环在原生框架里是 Python 层面的控制流,导出成 ONNX 静态图时,要么把循环展开成固定长度,要么用 ONNX 的循环算子表达。前者灵活性差,后者兼容性参差。
第二个难点是动态序列长度。翻译的输入句子长短不一,导出时如果把序列长度写死,那短句浪费计算、长句直接截断。必须把序列长度维度设为动态维度。
第三个难点是注意力掩码的处理。编码器和解码器都需要掩码来屏蔽填充位置,这些掩码在导出时需要作为输入显式传入,而不是在模型内部动态生成。
理解了这三点,后面的导出策略就有的放矢了。我的建议是采用编码器与解码器分离导出的方案,把编码器导成一个模型,解码器导成另一个模型,中间用 numpy 数组传递状态。这样做的好处是解码循环留在 Python 层面控制,灵活度高,调试也方便。
2.3 模型权重的获取与检查
拿到模型权重后,别急着导出,先做几项检查。第一,确认模型的配置信息,包括词表大小、隐藏层维度、注意力头数、层数这些关键参数,后面导出时的 dummy input 要按这些参数构造。第二,确认分词器的类型和特殊 token 的 ID,翻译模型对起始符、结束符、填充符的处理很敏感,搞错了会导致输出乱码。
我一般会写一小段脚本先把模型跑通,用几个典型句子测试翻译质量,把结果存下来作为基准。等 ONNX 转换完成后,再用同样的句子跑一遍,对比输出是否一致。这个基准测试是后面排查问题的关键参照。
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_name = "your-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSeq2SeqLM.from_pretrained(model_name) model.eval() test_sentences = [ "The quick brown fox jumps over the lazy dog.", "Machine translation has improved dramatically.", "Please confirm your order before shipping." ] for sent in test_sentences: inputs = tokenizer(sent, return_tensors="pt") with torch.no_grad(): outputs = model.generate(**inputs, max_new_tokens=64) print(sent, "->", tokenizer.decode(outputs[0], skip_special_tokens=True))把这段输出保存好,它就是你的“标准答案”。
3. 编码器与解码器的导出实操
3.1 编码器导出:把源句变成上下文向量
编码器的职责是把输入的英文 token 序列编码成一串隐藏状态向量。导出时最关键的是把输入维度设为动态,让模型能接受任意长度的句子。
先构造 dummy input。假设模型的隐藏维度是 512,注意力头数是 8,编码器层数是 6,词表大小是 32000。dummy input 的 shape 是(batch_size, sequence_length),两个维度都设为动态。
import torch from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_name = "your-model-path" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSeq2SeqLM.from_pretrained(model_name) model.eval() encoder = model.get_encoder() batch_size = 2 seq_len = 16 dummy_input_ids = torch.randint(0, 32000, (batch_size, seq_len), dtype=torch.long) dummy_attention_mask = torch.ones((batch_size, seq_len), dtype=torch.long) torch.onnx.export( encoder, (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: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "encoder_hidden_states": {0: "batch", 1: "sequence"} }, opset_version=14, do_constant_folding=True )这里有几个细节值得展开。opset_version选 14 是因为它对视注意力相关的算子支持比较完善,同时主流运行时都能兼容。do_constant_folding=True会把能提前计算的常量折叠掉,减小模型体积。dynamic_axes里把 batch 和 sequence 两个维度都标为动态,这样导出后的模型才能真正接受变长输入。
导出完成后,务必用 ONNX Runtime 加载一遍,和原生编码器的输出做数值对比。我一般会算最大绝对误差,正常情况下应该在 1e-4 以内。如果误差偏大,多半是某个算子导出时精度损失了,需要检查是否有不支持的自定义算子。
3.2 解码器导出:逐词生成的核心逻辑
解码器比编码器复杂,因为它涉及自注意力和交叉注意力两层机制。自注意力处理已生成的中文序列,交叉注意力则要关注编码器的输出。导出时,解码器的输入包括:当前已生成的 token 序列、编码器隐藏状态、以及各种掩码。
这里有个关键决策:是否把 KV Cache 纳入导出。KV Cache 是解码时的加速技巧,把之前计算过的键值对缓存下来,避免重复计算。如果不导出 KV Cache,每次生成一个词都要重新计算整个序列的注意力,长文本下性能会急剧下降。但导出 KV Cache 会让模型输入输出变复杂,需要额外管理缓存张量。
我的建议是分两步走:先导出一个不带 KV Cache 的版本,确保功能正确;再导出带 KV Cache 的版本用于生产。下面先看不带缓存的版本。
decoder = model.get_decoder() lm_head = model.lm_head class DecoderWrapper(torch.nn.Module): def __init__(self, decoder, lm_head): super().__init__() self.decoder = decoder self.lm_head = lm_head def forward(self, decoder_input_ids, encoder_hidden_states, decoder_attention_mask, encoder_attention_mask): outputs = self.decoder( input_ids=decoder_input_ids, encoder_hidden_states=encoder_hidden_states, attention_mask=decoder_attention_mask, encoder_attention_mask=encoder_attention_mask ) hidden = outputs.last_hidden_state logits = self.lm_head(hidden) return logits wrapper = DecoderWrapper(decoder, lm_head) wrapper.eval() dummy_decoder_ids = torch.randint(0, 32000, (batch_size, 1), dtype=torch.long) dummy_encoder_hidden = torch.randn(batch_size, seq_len, 512) dummy_decoder_mask = torch.ones((batch_size, 1), dtype=torch.long) dummy_encoder_mask = torch.ones((batch_size, seq_len), dtype=torch.long) torch.onnx.export( wrapper, (dummy_decoder_ids, dummy_encoder_hidden, dummy_decoder_mask, dummy_encoder_mask), "decoder.onnx", input_names=["decoder_input_ids", "encoder_hidden_states", "decoder_attention_mask", "encoder_attention_mask"], output_names=["logits"], dynamic_axes={ "decoder_input_ids": {0: "batch", 1: "decoder_sequence"}, "encoder_hidden_states": {0: "batch", 1: "encoder_sequence"}, "decoder_attention_mask": {0: "batch", 1: "decoder_sequence"}, "encoder_attention_mask": {0: "batch", 1: "encoder_sequence"}, "logits": {0: "batch", 1: "decoder_sequence"} }, opset_version=14, do_constant_folding=True )注意encoder_attention_mask这个输入。有些模型的解码器内部会自动从编码器输出推断掩码,但导出成 ONNX 后这种隐式行为往往失效,必须显式传入。这是很多人导出后输出乱码的常见原因。
3.3 带 KV Cache 的优化版本
生产环境一定要用带 KV Cache 的版本。原理不复杂:解码器每一层的自注意力都会产生 key 和 value 张量,把这些张量缓存起来,下一步生成时直接拼接,就不用重算前面的部分了。
导出时,解码器的输入要多出 past_key_values 相关的张量,输出也要多出 present_key_values。张量的形状是(num_layers * 2, batch, num_heads, past_seq_len, head_dim)。这个结构比较绕,建议直接参考框架提供的转换脚本,或者用现成的优化工具做转换。
我实测下来,带 KV Cache 的版本在生成 50 个 token 的句子上,比不带缓存的版本快 4 到 6 倍,句子越长差距越明显。这个优化对于翻译服务来说是必须的,因为用户提交的往往是整段文本。
4. 推理流程的编排与性能调优
4.1 用 ONNX Runtime 串起完整翻译流程
模型导出只是第一步,真正要跑起来还得写推理编排代码。整个流程是:分词 -> 编码器推理 -> 解码循环 -> 反分词。解码循环里每一步都要调用解码器 ONNX 模型,拿到 logits 后用贪心或束搜索选出下一个 token。
import numpy as np import onnxruntime as ort from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-model-path") encoder_session = ort.InferenceSession("encoder.onnx", providers=["CPUExecutionProvider"]) decoder_session = ort.InferenceSession("decoder.onnx", providers=["CPUExecutionProvider"]) def translate(text, max_new_tokens=64): inputs = tokenizer(text, return_tensors="np") input_ids = inputs["input_ids"].astype(np.int64) attention_mask = inputs["attention_mask"].astype(np.int64) encoder_hidden = encoder_session.run( ["encoder_hidden_states"], {"input_ids": input_ids, "attention_mask": attention_mask} )[0] decoder_input_ids = np.array([[tokenizer.bos_token_id]], dtype=np.int64) generated = [] for _ in range(max_new_tokens): decoder_mask = np.ones_like(decoder_input_ids, dtype=np.int64) logits = decoder_session.run( ["logits"], { "decoder_input_ids": decoder_input_ids, "encoder_hidden_states": encoder_hidden, "decoder_attention_mask": decoder_mask, "encoder_attention_mask": attention_mask } )[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.concatenate( [decoder_input_ids, np.array([[next_token]], dtype=np.int64)], axis=1 ) return tokenizer.decode(generated, skip_special_tokens=True)这段代码是功能验证版本,直接跑能出结果,但性能不是最优。每次循环都重新构造 decoder_input_ids 并传入完整序列,计算量随序列增长而增长。生产版本要换成 KV Cache 方案,把 past 张量在循环中传递。
4.2 执行提供器与线程配置
ONNX Runtime 的性能很大程度上取决于执行提供器的选择。CPU 场景下,默认的 CPUExecutionProvider 已经不错,但可以通过 SessionOptions 进一步调优。
opts = ort.SessionOptions() opts.intra_op_num_threads = 4 opts.inter_op_num_threads = 2 opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL opts.enable_mem_pattern = True opts.enable_cpu_mem_arena = Trueintra_op_num_threads控制单个算子内部的并行度,inter_op_num_threads控制算子之间的并行度。这两个值不是越大越好,要根据 CPU 核心数和模型特点调。我的经验是,翻译模型这种以矩阵乘法为主的负载,intra 设为物理核心数的一半到全部之间比较合适,inter 设小一点避免线程争抢。
如果有 GPU,切换到 CUDAExecutionProvider 或 TensorRTExecutionProvider,性能会有数量级提升。但要注意首次加载时的图优化耗时,以及显存占用。TensorRT 的优化效果最好,但构建引擎的时间可能长达几分钟,适合长期驻留的服务。
4.3 批处理与动态形状的权衡
翻译服务通常要处理并发请求,批处理能显著提升吞吐。但翻译的输入长度差异大,如果按最长句子补齐,短句子会浪费大量计算。这里有个策略选择:按长度分桶,把长度相近的请求凑成一批。
ONNX 模型导出时已经把 batch 和 sequence 设为动态,所以理论上可以接受任意 batch size。但实际运行时,过大的 batch 会导致内存峰值过高,过小则吞吐上不去。我一般会设一个上限,比如 batch size 不超过 16,同时监控内存使用。
还有一个细节:解码循环中每一步的 decoder_input_ids 长度都在变,如果每步都重新分配内存,开销不小。可以用预分配的缓冲区,或者利用 ONNX Runtime 的内存复用机制。开启enable_cpu_mem_arena后,运行时会维护一个内存池,减少频繁分配释放的开销。
5. 常见问题排查与避坑经验
5.1 输出乱码或重复的排查思路
导出后输出乱码是最常见的问题,原因通常有三类。第一类是特殊 token 处理错误,比如起始符 ID 传错,或者结束符没被正确识别。排查方法是打印每一步生成的 token ID,和原生模型的输出逐步对比,看从哪一步开始分叉。
第二类是掩码问题。编码器掩码或解码器掩码传错,会导致注意力计算到填充位置,输出质量急剧下降。特别是解码器的因果掩码,如果导出时没正确处理,模型会“看到”未来的 token,生成结果完全错乱。
第三类是数值精度问题。ONNX 默认用 float32,但如果某个算子导出时精度损失,累积误差会导致输出偏移。这种情况比较隐蔽,需要用数值对比工具逐层检查。
我整理了一个排查速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出全是填充符 | 起始符 ID 错误 | 检查 tokenizer 配置 |
| 输出重复同一词 | 结束符未生效 | 打印 token ID 序列 |
| 输出语义混乱 | 掩码传递错误 | 对比原生模型中间输出 |
| 长句质量骤降 | 动态维度未生效 | 检查 dynamic_axes 配置 |
| 数值偏差大 | 算子精度损失 | 逐层数值对比 |
5.2 性能不达预期的调优方向
有时候模型能跑通,但速度比原生框架还慢,这就尴尬了。我遇到过几次,原因各不相同。有一次是没开图优化,ONNX Runtime 默认的优化级别比较保守,手动设为 ORT_ENABLE_ALL 后速度提升明显。还有一次是线程配置不合理,intra 设成了 1,等于单线程跑,改成多线程后快了三倍。
另一个容易被忽视的点是输入数据的类型和布局。ONNX Runtime 对连续内存的数组处理更快,如果传入的是非连续数组,会触发一次拷贝。用np.ascontiguousarray确保输入连续,能省下不少开销。
如果用了 GPU,还要检查是否真的在 GPU 上执行。有时候 CUDA 版本不匹配,运行时会静默回退到 CPU,你以为在用 GPU 其实没有。可以在创建 session 后打印session.get_providers()确认。
5.3 模型体积与加载速度的优化
ONNX 模型文件有时候比原生权重还大,这是因为导出时把一些常量也存进去了。可以用 ONNX 自带的优化工具做一次模型精简,把冗余的常量和无用节点去掉。
import onnx from onnxruntime.transformers import optimizer optimized_model = optimizer.optimize_model( "decoder.onnx", model_type="bert", num_heads=8, hidden_size=512 ) optimized_model.save_model_to_file("decoder_optimized.onnx")这个优化器会做算子融合、常量折叠、冗余节点消除等操作,通常能减小 10% 到 30% 的体积,同时提升推理速度。不过要注意,优化后的模型要重新做数值验证,确保输出没变。
加载速度方面,如果服务需要频繁重启,可以考虑把模型加载做成常驻进程,或者用内存映射的方式加载。ONNX Runtime 支持从字节流加载模型,配合缓存机制能减少磁盘 IO。
6. 从验证到上线的完整检查清单
6.1 数值一致性验证
上线前必须做严格的数值验证。我的做法是准备一组测试集,覆盖短句、长句、特殊符号、数字、专有名词等场景,分别用原生模型和 ONNX 模型跑一遍,对比输出。
对比不能只看最终译文,还要看中间张量。编码器输出、解码器每步的 logits,都要算最大绝对误差和相对误差。正常情况下,float32 的误差应该在 1e-4 以内。如果某个张量误差偏大,就定位到对应的算子,检查是否有精度问题。
def compare_outputs(native_out, onnx_out, name): diff = np.abs(native_out - onnx_out) max_abs = diff.max() max_rel = (diff / (np.abs(native_out) + 1e-8)).max() print(f"{name}: max_abs={max_abs:.6f}, max_rel={max_rel:.6f}") return max_abs < 1e-36.2 边界场景测试
翻译模型的边界场景比一般模型多。空输入、超长输入、纯符号输入、混合语言输入,都要测。特别是超长输入,要确认动态维度真的生效,不会因为序列太长而崩溃或输出截断。
还有一个容易被忽略的场景是批处理中的变长输入。如果一批里有长有短,短句补齐后,掩码是否正确屏蔽了填充位置。这个测试能暴露很多掩码相关的 bug。
6.3 性能基准与监控
上线前要建立性能基准,记录不同输入长度、不同 batch size 下的延迟和吞吐。这些数据是后续容量规划和问题排查的依据。
上线后要加监控,重点关注推理延迟的 P99 值、内存占用、以及输出质量的抽样检查。翻译服务有个特点,模型退化往往是渐进的,不会突然报错,而是译文质量慢慢变差。定期抽样人工评估,或者用自动指标监控,能及早发现问题。
我个人在实际操作中的体会是,ONNX 迁移这件事,七分靠准备,三分靠转换。环境版本对齐、模型结构理解、验证方案设计,这些前期工作做扎实了,转换过程反而很快。反过来,如果前期偷懒,后面排查问题的时间会成倍增加。另外,别指望一次转换就完美,留出迭代优化的时间,先保证功能正确,再逐步调性能,这个节奏比较稳妥。