修复 GatherBlockQuantized:浏览器无 WebGPU 运行 4-bit Gemma

Transformers.js 默认的 asyncify 构建缺少 GatherBlockQuantized 算子内核,手动指定 wasmPaths 指向普通构建即可修复。
文章记录了在 Chrome 无 WebGPU 环境下用 Transformers.js 4.x 加载 4-bit Gemma ONNX 模型时遇到的会话创建失败问题。报错根源是默认选用的 asyncify WebAssembly 构建缺少 `GatherBlockQuantized` 贡献算子的 CPU 内核,而普通构建带有该内核。修复方法是在首次 `pipeline()` 调用前手动设置 `ort.wasm.wasmPaths`,将运行时指向 `ort-wasm-simd-threaded` 普通构建,并用 `env.backends.onnx.versions.web` 绑定版本。文章还强调两个细节:wasmPaths 必须在任何模型加载前设置(运行时每页只启动一次),以及版本绑定可避免胶水代码与 wasm 文件不匹配。该问题在其他项目中已有绕过案例,但官方追踪器当时无相关 issue。
在浏览器里跑 4 位量化的 Gemma 模型,本来是一件颇有吸引力的事:不用显卡,纯 CPU 就能让一个小模型回答本地问题。但如果你用 Transformers.js 4.x 加载 4-bit Gemma ONNX 模型、并且设备是 WebAssembly(没有 WebGPU),很可能会迎面撞上一条情绪稳定的报错:
Can't create a session. ERROR_CODE: 9, ERROR_MESSAGE: Could not find an
implementation for GatherBlockQuantized(1) node with name
'/model/embed_tokens/Gather_Quant'
好消息是,模型本身没有问题,问题出在运行时文件选错了。把 ONNX Runtime 指向普通的 WebAssembly 构建,就能绕过这个坑。

