
搞多语种内容平台这两年后台最离不开的服务就是英译中。最开始我直接从 HuggingFace 拉模型用 transformers 的 pipeline 几行代码就能跑开发期确实舒服。但一上线问题就来了线上机器不想装完整的 PyTorch 全家桶镜像体积直线上涨而且翻译服务主要跑在 CPU 上PyTorch 原生推理既慢又浪费资源。于是我把 Helsinki-NLP/opus-mt-en-zh 这个英译中模型迁到了 ONNX 格式用 onnxruntime 做推理整体依赖和资源开销都降了一大截量化之后体积和延迟更是进一步压缩。这篇文章把迁移过程中最关键的模型选型、导出参数、量化细节和生产落地经验完整写出来希望能让你少走弯路。1. 思路拆解为什么要把翻译模型搬上 ONNX1.1 线上服务怕的不是模型大是依赖重很多人以为模型上线最难的是模型本身真正头疼的是依赖链。一个 transformers 加 torch 的 Python 服务打包进 Docker 镜像通常在 2GB 以上传到内网仓库、拉起容器、每次升级都要忍受这个体积。更别提 torch 的线程库和机器的 CPU 特性绑定很深容器环境一变推理性能波动就很大。ONNX 是一种开放模型格式模型转出来后跟训练框架完全解耦运行时只需要 onnxruntime 一个依赖镜像体积能砍到原来的五分之一以下。翻译场景还有个现实需求服务可能会部署到多个区域、多规格的机器甚至未来挪到边缘设备。ONNX 的跨平台能力很强Windows、Linux、ARM 都有对应的 runtime模型文件本身不绑定任何训练框架这是 PyTorch 原生格式做不到的。后来我发现不只是翻译模型语音合成、视觉检测这类模型走 ONNX 路线的逻辑也基本一致把训练产物沉淀成运行时无关的图格式剩下的交给专用推理引擎去优化。1.2 迁移方案的取舍把 HuggingFace 模型迁移到 ONNX 有两条主流路线。一条是用 transformers.onnx 或 optimum 的 CLI 自动转换省事但灵活性差另一条是用 torch.onnx.export 手动导出 encoder 和 decoder可控性强适合要上生产、要定制量化的项目。我两条都走了先用 optimum 快速验证可行性再用 torch.onnx.export 重建后面所有量化和服务化都是基于手动导出的这套文件做的。这里要特别提醒ONNX 导出和量化本身不难难的是对模型结构的理解。MarianMT 是标准的 encoder-decoder 结构翻译时 encoder 只对源句跑一次把整句话编码成一组隐向量decoder 每次只看一个目标 token结合隐向量预测下一个 token循环往复直到遇到结束符。如果你对这套生成流程没有概念后面排查各种维度错误会非常痛苦。先把这一步想清楚后面所有问题都好解。2. 环境准备与模型选型2.1 模型选型为什么用 opus-mt-en-zhHuggingFace 上英译中的模型不算少我选了 Helsinki-NLP/opus-mt-en-zh。它是 MarianMT 架构encoder 和 decoder 各 6 层权重体积在几百 MB 量级CPU 上推理压力不大翻译质量对一般的内容场景完全够用。相比之下 NLLB-200 这类多语言模型虽然强但动辄好几个 GB对小服务来说不太划算。选模型时建议先想清楚场景是短句为主还是长文为主对延迟敏感还是对质量敏感纯 CPU 服务的话模型体积直接决定成本。opus-mt-en-zh 还有个好处是 tokenizer 和模型行为都很常规导出 ONNX 时基本不会遇到奇怪算子对新手非常友好。如果你的业务对术语质量要求更高也可以考虑在导入模型后用领域语料做一次微调再走后面这套导出流程逻辑是一样的。2.2 依赖版本与 HuggingFace 下载我的环境是 Python 3.10装齐下面这套依赖包名版本作用torch2.1.2导出模型时需要推理时不需要transformers4.38.2加载 tokenizer 和模型optimum1.20.0带 onnxruntime extra提供一键导出工具onnx1.15.0模型格式基础库onnxruntime1.17.1线上推理引擎版本别太激进我试过 transformers 4.44 加 optimum 1.22 的组合导出 MarianMT 时出现过兼容性问题后来锁回上面这组一切正常。环境装好后先下载模型export HF_ENDPOINThttps://hf-mirror.com huggingface-cli download Helsinki-NLP/opus-mt-en-zh --local-dir ./opus_mt_en_zh直接从 HuggingFace 拉模型经常超时把下载源切到国内镜像能明显提速下载完的目录结构跟官方一致后续 from_pretrained 直接指本地路径即可。这一步是整个流程里最不用动脑、但又最容易卡人的地方先把它解决再谈导出。3. 导出 ONNX 的实操过程3.1 用 optimum-cli 快速导出最快验证方案可行性的方式是装好 optimum 后一行命令完成导出optimum-cli export onnx --model ./opus_mt_en_zh ./opus_onnx导出完成后会得到 encoder_model.onnx、decoder_model.onnx、decoder_model_merged.onnx以及 config.json 和 tokenizer 相关的配置、词表文件。其中 decoder_model_merged 是把 past_key_values 缓存逻辑合并进图的版本推理时不用每次从头算 decoder 的注意力长句翻译时速度提升非常明显。如果只是验证方案直接配合 optimum 的 ORTModelForSeq2SeqLM 类就能跑起来from optimum.onnxruntime import ORTModelForSeq2SeqLM model ORTModelForSeq2SeqLM.from_pretrained(./opus_onnx)之后可以像普通 transformers 模型一样接 pipeline。但注意optimum 自动导出把很多配置固化在 config.json 里后面对模型做 INT8 量化或自定义推理时文件结构越复杂越容易出错。所以我后续生产用的手动导出方案下面详细说。3.2 手动 torch.onnx.export 兜底手动导出前要先理解 MarianMT 的输入输出。encoder 输入是 input_ids 和 attention_mask输出是 encoder_hidden_statesdecoder 输入是 decoder_input_ids、encoder_hidden_states、encoder_attention_mask 和 decoder_attention_mask输出是 logits。由于 ONNX 图不方便处理循环我们只导出单步推理的 encoder 和 decoder生成循环在外部用 Python 控制。import torch from transformers import MarianMTModel model MarianMTModel.from_pretrained(./opus_mt_en_zh) model.eval() class EncoderWrapper(torch.nn.Module): def __init__(self, m): super().__init__() self.enc m.get_encoder() def forward(self, input_ids, attention_mask): return self.enc(input_idsinput_ids, attention_maskattention_mask)[0] class DecoderWrapper(torch.nn.Module): def __init__(self, m): super().__init__() self.dec m.get_decoder() def forward(self, decoder_input_ids, encoder_hidden_states, encoder_attention_mask, decoder_attention_mask): return self.dec( input_idsdecoder_input_ids, encoder_hidden_statesencoder_hidden_states, encoder_attention_maskencoder_attention_mask, attention_maskdecoder_attention_mask, use_cacheFalse, )[0] enc_wrapper EncoderWrapper(model) dec_wrapper DecoderWrapper(model) dummy_ids torch.tensor([[model.config.decoder_start_token_id]], dtypetorch.int64) dummy_enc torch.randn(1, 32, model.config.d_model) dummy_attn torch.ones(1, 32, dtypetorch.int64) dummy_dec_attn torch.ones_like(dummy_ids) torch.onnx.export( enc_wrapper, (torch.ones(1, 32, dtypetorch.int64), dummy_attn), encoder.onnx, input_names[input_ids, attention_mask], output_names[encoder_hidden_states], dynamic_axes{ input_ids: {0: batch, 1: seq}, attention_mask: {0: batch, 1: seq}, }, opset_version14, ) torch.onnx.export( dec_wrapper, (dummy_ids, dummy_enc, dummy_attn, dummy_dec_attn), decoder.onnx, input_names[decoder_input_ids, encoder_hidden_states, encoder_attention_mask, decoder_attention_mask], output_names[logits], dynamic_axes{ decoder_input_ids: {0: batch, 1: dec_seq}, encoder_hidden_states: {0: batch, 1: enc_seq}, encoder_attention_mask: {0: batch, 1: enc_seq}, decoder_attention_mask: {0: batch, 1: dec_seq}, }, opset_version14, )这里有个容易踩坑的点MarianDecoder 的第一个 attention_mask 参数指的是 decoder 自身的自注意力掩码encoder 的掩码要单独放在 encoder_attention_mask 参数里。我第一次导出时把两者搞反了结果推理出来的翻译完全是乱码后来排查才发现是语义反了。导出完可以用 onnx.checker.check_model 校验一遍避免低级错误。3.3 导出参数背后的道理dynamic_axes 是 ONNX 导出中最重要的配置。翻译场景里每次输入长度都不一样decoder 循环里序列还会越变越长所以必须把 batch 和 seq 两个维度都标成动态如果只标 seq后面想批处理时就会报维度不匹配。opset 版本我选 14。ONNX 对算子版本的要求一直在涨opset 太老会触发算子 fallback运行时效率低opset 太新则要求 onnxruntime 版本足够新一些老机器上的 runtime 可能不支持。14 是目前兼容性较好的折中档我在这套环境上没遇到任何算子兼容问题。do_constant_folding 默认开启可以折叠一部分常量算子对推理有正面帮助不需要额外处理。另外建议导出前设置 torch.set_num_threads(1) 或 OMP_NUM_THREADS 环境变量让 trace 过程保持单线程。否则 torch 会自动开满 CPU 线程导出时间反而变长还容易出现莫名其妙的卡顿。4. ONNX 推理与结果对齐4.1 onnxruntime 下的推理循环ONNX 图本身不会替你管理生成循环翻译时要在外面手动模拟 generate。核心流程是先用 encoder 把源句编码成隐向量再拿 decoder 一步步产出目标 token直到遇到 eos 或达到最大长度。import numpy as np import onnxruntime as ort from transformers import MarianTokenizer tokenizer MarianTokenizer.from_pretrained(./opus_mt_en_zh) enc_sess ort.InferenceSession(encoder.onnx, providers[CPUExecutionProvider]) dec_sess ort.InferenceSession(decoder.onnx, providers[CPUExecutionProvider]) def translate(text, max_len64): tok tokenizer(text, return_tensorsnp, max_length512, truncationTrue) enc_out enc_sess.run(None, { input_ids: tok[input_ids], attention_mask: tok[attention_mask], })[0] dec_ids np.array([[model.config.decoder_start_token_id]], dtypenp.int64) enc_attn tok[attention_mask] for _ in range(max_len): dec_attn np.ones_like(dec_ids) logits dec_sess.run(None, { decoder_input_ids: dec_ids, encoder_hidden_states: enc_out, encoder_attention_mask: enc_attn, decoder_attention_mask: dec_attn, })[0] next_id np.argmax(logits[:, -1, :], axis-1, keepdimsTrue) if next_id.item() model.config.eos_token_id: break dec_ids np.concatenate([dec_ids, next_id], axis1) return tokenizer.decode(dec_ids[0], skip_special_tokensTrue)这段代码对应的是手动导出的 encoder.onnx 和 decoder.onnx不依赖 optimum 运行时。如果你用的是 optimum 导出的 decoder_model_merged.onnx输入会多出一长串 past_key_values 相关张量纯 ORT 写起来会繁琐不少。先跑通这段基础版再考虑加缓存优化这个顺序能省大量调试时间。session 创建一次放在模块级不要在每次请求里重复初始化这点在后面的部署部分还会再强调。4.2 与 PyTorch 的翻译结果对比模型迁移最怕结果对不上。我把同一批句子分别用 transformers 的 generate 和上面这段 ONNX 推理跑了一遍对比了 20 个句子绝大多数字面完全一致个别句子因为浮点精度和算子合并出现了同义词级别的差异比如 “the” 翻成 “一个” 而不是 “这个”语义完全不受影响。英文输入PyTorch 输出ONNX 输出The quick brown fox jumps over the lazy dog.敏捷的棕色狐狸跳过懒狗。敏捷的棕色狐狸跳过懒狗。The conference will be held in Beijing next month.会议将于下个月在北京举行。会议将于下个月在北京举行。Please send me the report before Friday.请在星期五之前把报告寄给我。请在周五前把报告寄给我。验证时建议先固定 greedy searchnum_beams1别急着上 beam search。beam search 要在外部自己维护多条候选序列复杂度高不少先保证 greedy 结果一致能更精准判断问题出在导出环节还是量化环节。等确认推理正确后需要更高的翻译质量再回来做 beam search也是合理的迭代路径。5. INT8 量化体积与延迟一起压5.1 动态量化的实操ONNX 模型量化有两条路线动态量化和静态量化。翻译模型这种输出张量粒度比较细的场景静态量化需要准备校准数据集对数据分布敏感调起来费劲我实际生产中用动态量化只把权重转成 INT8激活值在推理时动态计算。效果上体积和延迟都有明显收益对精度影响却很小。from onnxruntime.quantization import quantize_dynamic, QuantType for src, dst in [ (encoder.onnx, encoder_q8.onnx), (decoder.onnx, decoder_q8.onnx), ]: quantize_dynamic(src, dst, weight_typeQuantType.QInt8)QuantType 我选了 QInt8。QUInt8 在部分老 CPU 上可能更快但兼容性和数值表现不如 QInt8 可控我实测下来 QInt8 更放心。量化参数里还有个 per_channel默认 False按整个权重张量算一个缩放系数如果量化后精度损失明显把它改成 per_channelTrue量化粒度更细精度损失更小缺点是文件略大。我这边开启 per_channel 后翻译结果和 FP32 几乎一致。5.2 量化前后的数据对比我当时的运行环境是 8 vCPU、内存上限 4GB 的容器翻译“The quick brown fox jumps over the lazy dog.”最大生成长度 32预热后连续测 20 次取平均版本encoder 体积decoder 体积单句平均耗时内存峰值FP32约 130 MB约 180 MB约 320 ms约 1.1 GBINT8约 34 MB约 46 MB约 185 ms约 600 MB数字只是相对参考不同机器差异很大。关键信息是体积降到四分之一左右延迟大约减少四成内存峰值接近腰斩。对纯 CPU 小容器来说这个差距有时候就是一台机器钱的区别。精度方面我拿 200 条新闻标题做了对比INT8 和 FP32 只有不到 5% 的句子存在用词差异没有发现翻错语义的情况完全够用。6. 生产部署实战与常见坑6.1 服务化落地session 复用与线程模型模型文件准备好后我用 FastAPI 包了一层 HTTP 服务。这里最重要的不是接口代码而是 onnxruntime session 的使用方式全局只初始化一次多个请求线程共享同一个 session。onnxruntime 的 session 是线程安全的重复创建 session 反而会反复加载模型、浪费几百毫秒。我最初实现时把 session 创建写进了请求函数里压测一上来并发延迟立刻飙升改成模块级懒加载后问题消失。from functools import lru_cache import onnxruntime as ort lru_cache(maxsize1) def get_sessions(): enc ort.InferenceSession(encoder_q8.onnx, providers[CPUExecutionProvider]) dec ort.InferenceSession(decoder_q8.onnx, providers[CPUExecutionProvider]) return enc, dec还要注意容器启动后主动做一次暖机推理。我踩过坑ONNX Runtime 第一次跑某个 shape 的图时初始化开销不小如果正好赶上请求高峰线上就会出现明显毛刺。启动时拿一条固定句子先跑一次把初始化成本提前消耗掉这个动作效果很直接。另外建议所有 session 显式指定 providers避免不同环境的默认 provider 不一致导致性能差异。6.2 常见问题速查表现象原因解决办法导出时 CPU 占用 100% 且长时间卡住torch 多线程与 trace 冲突设置 OMP_NUM_THREADS4 或 torch.set_num_threads(1)结果全是同一个结束符decoder_input_ids 初始值没用 decoder_start_token_id检查 config 里的 decoder_start_token_id长句翻译报维度错误源句超过模型能处理的长度tokenizer 设置 max_length512 和 truncationTrue并发请求延迟剧增每次请求重复创建 session全局复用 session注意容器内存限制INT8 量化后结果明显变差默认量化粒度不够改用 per_channelTrue或调整 QuantTypeonnxruntime 报算子不支持opset 版本过新回退到 opset_version14并升级 onnxruntime模型下载一直超时默认下载源网络慢设置 HF_ENDPOINT 指向国内镜像后再拉取这些坑我基本都真实踩过。尤其是第一个新手最容易卡住导出是单线程 trace不需要 torch 开一堆线程加上环境变量限制就好。顺带一提这套用 onnxruntime 服务模型的思路并不局限于翻译语音合成比如 sherpa-onnx TTS、视觉检测比如 YOLO 导出 ONNX走的也是同一套路线先拿到标准格式的图再用轻量运行时接管推理最后按需量化部署。核心方法是一致的。把翻译模型迁到 ONNX 这件事我前后折腾了差不多两个星期。最开始以为难点在导出命令上后来才发现真正花时间的是理解 encoder-decoder 推理流程、设计动态维度、以及处理量化和部署里的各种细节。现在这套方案在线上已经跑了一段时间响应速度和服务资源占用都符合预期。如果你也在做类似的事我的建议是先用 optimum-cli 快速跑通全流程建立信心再手动导出做精细控制量化优先选动态量化部署前一定做好 session 复用和暖机。把这些点做到位ONNX 迁移这条路的性价比确实很高。