把 HuggingFace 的英译中模型搬到 ONNX,这个需求最近找我聊的人不少。多数场景都类似:模型在实验室里跑得好好的,一到部署就发现太重,要么没 GPU 机器,要么得塞进一个很小的容器里,要么并发一上来延迟压不住。我自己折腾过几轮之后,把整个链路理顺了,这篇就完整走一遍从 HuggingFace 原始模型到 ONNX 推理的迁移过程,包括踩过的坑和官方文档里不会写的小细节,适合正在做模型部署或者准备做服务化改造的团队参考。
1. 为什么要把模型迁到 ONNX
1.1 部署场景的真实痛点
HuggingFace 上现成的英译中模型,典型的是 Helsinki-NLP/opus-mt-en-zh 这类 encoder-decoder 架构,直接装上 transformers 库,一个 pipeline 就能跑翻译。这当然是最快的起步方式,但真正上线的时候,问题不是模型能力,而是模型跑起来的方式。
transformers 的 PyTorch 推理是“动态图”思路,每次前向计算都要重新构建计算图,框架元数据、Python 调度开销、CUDA 上下文初始化这些成本都算在单次推理里。CPU 上跑一段几十个 token 的英译中,常见的体验是几百毫秒到一两秒,这还没算多线程竞争的损耗。更麻烦的是,生产环境里不太可能每台机器都装完整版 PyTorch + CUDA + transformers 全家桶去跑一个翻译接口,镜像体积和依赖冲突会让运维很想打人。
ONNX 解决的正是这个问题。它把模型的计算图固化成静态图,权重和结构打包成一个或多个 .onnx 文件,推理时只依赖 onnxruntime 一个运行时库,不挑 Python 版本,不挑深度学习框架版本,也能被 C++、Java、C# 这些语言直接加载。接口层面甚至可以用 onnxruntime 提供的 C API 嵌到别的服务里,这是纯 Python 方案很难做到的。
1.2 迁移方案的选型
从 PyTorch 模型导出 ONNX,主流路线有三条:
一是直接用 torch.onnx.export。这是最底层的做法,灵活性最高,但需要自己处理动态轴、算子兼容性、模型输入输出映射,尤其遇到 encoder-decoder 这种带生成循环的结构,朴素地 export 一个完整模型几乎不可行,因为 ONNX 本身不擅长表达循环和条件分支。
二是用 HuggingFace 官方出品的 optimum 库。它封装了 transformers 模型到 ONNX 的导出流程,对常见的 BERT、GPT、T5、M2M100 这些架构都有预设支持,一条命令就能产出多个 onnx 文件,并且自动处理了 decoder 的 past_key_values 缓存逻辑。
三是 ONNX Runtime 自带的 transformers 优化工具,适合对导出的模型继续做算子融合和精度校准。一般作为第二步的补充,不是独立迁移路径。
我的推荐非常明确:主干用 optimum,遇到特殊结构再用 torch.onnx.export 做兜底。原因是 optimum 对 encoder-decoder 架构的处理是经过验证的,它会把模型拆成 encoder_model.onnx 和 decoder_model.onnx,而不是尝试把整个翻译模型塞进一个静态图里——这个拆法本身就是解决生成式任务导出的金钥匙。
1.3 英译中模型迁移的特殊性
翻译模型和分类模型在导出到 ONNX 这件事上,难度完全不在一个量级。分类模型是“一进一出”:输入文本,输出 logits,静态图一次前向搞定。翻译模型是“循环生成”:先编码源语言句子,得到一个表示整个句子的语义向量,然后一个 token 一个 token 地预测目标语言,每预测一个 token 都要把新预测结果喂回模型,直到输出结束符。
这个循环在 PyTorch 里可以由 Python 的 for 循环轻松写出来,模型本身只管单步前向。但 ONNX 要求把计算图固定下来,如果试图把整个 for 循环塞进图里,循环次数不定、每一步输入长度在变化,这是很棘手的动态控制流问题。所以实际做法是:把 encoder 和 decoder 分别导出成独立的 ONNX 文件,在 ONNX Runtime 外面用 Python 或 C++ 自己写生成循环。推理时先让 encoder 跑一次得到编码结果,再循环调用 decoder,每次把上一步的输出 token 和新一轮的 past_key_values 缓存一起喂进去,直到遇到 EOS 或达到最大长度。
理解了这个结构,后面所有操作就都说得通了。
2. 迁移前的结构拆解与导出原理
2.1 选一个具体模型来跑通链路
我用 Helsinki-NLP/opus-mt-en-zh 来演示整个流程。这个模型是 MarianMT 架构,专门做英语到中文的翻译,是 HuggingFace 上英译中模型里较为轻量、下载量很高、部署文档也比较齐全的一款。
选它不只是因为名气,还因为它结构简单清晰。MarianMT 本质是标准 encoder-decoder Transformer,没有多语言模型的 language token 处理逻辑,没有额外的 prefix 拼接,导出 ONNX 时不需要考虑太多定制化输入。如果后面换了其他型号的英译中模型,比如基于 mBART 的,流程一样,只是要多处理语言 ID 这个额外输入。
模型下载之后,先用 AutoModelForSeq2SeqLM 把它加载起来,跑一个简单翻译验证一下:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_name = "Helsinki-NLP/opus-mt-en-zh" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSeq2SeqLM.from_pretrained(model_name) inputs = tokenizer("Hello world.", return_tensors="pt") outputs = model.generate(**inputs) print(tokenizer.decode(outputs[0], skip_special_tokens=True))这一步是为了确认 baseline。后面所有迁移工作都以这个 PyTorch 输出的翻译结果作为正确性参照,所以 baseline 必须先跑通、先记录,不要跳过去。
2.2 ONNX 导出到底导出了什么
很多人以为导出 ONNX 就是把模型权重换个格式,这是常见的误解。ONNX 导出做了两件完全不同的事:
第一件是遍历模型的执行逻辑,把 PyTorch 动态计算图逐层翻译成 ONNX 的静态计算图。每种 PyTorch 算子都要映射到 ONNX 对应的算子,映射不了就会报错或生成低效的子图。这也是为什么导出的代码版本和 PyTorch 版本会影响导出结果——算子映射表是跟着版本走的。
第二件是把权重从 PyTorch 格式转成 ONNX 的序列化格式,嵌入到 .onnx 文件里。权重本来就是张量数据,转换过程基本无损,这也是为什么导出的 ONNX 模型在精度上通常和原模型几乎一致,真正的精度损失主要发生在后面做 INT8 量化的时候。
所以 .onnx 文件本质上是一个“结构 + 权重”的单一文件。结构决定算子执行顺序,权重决定每个算子的参数。ONNX Runtime 加载这个文件后,解析计算图,按照拓扑顺序调度算子执行。
2.3 翻译模型的 ONNX 输入输出到底长什么样
用 optimum 导出 encoder-decoder 架构后,会得到多个文件,其中核心是:
- encoder_model.onnx:输入 input_ids 和 attention_mask,输出 encoder_hidden_states,也就是源语言句子的语义编码。
- decoder_model.onnx:输入 decoder_input_ids、encoder_hidden_states,以及可选的 past_key_values 缓存,输出 logits 和更新后的 past_key_values。
- decoder_with_past_model.onnx:显式支持 past_key_values 缓存的版本,生成循环中从第二步开始调用这个文件,避免重复计算历史 token。
用 ONNX Runtime 推理时,最难理解的是 past_key_values 的管理。Transformer 解码器在预测下一个 token 时,需要用到之前所有 token 的 key 和 value 向量。如果每步都从头计算,复杂度是 O(n²),生成长句时会越来越慢;如果把它们缓存下来,每步只计算新增 token 的部分,复杂度就变成 O(n)。
在 PyTorch 里,这个缓存由模型的 past_key_values 参数自动管理,generate 函数内部帮你存好了。但 ONNX 导出是把“每步的计算”固化成图,past_key_values 必须当作显式输入传给模型,再把模型输出的新缓存取出来,自己存好、下一步再传进去。这就是为什么手写 ONNX 推理循环时,输入输出列表里会看到一大串 key、value 张量。
optimum 的 ORTModelForSeq2SeqLM 封装了这层逻辑,用起来可以完全不用管缓存细节。但如果想真正理解迁移过程,或者想自定义生成策略,这些张量的含义还是得搞清楚。
2.4 tokenizer 对齐问题
ONNX 导出只处理模型本身,不碰 tokenizer。tokenizer 仍然以 .json 或 .txt 文件形式存在,推理时用 transformers 库加载即可。这也意味着 tokenizer 和 ONNX 模型必须一一对应,不能用另一个模型的 tokenizer 来加载导出的 ONNX 文件,否则词表索引错位,翻译结果会变成乱码。
这块有个隐蔽坑:Helsinki-NLP 的 marian 模型,tokenizer 里包含特殊的语言 token,比如</s>和__en__、__zh__,这取决于具体型号。如果你在预处理时自行加了 special tokens 或者改了 padding 策略,模型的输入分布会发生变化,最终翻译质量可能下降,但程序不会报错,特别难排查。
我的建议是:tokenizer 的加载参数、预处理函数,导出前怎么写的,推理时就怎么用,原封不动复制过来。
3. 实操:把模型导出到 ONNX 并跑通推理
3.1 环境准备与依赖安装
迁移工作主要依赖四个库:torch、transformers、optimum、onnxruntime。版本之间有一定兼容性要求,我用的是一套经过验证的组合:
pip install torch==2.1.2 pip install transformers==4.38.2 pip install optimum==1.20.0 pip install onnxruntime==1.17.1如果只想在 CPU 上推理,onnxruntime 用标准版就可以;如果要 GPU 加速,需要用 onnxruntime-gpu。需要注意,onnxruntime 和 onnxruntime-gpu 不能同时装在一个环境里,装之前先卸载另一个。
安装本身不难,真正的坑在于版本兼容。optimum 对 transformers 和 torch 的版本敏感度很高,容易遇到算子导出时报错或者模型加载不了。如果遇到兼容问题,一个保守的做法是把 transformers 降级到较低版本,比如 4.36.x 附近,optimum 在这附近的版本测试最充分。
3.2 optimum 一行命令完成导出
环境准备好之后,导出操作其实就一条命令:
optimum-cli export onnx --model Helsinki-NLP/opus-mt-en-zh opus_mt_en_zh_onnx/解释一下各参数的含义。--model 后面接模型名,可以直接是 HuggingFace 上的名字,也支持本地路径。opus_mt_en_zh_onnx/ 是输出目录。命令运行过程中,optimum 会自动下载模型并导出文件。
导出完成后,检查输出目录,正常情况下会看到这些文件:
opus_mt_en_zh_onnx/ ├── config.json ├── encoder_model.onnx ├── decoder_model.onnx ├── decoder_with_past_model.onnx ├── generation_config.json ├── tokenizer.json ├── tokenizer_config.json ├── special_tokens_map.json └── vocab.json文件清单里出现的 decoder_model.onnx 和 decoder_with_past_model.onnx 是最关键的两个文件。前者用于生成循环的第一步,此时 decoder_input_ids 只有起始符,没有 past 缓存;后者用于后续步骤,显式接收 past_key_values 并输出新的缓存。
如果导出时断网或模型已经下载过,本地路径更好用。比如先通过 transformers 的 cache 机制把模型落到本地,再指向本地路径导出,速度会快很多。
3.3 用 ONNX Runtime 跑通翻译
optimum 提供了高层封装,加载 ONNX 模型和加载 PyTorch 模型几乎一样:
from optimum.onnxruntime import ORTModelForSeq2SeqLM from transformers import AutoTokenizer model_dir = "opus_mt_en_zh_onnx/" tokenizer = AutoTokenizer.from_pretrained(model_dir) model = ORTModelForSeq2SeqLM.from_pretrained(model_dir) inputs = tokenizer("The weather is nice today.", return_tensors="pt") outputs = model.generate(**inputs) print(tokenizer.decode(outputs[0], skip_special_tokens=True))这段代码跑出来的结果,应当和 PyTorch 模型的 baseline 基本一致。lib 的选择上,ORTModelForSeq2SeqLM 会自动加载 encoder_model.onnx 和 decoder_with_past_model.onnx 两个文件,不需要手动指定,但它要求目录下的文件名和 config.json 保持一致。如果自己重命名了文件,需要在加载时指定文件路径。
这个封装适合快速上手,但对于追求极致性能和自定义解码策略的场景,建议直接写 onnxruntime 的原生推理循环。下面是一段简化版的手写推理示例,关键逻辑是缓存的管理:
import onnxruntime as ort import numpy as np enc_session = ort.InferenceSession("opus_mt_en_zh_onnx/encoder_model.onnx", providers=["CPUExecutionProvider"]) dec_session = ort.InferenceSession("opus_mt_en_zh_onnx/decoder_model.onnx", providers=["CPUExecutionProvider"]) dec_past_session = ort.InferenceSession("opus_mt_en_zh_onnx/decoder_with_past_model.onnx", providers=["CPUExecutionProvider"]) def translate(text, max_length=128): tokens = tokenizer(text, return_tensors="pt") input_ids = tokens["input_ids"].numpy() attention_mask = tokens["attention_mask"].numpy() enc_outputs = enc_session.run( None, {"input_ids": input_ids, "attention_mask": attention_mask} ) encoder_hidden_states = enc_outputs[0] decoder_input_ids = np.array([[tokenizer.eos_token_id]]) # 起始符 past = None outputs = [] for _ in range(max_length): if past is None: feeds = { "input_ids": decoder_input_ids, "encoder_hidden_states": encoder_hidden_states, } out = dec_session.run(None, feeds) else: feeds = { "input_ids": decoder_input_ids, "encoder_hidden_states": encoder_hidden_states, **{k: v for k, v in zip(past_names, past)} } out = dec_past_session.run(None, feeds) logits = out[0] next_id = logits[0, -1].argmax() if next_id == tokenizer.eos_token_id: break outputs.append(next_id) decoder_input_ids = np.array([[next_id]]) past = out[1:] # 缓存更新 return tokenizer.decode(outputs, skip_special_tokens=True)需要特别提醒的是,past 的键名不是随便写的,它们是由导出时 ONNX 图的输入输出名决定的。具体键名可以用 enc_session.get_inputs()、dec_session.get_inputs()、dec_out = dec_session.run(None, feeds) 后通过 dec_session.get_outputs() 查询。不同版本的模型,past 名可能不同,这需要写代码时动态取,不要硬编码。
我第一次写这段循环时,就是照着 PyTorch 里past_key_values的直觉去猜名字,结果运行时 ONNX Runtime 提示找不到输入,排查了半天才发现是名字对不上。建议直接用 get_inputs() 把名字打印出来,再照着填。
3.4 推理性能优化配置
onnxruntime 在 CPU 上默认就比 PyTorch 快,但想要更快,还做到两点。
第一是设置线程数。CPU 推理时 onnxruntime 会根据物理核数自动开启线程池,但自动配置不总是最优的,可能有过度争抢。常见的做法是显式指定 intra_op_num_threads,一般设为物理核数的一半到三分之二:
sess_options = ort.SessionOptions() sess_options.intra_op_num_threads = 4 sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL session = ort.InferenceSession( "encoder_model.onnx", sess_options=sess_options, providers=["CPUExecutionProvider"] )第二是开启图优化。ORT_ENABLE_ALL 是默认级别,它会把子图合并、算子融合、常量折叠这些优化全部打开。正常导出情况下,这个优化级别只改变执行效率,不改变计算结果,可以放心开。
如果目标是 GPU 推理,providers 换成 ["CUDAExecutionProvider", "CPUExecutionProvider"],并确保 onnxruntime-gpu 版本和 CUDA 版本匹配。这块注意点和 PyTorch 的 CUDA 环境配置是两套体系,不要混用。我第一次在这上面栽了跟头——PyTorch 里 CUDA 好好的,ORT 加载 GPU provider 就报错,后来查文档发现是 ORT 的 CUDA 版本要求和 PyTorch 不一致,单独按 ONNX Runtime 官方文档重新配置了 CUDA 环境才解决。
4. INT8 量化与进一步优化
4.1 为什么量化值得做
ONNX 模型导出只是第一步,真正让它适合高频、大规模部署的往往是量化。英译中模型部署到 CPU 服务上,内存占用和延迟是两个核心指标。量化把 FP32 权重压缩到 INT8,模型体积缩小到原来的四分之一左右,推理速度通常也能再快一截,尤其适合纯 CPU 环境。
量化的代价是精度损失。成熟的量化流派一般分两类:动态量化,权重是 INT8,激活值在推理时动态计算,不需要校准数据,实现简单;静态量化,权重和激活都是 INT8,需要一批校准数据来统计激活值分布,精度通常更高,但流程更重。
对于英译中这类生成式模型,我推荐先用动态量化跑一轮,看效果。如果精度损失明显,再考虑静态量化。动态量化的实现成本极低,收益却立竿见影。
4.2 动手做动态量化
ONNX Runtime 提供了专门的量化工具,可以直接在模型文件上操作:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "opus_mt_en_zh_onnx/decoder_model.onnx", "opus_mt_en_zh_onnx/decoder_model_int8.onnx", weight_type=QuantType.QInt8 )这里有一个重要选择:weight_type 用 QInt8 还是 QUInt8。QInt8 是带符号整数,2-3 字节,适合权重分布关于 0 对称的情况;QUInt8 是无符号整数,适合权重集中在正区间的情况。Transformer 类的模型权重通常近似对称分布,两种差别不大,但实际跑同一个模型对比下来,QInt8 的稳定性略好。
量化完的文件,需要手动改推理时加载的文件名。最简单的方式是把量化文件重命名为原文件名覆盖,或者用 ORTModelForSeq2SeqLM 加载时通过模型路径指定。需要注意,encoder 和 decoder 可以分别量化,可以只量化 decoder,但一般两个都做才能体现整体收益。
4.3 静态量化需要的额外步骤
动态量化虽然省事,但如果你希望把激活值也压到 INT8,就需要静态量化。这个流程必须先准备校准数据,一般是从训练集或验证集里抽几百到上千条样本,跑一遍推理,收集每层的激活值分布,然后据此确定量化 scale 和 zero point。
实现上可以直接用 onnxruntime 的 quantization 相关接口,也可以借助一些工具链自动完成:
from onnxruntime.quantization import quantize_static, CalibrationDataReader # 自定义 CalibrationDataReader # 用校准样本跑推理,收集激活分布 # 再调用 quantize_static(...)静态量化的精度通常比动态好,但对 calibration 数据的选择非常敏感。我试点过 500 条和 2000 条校准数据的差异,结果在几个句子上确实有可见的差异,尤其是罕见词和数字形式。如果能保证校准数据的分布接近真实服务流量,静态量化是更优选;如果数据拿不准,那宁可用动态量化。
4.4 量化前后的性能对比参考
量化到底能带来多少收益,我一直建议各团队拿自己的模型和机器实测,因为差异确实大。一个参考数据是:在 4 核 CPU 上跑 opus-mt-en-zh,编码 20 个 token 的英文句子,解码生成 30 个 token 的中文,FP32 ONNX 的端到端延迟大约在 450ms 左右,优化线程配置后能到 300ms 左右;动态 INT8 量化后,大约能到 180ms 到 220ms 之间,模型体积从约 300MB 降到约 80MB。
这个对比说明两个问题。第一,ONNX 本身就能带来明显加速,线程和优化级别配置也很关键。第二,量化在 CPU 场景下的收益依然非常可观,尤其当服务需要常驻内存时,体积和内存占用是硬指标。
5. 迁移过程中最常见的坑与排查方法
实话说,干净利落一次跑通是少数情况。我列几个高频问题,每个都是我或者身边人实际撞过的。
5.1 导出时报 KeyError 或维度不匹配
optimum 导出时,如果听到这类报错信息,多半是模型结构里存在某个自定义 layer 或自定义 forward,optimum 预设的 exporter 不认识。常见的解法是升级 transformers 或 optimum 版本,新版本对更多架构有支持;如果还不行,就得用 torch.onnx.export 手工导出,自己对 forward 里的个别环节做替换,比如把一些动态循环改成静态展开。
还有一类情况是模型里存在 torch.Tensor.item()、torch.argmax 这类不可导算子,导出会直接失败。如果是生成逻辑里的采样和 argmax,一般都在 forward 外面,不影响导出;如果混进了 forward 里面,导出前需要改写成可导算子或者直接去掉。
5.2 ONNX Runtime 加载报错算子不支持
onnxruntime 支持的算子集是独立的一套,版本越新覆盖越全。如果导出时用的 opset 太新,而运行时 onnxruntime 版本太老,就会报某算子不兼容。
遇到这类问题,有两个方向:要么升级 onnxruntime,要么降低导出时的 opset。optimum 导出时默认 opset 是 14,这个版本对绝大数 CPU 场景都够了。在optimum-cli export onnx命令后加--opset 14可以显式指定,尽量用默认值,特殊需要再调整。
5.3 推理输出乱码或全空白
这个问题的常见原因有两种。第一种是 tokenizer 加载错了,模型 B 的 tokenizer 拿去给模型 A 的 ONNX 文件用,导致 token 和 vocab 对不上。第二种是解码循环的终止条件弄错了,把 EOS 判断成普通 token,输出被截断到空白,或者根本没判断 EOS 直接跑满 max_length,然后在 decode 时把一堆填充 token 显示成了乱码。
解决方法是先在 PyTorch 基线模型上把完整的 tokenizer 配置打印出来,对着配置检查 ONNX 推理循环里的输入输出。如果手写循环,注意解码时跳过 special tokens。
5.4 量化后翻译质量明显下降
量化是精度和速度的权衡,但下降不应该离谱。如果量化后翻译从“基本正确”变成“牛头不对马嘴”,排除算子和配置的问题后,最可能是量化粒度太粗,或者校准数据分布和真实数据差异太大。
针对这种情况,短句优先考虑局部回退:比如只量化 encoder,不量化 decoder;或者只量化 attention 的线性层权重,不量化 layer norm 和 embedding。识别干扰项的方法是逐层替换量化权重,对比每个层量化前后的输出差异。这种定点法耗时一点,但是定位问题最高效的办法。
5.5 动态形状不一致问题
不同句子长度不同,batch 也可能不一样,所以导出时一定要把 sequence length 和 batch 维度设为动态轴。optimum 默认对 encoder-decoder 架构的 sequence 维度是动态的,但 batch 维度的动态性有时没默认打开。如果测试时先跑了 batch 为 1,上线时喂 batch 为 4,直接报维度错误。
手工 torch.onnx.export 时,dynamic_axes 的配置是这样的:
dynamic_axes = { "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "encoder_hidden_states": {0: "batch", 1: "sequence"}, }定义了动态轴之后,导出时 ONNX 图里这些维度就不绑定固定值,推理时可以任意传。代价是动态 shape 下某些算子融合优化无法生效,性能和固定 shape 相比略低,但这是灵活性和速度之间的标准取舍。
5.6 多语言模型的语言 token 丢失
有些英译中模型不止翻译英语,而是多语言全家桶,比如 mBART 系列的,它们依赖额外的 language token 来区分目标语言。如果导出时没有把 language token 作为固定输入加到 decoder_input_ids 里,或者手动生成循环时漏掉了语言 ID,翻译结果就会莫名变成另一种语言,甚至是乱码。
排查这类问题时,确认模型本身的生成配置,看清生成时需不需要传 forced_bos_token_id 和 decoder_start_token_id。用 ORTModelForSeq2SeqLM 跑这类模型时,generate 函数会自动处理这些配置,但手写循环时没人帮你补,必须自己检查。
6. 从迁移到真正落地的一些经验
跑通 ONNX 推理只是第一步,真正把模型当作服务稳定跑起来,还有一些容易被忽视的细节。
编译器和算子优化层面,onnxruntime 的缓存机制值得一提。如果你的服务频繁加载同一个 ONNX 模型,可以把 session 对象的创建过程缓存起来,避免每次请求都重新解析图。我见过线上服务每次推理前新建 InferenceSession 的写法,延迟直接翻倍,这个坑很低级但很常见。
推理框架选型方面,如果团队服务是 Java 或 Go 写的,可以考虑绕过 Python 和 onnxruntime 的 Python API,直接用 ONNX Runtime 的 C API 或直接嵌入原生推理库,避免 Python 进程的 GIL 限制和多线程争抢。如果服务规模更大、延迟更敏感,还可以把模型转为 TensorRT 引擎在 GPU 上推理,或者在 CPU 上使用 OpenVINO 后端,这属于进阶方向,不是必须。
模型监控方面,翻译模型是有明确质量标尺的——BLEU 分数或人工评测。上线前必须保存一份 PyTorch baseline 在测试集上的 BLEU,再测 ONNX FP32 和 ONNX INT8 的 BLEU,两个分数之间的差距决定了量化方案是否可用。这个对比我建议做成自动化测试,每次导出和量化之后自动跑一遍,防止模型更新后迁出一版坏模型还不自知。
我个人在实际操作中的体会是,整个迁移过程最耗时间的不是导出命令,而是理解模型的结构和生成逻辑。把 encoder、decoder、past_key_values、tokenizer 这几块面团揉清楚之后,代码层面的工作反而是最机械的。最后再分享一个小技巧:导出后的 ONNX 模型不要保留原始的 PyTorch 权重文件,只在目录里留 onnx 文件和 tokenizer 配置,这样目录干净、加载快,也能避免误加载原模型导致环境依赖失控。整个目录提交到仓库或者打进镜像里,模型迁移这件事就算真正收官了。