Qiming Bao · Blog · 2026 年 11 月 11 日 · English / 中文

一个“乱码”报告背后的三个 bug:排查 Gemma 在 ONNX Runtime GenAI 中的导出问题

用 ONNX Runtime GenAI 的 model builder 导出 Gemma 3 4B 之后,问它一个波斯语问题,它回答的是空字符串;同一个 checkpoint 放在 transformers 里却回答得好好的。这个问题(microsoft/onnxruntime-genai#2277)从今年 7 月起一直没人解决。顺着它查下去,我在 builder 里找到了三个独立的 bug。比起具体的修复,找到它们的方法更值得分享。

这篇文章讲三件事:怎么和 transformers 逐 token 对比;怎么用一个对照实验证明根因;以及怎么区分“真的有数值 bug”和“模型本身对数值敏感”。下文所有数字都来自 CPU 上的 fp32 构建和贪心解码。

测试方法:和 transformers 逐 token 对比

参考结果是同一个 checkpoint 在 transformers 里的输出,用 fp32 和 eager 注意力。对每条提示词,先记下 transformers 的贪心生成结果和每一步的 logits,再用 onnxruntime-genai 的 Generator 跑导出的模型,两边对比。

每条提示词看两个指标:

五条提示词直接取自 issue:普通英文、一句生僻词很多的英文、中英混合,以及两句波斯语。所有提示词都套用 chat template,因为大家实际就是这么用的。

有两个细节让我踩了坑:

  1. generation config。即使传了 do_sample=False,generate() 仍然会套用 checkpoint 自带的 repetition_penalty。Qwen2.5 把它设成了 1.1,结果一个正确的导出看起来像是错的。生成参考结果时要显式传 repetition_penalty=1.0。
  2. KV cache 容量。如果 max_length 小于模型的滑动窗口(Gemma 3 1B 是 512,Gemma 2 是 4096),genai 运行时会直接报错。要把它设得远大于窗口。

Bug 1:局部层继承了全局层的 RoPE 缩放

Gemma 3 每 6 层里有 5 层是滑动窗口(局部)注意力,1 层是全局注意力。在 4B、12B 和 27B 里,只有全局层用了 factor 为 8 的线性 RoPE 缩放,局部层用的是普通 RoPE。builder 在构建全局层的 cos/sin cache 时设了 rescale_factors = 8,接着构建局部层的 cache 时却没有把它改回来。于是每 6 层里有 5 层的旋转频率只有正确值的 1/8,而且从第 0 层就开始了,这正好是 issue 报告人追到分歧的地方。

Gemma 3 1B 没有 RoPE 缩放,所以从来没受影响。而在 transformers 5 下,builder 根本走不到这一步:新的嵌套 rope_parameters 格式会让所有尺寸的 Gemma 3 都报 KeyError: 'rope_type'。

“看起来合理”的原因不等于已经证明的原因。为了证明它,我把同一个纯文本 4B checkpoint 用三种方式构建,每次只改一处,分别和 transformers 对比:

同一个 checkpoint 的导出方式第一个 token 的 logits 最大绝对差贪心生成一致
builder 0.14.1,和 issue 里一样26.8 – 41.30/5,两条波斯语都是空输出
0.14.1,只把局部 cache 的 rescale_factors 改成 10.0008 – 0.00235/5
修复后的 main builder0.0008 – 0.00235/5

在 0.14.1 上只打这一行补丁,结果就和 main 完全一样,说明旧 builder 里没有别的因素在起作用。修复是 #2884:按层类型读取 rope_parameters,构建局部 cache 时不再带上全局缩放。我还用 4 个尺寸真实的 config.json,把 builder 生成的 RoPE cache 和 transformers 的 Gemma3RotaryEmbedding 逐个位置对比(1B 共 32768 个位置,其余各 131072 个),最大差值都是 0。

Bug 2 和 3:文字是对的,但 logits 偏了 0.1

修好 Bug 1 之后,所有 Gemma 导出模型的贪心生成结果都和 transformers 一致了,但 logits 还是对不上。Gemma 2 2B 和 Gemma 3 1B 差了 0.04 到 0.24,Gemma 3 270M 最多差 0.44。而同一套流程里,Qwen3、Granite、LFM2 和 SmolLM2 的误差都在 0.0001 以内。

是不是模型本身就对数值敏感?Gemma 以“巨大激活值”出名,所以第一个问题是 transformers 自己会不会波动这么大。答案是不会。Gemma 3 1B 的 fp32 和 fp64 之间只差 6e-5,eager 和 SDPA 注意力之间也只差 6e-5,和 Qwen3 一样。导出模型的误差比模型自身的噪声大了三个数量级。

误差从哪里开始?我把 1B 模型截断成 1 层、2 层和 6 层,transformers 和 builder 都用截断后的配置,再对比一次。只有 1 层时误差就已经是 0.034 了,所以问题在单层内部,不是层与层之间累积出来的。

和之前有什么不同?Bug 1 用的 4B checkpoint 是我自己存成 fp32 的,结果很准;而 Hub 上 1B 和 Gemma 2 的 checkpoint 是 bfloat16。把 1B 的权重先转成 fp32 再导出,误差就降到了 0.0005 到 0.0016。顺着这个线索找到了两处问题:

两处都修好后,直接从 Hub 上原版的 bfloat16 checkpoint 导出:

模型main 上的 logits 误差修复后贪心生成一致
Gemma 2 2B0.044 – 0.1120.00003 – 0.000074/5 → 5/5
Gemma 3 1B0.087 – 0.2430.00005 – 0.000125/5
Gemma 3 270M0.119 – 0.4420.00007 – 0.000275/5

所有把 norm 权重加 1 的模型都走同一段加偏移的代码,所以这个修改也会作用到 Nemotron 上,但我没有实际测过 Nemotron。另外有一个单元测试专门检查 fp16 导出里 Gemma 的 q/k norm。

做模型导出验证的几点经验

  1. 要比 logits,不能只比生成的文字。Bug 2 和 3 在我的提示词上一个贪心 token 都没改变,只有 logits 能暴露它们。而它们会影响采样输出和长文本生成。
  2. 先量一下参考结果自己的噪声。fp32 对 fp64、eager 对 SDPA,能告诉你对这个模型来说多大的误差算“接近”。不先量,0.1 的误差很容易被当成“Gemma 就是这样”而放过。
  3. 用一处改动证明根因。只在出问题的版本上打你怀疑的那个补丁,看它能不能达到正确的数字。这次就是靠它排除了 0.14.1 和 main 之间的所有其他差异。
  4. 按层数二分。在参考实现和导出模型里同时截断 num_hidden_layers,就能判断误差来自单层内部,还是在很多层之间累积。
  5. 测大家真正会下载的 checkpoint。一个 fp32 的测试 checkpoint 把 bfloat16 的 bug 完全掩盖了,而 Hub 上的 checkpoint 就是 bfloat16。
  6. 留意悄悄变化的默认值。一次依赖升级(transformers 4 到 5)改变了加载的 dtype,一个 generation_config 加上了重复惩罚,两者都没有报任何错。

修复状态

RoPE 的修复已经合并,精度修复还在审查中。在它合并之前,先把 checkpoint 转成 fp32 再导出,就能避开偏移的误差。

编号内容状态(2026 年 11 月 11 日)
#2884Gemma 3 RoPE:支持 transformers 5 的参数格式,局部 cache 不再带缩放已合并
#2277Gemma 3 4B 输出乱码根因已由 #2884 修复,验证结果已发布
#2939Gemma RMSNorm 偏移改在 fp32 下计算,嵌入缩放用精确值审查中
#2938SmolLM3 导出崩溃(用同一套对比流程发现)已报告

这套对比流程用不到一百行 Python,只依赖 transformers 和 onnxruntime-genai。如果你在维护某个模型家族的导出流程,用同样的五条提示词和 transformers 对比一下,是成本很低的第一道检查。

排查这些 bug 和撰写本文时,我使用了 Claude(Anthropic)辅助。所有结果都经过我本人审阅,各项检查也由我亲自运行。


包启明,现居新西兰奥克兰的 AI 研究者,研究方向为大模型推理与模型部署。博士毕业于奥克兰大学 Strong AI Lab,参与 PyTorch ONNX 导出器、ONNX Runtime GenAI 和 Microsoft PyRIT 等开源项目的贡献。