
先说个场景两星期前我接了个部署任务要把一个 HuggingFace 上的英译中翻译模型弄进一台只有 2 核 CPU、512MB 内存的容器里跑实时推理。PyTorch 版本跑起来延迟在 1.5 秒左右内存还总是顶着上限线上差点出事故。后来花了两天时间把模型从 PyTorch 迁移到 ONNX再用 ONNX Runtime 做推理延迟直接降到 400 毫秒以内内存占用砍掉一半还多。这中间踩了不少坑也把整个转换链路摸透了。这篇内容不是那种“跑个 Demo 就完事”的教程而是把我从模型选型、权重下载、导出脚本编写、输出验证到 int8 量化的完整过程都拆开讲每一步都告诉你为什么这么做以及哪些地方容易翻车。如果你手里也有个 HuggingFace 上的翻译、生成或文本理解模型想迁移到 ONNX 做服务化部署这篇应该能帮你少走很多弯路。1. 为什么要把现成的英译中模型搬进 ONNX 部署先说核心问题HuggingFace 上的 Transformer 模型直接用 PyTorch 跑推理难道不行吗行但在生产环境里很难受。PyTorch 的动态图机制非常灵活灵活带来的副作用是推理时开销大尤其是在 CPU 上。Transformer 的每一步推理都涉及大量的张量操作、算子调度和内存分配PyTorch 在 CPU 上做这些事时很多算子要经过一层比较重的分发逻辑吃不满 CPU 的算力。我实测过一个 30MB 左右的 MarianMT 英译中模型单句翻译在 PyTorch CPU 模式下平均延迟 1.4~1.8 秒其中真正花在矩阵运算上的时间只占一半不到剩下都耗在框架调度和中间张量拷贝上。ONNX Runtime 解决的就是这个问题。它把模型静态化计算图在会话初始化的时候就做完了图优化、算子融合、内存规划推理时不再有动态图的调度开销。对于 BERT 类的 Encoder 模型ONNX Runtime 在 CPU 上通常能比 PyTorch Eager 模式快 1.5~3 倍对于 Encoder-Decoder 的翻译模型虽然瓶颈在自回归解码的串行过程里但每一轮 decode 的算子开销降下来整体延迟照样有明显改善。我自己实测的延迟情况是这样的推理框架平均延迟单句约 30 tokenCPU 峰值内存冷启动时间PyTorch EagerCPU1.4~1.8s620MB 左右3~4sONNX RuntimeCPU0.6~0.8s380MB 左右1s 内ONNX Runtime int8 动态量化0.35~0.45s300MB 左右1s 内这个表看起来像是宣传话术但数据我是用同一台机器、同一个模型权重、同一批测试句子跑出来的唯一变量就是推理后端。ONNX 迁移的收益就在这里不动模型权重不重新训练只是换了一下模型的表现形式和运行时部署指标就上了一个台阶。还有一个容易被忽略的点环境依赖。PyTorch 那个 torch 包装下来就得几百 MB再加上 transformers 库、tokenizers、huggingface_hub 等一堆依赖镜像构建起来非常臃肿。ONNX Runtime 的 CPU 包只有几十 MB压根不需要 Python 里的 torch 环境甚至可以配合 C 服务跑。像这次要迁的英译中模型迁完之后我的服务容器里连 torch 都不用装了只保留 onnxruntime 和 transformers 的 tokenizer 部分镜像体积直接从 1.2GB 缩到 400MB 左右。不过也别把 ONNX 想成万能药。如果你的模型是部署在 GPU 上且已经用 TensorRT 优化过那 ONNX 的收益就不那么明显。ONNX 最大的优势区域是 CPU 推理、边缘部署、异构环境以及不想被某个深度学习框架绑定的场景。在决定迁移前先想想你到底瓶颈在哪别为了“跟上技术热点”而迁移。2. 模型选型和权重获取HuggingFace 英译中模型的各种门道第一步不是写代码而是选模型。HuggingFace 上挂着几十个英译中翻译模型不同模型的结构、精度、license 都不一样选错了后面全白干。我这次用的是 Helsinki-NLP 的opus-mt-en-zh这是 OPUS 项目里的一套多语言机器翻译模型基于 Marian 架构也就是类似 Transformer 的 Encoder-Decoder 结构专门做翻译不是通用 NLU 模型。它训练数据是从 OPUS 语料库里捞的对新闻、日常对话、偏口语化的文本翻译效果还行适合做通用翻译服务。如果你要做法律、医疗这类垂直领域那最好找领域微调过的模型或者干脆自己微调一个。顺便说一嘴模型选型的坑。HuggingFace 上英文名看起来差不多的模型实际能力差很多。拿英译中来说有opus-mt-en-zh、mbart-large-50-many-to-many-mmt、nllb-200-distilled-600M这些。NLLB 模型翻译质量普遍更高但模型体量大得多推理速度也慢在 2 核 CPU 的容器里基本跑不动。MBART 是多语言生成模型把英译中当一个生成任务来做但它的分词方式和特化翻译模型不一样导出 ONNX 时还要额外处理语言编码输入复杂度高不少。我当时的判断标准很简单模型体积 500MB、推理延迟能接受、结构适合 OpenSeq2Seq 这类标准 Encoder-Decoder。OPUS-MT 完全符合而且它的权重只有 300MB 不到。权重获取上最常见的问题反而不是模型本身而是网络环境。HuggingFace 的模型文件动辄几百 MB直接调用transformers的from_pretrained时如果网络波动经常卡在Fetching......半天没反应。我个人习惯的做法是分两步走先把模型仓库完整拉到本地再离线加载。具体来说用huggingface_hub的snapshot_download把整个仓库下载下来命令大概是这样的pip install huggingface_hub python -c from huggingface_hub import snapshot_download; snapshot_download(repo_idHelsinki-NLP/opus-mt-en-zh, local_dir./opus-mt-en-zh-cache)下载完成后以后加载模型就直接指定本地路径或者设置TRANSFORMERS_OFFLINE1让所有from_pretrained都走本地缓存不再触碰网络。网络环境不佳的情况也可以配置HF_ENDPOINT环境变量指向你所在区域可用的镜像源这属于 HuggingFace 提供的标准下载配置不是什么野路子。总之一个原则别让线上服务在启动时依赖网络拉取权重权重文件一定要在构建镜像时或部署前固化下来。选好模型后加载方式也很关键。我强烈建议先用一句话做 smoke test确认模型加载正常、能出翻译结果再继续往下做 ONNX 转换。这个 smoke test 的代码非常简单from transformers import MarianMTModel, MarianTokenizer model_name ./opus-mt-en-zh-cache # 本地路径 tokenizer MarianTokenizer.from_pretrained(model_name) model MarianMTModel.from_pretrained(model_name) text Hello, this is a test sentence for machine translation. batch tokenizer(text, return_tensorspt) translated model.generate(**batch) print(tokenizer.decode(translated[0], skip_special_tokensTrue))如果这一步能稳定输出“你好这是一个用于机器翻译的测试句子。”之类的译文说明模型和分词器都在正常工作可以进入转换环节。这里要特别留个心眼MarianTokenizer的词汇表大小和模型 embedding 层维度是严格对应的如果加载模型时换了不同的分词器版本很可能在forward时报 index out of range这个坑后面我会再展开讲。3. 核心转换流程写一个稳定的 PyTorch → ONNX 导出脚本环境准备是第一步也是很多人不耐烦的一步。需要安装的包有四个torch、transformers、onnx、onnxruntime。转换机最好用 GPU 或者性能好一点的 CPU因为导出时要跑一次完整的前向计算CPU 太弱会非常慢。我的环境是 Python 3.10 PyTorch 2.1.2 transformers 4.36.2 onnx 1.15.0这个组合实测稳定。PyTorch 1.x 和 2.x 的torch.onnx.export接口差异不小如果你项目里还在用旧版本建议直接建一个干净的虚拟环境来跑导出不要在生产依赖里直接转。下面直接给导出脚本。先说思路把模型拆成 Encoder 和 Decoder 两个 ONNX 模型导出。为什么要拆开因为翻译模型是自回归解码的Decoder 每生成一个 token 都要跑一次完整的前向而且生成过程是动态长度的。如果整个模型打成一个 ONNX 图导入到 ONNX Runtime 后很难处理这种循环推理结构。拆开后编码器只需要跑一次把 encoder 的 hidden state 存下来解码器在循环里反复调用每一步生成一个 token。这正好符合 ONNX Runtime 的使用习惯——一个 session 跑一次前向返回结果再喂下一次。import torch from transformers import MarianMTModel, MarianTokenizer model_path ./opus-mt-en-zh-cache tokenizer MarianTokenizer.from_pretrained(model_path) model MarianMTModel.from_pretrained(model_path) model model.eval() # tokenizer 默认输出是 BatchEncoding转成 tensor 再传给 model sample_text [This is an example input.] inputs tokenizer(sample_text, return_tensorspt, paddingTrue, truncationTrue, max_length64) input_ids inputs[input_ids] attention_mask inputs[attention_mask] # 导出 Encoder torch.onnx.export( model.get_encoder(), (input_ids, attention_mask), encoder.onnx, input_names[input_ids, attention_mask], output_names[last_hidden_state], dynamic_axes{ input_ids: {0: batch_size, 1: encoder_sequence}, attention_mask: {0: batch_size, 1: encoder_sequence}, last_hidden_state: {0: batch_size, 1: encoder_sequence}, }, opset_version17, ) # 导出 Decoder需要构造一个假的 decoder_input_ids # Marian 系列 decoder 依赖 encoder 的输出所以把 encoder_outputs 一起作为输入 dummy_encoder_outputs model.get_encoder()(input_ids, attention_mask).last_hidden_state # 方便起见用一个长度为 2 的 decoder 输入序列 dummy_decoder_input_ids torch.tensor([[tokenizer.pad_token_id] * 2], dtypetorch.long) torch.onnx.export( model, ( dummy_decoder_input_ids, attention_mask, dummy_encoder_outputs, ), decoder.onnx, input_names[decoder_input_ids, attention_mask, encoder_outputs], output_names[logits], dynamic_axes{ decoder_input_ids: {0: batch_size, 1: decoder_sequence}, attention_mask: {0: batch_size, 1: encoder_sequence}, encoder_outputs: {0: batch_size, 1: encoder_sequence, 2: hidden_size}, logits: {0: batch_size, 1: decoder_sequence}, }, opset_version17, )导出完会得到encoder.onnx和decoder.onnx两个文件。可以用onnx.checker.check_model和onnxruntime的 inference session 做一次基本 sanity check确认图表能被正确解析。opset 版本这里选了 17因为 17 以上的 ONNX 算子集对 Transformer 里常用的LayerNormalization、Gelu等算子支持得更完整。如果 opset 太低导出时 TorchScript 会把某些算子分解成很底层的小算子推理时不仅慢还容易触发 ONNX Runtime 的 fallback 行为。转换过程中最常看到的警告是关于某个算子不支持或走了 fallback 路径。比如老版本 torch 在导出MarianDecoderLayer的层归一化时经常提示Unsupported operator Reshape之类的 warning这通常是 torch 版本和 ONNX 算子集不匹配导致的。解决办法不是无视警告而是把警告信息逐条捞出来确认是哪个算子然后去找对应的算子文档。如果只是性能相关的提示可以暂时不管如果是Exporting the operator ... to ONNX is not supported这种那就得改代码或者升级框架。有一个很容易被忽略的细节decoder 导出时虽然传了encoder_outputs作为输入但torch.onnx.export在 trace 模型时会把model.get_encoder()也跟着 trace 进图里。也就是说decoder.onnx里可能包含整个编码器的一份副本。这会让文件体积变大推理时白算一遍编码器。正确的做法是像上面代码里那样单独调用model.get_encoder()输出 encoder hidden state再手动传给 decoder同时在导出 decoder 时把模型封装成一个纯 decoder 调用比如先建一个nn.Module只执行model.decoder(input_ids, encoder_hidden_states...)这样才不会算重。这个坑我踩过一次导出后模型文件比预期大了整整一倍排查半天才发现是图里塞进了编码器。4. 转换后的质量验证拿同一句话跑一遍新旧模型迁移完不是结束验证才是关键。我在验证上吃过亏有一版模型导出后单测里跑了几句话看着都对结果测了一批长句时发现句首和句尾都有莫名其妙的字符后来才发现是 decoder 的输出头没对齐导致概率分布整体偏移。所以这一部分的验证不能省而且要按层级来。第一步验证是 logits 数值层面的对比不是看最终翻译句子。把同一个 batch 分别喂给 PyTorch 模型和 ONNX Runtime 模型取出每一步生成的 logits计算最大绝对误差和余弦相似度。我这里写了一个简单脚本来做对比import numpy as np import onnxruntime as ort from transformers import MarianMTModel, MarianTokenizer model_path ./opus-mt-en-zh-cache tokenizer MarianTokenizer.from_pretrained(model_path) pt_model MarianMTModel.from_pretrained(model_path) pt_model.eval() text The quick brown fox jumps over the lazy dog. inputs tokenizer(text, return_tensorspt) input_ids inputs[input_ids].numpy() attention_mask inputs[attention_mask].numpy() # ONNX Runtime sessions session_enc ort.InferenceSession(encoder.onnx, providers[CPUExecutionProvider]) session_dec ort.InferenceSession(decoder.onnx, providers[CPUExecutionProvider]) # 先把 encoder 输出跑出来 enc_out session_enc.run(None, {input_ids: input_ids, attention_mask: attention_mask})[0] # 用同样的 decoder input 跑 PyTorch 和 ONNX decoder_input_ids np.array([[tokenizer.pad_token_id]], dtypenp.int64) pt_logits pt_model.model.decoder( input_idstorch.tensor(decoder_input_ids), encoder_hidden_statestorch.tensor(enc_out), attention_masktorch.tensor(attention_mask), ).last_hidden_state ort_logits session_dec.run(None, { decoder_input_ids: decoder_input_ids, attention_mask: attention_mask, encoder_outputs: enc_out, })[0] diff np.abs(pt_logits.detach().numpy() - ort_logits).max() print(maximum absolute difference:, diff)正常来说因为浮点计算顺序不同PyTorch 和 ONNX Runtime 跑出来的 logits 会有微小误差最大绝对差在 1e-3 到 1e-5 之间都是合理的。如果误差超过 0.1那就要警惕了大概率是导出时某些权重被错误地静态化了或者某个算子在 ONNX 上走了不正确的分支。我个人的习惯是把最大绝对误差控制在 1e-3 以内才算通过数值验证。如果误差只在 1e-2 量级但翻译结果基本一致也要回头检查一下是哪个层造成误差不能直接跳过。第二步验证才是翻译句子层面的对比。准备一组包含长句、短句、数字、专有名词、口语表达的测试句子分别用 PyTorch 模型和 ONNX Runtime 模型生成完整译文然后逐句对比。这时候不用追求 token 完全一致因为采样策略和随机种子可能造成细微差异但语义必须一致、无明显乱码。有个值得注意的点ONNX Runtime 推理时生成循环需要自己写。transformers里的generate方法在纯 PyTorch 环境里可以直接用但切到 ONNX 后没法直接用generate得手动实现一个贪心解码循环。def greedy_decode_onnx(session_enc, session_dec, input_ids, attention_mask, max_len128): # 编码 enc_out session_enc.run(None, { input_ids: input_ids, attention_mask: attention_mask, })[0] # 初始 decoder 输入 decoded [] decoder_input_ids np.array([[tokenizer.pad_token_id]], dtypenp.int64) for _ in range(max_len): logits session_dec.run(None, { decoder_input_ids: decoder_input_ids, attention_mask: attention_mask, encoder_outputs: enc_out, })[0] # 取最后一个 token 的 logits看到 PAD 为止 next_token_id int(logits[0, -1, :].argmax(-1)) if next_token_id tokenizer.eos_token_id: break decoded.append(next_token_id) decoder_input_ids np.array([decoded], dtypenp.int64) return tokenizer.decode(decoded, skip_special_tokensTrue)这个手写解码循环虽然简单但反映了一个重要事实ONNX 模型推理时的“生成策略”和框架无关完全掌握在你自己的代码里。你可以在这个循环里加 beam search、加长度惩罚、加重复惩罚等自由度其实比transformers的 generate 更可控。我的建议是从贪心解码开始验证确认模型本身没问题后再逐步加复杂策略。验证阶段还有一个很容易踩的坑decoder input ids 的初始化。MarianMT的 decoder 输入通常是以 PAD token 开始的因为模型训练时 decoder 侧会先输入一个起始 token。如果你的模型起始 token 不是 PAD那上面代码里的pad_token_id就该换成eos_token_id或decoder_start_token_id。判断方法很简单先打一眼tokenizer.special_tokens_map或者直接打印tokenizer.convert_ids_to_tokens(decoder_start_token_id)。我最初用pad_token_id初始化怎么跑都出语法不通的译文换成decoder_start_token_id后立刻恢复正常。5. 上线前的性能优化动态量化、int8 和 Runtime 参数验证通过后模型已经能跑了但如果你和我一样要部署到 CPU 上接下来这一步才是大头——性能优化。ONNX Runtime 的动态量化接口叫quantize_dynamic它可以把模型中权重从 FP32 降到 INT8。整个过程不需要训练也不需要一个额外的校准集它用的是推理时动态计算激活值范围的方式操作起来非常方便from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( decoder.onnx, decoder_int8.onnx, weight_typeQuantType.QInt8, op_types_to_quantize[MatMul, Gemm, Attention, Gather], )量化之后模型文件的体积直接缩到原来的四分之一左右。精度方面翻译模型对量化的容忍度比我预想中高int8 量化后同一个测试集上跑出来的译文绝大多数句子和 FP32 版本完全一致偶尔有一两个句子词序微调但意思不变。如果你做了我前面说的 logits 对比会看到量化后最大绝对误差从 1e-5 量级涨到了 1e-1 量级这个幅度乍一看吓人但实际反映到 argmax 后的 token 选择上影响极小因为模型在最高概率的那个 token 上往往拉开很大差距。不过有个必须提醒的点不要对 Encoder 和 Decoder 无差别量化。Encoder 的输出会直接喂给 Decoder 的 cross-attentionEncoder 的微小误差会被 Decoder 每一层放大。我实测过Encoder 量化到 int8 后长句翻译质量会出现肉眼可见的下降而只量化 Decoder 影响就小得多。所以我的方案是 Encoder 保持 FP32Decoder 量化到 int8。这个取舍没有绝对标准建议你用自己的测试集跑一次对比再决定。量化做完再来调 ONNX Runtime 的 Session 参数。我一般会固定intra_op_num_threads并开启spin优化。形如import onnxruntime as ort options ort.SessionOptions() options.intra_op_num_threads 2 options.execution_mode ort.ExecutionMode.ORT_SEQUENTIAL options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL options.enable_profiling False session_enc ort.InferenceSession(encoder_int8.onnx, sess_optionsoptions, providers[CPUExecutionProvider]) session_dec ort.InferenceSession(decoder_int8.onnx, sess_optionsoptions, providers[CPUExecutionProvider])线程数这里不要盲目设大。我一开始设成0让 ORT 自己决定结果请求并发高时线程频繁切换延迟反而抖得很厉害。后来把线程数固定到和容器 CPU 核数一致延迟曲线立刻稳了。ORT_SEQUENTIAL模式适合单请求场景如果你的服务是并发的可以试试ORT_PARALLEL但要注意内存占用会上去。还有个我觉得特别容易被忽略的小地方模型文件重新加载时的内存映射。ONNX Runtime 支持sess_options.add_session_config_entry(session.intra_op.allow_spinning, 1)这样的优化配置但这些其实都是微调大头还是图优化和量化。最终我在容器里只保留了三个文件encoder.onnx、decoder_int8.onnx、tokenizer相关文件然后在服务进程里提前把两个 session 初始化好初始化发生在启动阶段不能放在首次请求中否则第一个请求的耗时直接爆表。行了说说性能优化的效果。同样的机器整条链路从 PyTorch 路径的 1.4~1.8 秒到 ONNX FP32 的 0.6~0.8 秒到 Decoder 量化 int8 后稳定在 0.4 秒左右。单请求峰值内存从接近 600MB 掉到 300MB 上下。这个提升对“能否上线”这件事是决定性的。6. 来回折腾中踩过的坑以及一些建议前面那些内容基本是我把正向流程走完了但过程中的坑比教程本身更值得记录。挑几个印象最深的展开说。第一个坑是 PyTorch 版本和 ONNX 算子集的匹配问题。我最初在 torch 1.10 上导出同一个模型不管怎么调参decoder.onnx在 onnxruntime 里跑都报Type Error具体是指Slice算子的某些输入维度对不上。查了一圈发现问题出在 torch 1.10 导出MarianDecoderLayer时某些view和reshape操作生成了不规范的 shape 推理结果。升级到 torch 2.x 后同样代码一次性导出成功。这也提醒我如果导出不顺不要一股脑钻到业务代码里去排查先检查框架版本是不是太老。版本矩阵错了后面的所有努力都是白费。第二个坑是关于分词器和模型不匹配的隐性风险。有一次我换了一台机器重新拉权重图省事直接pip install transformers --upgrade到最新版本结果模型推理时频繁报 index out of range。原因是新的 transformers 里MarianTokenizer的 vocab 文件和模型使用的 vocab 不一致tokenizer 把某个 token 映射成了模型 vocab 之外的 id。幸好在验证阶段用同一句话对比时发现了否则上线后中文乱码问题会很难定位。处理方式也很简单把 transformers 的版本固定在导出时的版本不要随意升级。如果你要换环境跑别只拷 .onnx 文件要把 tokenizer 的tokenizer_config.json、vocab.json、special_tokens_map.json一起带上这三个文件缺少任何一个解码都是玄学。第三个坑是decoder_input_ids使用方式的误解。我用贪心解码验证时一开始没有把历史 token 累积到 decoder 输入上而是每次只传入最后一个 token。这样跑出来的译文全是第一个 token 的重复看起来就像模型失去了上下文记忆。搞了半天才想明白虽然你想要模型只看最后一个 token 来预测下一个 token但这个翻译模型本身没有像 GPT 那种把历史 token 缓存成 key-value cache 的能力至少在这个导出方式下没有它需要用历史序列来重构 attention 的上下文。所以你在手写解码循环时decoder_input_ids 一定要不断 append而不是只喂最后一步。如果你想提高长句推理速度可以考虑给 decoder 接入 ONNX 的动态缓存机制但那是另一个工程量当前这个场景没必要。第四个坑比较隐蔽是关于 batch 维度和动态轴。导出时动态轴设了 batch如果你服务里习惯一次性把多个句子拼成一个 batch 丢进去模型的输出其实也会对应拼接。但在自回归解码时batch 内每个句子的终止时间不一样一个句子已经生成了 EOS另一个句子还在继续怎么处理这个“长度不齐”的问题我的方案是请求进来全部拆成单句逐句翻译必要时再用线程池并发。别看这个方案土它避免了动态 batch 解码的复杂性并且在线程数可控的情况下吞吐量完全够用。label 长度动态你可以靠decoder_input_ids的 shape 动态变化来体现但最初导出时固定了 opset 为 17已经包含了动态 shape 的能力所以不冲突。最后一条建议是关于这类迁移任务的工程流程。别一步到位我建议分三阶段推进。第一阶段本地跑通并完成数值对比第二阶段在测试环境做并发和稳定性压测第三阶段才上生产。每走一步都把对应的验证数据记录在案。特别是验证过的句子集合要固化下来任何一次模型更新都要先过同一套回归集再放量。迁移到 ONNX 这事说白了就是把一个“拖着整个 PyTorch 运行时”的模型换成一个轻量化的、可被专用运行时高效执行的图。做完这个英译中模型的迁移之后另一个体会也冒出来了HuggingFace 生态里很多模型其实都是现成的瓶颈往往不在模型能力而在部署环节。把 ONNX Runtime 这套链路吃透以后遇到类似模型时迁移成本会低很多。