问题根因:默认构建缺少算子内核
Gemma 的 4-bit ONNX 导出把 embedding table 做成了 block-quantized,因此计算图里的第一个节点 /model/embed_tokens/Gather_Quant 就是一个 GatherBlockQuantized 算子。这是 ONNX Runtime 的贡献算子(contrib operator),并不是每个运行时构建都自带它的 CPU 内核。
onnxruntime-web 的 dist/ 目录里发布了多个 WebAssembly 构建。Transformers.js 4.3.1 默认选择的是 asyncify 构建(ort-wasm-simd-threaded.asyncify.{mjs,wasm}),这个版本是为较新的 WebGPU 执行提供器准备的,其中没有 GatherBlockQuantized 的 CPU 内核,所以会话在第一个节点上就创建失败。
而普通构建(ort-wasm-simd-threaded.{mjs,wasm})里带有这个内核。Transformers.js 4.3.1(搭配 onnxruntime-web 1.31.0-dev.20260914)只会在一种情况下自动使用普通构建:Safari 版本低于 26 且没有 WebGPU 时。换句话说,Chrome 用户走的是 asyncify 路径,于是撞墙。
问题的关键结论很简单:你不需要换模型文件、不需要 GPU、也不需要换量化方式,只需要换一个运行时文件。
什么是贡献算子(contrib operator)? ONNX 定义了一套标准算子集(如 MatMul、Gather、Softmax),理论上任何符合规范的运行时都应实现它们。但社区和厂商会不断提出超出标准范围的新算子需求,这些算子被集中放在 ONNX Runtime 的 contrib 命名空间下,称为贡献算子。GatherBlockQuantized 就是其中之一,它把「查表(Gather)」和「块量化(Block Quantized)」融合成单个算子,专为低比特量化的 embedding 层设计。由于它是扩展算子而非标准算子,并非每个 ONNX Runtime 构建都会编译其 CPU 内核——这解释了为什么同一份模型文件在不同运行时构建上表现截然不同。
asyncify 构建是什么? asyncify 是 Emscripten 提供的一种编译技术,它允许同步的 C/C++ 代码「暂停」并等待异步操作完成,再恢复执行。这在 WebGPU 场景下非常关键,因为 GPU 命令提交、缓冲区映射等操作本质上是异步的。onnxruntime-web 的 asyncify 构建正是为此优化,但代价是它精简了部分 CPU 侧的算子实现,以控制体积和复杂度。相反,普通构建追求 CPU 算子的完整覆盖,但不具备 asyncify 的异步挂起能力。选择哪种构建,本质上是在「支持 WebGPU」和「CPU 算子齐全」之间做权衡。
修复方式:手动指定 wasmPaths
解决办法是在任何模型加载之前,把 ONNX Runtime 的 wasmPaths 指向普通 WebAssembly 构建。下面这段代码必须在第一个 pipeline() 调用之前执行,因为运行时每个页面只启动一次:
import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.1';
// 必须在第一次 pipeline() 调用前运行:运行时每页只启动一次。
const ort = env.backends.onnx;
const dist = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${ort.versions.web}/dist/`;
ort.wasm.wasmPaths = {
mjs: `${dist}ort-wasm-simd-threaded.mjs`,
wasm: `${dist}ort-wasm-simd-threaded.wasm`,
};
const gemma = await pipeline('text-generation', 'onnx-community/gemma-3-270m-it-ONNX', {
dtype: 'q4',
device: 'wasm',
});
加上这段改动后,Gemma 3 270M 的 4 位版本(约 323 MB)就能在 Chrome 的 CPU 上加载并运行。这个修复由 AI agent 完成,并在真实 Chrome 浏览器中验证通过后才写下记录。
wasmPaths 覆盖的机制:onnxruntime-web 在初始化时会根据运行环境自动推断要加载哪个 .mjs(JavaScript 胶水代码)和 .wasm(WebAssembly 二进制)文件。Transformers.js 在此基础上加了一层选择逻辑,会根据设备能力(是否有 WebGPU、浏览器类型与版本)决定指向 asyncify 还是普通构建。ort.wasm.wasmPaths 允许你绕过这层自动选择,直接告诉运行时去哪里取文件。它接受一个对象,分别指定 .mjs 和 .wasm 的 URL。由于这个配置在运行时首次初始化时读取一次,之后就固化在内存中,因此必须在第一个 pipeline() 调用前设置。
为什么用 versions.web 而不是硬编码版本号:env.backends.onnx.versions.web 暴露的是当前 Transformers.js 实际打包和依赖的 onnxruntime-web 版本。.wasm 二进制和 .mjs 胶水代码之间通过内部 ABI 约定通信,如果两者版本不一致,可能出现难以定位的运行时错误(如函数表不匹配、内存布局错位)。用 versions.web 拼 URL 能保证你下载的 wasm 文件与已加载的 JS 代码严格配对,避免手动升级或降级时引入隐性不兼容。
两个容易踩的细节
修复本身只有几行代码,但有两个地方稍不注意就会失效。
第一,wasmPaths 必须在任何模型加载之前设置。 运行时每个页面只启动一次,所以如果你的页面先加载了别的模型(比如先加载一个 BERT NER 模型再加载 Gemma),那个模型会为整个页面固定运行时构建。实际项目里,这段覆盖代码被放在了所有页面最先 import 的模块顶部。
第二,注意版本绑定。 上面的 URL 使用了 env.backends.onnx.versions.web,这样 .wasm 文件始终和 Transformers.js 打包的 JavaScript 胶水代码版本一致。手动混用版本导致的错误往往更难排查。
此外保留一个降级方案:如果 4-bit 文件仍然启动失败,可以改用 dtype: 'q8'(约 545 MB),并向用户展示实际加载的是哪一个。在浏览器检查中,4-bit 文件首次尝试即加载成功。
这个问题是怎么暴露出来的
发现问题的是一个名为 Paste-Safe 的应用,它在你把文本粘贴进聊天 AI 之前,在浏览器本地屏蔽客户名称。Gemma 3 270M 负责对第一个模型漏掉的名字做可选的二次检查。
值得一提的是暴露路径:所有单元测试都通过了,因为单元测试不会加载真实模型。错误第一次出现,是另一个 agent 在真实 Chrome 窗口里点击“Run the test”的时候。这也侧面说明,涉及真实模型加载和运行时选择的场景,单靠单元测试很难覆盖。改动之后,同一页面成功加载了 4-bit 的 Gemma,并跑完了 30 条消息的测试集。
这个问题是否已知?
部分已知。其他开发者也在各自项目中撞过同一堵墙,并在代码层面做了规避:okasi/onnx-lab#2 记录了“asyncify 缺少 GatherBlockQuantized”,改用 JSEP 构建;dephelion/ufeed#64 为 Gemma 2 q4 打包了 JSEP 运行时;Alarm2024/outside-35#1 也是类似情况。
但在写下这段记录时,Transformers.js 和 ONNX Runtime 的 issue tracker 中都没有针对这个问题的条目,也没有成文的修复说明。所以这篇记录的目的,是提供一个可被搜索到的版本。如果你恰好是这两个项目的维护者,并且有更好的修复方式(比如改默认行为,或者给出更清晰的错误信息),那对每一个想在无 GPU 环境下跑小 Gemma 模型的人来说都会是帮助。
从更大的视角看,这类问题折射出本地 AI 在浏览器端的一个现实:模型文件、量化格式、运行时构建三者之间存在隐式耦合,而默认选择未必适配所有设备组合。4-bit 量化把模型压到 323 MB 便于分发,但也引入了像 GatherBlockQuantized 这样的贡献算子依赖,把可移植性问题从模型层推到了运行时层。对开发者而言,记住一个经验:当 ONNX 会话在第一个节点就失败、且报错里出现 contrib 算子名时,先怀疑运行时构建,而不是模型本身。
相关推荐

RAG入门实战:从原理到企业级知识库搭建
B站RAG保姆级课程精华解读:从检索增强生成的核心原理、文档切分与向量化流程,到影响准确度的五大因素及企业级知识库搭建的适用边界,附微调与RAG的选型建议。

AI大模型选型指南:DeepSeek与千问三实战对比
AI大模型开发者如何选型?本文基于B站教程内容,拆解DeepSeek V3与R1的Function Calling差异、千问三的编程式思维模式切换与智能体能力,并梳理多模型并行的学习路径与实战选型逻辑。

锚定、损失厌恶与助推:零售商操纵你花钱的5种心理战术
行为经济学揭示零售商如何用锚定效应、框定效应、损失厌恶和助推理论操纵你的消费决策。本文拆解5种常见心理战术,并给出放慢决策、自定锚点等反制方法,帮你看穿折扣背后的定价设计。