去年年中我接了一个内部知识库辅助工具的部署任务核心功能是让用户用英文提问系统自动翻译成中文再交给后面的中文检索链路。机器是常见的 Linux 容器只有 4 核 CPU、4GB 左右内存没有 GPU 可用。我一开始图省事直接装了 PyTorch 和 transformers从 HuggingFace 拉了一个英译中模型下来用 pipeline 跑通功能就准备上线。结果一测就傻眼整个容器镜像膨胀到接近 2GB启动要十几秒翻译一个短句居然要烧 2 秒多。后来我把这个 HuggingFace 上的英译中模型完整迁移到了 ONNX配合 ONNX Runtime 做推理依赖体积缩到了原来的五分之一以下单句翻译时间从 2.4 秒降到了 0.9 秒左右。这篇文章就是这次迁移的全过程复盘包括模型选型、导出、解码循环、量化以及最后上线前必须处理的一堆细节。1. 背景一台只有 CPU 的服务器装不下也更跑不动 PyTorch先说清楚这次部署的具体约束。那个容器环境算是比较典型的线上环境CPU 核数不多内存紧张没有 GPU 加速卡而且部署时还不能随便装大型 Python 包。PyTorch 加上 transformers 全家桶光是 torch 的安装包就有几百 MB算上依赖、缓存和模型权重容器的镜像体积轻轻松松逼近 2GB。最关键的还不是体积而是推理性能。我做了个简单计时用默认 pipeline 翻译一句 20 个词左右的英文CPU 下大概要 2.4 秒这里还只是单条请求的延迟一旦并发上来4 个核的机器基本就卡死了。换到 ONNX 的动机说白了是三笔账。第一笔是运行时体积。ONNX Runtime 的 CPU 版安装包只有几十 MB配合离线加载 tokenizer 和模型文件整个部署依赖可以压到 PyTorch 方案的五分之一以下。这对容器镜像、云函数、边缘盒子这类资源受限的场景非常关键镜像小冷启动也快。第二笔是推理性能。ONNX Runtime 在 CPU 上有图优化和算子融合Transformer 结构里的 LayerNorm、Softmax、矩阵乘这些算子会被合理合并并利用底层指令集做加速。后面再叠加 INT8 动态量化纯 CPU 上获得的提速是 PyTorch 直接跑很难达到的。我这次实测的单句延迟曲线很直观PyTorch 2.4 秒ONNX FP32 约 1.6 秒ONNX INT8 约 0.9 秒。第三笔是可维护性和跨语言调用。ONNX 是开放的模型格式Runtime 支持 C、Java、C#、Rust 等主流语言。如果生产环境的主程序不是 Python你完全可以把模型文件直接交给对应语言的 Runtime 去加载不必在服务里硬塞一个 Python 进程来维持 PyTorch 环境。不过在动手之前必须想清楚一件事ONNX 解决的是模型怎么高效跑起来的问题并没有帮你解决模型怎么导出和翻译算法怎么实现的问题。尤其是英译中这类 Seq2Seq 模型导出的是一堆网络权重和算子图真正的翻译过程——那个一个 token 一个 token 往外蹦的循环——需要你自己在代码里维护。这一点后面我会专门用一章展开因为它是整个迁移过程中最容易被低估的部分。1.1 迁移前先确认的三个前提我建议任何人在动手迁移前先确认三件事可以避免做到一半发现方向跑偏。第一目标模型是 Encoder-Decoder 架构还是 Decoder-only 架构。翻译模型绝大多数是前者比如 Marian、NLLB、T5 系列但也有人想用大语言模型做翻译那结构完全不同导出和推理方式也完全是另一套逻辑。第二HuggingFace 上是否已有社区验证过的 ONNX 导出配置。如果一个模型已经有大量导出的问答记录说明坑已经被前人踩得差不多了。第三你打算用贪心解码还是 beam search。贪心解码自己写循环不难beam search 的复杂度要高一个量级需要的工程时间也完全不同。把这三件事想清楚再决定要不要迁、迁到什么程度。以我这次的经验Marian 这类小模型迁移非常顺一两天就能走完全链路如果是几百亿参数的大模型或者业务对翻译效果要求极高、必须上重量 beam search那成本就要重新估了。1.2 迁移不是零成本但投产后的收益通常大于成本有一个常见误区是认为换个 Runtime 很轻松。其实不是。HuggingFace 模型在 PyTorch 里跑得好好的不代表导出成 ONNX 后能一键跑起来。编码器、解码器要分开导出动态轴要处理KV cache 的维度要对齐。尤其是解码器每次生成的 token 都要回传新的 key/value这里一旦处理错了翻译结果会逐渐跑偏而且不太容易定位。但是把这些成本算进去之后结论仍然明确只要你的业务需要在 CPU 上长期运行翻译服务ONNX 迁移就是值得的。原因很简单CPU 是绝大多数生产环境的默认配置ONNX Runtime 加上量化带来的性能收益是实打实的而且它不绑定 Python往后的部署选项也多得多。2. 选型Marian 还是 NLLB英译中模型迁移前先看结构HuggingFace 上常见的英译中模型大体可以分两类。一类是 MarianMT 系列下的 opus-mt-en-zh 这类 checkpoint结构是标准的 6 层 Encoder 加 6 层 Decoder参数量在 8000 万以内FP32 权重约 300MB是典型的中小体积 Seq2Seq 翻译模型。另一类是 NLLB 系列比如 nllb-200-distilled-600M虽然也是 Encoder-Decoder 结构但参数量更大、多语言能力更强同时还需要在输入层额外传入源语言和目标语言的标识 token推理逻辑更复杂一些。为什么选型会直接影响迁移成本因为 ONNX 导出本质上要处理模型的输入输出接口。Marian 这类模型的输入就是 input_ids 和 attention_mask输出是 logits结构非常干净。NLLB 则需要在每个句子的开头拼接语言标识 token这会让 tokenizer 的配置和解码循环的写法都变得复杂。如果你的业务只是纯粹的英文到中文翻译没有多语言需求Marian 的 opus-mt-en-zh 是迁移成本最低的选择。如果业务需要多语言互译或者对长句和专业领域译文的流畅度要求很高NLLB 的 distilled 版本会更强。但代价也很明显模型体积大导出后的 ONNX 文件更大CPU 推理延迟更高量化后的体积也不在一个量级。就我个人经验来说先用一个结构简单的小模型把完整链路跑通确认导出—推理—验收这套流程没问题再换更大的模型是代价最小、也最容易出结果的路径。我这次最终选的就是 opus-mt-en-zh。理由有三个结构简单导出后只有 encoder 和 decoder 两个核心 ONNX 文件排查问题容易体积适中INT8 量化后大约 180MB放在容器里很从容社区使用广泛网上能搜到的踩坑记录也多遇到问题不容易卡死。2.1 从 forward 签名预判迁移难度的小技巧有一个很实用的判断方法在确认模型到 HuggingFace 模型卡的代码页面看一眼模型的 forward 签名。如果 forward 参数里出现了 past_key_values、encoder_hidden_states、use_cache 这类关键字说明这是一个标准的 Seq2Seq 模型导出时需要显式处理 KV cache。如果 forward 参数只有 input_ids 和 attention_mask迁移会轻松很多。这个方法不用读源码只花两分钟看签名就能对迁移成本有个初步判断。我当时就是先用这个方法筛了几个候选模型发现有的社区模型 forward 写得很随意自定义逻辑多光看签名就知道导出大概率会报错果断放弃。2.2 TorchScript 兼容性也是一个隐藏变量选模型时还有一个容易被忽略的点模型本身的 TorchScript 兼容性。ONNX 导出本质上走的是 TorchScript 的 tracing 或 scripting 路径如果模型里有 Python 控制流、动态形状、自定义算子导出时很容易中断或产生错误的图结构。Marian 这类官方维护良好的模型兼容性很好NLLB 也基本稳定但社区个人训练的模型就要谨慎验证。建议在正式导出之前先跑一轮最小测试用一个极短的句子走一遍 torch.onnx.export快速确认算子能不能被完整记录。这一步只需要几分钟却能帮你筛掉很多后续可能花上一整天的报错。3. 导出比起 torch.onnx.export我推荐先用 Optimum 走通全链路工具选型上我直接给结论如果目的是把模型迁到 ONNX 并在生产环境跑起来先用 Optimum 走通全链路是最合理的。Optimum 是 HuggingFace 官方维护的优化工具链里面的 ONNX 导出部分封装了 ORTModelForSeq2SeqLM 入口。它会把 Seq2Seq 模型自动拆成编码器、无缓存解码器、带缓存解码器三个子模型并处理好动态轴、past_key_values 的输入输出、以及 use_cache_branch 等细节。实际代码量非常少from optimum.onnxruntime import ORTModelForSeq2SeqLM from transformers import AutoTokenizer source Helsinki-NLP/opus-mt-en-zh output ./onnx_opus_mt_en_zh model ORTModelForSeq2SeqLM.from_pretrained(source, exportTrue) model.save_pretrained(output) tokenizer AutoTokenizer.from_pretrained(source) tokenizer.save_pretrained(output)跑完之后输出目录下会生成 encoder_model.onnx、decoder_model.onnx、decoder_with_past_model.onnx以及模型配置文件、tokenizer 文件。这三个 ONNX 文件分别对应编码器、无缓存的解码器和带缓存的解码器。推理时第一步用无缓存 decoder从第二步开始换成带缓存 decoder并且把上一轮得到的 key/value 作为输入继续传进去。推荐 Optimum 的核心原因是它把最容易出错的两个环节都处理掉了。第一个环节是动态轴。翻译模型的输入序列长度不定而原生 ONNX 默认要求每个输入张量维度固定所以必须在导出时明确声明哪些维度是动态的。Optimum 会自动为 batch、序列长度、解码步数设置好动态轴不需要手动干预。第二个环节是 KV cache 拼接。带缓存 decoder 要把每一轮新算出的 key/value 拼接到历史缓存上这个拼接逻辑放在模型内部由 ONNX 算子完成。自己用 torch.onnx.export 处理的话不仅要手工组织张量形状还要在循环代码里管理缓存业务代码会瞬间膨胀。当然原生路径也不是没有价值。我当时为了弄透原理也手动写过一遍 torch.onnx.export对模型结构的理解确实更深。后面专门写一节。3.1 两种导出方式的对比两种路径我都试过直接把对比结论整理出来。对比项Optimum 一键导出原生 torch.onnx.export编码器/解码器拆分自动处理需要手动拆模块动态轴处理自动声明需要逐输入手动设置KV cache 输入输出自动生成带缓存 decoder需要自行拼接和管理解码循环封装已封装好推理逻辑完全自己实现排错难度中等封装层次多较高但原理透明适用场景生产部署、快速验证深度定制、学习原理结论很直白第一次做迁移的时候先用 Optimum性价比最高。等整体流程跑通、你理解了内部结构再针对业务需要去用手写导出和自定义逻辑思路会清晰得多。3.2 原生导出时最关键的是动态轴设置如果你还是想自己写 torch.onnx.export最核心的细节就是动态轴设置这里举例说明。encoder 导出时输入是 input_ids 和 attention_mask输出是 encoder_hidden_states。动态轴至少要把 batch 维度和序列长度维度都标出来。容易漏的是 attention_mask只给 input_ids 开动态轴没有给 attention_mask 同步开实际推理时只要输入长度一变马上就会报维度不匹配。同理encoder_hidden_states 的序列长度维度也必须跟着动态化。decoder 导出时就更复杂了它要输入的包括 input_ids、encoder_hidden_states、encoder 的 attention_mask还有一组 past_key_values。每组 key/value 张量的形状是 batch size、num_heads、过去时间步数、每头维度其中过去时间步数会随着解码进度不断增长必须声明为动态轴。整体思路就是一句话凡是会随句子长度或解码步数变化的维度全部声明成动态轴漏掉任何一个都会在解码进行到第二步的时候报错。4. 解码循环ONNX 只导出了网络翻译逻辑还得自己写这是整个迁移过程中最容易被低估的部分也是很多初学者卡住的地方。ONNX 模型描述的只是一次前向计算的网络结构。而翻译是一个循环过程先把完整源句子喂给编码器得到编码表示然后让解码器从句子起始符号开始一个 token 一个 token 地生成目标译文每生成一个 token就把它作为下一步的输入喂回去直到遇到结束符号或者达到最大长度。PyTorch 里这一切被 model.generate 封装了你不需要关心内部循环。但 ONNX 没有标准化的 generate 算子至少在常见的 Runtime 版本里没有。所以你必须自己在业务代码里维护解码循环。不过理解这个循环并不难拆成三个阶段看就清楚了。编码阶段加载 ONNX 会话把源句子的 input_ids 和 attention_mask 传进去得到 encoder_hidden_states。这个张量在后面的每一步解码中都要反复作为输入传给 decoder所以要提前缓存在内存里。解码起步把 decoder 的输入 input_ids 初始化为起始 token。Marian 的 tokenizer 里有明确的起始标记具体要根据模型配置确认。这一步如果用的是 Optimum 的 ORTModel已经被封装了自己实现时很容易忘记或者把起始 token 找错导致第一轮就产出垃圾内容。循环解码每一步把当前 input_ids 和缓存的 past_key_values 传给 decoder拿到新的 logits 和更新后的 key/value。logits 里对应当前位置的那个 token 概率分布用 argmax 选出下一个 token然后把它 reshape 成 [batch, 1] 作为下一轮的输入。4.1 带缓存和不带缓存 decoder 的设计逻辑Optimum 导出时会生成两个 decoder 文件它们的区别就在这里。decoder_model.onnx 是不带缓存的版本用于第一轮。因为此时还没有任何历史 key/value输入里没有 past 信息。decoder_with_past_model.onnx 是带缓存的版本用于后续每一轮它的输入里包含上一轮的 key/value输出里则包含本轮更新后的 key/value。这样做的好处是解码到第 20 个 token 时不需要重新为前面 19 个 token 计算注意力只需要对当前 token 计算新的 key/value并拼接到历史缓存上。这就是 KV cache 机制也是翻译解码速度的关键。如果不用缓存每一轮都要把整个已生成序列重新过一遍解码器序列越长计算越慢复杂度成平方增长。4.2 贪心解码循环的骨架基于 onnxruntime 写贪心解码核心循环大概长这样。代码里我省略了张量轴对齐和数据类型的细节实际实现时需要用 numpy 认真处理这两块。import numpy as np import onnxruntime as ort encoder ort.InferenceSession(encoder_model.onnx, providers[CPUExecutionProvider]) decoder ort.InferenceSession(decoder_model.onnx, providers[CPUExecutionProvider]) decoder_with_past ort.InferenceSession( decoder_with_past_model.onnx, providers[CPUExecutionProvider] ) input_ids np.array([[bos_id]], dtypenp.int64) attention_mask np.array([[1]], dtypenp.int64) encoder_out encoder.run( None, {input_ids: source_ids, attention_mask: source_mask} )[0] outputs decoder.run( None, { input_ids: input_ids, encoder_hidden_states: encoder_out, attention_mask: source_mask, }, ) logits, past_kv outputs[0], outputs[1:] next_token np.argmax(logits[:, -1, :], axis-1) while next_token ! eos_id and step max_len: input_ids next_token.reshape(-1, 1) outputs decoder_with_past.run( None, { input_ids: input_ids, encoder_hidden_states: encoder_out, attention_mask: source_mask, past_key_values: past_kv, }, ) logits, past_kv outputs[0], outputs[1:]这里最值得注意的地方是第一步和后续步的模型不同。很多人在导出后直接拿着带缓存模型跑第一次推理结果因为 past 为空而报错或者全程用不带缓存的模型导致速度非常慢。把两个会话的分工搞清楚整个循环就顺了。4.3 什么时候要自己写 beam search从工程角度讲先跑贪心解码是最稳的逻辑简单、结果稳定、便于对照验证。如果业务对翻译质量要求更高想用 beam search就要额外面对几个问题多条候选路径的维护、每步对所有候选 logits 的排序与剪枝、长度惩罚和结束条件的处理。beam search 每一步都会对多个候选分别跑一次 decoderPython 实现的性能影响很大。我个人的做法是自己写贪心解码验证 ONNX 迁移的正确性如果业务需要 beam search优先使用 Optimum 内置的推理实现。它把 beam search 的解码逻辑封装好了你不需要自己实现一堆容易出错的候选管理代码。5. 量化把 FP32 压到 INT8英译中模型的实际收益模型能在 ONNX Runtime 上正常跑起来之后下一步就是性能优化。对 CPU 部署来说最直接的手段就是量化。翻译模型的大部分计算量集中在 Linear 层的矩阵乘法上矩阵乘法对量化的容忍度相对较高所以英译中模型非常适合做 INT8 动态量化。量化的基本原理是把 FP32 权重映射到 INT8 整数空间用容量换速度。动态量化的实现最简单不需要统计激活值的分布范围直接基于已有权重做转换调用一个函数就能完成。静态量化会把激活值也量化成 INT8推理更快但需要准备一组有代表性的校准数据来统计分布复杂度高不少。对大多数首次迁移的项目我的建议是先上动态量化。它代码改动小、风险低能拿到一部分提速和体积收益。如果跑完实测还不满足再投入静态量化。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic(encoder_model.onnx, encoder_model_int8.onnx, weight_typeQuantType.QInt8) quantize_dynamic(decoder_model.onnx, decoder_model_int8.onnx, weight_typeQuantType.QInt8) quantize_dynamic(decoder_with_past_model.onnx, decoder_with_past_model_int8.onnx, weight_typeQuantType.QInt8)这里有一个非常容易踩的坑带缓存的 decoder 也必须单独量化一份。推理时如果走的是带缓存路径而 session 加载的还是原来的 FP32 文件量化就等于白做了。我当时就是先量了前两个忘了第三个结果发现延迟没变化排查了半天才意识到这个问题。5.1 测试集上的实测数据我在一个内部英文到中文测试集上做了对比句子覆盖日常对话、技术文档、新闻简讯三类记录的是同一台 CPU 容器下的平均表现。这不算严谨的学术评测但作为工程选型参考已经足够。方案部署体积单句平均耗时译文质量PyTorch FP32约 1.1GB含运行时2.4 秒基线ONNX FP32约 310MB1.6 秒与基线一致ONNX INT8 动态量化约 180MB0.9 秒个别长句用词有变化模型体积的压缩主要来自 Linear 层权重Embedding 表仍然是 FP32所以体积从 310MB 降到 180MB 左右不是直接减半。速度提升则来自 INT8 矩阵乘法和算子融合的叠加。质量方面短句和常见句与基线几乎没差别少数带复杂从句的长句会出现用词调整但语义方向没有跑偏。5.2 量化后必须盯住的两个问题第一个问题是算子类型不全是 INT8。动态量化会把部分算子保留为 FP32 精度如果模型里小算子的比例高或者大量使用 LayerNorm、Softmax 这类非矩阵运算整体加速可能不如预期。所以量化之后别只看文件大小和理论提速一定要实测端到端延迟确认业务目标有没有达成。第二个问题是结果一致性验证。不要默认量化只影响速度、不影响质量。我在测试时确实发现INT8 下部分长句的输出与 FP32 有细微差别这是量化误差的正常表现。上线前建议准备一个固定回归测试集自动对比每个句子的输出长度和关键词语出现明显变化再人工复核。把这道检查放进发布流程能省掉大量上线后的麻烦。6. 迁移完成后的检查清单与常用配置很多人做到量化这一步就觉得项目收尾了。实际上还有几个检查项和配置习惯会在上线后持续影响稳定性。我按自己的经验列一份清单每一条都来自实际踩坑。先做一致性验证。ONNX 模型和原 PyTorch 模型使用同一 tokenizer、同一输入、同一解码策略时输出应该在浮点误差范围内完全一致。上线前一定要写一个对比脚本把 transformers pipeline 的结果和 ONNX 推理的结果逐句对比。如果发现某个句子的第一个 token 就不同八成是动态轴设置错误或 KV cache 拼接有误而不是模型本身的问题。再查 tokenizer 的一致性。ONNX 模型文件不包含 tokenizer但 tokenizer 的版本直接决定源句子的切分方式也就决定了进入模型的 input_ids。我遇到过部署环境自动升级 transformers 后tokenizer 行为发生变化导致同样的句子喂进去的 input_ids 完全不一样译文也随之变化。正确做法是在导出目录里固定保存 tokenizer 文件并在部署时离线加载不依赖联网更新。6.1 ONNX Runtime 的会话参数直接影响推理速度ONNX Runtime 默认的线程策略不一定适合你的 CPU 环境。我见过只设置 intra_op_num_threads 而不管 inter_op_num_threads 的情况多核 CPU 下反而产生额外的线程调度开销。建议按实际核数做一次小实验比较默认配置和手动配置下的端到端延迟。import onnxruntime as ort sess_options ort.SessionOptions() sess_options.intra_op_num_threads 2 sess_options.inter_op_num_threads 2 sess_options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL model ort.InferenceSession( encoder_model.onnx, sess_optionssess_options, providers[CPUExecutionProvider] )核心思路是让 intra 和 inter 线程数之和围绕 CPU 核数调整不要不设也不要设得比核数还大。graph_optimization_level 建议直接开到 ORT_ENABLE_ALL让 Runtime 做最大程度的图优化。6.2 opset 版本和模型更新也是隐藏的坑导出时如果默认 opset 版本过低某些 Transformer 算子可能不会被高效融合。我这边以 opset 14 作为稳妥默认值。另外HuggingFace 上的模型会不定期更新 checkpoint如果你的 ONNX 模型是从旧版本导出的而服务端代码用了新版本 tokenizer可能出现 input_ids 映射不一致。我在部署目录里会放一个简单的说明文件记录来源模型、导出日期、opset 和所依赖库的版本这样一旦出问题能快速定位。6.3 部署之后还能怎么优化如果 INT8 动态量化之后的性能仍然不够可以考虑两条路径。一条是批处理多个源句子同时走编码器摊薄线程调度和内存分配的开销翻译服务的吞吐量往往靠这个拉上来但要注意动态轴和内存占用。另一条是静态量化准备几百条代表性语料做校准激活值也压成 INT8推理延迟通常还能再降一截代价是维护校准流程模型更新后要重新校准。最后分享一点个人体会。这段迁移做下来我最大的感受是把模型转成 ONNX 本身不算难真正难的是把模型在推理时是怎么运转的这件事搞清楚。一开始我也在找一键导出工具后来老老实实把解码循环的原理弄明白再回头看 Optimum 的封装才知道它到底替我做了什么。如果你也想把 HuggingFace 上的英译中模型迁到 ONNX我建议按这个顺序来先用 Optimum 快速跑通全链路再用原生导出看一次内部结构最后再考虑量化和深度优化。这条路完整走一遍你对翻译模型前向过程的理解会远超那些只看过文档的人。