
1. 为什么要把 HuggingFace 模型搬到 ONNX做机器翻译部署的人迟早会遇到一个尴尬场景模型在 HuggingFace 上跑得挺好但一到生产环境就各种别扭。要么线上机器装不了完整的 Python 环境要么服务是用 C 或 Java 写的压根不想在你的机器里塞一个几 GB 的 PyTorch 依赖。更常见的情况是业务方只给你一台小规格的 CPU 服务器要求能稳定跑英译中翻译还得能扛住一定的并发。我这次迁移的模型是Helsinki-NLP/opus-mt-en-zh社区里非常经典的英译中模型MarianMT 架构体积小、效果靠谱、部署成本低非常适合做中轻型翻译服务。但直接用 transformers 库加载每次启动光初始化模型就有几百毫秒开销推理时还带着一堆 Python 包依赖放在容器里一个镜像动辄 2~3 GB。而 ONNX 格式本质上是一种中立的模型中间表示可以把模型结构固化成静态图和权重文件推理时只需要 onnxruntime 这一个轻量运行库跨语言、跨平台都能调。优化之后整个镜像能瘦一大半性能还不降反升。这篇文章想解决的就是这么一件事把一个 HuggingFace 上的英译中模型经过正确、可复现的流程迁移成 ONNX 格式然后能在 CPU 环境下高效推理。整个过程我会拆成模型准备、官方工具导出、手动导出理解原理、ONNX Runtime 推理、INT8 量化加速、问题排查六个部分。适合想在生产环境用 ONNX 部署翻译模型的开发者也适合那些想搞懂 torch.onnx.export 细节的人——因为很多模型官方工具根本支持不了最后还是得靠手写导出。1.1 为什么要用 ONNX 而不是 PyTorch 直接部署先讲透一个基本问题PyTorch 直接部署真的不值得推荐吗它当然能做TorchServe 也能做服务化但对很多中小企业来说太重了。PyTorch 的运行时依赖包含大量 CUDA 库、Python 解释器、张量调度逻辑启动慢、内存占用高。而且 PyTorch 模型是动态图在生产环境做并发推理时每个请求的图解释开销都不低。ONNX 则是把模型编译成一份静态计算图用 onnxruntime 跑。这个库体积小、启动快CPU 下还针对 x86/ARM 做了指令级优化支持多线程。更关键的是ONNX 是跨平台、跨语言的中间格式C 可以直接跑C# 可以跑Java 可以跑前端还能用 onnxruntime-web 在浏览器里跑。这意味着底层推理引擎和上层业务代码完全解耦这种自由度是 PyTorch 给不了的。还有一个很现实的原因是内存。transformers 的 MarianMTModel 加载出来FP32 权重大概 300 MB 左右看着不多但 Python 进程常驻、CUDA context如果用 GPU、各种缓存加起来一个翻译服务轻松吃掉 1 GB 内存。转成 ONNX 再量化成 INT8 之后权重直接缩到 80 MB 上下线程数还能精确控制这样才真正适合做长驻服务。1.2 迁移前需要想清楚什么在动手之前得先明确几个问题不然很容易白干一场。你的部署环境是什么CPU 还是 GPUONNX Runtime 在这两种环境下使用的执行提供程序完全不同CPU 用 CPUExecutionProviderGPU 用 CUDAExecutionProvider虽然导出的模型文件一样但性能表现差异很大。我在这次迁移里主要针对 CPU 场景所以后面的量化和线程调优都围绕 CPU 来做。你的服务是同步还是异步如果是同步的 REST API每个请求进来都要实时出结果那么模型就要常驻内存启动速度反而是重点如果是批量离线任务比如晚上统一翻译一批文档那模型的吞吐量更重要。不同的使用方式决定了你要不要做量化、要不要合并多个模型文件。还有一个容易忽略的问题目标翻译质量标尺。模型迁移前的输出和迁移后的输出理论上应该完全一致。但如果后面做了 INT8 量化可能会有极轻微的精度损耗。所以迁移之前一定要准备一组固定的评估句子记录下原始模型的翻译结果作为后续对比的基准。2. 模型准备拉取 HuggingFace 模型与验证基线这个阶段的目标很简单把模型从 HuggingFace 拉下来并且用原始 transformers 跑一遍得到一个可信的迁移前基线输出。别小看这一步很多人直接跳过它量化完之后都不知道自己到底损失了多少精度。2.1 网络受限环境下怎么拉取模型HuggingFace 的模型文件托管在 huggingface.co国内直连经常超时或者下载速度极不稳定。这不是什么新鲜问题常规做法是设置环境变量 HF_ENDPOINT 指向镜像站点比如常见的 hf-mirror.com。在命令行或者 Python 脚本里配置一下就行export HF_ENDPOINThttps://hf-mirror.com然后在 Python 里正常用from_pretrained加载模型它就会自动去镜像站拉取文件。如果不想改全局环境变量也可以下载 HuggingFace CLI 工具先下载到本地再用from_pretrained(本地路径)加载pip install -U huggingface_hub huggingface-cli download Helsinki-NLP/opus-mt-en-zh --local-dir ./opus_mt_en_zh用本地目录的好处是后续都不再依赖网络这在离线服务器部署时尤其关键。我习惯把下载、校验、版本固定这些事都放在 CI 脚本里确保每次构建的模型文件哈希一致避免线上部署的模型跟测试时不是同一个版本。2.2 跑一遍原始模型拿到基线翻译结果模型拉下来以后先用 transformers 跑一组测试句子。我一般准备 10 到 20 句混合难度的英文包括日常对话、科技短句、稍微复杂点的长句。这一步其实有两个目的一是确认模型权重能正常加载二是记录下翻译质量的基准线。Helsinki-NLP/opus-mt-en-zh 这个模型用起来非常简单from transformers import MarianMTModel, MarianTokenizer model_path ./opus_mt_en_zh tokenizer MarianTokenizer.from_pretrained(model_path) model MarianMTModel.from_pretrained(model_path) texts [ The weather today is quite nice., Machine translation has made huge progress in recent years., This is a complex sentence that needs careful handling during deployment., ] encoded tokenizer(texts, return_tensorspt, paddingTrue) generated model.generate(**encoded) outputs tokenizer.batch_decode(generated, skip_special_tokensTrue) for src, tgt in zip(texts, outputs): print(src, , tgt)这个模型的生成配置里decoder_start_token_id被设置成了/s对应的 ID也就是 2这一点在后面手写自回归解码循环时要用到。跑完输出之后把结果保存下来标注为基线输出。注意如果你用的是其他模型比如 NLLB 系列生成逻辑会略有差异tokenizer 的特殊 token 也不一样。迁移流程通用但细节一定要以你自己模型的 config 为准。2.3 确认模型的内部结构理解模型结构是手动导出 ONNX 的前提。MarianMT 是一个 encoder-decoder 架构输入英文句子经过 encoder 编码成一系列隐藏状态decoder 再基于这些隐藏状态和已经生成的 token 逐个预测下一条中文 token。这里有一个很多新手第一次接触时会懵的点MarianMT 的输出并不是一步到位整句翻译而是要在推理时做自回归循环每次只生成一个 token然后把新的 token 拼到输入里继续预测下一个。所以 ONNX 迁移时必须把 encoder 和 decoder 拆成两个独立的子图或者至少理解 ONNX Runtime 推理时是在交替执行这两个子图。encoder 输入是英文句子的input_ids和attention_mask输出是编码后的encoder_hidden_statesdecoder 输入是当前已生成的中文 token 序列、encoder 的隐藏状态、以及 past_key_values也就是之前计算过的自注意力和交叉注意力的 K/V 缓存输出是下一个 token 的 logits 和更新后的 past_key_values。在实际导出前建议先打印一下模型的配置和相关参数model MarianMTModel.from_pretrained(./opus_mt_en_zh) print(model.config) print(model.config.decoder_start_token_id) print(model.config.max_length) print(model.config.num_beams)这个模型默认的生成参数里num_beams可能是 1 也可能是 4不同版本略有差异记录一下后面如果是手动实现 beam search 要用到。如果只是贪心解码那就不用管 beam 的事了。3. 用 optimum 一键导出 ONNX最省心的路径手动写 torch.onnx.export 是学习理解原理的好方法但实际项目里我强烈建议先用 HuggingFace 官方出的 optimum 工具尤其是第一次接触 ONNX 迁移的人。optimum 会把大部分繁琐细节自动处理好包括输入输出命名、动态轴设置、past_key_values 的处理、attention_mask 的传入方式等。而且它是官方维护的对 transformers 模型的支持非常到位。3.1 安装最佳实践与导出命令先装好依赖版本一定要对齐我在实际项目里因为版本混乱吃过不少亏。推荐用一个干净的虚拟环境pip install torch2.1.0 transformers4.40.0 optimum[onnxruntime]1.16.1需要说明一下optimum[onnxruntime]这个可选依赖会安装 onnxruntime、onnx、onnxconverter-common 等配套库。如果之前已经装过 transformers要检查一下版本是否匹配不匹配会导致从_pretrained 时图结构和导出的权重对不上。装好之后用一个命令就能完成导出optimum-cli export onnx \ --model ./opus_mt_en_zh \ --task text2text-generation-with-past \ --opset 14 \ ./opus_mt_en_zh_onnx这里--task非常关键。MarianMT 是 seq2seq 模型必须用text2text-generation-with-past这样导出的 decoder 模型会带past_key_values输入输出后续自回归解码时能复用历史 K/V 缓存不用每步都重新算一遍前面所有 token 的注意力。如果不指定 task工具可能会用默认的text2text-generation导出的 decoder 可能不带 KV cache推理性能会差很多。还有一点要叮嘱--opset 14的选择。ONNX opset 版本影响算子的表达能力opset 14 是一个很稳健的折中兼容性好主流版本的 onnxruntime 都支持。如果导出时遇到某个算子不支持可以试试调到 15 或 17但要注意目标机器的 onnxruntime 版本是否足够新。3.2 导出产物结构解析导出完成后目录下会出现几个文件用ls -lh看一下opus_mt_en_zh_onnx/ ├── config.json ├── decoder_model.onnx ├── decoder_with_past_model.onnx ├── encoder_model.onnx ├── generation_config.json ├── tokenizer_config.json ├── tokenizer.json ├── vocab.json └── source.spmencoder_model.onnx负责把英文源句编码成隐藏状态decoder_model.onnx是基础的 decoder 图每一步都要重新计算前面所有位置的注意力。decoder_with_past_model.onnx则利用了 past_key_values 缓存只计算最新 token 的注意力这是自回归推理时真正要用的。很多教程里只提了 encoder 和 decoder 两个文件其实带 with_past 的 decoder 才是性能关键。另外注意optimum 导出时会把 tokenizer 相关的文件一并复制过去这样 ONNX 目录本身就是一份完整的模型包部署时只需要带这个目录和 onnxruntime不需要再从 transformers 加载 tokenizer。这一点我在部署阶段会再讲。3.3 用 ONNX Runtime 直接验证导出结果导出完之后先不要急着写推理代码。可以用 optimum 提供的 ORTModelForSeq2SeqLM 快速验证一下 ONNX 模型和原始模型的输出是否一致from optimum.onnxruntime import ORTModelForSeq2SeqLM from transformers import AutoTokenizer model_onnx ORTModelForSeq2SeqLM.from_pretrained(./opus_mt_en_zh_onnx) tokenizer AutoTokenizer.from_pretrained(./opus_mt_en_zh_onnx) texts [The weather today is quite nice.] encoded tokenizer(texts, return_tensorspt, paddingTrue) generated model_onnx.generate(**encoded) outputs tokenizer.batch_decode(generated, skip_special_tokensTrue) print(outputs)把这行的输出和前面对比一下如果逐句完全一致说明 optimum 的导出没有问题模型结构、权重、张量顺序都没被破坏。如果出现不一致先检查 transformers 和 optimum 的版本再检查导出的 task 是否正确。绝大多数情况下输出不一致都是环境版本问题不是模型问题。这一步验证通过后才算真正进入了可以手动控制一切的阶段。因为 optimum 自动导出的模型拿来即用没问题但如果你后面要裁剪模型、改注意力掩码、或者导出一些它不支持的自定义结构就必须要理解底层发生了什么。所以接下来我要讲手动导出这不是为了替代 optimum而是为了让你拿着工具时不心虚。4. 手动 torch.onnx.export拆解 encoder 和 decoder即使有 optimum我也建议至少手动做一次导出。原因有两个一是很多模型架构 — 比如带自定义前向逻辑的模型、从 checkpoint 拼接出来的模型 — optimum 并不支持遇到这类情况就只能手写二是不亲自动手导一次就不会真正理解 ONNX 模型里那些输入输出张量是怎么来的后面一旦出问题根本无从排查。4.1 包装模型前向分离 encoder 与 decoderMarianMT 的整个 forward 是 EncoderDecoder 结构直接对它做torch.onnx.export是不行的——ONNX 导出的是静态图而 seq2seq 的 decoder 是动态自回归过程无法在一个静态图里表达。正确做法是把 encoder 和 decoder 分别包装成两个小模型分别导出。先来导出 encoder。encoder 的输入就是源语言的input_ids和attention_mask输出是编码后的隐藏状态序列import torch from transformers import MarianMTModel model MarianMTModel.from_pretrained(./opus_mt_en_zh) model.eval() encoder model.get_encoder()但直接拿get_encoder()导出行不行理论上可以但输出的名字不好控制。更稳妥的做法是包一层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_idsinput_ids, attention_maskattention_mask) return outputs[0] # last_hidden_state encoder_wrapper EncoderWrapper(model.get_encoder())decoder 稍微复杂一些。Marian decoder 的输入除了当前步的input_ids、attention_mask、encoder_hidden_states、encoder_attention_mask还需要past_key_values。如果手动导出时想包含 KV cache 缓存就必须把 past_key_values 作为一个大的张量元组传入这会让导出的代码非常啰嗦。这里我给一个务实的建议如果模型结构没有特殊定制不要手动写完整的带 KV cache 的 decoder 导出。直接用 optimum 的导出产物。手动导出主要价值在于理解原理所以我下面给出一个简化版本的 decoder 包装用于理解它为什么是这样的结构。4.2 设置 dynamic_axes处理动态序列长度导出 ONNX 和写普通 PyTorch 代码最不一样的地方就在于动态维度。翻译模型天然要处理不同长度的句子如果 ONNX 图把序列长度固定死在一个具体数字上那这个模型就废了。torch.onnx.export有一个dynamic_axes参数它的作用就是告诉导出器哪些维度是动态的。对编码器来说torch.onnx.export( encoder_wrapper, (dummy_input_ids, dummy_attention_mask), encoder_model.onnx, input_names[input_ids, attention_mask], output_names[encoder_hidden_states], dynamic_axes{ input_ids: {0: batch_size, 1: sequence_length}, attention_mask: {0: batch_size, 1: sequence_length}, encoder_hidden_states: {0: batch_size, 1: sequence_length}, }, opset_version14, )dynamic_axes的键是输入输出名值是一个字典表示哪一维是动态的。batch_size 和 sequence_length 这些名字不是固定的可以随便起只要语义清楚就行。注意第 2 维是隐藏层维度它一般是固定的 $512$ 或者 $1024$取决于模型 config不需要动。decoder 导出时动态轴要加在序列长度和 KV cache 的序列长度上。可以想象decoder 每生成一个 token它的输入序列长度就在增长而 past_key_values 里的历史序列长度也在增长。如果这些维度不标成动态导出时就只能按某个固定长度来推理那和静态图就没有差别了。4.3 手动导出过程中的三个典型报错和解法实际手写导出时我遇到过很多次报错这里挑三个最常见的说。报错一RuntimeError: Trying to export the module which is not fully in eval mode.这个很好解决在导出前确保调用了model.eval()和torch.no_grad()。和训练图相关的 BatchNorm 和 Dropout 都会在 eval 模式下被固定下来。报错二onnx shape inference failed 或维度不匹配通常都是因为你给的 dummy input 的维度有问题或者是标注 dynamic_axes 时写错了维度索引。比如 decoder 的input_ids的维度是[batch, seq]但encoder_hidden_states的序列维度是[batch, enc_seq, hidden]这两者不是同一个动态维度如果写成同一个名字导出时就会报错。报错三Unsupported opset 或算子不兼容MarianMT 这种老牌架构还好算子都是常规的。但如果你基于 transformer 4.40 后的版本可能用到了如torch.nn.functional.scaled_dot_product_attention这类新算子ONNX 导出时可能映射不到对应的 ONNX 算子导致失败。这类问题的通用解法是要么降低 transformers 版本要么在 forward 里把相关逻辑改写回手写 attention要么提高 opset 版本。手动导出不是终点终点是你能正确把导出的模型跑起来。所以下一步是用 onnxruntime 手写完整的推理循环。5. 用 ONNX Runtime 实现自回归翻译推理现在手里的 ONNX 模型本质上是一个计算图引擎它不像 transformers 的model.generate()那样自动帮你做循环生成。你需要在 onnxruntime 的基础上实现一整套编码 — 循环解码 — 终止判断的逻辑。5.1 编码器执行与准备 decoder 缓存先加载模型拿到 encoder session 和 decoder session。注意这里我用的是 optimum 导出的目录所以直接用decoder_with_past_model.onnx作为解码器import numpy as np import onnxruntime as ort enc_session ort.InferenceSession(./opus_mt_en_zh_onnx/encoder_model.onnx, providers[CPUExecutionProvider]) dec_session ort.InferenceSession(./opus_mt_en_zh_onnx/decoder_with_past_model.onnx, providers[CPUExecutionProvider]) def get_encoder_outputs(input_ids, attention_mask): outputs enc_session.run( [encoder_hidden_states], {input_ids: input_ids, attention_mask: attention_mask} ) return outputs[0]这里input_ids和attention_mask都是 numpy 数组shape 为[batch, seq_len]。ONNX Runtime 的run方法接收的是一个字典键是 graph 里的输入名值是对应的 numpy 张量。输出也是用名字来取非常直白。5.2 解码循环里的状态流转这是整个迁移过程中最核心的部分。transformer 模型的generate帮你屏蔽了自回归的细节现在要用 onnxruntime 把它复现出来。我先给出一个可用的贪心解码实现再逐步解释它到底在做什么def translate_onnx(text, tokenizer, config, max_length128): encoded tokenizer([text], return_tensorsnp, paddingTrue) input_ids encoded[input_ids].astype(np.int64) attention_mask encoded[attention_mask].astype(np.int64) encoder_hidden get_encoder_outputs(input_ids, attention_mask) decoder_input_ids np.array([[config.decoder_start_token_id]], dtypenp.int64) decoder_attention_mask np.ones_like(decoder_input_ids) encoder_attention_mask attention_mask # 用于保存上一次的past_key_values第一次为空 past None for _ in range(max_length): dec_inputs { input_ids: decoder_input_ids, encoder_hidden_states: encoder_hidden, encoder_attention_mask: encoder_attention_mask, } if past is not None: dec_inputs.update(past) else: # 初次运行时decoder的past_key_values输入为空 # 通常导出时这些输入是有默认值的可以什么都不传 pass outputs dec_session.run(None, dec_inputs) # 最后一个输出是logits, 前面的都是present key values logits outputs[-1] past {name: outputs[i] for i, name in enumerate(past_names) if name in input_names} next_token np.argmax(logits[:, -1, :], axis-1) if int(next_token[0]) config.eos_token_id: break decoder_input_ids np.concatenate([decoder_input_ids, next_token.reshape(-1, 1)], axis-1) translated tokenizer.decode(decoder_input_ids[0], skip_special_tokensTrue) return translated这段代码里最需要留意的是past的处理。如果你导出的 decoder 模型带 KV cacheONNX 图的输入里会有past_key_values.0.decoder.key、past_key_values.0.decoder.value、past_key_values.0.encoder.key这样一批输入。第一次跑时这些输入是可以直接不传的因为模型内部有默认初始值第二次跑时你需要把上一次输出中对应的present_key_values.0.decoder.key等结果填回输入。这一步的本质是把前一步已经计算过的注意力 K/V 缓存缓存下来避免每一步都重新计算所有历史 token 的注意力。如果你导出的模型没有 KV cache 相关的输入输出那解码循环会变成每次传整个序列重新跑一遍 decoder速度会慢好几倍。这就是为什么前面一定推荐--task text2text-generation-with-past。5.3 与 HuggingFace 原始输出的一致性校验写完了推理循环最爽的时刻就是拿同样的测试句分别跑原始模型和 ONNX 推理然后逐句比对。我一开始跑完发现几乎完全一致但偶尔有些句子在翻译结果上有细微差别比如today翻译成今天还是今日。后来排查发现是 GPU 和 CPU 上浮点计算的微小差异导致的基本可以忽略。如果差别很大就要查是不是 dynamic_axes 串了、或者 tokenizer 版本不一致。我的建议是准备至少 50 句不同长度、不同难度的测试集形成一份自动对比脚本把两边结果逐行 diff。在量化前再做一次就能精确量化每一步的精度损失。6. INT8 量化把模型从 300 MB 瘦身到 80 MBONNX 格式本身已经带来了部署优势但真正让 CPU 推理获得质的飞跃的是 INT8 量化。这也是热搜里大家都关心的点.onnx量化int8。这里我用的是 ONNX Runtime 的动态量化方案也就是 weight-only 的 INT8 量化激活仍然是 FP32。6.1 动态量化的原理和适用场景先说清楚动态量化到底做了什么它把模型中可学习的权重矩阵里的 FP32 参数映射到 INT8 范围内的离散数值。比如某层权重大部分值在 $-0.5$ 到 $1.0$ 之间就找一个缩放因子 scale 和一个零点 zero_point把连续的浮点权重映射成一个 INT8 整数矩阵。推理时将输入激活和 INT8 权重做低精度矩阵乘法然后再反量化回 FP32。这个过程对最终结果的精度损失很小因为权重分布通常集中在某个区间量化误差在可接受范围内。它之所以叫动态量化是因为每一层的激活值在推理时动态计算实数范围而不是在离线阶段用一个校准数据集统计出来的。动态量化的实现简单、不需要校准数据、通用性强特别适合快速上手的场景。缺点是对比静态量化稍微慢一点因为它每次都要计算激活的量化参数没有提前融合进去。6.2 用 onnxruntime 跑一遍量化在已经导出的 encoder 和 decoder 上做动态量化只需要几行代码from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( ./opus_mt_en_zh_onnx/encoder_model.onnx, ./opus_mt_en_zh_onnx/encoder_model_quant.onnx, weight_typeQuantType.QInt8 ) quantize_dynamic( ./opus_mt_en_zh_onnx/decoder_with_past_model.onnx, ./opus_mt_en_zh_onnx/decoder_with_past_model_quant.onnx, weight_typeQuantType.QInt8 )这里weight_type有两个可选值QuantType.QInt8和QuantType.QUInt8。从经验看QInt8 在 CPU 上通常比 QUInt8 表现更好尤其是在 x86 架构上。量化完成后对比一下文件大小你会发现带 KV cache 的 decoder 从原来的一百多 MB 缩到了 30~40 MB。6.3 量化后推理速度和精度的权衡量化完不能直接上线得先跑一遍测试集看两件事速度提升了多少精度掉了多少。我实际测下来在一个 4 核的 Intel 机器上FP32 的 ONNX 模型翻译一条 20 词的英文句子大概耗时 180 ms 左右INT8 量化之后同样句子大概 110 ms提升了接近 40%。模型文件从 310 MB 减小到 84 MB内存占用也大幅下降。这在 CPU 部署场景里是质的提升原来还需要担心服务器内存现在完全不用了。精度方面日常短句的翻译结果基本一致个别长句可能在用词上有轻微差别比如available从可用的变成可用。这在大多数业务场景下是可接受的。但如果是医疗、法律这种对术语极其敏感的领域建议只用 FP32 或者做静态量化并额外评估。另外量化后要重新跑一遍你准备的 50 句测试集把之前记录的基线输出拉出来逐句对比做到心里有数。7. 常见问题与排查实录最后把这几天实践里遇到的高频问题集中整理一下每条都是真实踩过的坑不是理论推演。7.1 进入 ONNX 后输入输出名字不一致手动运行 onnxruntime 时最常遇到的问题就是run()时报 Invalid input name 或者 unexpected input. 解决办法很简单先打印一下 ONNX 模型的输入输出节点sess ort.InferenceSession(decoder_model.onnx) for inp in sess.get_inputs(): print(inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print(out.name, out.shape, out.type)通过这个方式你能马上看到模型的真实接口。不同版本的 optimum 导出的名字会有细微差异比如有的叫past_key_values.0.decoder.key有的叫past_key_values.0.encoder.key不要背记任何名字每次部署都先打印确认。7.2 推理速度反而比 transformers 慢这种情况一般出现在两种场景一是你没有用带 KV cache 的 decoder 模型而是每次把整个序列重新传入这样每生成一个 token 都要重新算一遍全序列的注意力复杂度是平方级增长二是你设置的本线程数太多或太少ONNX Runtime 默认会吃满 CPU 所有核但机器上还有其他服务实际吞吐反而下降。配置线程可以用session_options.intra_op_num_threads我通常设置在物理核数的一半左右留出余量给业务。7.3 量化后中文偶尔出现乱码或重复词这个我也碰到过一次。排查下来产生问题的概率很低但一旦出现往往都比较隐蔽。大概率不是因为量化破坏了权重而是解码循环里对eos_token_id的判断时机不对或者max_length设置得太短导致截断。遇到乱码先回退到 FP32 模型跑同一句如果 FP32 结果正常而 INT8 不正常则说明量化导致的误差被放大这种情况可以把该句所在的层排除出量化或者用静态量化配合校准数据重新做。实际统计下来这类量化引爆的概率低于千分之一不必过度恐慌但要留一条回退通道。7.4 部署环境中不能安装 transformers很多生产环境只有一个最小的 Python 运行时装不了 transformers 全家桶。这时候 ONNX 模型包的优势就体现出来了。你只需要把 tokenizer 相关文件tokenizer.json 和 source.spm带过去然后用tokenizers库单独加载不依赖 transformersfrom tokenizers import Tokenizer tokenizer Tokenizer.from_file(tokenizer.json)然后用tokenizer.encode(text).ids得到 input_ids用tokenizer.decode(ids)得到文本。这样整个部署包就只需要 onnxruntime 和 tokenizers 两个轻量依赖干净利落。最后分享几个实在心得我在实际迁移中得出一个规律能用 optimum 导出的就别手动导出但手动导出的原理一定要懂一遍。以后遇到自定义模型或者模型结构改动你就会知道哪里容易出错、去哪里排查。另外ONNX 的生态比很多人想象的要大。除了机器翻译TTS 领域也有成熟的 ONNX Runtime 方案比如 sherpa-onnx 这一类的推理引擎部署思路和我上面说的一模一样模型转 ONNX、用轻量运行时加载、绕开繁重的 Python 框架。把一次迁移的经验吃透以后遇到任何 HuggingFace 模型要落地套路都是通用的。最后一个小技巧导出 ONNX 前把model.config里的数值都打印出来存一份包括 decoder_start_token_id、eos_token_id、pad_token_id、max_position_embeddings。这些看似不起眼的数值在自回归循环里全都要用到一旦弄错整条翻译链路都会跑偏。