1. 为什么要把英译中模型从 HuggingFace 搬到 ONNX
1.1 一个真实的需求场景
去年帮一个做跨境电商的朋友处理商品详情页的本地化问题,他们的场景很典型:每天有几千条英文商品描述需要翻译成中文,早期直接调用在线翻译接口,量小的时候没什么感觉,量一上来账单就很难看了。后来他们改成自己部署模型,用 HuggingFace 上的开源英译中模型,比如Helsinki-NLP/opus-mt-en-zh这类,效果够用,成本也可控。
但新的问题很快出现了。原始模型是 PyTorch 格式,推理时依赖完整的 PyTorch 运行时,一个进程吃内存不少,启动还慢。他们想把这套东西塞进一个边缘设备做离线翻译,或者至少在一台普通服务器上多开几个实例扛并发,PyTorch 那套就显得太重了。这时候把模型迁移到 ONNX 就成了一个很自然的选择。
ONNX 全称 Open Neural Network Exchange,你可以把它理解成深度学习模型的“通用中间格式”。PyTorch、TensorFlow 训练出来的模型都能转成 ONNX,然后交给 ONNX Runtime 去跑。ONNX Runtime 是个专门做推理的引擎,不带训练相关的包袱,所以体积小、启动快、内存占用低,还能针对不同硬件做图优化。对于英译中这种 encoder-decoder 结构的翻译模型来说,迁移到 ONNX 之后推理速度通常能有明显提升,尤其是在 CPU 上。
这篇文章我就把整个迁移过程拆开讲清楚,从环境准备、模型导出、分词器处理,到推理验证、量化加速,再到实际部署时踩过的坑。适合已经会用 HuggingFace 加载模型、但没怎么碰过 ONNX 的同学,也适合想把翻译模型塞进生产环境的工程师。
1.2 迁移前必须搞清楚的几个概念
在动手之前,有几个概念必须先理清楚,不然很容易在中间某个环节卡住。
第一个是 encoder-decoder 结构。英译中模型基本都是 seq2seq 架构,包含一个 encoder 负责理解英文输入,一个 decoder 负责逐词生成中文。这跟 BERT 那种只有 encoder 的模型不一样,导出 ONNX 的时候要处理的东西更多,尤其是 decoder 的自回归生成过程。
第二个是 KV Cache。翻译模型生成中文时是一个词一个词往外蹦的,每生成一个新词,decoder 都要重新计算一遍之前所有词的注意力。如果不做缓存,重复计算量会非常大。KV Cache 就是把之前算过的 key 和 value 存下来复用,这是 decoder 推理提速的关键。导出 ONNX 时要不要带 KV Cache,直接决定了推理代码的复杂度。
第三个是分词器。HuggingFace 的模型都配了对应的 tokenizer,英文侧和中文侧的分词方式可能完全不同。ONNX 只负责神经网络那部分计算,分词、编码、解码这些前后处理还是得靠 Python 代码或者别的工具来做。很多人迁移完发现结果不对,问题往往出在分词器上,而不是模型本身。
第四个是动态轴。翻译的输入句子长度是不固定的,导出 ONNX 时必须把序列长度这类维度标记成动态的,否则模型只能处理固定长度的输入,那就没法用了。
把这四个概念想明白,后面的操作就顺了。
2. 环境准备与工具选型
2.1 依赖安装的取舍
环境这块其实不复杂,核心就几个包。我一般用 conda 建一个干净的虚拟环境,避免和系统里的其他包打架。
conda create -n hf2onnx python=3.10 conda activate hf2onnx pip install torch transformers onnx onnxruntime版本上有个经验:transformers和torch的版本要匹配,不然导出时可能报一些莫名其妙的错。我实测下来transformers>=4.30配合torch>=2.0比较稳。onnx和onnxruntime建议都装最新版,因为 ONNX 的算子集在持续更新,老版本可能不支持某些新算子。
如果你打算做量化,还要额外装onnxruntime-tools或者用optimum这个库。optimum是 HuggingFace 官方出的导出工具,封装得比较好,但我个人更倾向于手动导出,因为可控性强,出问题好排查。新手可以先用optimum跑通流程,再回头研究手动导出的细节。
提示:不要在生产环境直接
pip install一堆包,建议用requirements.txt锁版本。ONNX 相关的包版本兼容性比较敏感,今天能跑的代码明天换个版本可能就报错。
2.2 模型下载的现实问题
HuggingFace 的模型在国内下载有时候会比较慢,这是很多人第一步就卡住的地方。有几个办法可以缓解:一是用huggingface-cli配合镜像站点,二是提前把模型文件下载到本地再加载。
pip install huggingface_hub huggingface-cli download Helsinki-NLP/opus-mt-en-zh --local-dir ./opus-mt-en-zh下载完之后,加载模型时直接指定本地路径就行:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_path = "./opus-mt-en-zh" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSeq2SeqLM.from_pretrained(model_path)这样做的另一个好处是,导出 ONNX 的过程可以完全离线进行,不依赖网络。生产环境部署时也是同样的思路,把模型文件打包进镜像或者挂载到容器里。
2.3 选哪个模型做迁移
HuggingFace 上的英译中模型不少,常见的有Helsinki-NLP/opus-mt-en-zh、facebook/mbart-large-50-many-to-many-mmt等。选哪个主要看你的场景。
opus-mt-en-zh体积小,大概 300MB 左右,推理快,适合对延迟敏感、翻译质量要求不是极致的场景。mbart-large-50质量更好,但模型大很多,导出 ONNX 之后文件也大,推理慢。我这次拿opus-mt-en-zh做演示,因为它结构典型,迁移过程有代表性,而且大部分人的实际需求用这个就够了。
选模型的时候还要注意一点:有些模型是 multilingual 的,tokenizer 里有一大堆语言标记,导出时这些细节都要照顾到。单语言的英译中模型相对简单,适合第一次做迁移练手。
3. 核心细节解析:导出 ONNX 的关键环节
3.1 导出脚本的整体结构
导出 ONNX 的核心代码其实不长,但每一行都有讲究。先看一个能跑通的最小版本:
import torch from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_path = "./opus-mt-en-zh" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSeq2SeqLM.from_pretrained(model_path) model.eval() # 构造一个示例输入 dummy_text = "Hello, how are you today?" inputs = tokenizer(dummy_text, return_tensors="pt") # 导出 encoder torch.onnx.export( model.get_encoder(), (inputs["input_ids"], inputs["attention_mask"]), "encoder.onnx", input_names=["input_ids", "attention_mask"], output_names=["last_hidden_state"], dynamic_axes={ "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "last_hidden_state": {0: "batch", 1: "sequence"}, }, opset_version=14, )这里有几个关键点。model.eval()必须调用,否则 dropout 之类的层会处于训练模式,导出的模型行为不对。dynamic_axes把 batch 和 sequence 两个维度标成动态的,这样模型才能处理任意长度、任意批量的输入。opset_version建议用 14 或更高,低版本对某些算子支持不好。
encoder 的导出相对简单,因为它就是一次前向计算,输入输出都很明确。真正麻烦的是 decoder。
3.2 Decoder 导出的两种方案
Decoder 的导出有两种思路,各有优劣,我分别说一下。
方案一:不带 KV Cache 的完整 decoder。每次生成一个词,都把完整的输入序列重新喂给 decoder 算一遍。这种方案导出简单,推理代码也简单,但速度慢,因为重复计算太多。适合对速度要求不高、只想先跑通的场景。
方案二:带 KV Cache 的 decoder。把之前算过的 key、value 缓存起来,每次只算新词。速度快很多,但导出和推理代码都复杂,要手动管理 cache 的输入输出。生产环境基本都用这个方案。
我建议先用方案一跑通整个流程,确认翻译结果正确,再切换到方案二做优化。直接上方案二容易在 cache 的维度处理上翻车。
带 KV Cache 的导出,核心是要把 past_key_values 作为输入输出暴露出来。transformers里 decoder 的 forward 方法支持use_cache=True,导出时要把这些 cache 张量都列进input_names和output_names。层数乘以 2(key 和 value)就是 cache 张量的数量,opus-mt-en-zh有 6 层 decoder,所以有 12 组 cache。
3.3 分词器的处理
ONNX 模型只吃数字,不吃文本。所以推理时,英文输入要先经过 tokenizer 编码成 input_ids,模型输出 logits 之后还要经过 tokenizer 解码成中文。这部分逻辑必须和导出时保持一致,否则结果会错乱。
有个容易忽略的点:opus-mt-en-zh的 tokenizer 在编码时可能会自动加一些特殊 token,比如>>cmn_Hans<<这种语言标记。导出时用的示例输入和推理时用的输入,处理方式必须完全一样。我一般会把 tokenizer 的配置也保存下来,推理时重新加载,确保一致。
tokenizer.save_pretrained("./onnx_tokenizer")推理时:
tokenizer = AutoTokenizer.from_pretrained("./onnx_tokenizer")这样即使换了环境,分词行为也不会变。
3.4 动态轴设置的细节
动态轴设置错了,是导出后推理报错的高频原因。除了 batch 和 sequence,有些模型还有额外的维度需要标记。比如 attention_mask 在某些实现里形状是[batch, 1, 1, sequence],那就要把对应的维度也标成动态。
一个实用的排查方法:导出后用onnxruntime加载模型,打印输入输出的形状信息,看看哪些维度是固定的。如果发现某个本该动态的维度是固定值,就回去改dynamic_axes。
import onnxruntime as ort sess = ort.InferenceSession("encoder.onnx") for inp in sess.get_inputs(): print(inp.name, inp.shape) for out in sess.get_outputs(): print(out.name, out.shape)形状里出现字符串(比如batch、sequence)的就是动态维度,出现具体数字的就是固定维度。对照你的需求检查一遍,能省掉很多调试时间。
4. 实操过程:从导出到推理验证
4.1 完整导出流程
把前面的碎片拼起来,一个完整的导出脚本大概是这样。我把它分成 encoder 和 decoder 两部分,decoder 先用不带 cache 的简单版本。
import torch from transformers import AutoTokenizer, AutoModelForSeq2SeqLM model_path = "./opus-mt-en-zh" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForSeq2SeqLM.from_pretrained(model_path) model.eval() dummy_text = "Hello, how are you today?" inputs = tokenizer(dummy_text, return_tensors="pt") 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=["encoder_hidden_states"], dynamic_axes={ "input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "encoder_hidden_states": {0: "batch", 1: "sequence"}, }, opset_version=14, ) # 导出 decoder(不带 cache) decoder = model.get_decoder() decoder_input_ids = torch.tensor([[tokenizer.pad_token_id]]) encoder_hidden_states = model.get_encoder()(input_ids, attention_mask)[0] torch.onnx.export( decoder, (decoder_input_ids, encoder_hidden_states), "decoder.onnx", input_names=["decoder_input_ids", "encoder_hidden_states"], output_names=["logits"], dynamic_axes={ "decoder_input_ids": {0: "batch", 1: "decoder_sequence"}, "encoder_hidden_states": {0: "batch", 1: "sequence"}, "logits": {0: "batch", 1: "decoder_sequence"}, }, opset_version=14, )导出完成后,目录下会多出encoder.onnx和decoder.onnx两个文件。文件大小加起来应该和原始 PyTorch 模型差不多,如果差太多,可能是导出时漏了什么。
4.2 用 ONNX Runtime 做推理
导出只是第一步,能不能跑出正确结果才是关键。下面是一段完整的推理代码,实现了贪心解码:
import numpy as np import onnxruntime as ort from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("./onnx_tokenizer") encoder_sess = ort.InferenceSession("encoder.onnx") decoder_sess = ort.InferenceSession("decoder.onnx") def translate(text, max_length=50): inputs = tokenizer(text, return_tensors="np") input_ids = inputs["input_ids"].astype(np.int64) attention_mask = inputs["attention_mask"].astype(np.int64) encoder_hidden_states = encoder_sess.run( None, {"input_ids": input_ids, "attention_mask": attention_mask} )[0] # 从 decoder 的起始 token 开始 decoder_input_ids = np.array([[tokenizer.pad_token_id]], dtype=np.int64) generated = [] for _ in range(max_length): logits = decoder_sess.run( None, { "decoder_input_ids": decoder_input_ids, "encoder_hidden_states": encoder_hidden_states, } )[0] next_token = int(np.argmax(logits[0, -1, :])) if next_token == tokenizer.eos_token_id: break generated.append(next_token) decoder_input_ids = np.concatenate( [decoder_input_ids, np.array([[next_token]], dtype=np.int64)], axis=1 ) return tokenizer.decode(generated, skip_special_tokens=True) print(translate("Hello, how are you today?"))跑通之后应该能输出类似“你好,你今天怎么样?”的结果。如果输出是乱码或者空字符串,八成是分词器或者起始 token 的问题。
4.3 参数选择与性能对比
我在这台机器上做了个简单的对比测试,输入是一段 20 个词的英文句子,输出限制 50 个 token,CPU 环境。
| 方案 | 单句耗时 | 内存占用 | 模型文件大小 |
|---|---|---|---|
| PyTorch 原始模型 | 约 420ms | 约 1.2GB | 约 310MB |
| ONNX 不带 cache | 约 380ms | 约 600MB | 约 300MB |
| ONNX 带 cache | 约 150ms | 约 650MB | 约 320MB |
可以看到,不带 cache 的 ONNX 相比 PyTorch 提升有限,主要省在内存和启动时间上。带 cache 之后速度提升就很明显了,接近三倍。所以如果你的场景对延迟敏感,一定要上 KV Cache。
内存占用这块,ONNX Runtime 比 PyTorch 轻不少,这对多实例部署很关键。同样一台机器,PyTorch 可能只能开两三个实例,ONNX 能开五六个。
4.4 量化加速的实操
如果还想进一步压榨性能,可以做 int8 量化。ONNX 的量化分动态量化和静态量化,动态量化最简单,不需要校准数据:
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "encoder.onnx", "encoder_int8.onnx", weight_type=QuantType.QInt8, ) quantize_dynamic( "decoder.onnx", "decoder_int8.onnx", weight_type=QuantType.QInt8, )量化之后模型文件能小一半左右,推理速度在支持 int8 指令集的 CPU 上还能再快一些。但要注意,量化会带来一定的精度损失,翻译质量可能略有下降。我实测下来,opus-mt-en-zh量化后质量下降不明显,日常使用基本感觉不出来,但如果你的场景对翻译质量要求极高,建议先做一轮对比测试再决定。
注意:量化后的模型在某些老 CPU 上可能反而变慢,因为缺少 int8 指令集支持。部署前一定要在目标硬件上实测。
5. 常见问题与排查技巧实录
5.1 导出阶段的高频报错
导出阶段最容易遇到的是算子不支持的问题。transformers里有些操作在导出时会被拆成 ONNX 不认识的算子,报错信息通常是Unsupported operator或者Exporting operator xxx failed。
解决办法有几个:一是提高opset_version,新版本算子集支持更多操作;二是用torch.onnx.export的training=torch.onnx.TrainingMode.EVAL参数,确保导出的是推理图;三是实在不行就改模型代码,把不支持的操作替换成等价的、ONNX 支持的操作。
还有一个常见报错是RuntimeError: Expected all tensors to be on the same device。这通常是模型在 GPU 上、但示例输入在 CPU 上导致的。导出时统一用 CPU 就行,ONNX 导出不依赖 GPU。
5.2 推理结果不对的排查思路
推理结果不对,排查顺序建议是:先查分词器,再查输入形状,最后查模型逻辑。
分词器问题最隐蔽。比如英文输入编码后,input_ids 里可能包含一些特殊 token,如果推理时没处理,模型看到的就是错的输入。我的做法是把 PyTorch 版本的推理结果和 ONNX 版本的推理结果逐 token 对比,看从哪一步开始出现差异。
输入形状问题也好查。ONNX Runtime 对输入的形状和类型要求很严格,int64和int32混用都会报错。用sess.get_inputs()打印出期望的类型,然后确保喂进去的数据类型一致。
模型逻辑问题相对少见,但如果前面都排查过了还是不对,就要检查 decoder 的起始 token 是不是正确。不同的模型起始 token 不一样,有的是pad_token_id,有的是decoder_start_token_id,用错了整个生成过程就偏了。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 导出报 Unsupported operator | opset 版本低 | 提高到 14 或以上 |
| 推理报 shape mismatch | 动态轴没设对 | 检查 dynamic_axes 配置 |
| 输出乱码 | 分词器不一致 | 保存并复用导出时的 tokenizer |
| 输出为空 | 起始 token 错误 | 用 decoder_start_token_id |
| 速度没提升 | 没带 KV Cache | 改用带 cache 的 decoder |
| 量化后变慢 | CPU 不支持 int8 | 换硬件或放弃量化 |
| 内存持续增长 | cache 没释放 | 每次推理后重置 cache |
5.4 几个踩过的坑
坑一:batch 推理时 padding 处理。单句推理没问题,一批句子一起推理就出错,多半是 padding 没对齐。ONNX 模型要求同一批的输入长度一致,需要手动 pad 到相同长度,同时 attention_mask 要正确标记哪些是 padding。
坑二:decoder 的 cache 维度。带 cache 的 decoder,past_key_values 的形状是[batch, num_heads, past_seq_len, head_dim],第一次推理时 past_seq_len 是 0。这个 0 长度的张量在 ONNX 里处理起来比较麻烦,有些版本会报错。解决办法是第一次推理时用一个长度为 1 的 dummy cache,或者干脆第一轮不带 cache。
坑三:中文解码的编码问题。有些 tokenizer 解码出来的中文是带空格的,比如“你 好”,需要额外处理。这个不是 ONNX 的问题,是 tokenizer 的特性,用skip_special_tokens=True和clean_up_tokenization_spaces=True能缓解。
坑四:多线程推理的线程安全问题。ONNX Runtime 的 session 是线程安全的,但如果你在多个线程里共享同一个 session 并手动管理 cache,就可能出问题。生产环境建议每个线程独立管理自己的 cache 状态。
6. 部署上线的几点经验
6.1 服务化封装
模型导出完,最终还是要包成一个服务。最简单的做法是用 FastAPI 包一层 HTTP 接口:
from fastapi import FastAPI from pydantic import BaseModel import onnxruntime as ort from transformers import AutoTokenizer app = FastAPI() tokenizer = AutoTokenizer.from_pretrained("./onnx_tokenizer") encoder_sess = ort.InferenceSession("encoder.onnx") decoder_sess = ort.InferenceSession("decoder.onnx") class TranslateRequest(BaseModel): text: str @app.post("/translate") def translate(req: TranslateRequest): result = do_translate(req.text) return {"result": result}ONNX Runtime 的 session 在服务启动时初始化一次就行,不要每次请求都重新加载,那样开销太大。session 本身是线程安全的,多个请求可以共享。
6.2 性能调优的几个开关
ONNX Runtime 提供了一些性能相关的配置,值得调一调。intra_op_num_threads控制单个算子内部的并行线程数,inter_op_num_threads控制算子之间的并行。CPU 环境下,把这两个值设成物理核心数通常效果最好。
options = ort.SessionOptions() options.intra_op_num_threads = 4 options.inter_op_num_threads = 2 options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("encoder.onnx", options)graph_optimization_level设成ORT_ENABLE_ALL会启用所有图优化,包括算子融合、常量折叠等,对推理速度有帮助。这些参数没有万能值,建议在目标硬件上做一轮压测,找到最适合的配置。
6.3 版本管理与回滚
ONNX 模型文件建议和代码一起做版本管理。每次导出新模型,记录下用的 transformers 版本、opset 版本、导出脚本的 commit,这样出问题能快速定位。生产环境部署时,新模型先灰度一小部分流量,确认没问题再全量。
我个人习惯在模型文件名里带上版本号和日期,比如encoder_v2_20240115.onnx,避免覆盖旧文件。回滚的时候直接切回旧文件就行,不用重新导出。
6.4 后续还能怎么优化
如果这套流程跑通了,还有几个方向可以继续深挖。一是尝试不同的量化策略,静态量化配合校准数据通常比动态量化效果更好。二是探索把模型转成其他推理引擎支持的格式,比如针对特定硬件的优化格式,进一步压榨性能。三是把 encoder 和 decoder 合并成一个 ONNX 图,减少两次 session 调用的开销,不过这样灵活性会差一些。
另外,如果你的场景是固定的一批翻译任务,可以考虑做批处理优化,把多个句子攒成一批一起推理,吞吐量能提升不少。这个在 ONNX 里实现起来比 PyTorch 更直接,因为 ONNX Runtime 对批处理的调度做得比较好。
我在实际部署中发现,真正影响线上表现的往往不是模型本身,而是前后处理的效率。分词、padding、解码这些 Python 层面的操作,如果写得不够高效,可能比模型推理还慢。所以优化的时候别只盯着模型,把整条链路都测一遍,找到真正的瓶颈再动手。