1. 浏览器里跑嵌入模型这件事到底难在哪EmbeddingGemma2 是 Google 推出的新一代文本嵌入模型基于 Gemma 2 架构专为语义检索、文本聚类、RAG 等场景设计。它的核心能力是把一段文本压缩成一个高维向量让语义相近的文本在向量空间中距离更近。传统做法是把模型部署在服务器上客户端发请求、服务端返回向量。但在浏览器里直接跑意味着模型推理完全发生在用户的设备上没有网络往返、没有服务端成本、数据也不出本地。这个思路听起来很美好但真正动手做的时候你会发现几个硬骨头模型权重的体积、推理框架的选择、浏览器环境的算力限制、内存管理、首次加载体验。每一个环节都有坑而且这些坑不是看文档就能避开的得实际跑一遍才知道哪里会卡住。我最近花了两天时间把 EmbeddingGemma2 跑在了浏览器里用的是 ONNX Runtime Web 加 WebGPU 后端。整个过程踩了不少坑也积累了一些在官方文档里找不到的经验。这篇文章会把完整的思路、选型理由、实操步骤和避坑经验都讲清楚适合对前端推理、嵌入模型、RAG 应用感兴趣的开发者参考。即使你之前没接触过 ONNX Runtime Web跟着走也能跑通。2. 为什么选 ONNX Runtime Web 而不是 Transformers.js2.1 两个方案的核心差异在浏览器里跑模型目前主流的两条路是 Transformers.js 和 ONNX Runtime Web。Transformers.js 是 Hugging Face 出的API 设计跟 Python 端的 transformers 很像上手快社区模型库也丰富。ONNX Runtime Web 是微软的 ONNX Runtime 的 Web 版本更底层控制粒度更细。我一开始用的是 Transformers.js因为它确实方便几行代码就能加载模型。但跑 EmbeddingGemma2 的时候遇到了问题模型架构比较新Transformers.js 的模型转换脚本对 Gemma 2 系列的支持还不够完善转换出来的 ONNX 模型在浏览器里加载会报算子不支持。我试了手动调整配置折腾了半天没搞定果断换成了 ONNX Runtime Web。ONNX Runtime Web 的好处是它直接吃 ONNX 模型文件不关心你用什么框架训练的。只要模型能正确导出成 ONNX 格式并且算子集版本在支持范围内就能跑。EmbeddingGemma2 可以通过 Optimum 工具链导出 ONNX导出时指定 opset 17 或更高基本不会遇到算子兼容问题。2.2 推理后端的抉择WebGPU vs WASMONNX Runtime Web 支持多种执行后端最常用的是 WASM 和 WebGPU。WASM 后端兼容性最好几乎所有现代浏览器都支持但它是纯 CPU 推理速度慢。WebGPU 后端利用 GPU 加速速度快很多但对浏览器版本有要求而且不同显卡驱动的表现差异较大。我实测下来EmbeddingGemma2 在 WebGPU 后端下的推理速度大约是 WASM 后端的 5 到 8 倍。具体数字取决于你的 GPU但差距是数量级的。所以只要目标用户的浏览器支持 WebGPU就应该优先用 WebGPU。不支持的时候再降级到 WASM保证功能可用。这里有个细节ONNX Runtime Web 的 WebGPU 后端在首次初始化时会做一次着色器编译这个过程可能耗时几百毫秒到一两秒。如果你在页面加载时就初始化用户会感觉页面卡了一下。我的做法是把初始化放在用户触发操作之后比如点击开始分析按钮时再初始化这样首屏体验不受影响。2.3 模型量化的取舍EmbeddingGemma2 原始模型是 FP32 的权重大小约 1.2GB。这个体积直接放到浏览器里加载用户得等很久而且内存占用也大。所以量化是必须的。常见的量化方案有 FP16、INT8 和 INT4。FP16 把权重从 32 位浮点降到 16 位体积减半精度损失很小基本可以忽略。INT8 进一步降到 8 位整数体积再减半但精度会有可感知的下降尤其是在语义相似度任务上向量之间的细微差异可能被抹平。INT4 体积最小但精度损失明显不适合对精度要求高的场景。我的选择是 FP16。理由很简单EmbeddingGemma2 本身参数量不算特别大FP16 之后约 600MB虽然还是不小但配合浏览器的流式加载和缓存机制可以做到首次加载后后续秒开。INT8 虽然体积更小但我在实际测试中发现对于一些语义相近但需要区分的文本对INT8 量化后的向量余弦相似度区分度明显下降影响了检索质量。所以如果你的应用对精度敏感FP16 是更稳妥的选择。3. 从模型导出到浏览器加载的完整链路3.1 用 Optimum 导出 ONNX 模型第一步是把 EmbeddingGemma2 导出成 ONNX 格式。这里用 Hugging Face 的 Optimum 工具链最方便。安装好 optimum 和 onnxruntime 之后一条命令就能搞定optimum-cli export onnx \ --model google/embeddinggemma2 \ --task feature-extraction \ --opset 17 \ ./embeddinggemma2-onnx这里有几个关键参数需要解释。--task feature-extraction告诉 Optimum 这是一个特征提取任务也就是输出嵌入向量而不是做分类或生成。--opset 17指定 ONNX 算子集版本17 是目前 ONNX Runtime Web 支持得比较好的版本太新的算子集可能不被支持太旧的又可能缺少必要的算子。导出完成后你会得到一个包含model.onnx和若干配置文件的目录。model.onnx就是我们要的模型文件。但这时候它还是 FP32 的体积很大需要做量化。3.2 FP16 量化的具体操作ONNX 模型量化可以用 onnxruntime 自带的量化工具也可以用 optimum 的量化功能。我推荐用 onnxruntime 的quantize脚本控制更灵活from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_input./embeddinggemma2-onnx/model.onnx, model_output./embeddinggemma2-onnx/model_fp16.onnx, weight_typeQuantType.FLOAT16, optimize_modelTrue )注意这里用的是quantize_dynamic而不是quantize_static。动态量化不需要校准数据集直接对权重做转换适合嵌入模型这种没有明确分类头的场景。optimize_modelTrue会做一些图优化比如算子融合能提升推理速度。量化完成后模型体积应该从 1.2GB 降到 600MB 左右。你可以用ls -lh确认一下。如果体积没怎么变可能是量化没生效检查一下weight_type参数是否正确。3.3 模型分片与流式加载600MB 的模型文件直接让浏览器下载用户体验会很差。我的做法是把模型文件分片然后用 fetch 的流式读取配合 ONNX Runtime Web 的InferenceSession.create方法实现边下载边初始化。ONNX Runtime Web 支持传入一个Uint8Array作为模型数据所以你可以自己控制下载过程。我写了一个简单的分片下载逻辑async function loadModelInChunks(url, chunkSize 10 * 1024 * 1024) { const response await fetch(url); const contentLength response.headers.get(Content-Length); const chunks []; let received 0; const reader response.body.getReader(); while (true) { const { done, value } await reader.read(); if (done) break; chunks.push(value); received value.length; // 更新进度条 updateProgress(received / contentLength); } const allChunks new Uint8Array(received); let offset 0; for (const chunk of chunks) { allChunks.set(chunk, offset); offset chunk.length; } return allChunks; }这个逻辑本身不复杂但有个坑如果你在下载过程中用户刷新了页面下载就白费了。所以最好配合 Cache API 或者 IndexedDB 做持久化缓存。我的做法是用 Cache API 把模型文件缓存起来下次加载时先检查缓存有就直接用没有才走网络。这样第二次打开页面基本是秒开。4. 浏览器端推理的代码实现与参数调优4.1 初始化 InferenceSession模型数据准备好之后就可以创建推理会话了。ONNX Runtime Web 的初始化代码如下import * as ort from onnxruntime-web; // 设置 WASM 文件路径 ort.env.wasm.wasmPaths /ort-wasm/; async function createSession(modelBuffer) { const session await ort.InferenceSession.create(modelBuffer, { executionProviders: [webgpu, wasm], graphOptimizationLevel: all, enableCpuMemArena: true, enableMemPattern: true, }); return session; }executionProviders数组的顺序很重要。ONNX Runtime Web 会按顺序尝试如果 WebGPU 可用就用 WebGPU否则降级到 WASM。这样写的好处是兼容性和性能兼顾。graphOptimizationLevel: all会启用所有图优化包括常量折叠、算子融合等。对于嵌入模型这些优化能带来 10% 到 20% 的速度提升。enableCpuMemArena和enableMemPattern是内存管理相关的选项开启后能减少内存分配开销建议保持默认开启。4.2 文本预处理与 TokenizationEmbeddingGemma2 的输入是 token ID 序列所以需要先做 tokenization。浏览器端做 tokenization 有两种方案用 Transformers.js 的 tokenizer或者自己实现一个轻量级的 tokenizer。我一开始想自己实现因为 EmbeddingGemma2 用的是 SentencePiece 分词逻辑不算特别复杂。但实际做的时候发现SentencePiece 的规范化步骤很多包括 Unicode 标准化、空格处理、特殊字符转义等自己实现容易漏掉细节导致分词结果和 Python 端不一致最终影响嵌入质量。所以后来我改用了 Transformers.js 的AutoTokenizer只用它做分词模型推理还是走 ONNX Runtime Web。这样分工明确分词准确性有保障推理性能也最优。代码大概长这样import { AutoTokenizer } from xenova/transformers; const tokenizer await AutoTokenizer.from_pretrained(google/embeddinggemma2); async function tokenize(text) { const encoded await tokenizer(text, { padding: true, truncation: true, max_length: 512, return_tensor: false, }); return { inputIds: encoded.input_ids, attentionMask: encoded.attention_mask, }; }max_length: 512是 EmbeddingGemma2 的最大序列长度。超过这个长度的文本会被截断。如果你的应用需要处理长文本可以考虑分段嵌入再聚合但那是另一个话题了。4.3 推理执行与向量后处理有了 tokenized 的输入就可以喂给 ONNX Runtime 执行推理了async function getEmbedding(session, inputIds, attentionMask) { const feeds { input_ids: new ort.Tensor(int64, BigInt64Array.from(inputIds), [1, inputIds.length]), attention_mask: new ort.Tensor(int64, BigInt64Array.from(attentionMask), [1, attentionMask.length]), }; const results await session.run(feeds); const embedding results.last_hidden_state.data; // 均值池化 const pooled meanPooling(embedding, attentionMask, results.last_hidden_state.dims); // L2 归一化 const normalized l2Normalize(pooled); return normalized; }这里有两个关键的后处理步骤均值池化和 L2 归一化。均值池化是把序列中每个 token 的向量按 attention mask 加权平均得到一个固定长度的向量。L2 归一化是把向量长度缩放到 1这样余弦相似度就可以直接用点积计算省去了除法。均值池化的实现要注意维度处理。ONNX 模型输出的last_hidden_state通常是三维的[batch, seq_len, hidden_dim]。你需要按seq_len维度做平均同时用 attention mask 把 padding 位置排除掉。我见过有人忘了排除 padding导致嵌入向量被稀释检索效果明显下降。4.4 性能调优的几个关键参数在实际跑的过程中我调了几个参数效果比较明显参数默认值调整后效果max_length512256推理速度提升约 40%短文本场景精度几乎无损executionProviders[wasm][webgpu, wasm]速度提升 5-8 倍graphOptimizationLevelbasicall速度提升 10-20%enableCpuMemArenafalsetrue内存分配开销降低长序列更明显max_length的调整需要根据你的实际场景来。如果你的文本大多是短句或段落256 足够了没必要用 512。序列长度对推理时间的影响是平方级的因为 attention 的计算复杂度是 O(n²)。所以能短则短。WebGPU 后端的性能优势在长序列上更明显。我实测过512 长度的序列WebGPU 比 WASM 快大约 6 倍256 长度时快大约 5 倍。所以如果你的场景必须处理长文本WebGPU 几乎是必选项。5. 实际跑下来遇到的坑与排查过程5.1 WebGPU 初始化失败从报错到定位第一次跑的时候WebGPU 初始化直接失败了控制台报了一个很模糊的错误Failed to create WebGPU device。我一开始以为是浏览器不支持但检查了navigator.gpu是存在的说明浏览器版本没问题。排查过程是这样的先确认navigator.gpu.requestAdapter()是否能返回 adapter。结果返回了 null。这说明系统层面没有可用的 GPU 适配器。但我明明有独立显卡为什么拿不到 adapter后来查资料发现Chrome 在某些系统上默认不启用 WebGPU需要在chrome://flags里手动开启。开启之后重启浏览器adapter 就能拿到了。这个坑在官方文档里没有明显提示但实际开发中很常见。如果你也遇到类似问题先检查浏览器的 WebGPU 开关状态。还有一个相关的问题即使 adapter 拿到了requestDevice()也可能失败。这通常是因为 GPU 驱动版本太旧或者浏览器把该 GPU 列入了黑名单。这种情况下只能降级到 WASM 后端没有别的办法。5.2 内存溢出模型加载到一半崩溃模型加载到大约 70% 的时候页面直接崩溃了控制台报 Out of memory。这个问题困扰了我一阵子因为 600MB 的模型理论上不应该撑爆内存现代浏览器给单个标签页的内存限额通常是 2GB 到 4GB。后来用 Chrome 的 Performance 面板抓了一下内存曲线发现峰值内存到了 3.5GB。原因是 ONNX Runtime Web 在初始化时会同时保留原始模型数据、优化后的图结构、以及推理时的中间张量。如果模型数据是用Uint8Array一次性加载的那这份数据会一直占着内存不会被释放。解决办法是在创建 session 之后手动把模型 buffer 置空让垃圾回收器回收let modelBuffer await loadModelInChunks(url); const session await ort.InferenceSession.create(modelBuffer, options); modelBuffer null; // 释放引用另外enableCpuMemArena虽然能提升性能但也会增加内存占用。如果你的设备内存紧张可以把它关掉用时间换空间。5.3 分词结果不一致Python 和浏览器端的差异这个问题比较隐蔽。我在 Python 端算了一组文本的嵌入向量然后在浏览器端算同样的文本发现余弦相似度对不上差距还不小。排查了半天最后定位到是分词结果不一致。具体来说Python 端的 tokenizer 在处理某些特殊字符时会做额外的规范化而浏览器端的 tokenizer 版本不同规范化逻辑有细微差异。比如全角空格和半角空格的处理Python 端会统一转成半角浏览器端保留了全角。这个差异导致 token 序列不同最终嵌入向量也不同。解决办法是确保两端使用完全相同的 tokenizer 版本和配置。我在浏览器端加载 tokenizer 时显式指定了和 Python 端一致的revision参数问题就解决了。这个坑提醒我们跨端推理时预处理的一致性比推理本身更容易出问题。5.4 首次推理延迟着色器编译的代价WebGPU 后端在第一次执行推理时会有一个明显的延迟大概 1 到 2 秒。这是因为 WebGPU 需要编译着色器程序这个过程只发生一次后续推理就快了。但如果你的应用是用户点击按钮后才开始推理这个延迟会让用户觉得卡了。我的做法是在页面加载完成后用一个极短的文本做一次预热推理。这样着色器编译在后台完成用户真正操作时就没有延迟了。预热代码很简单async function warmup(session) { const dummyInput new ort.Tensor(int64, BigInt64Array.from([1, 2, 3]), [1, 3]); const dummyMask new ort.Tensor(int64, BigInt64Array.from([1, 1, 1]), [1, 3]); await session.run({ input_ids: dummyInput, attention_mask: dummyMask }); }这个预热推理的结果直接丢弃目的只是触发着色器编译。实测下来预热之后首次真实推理的延迟从 1.5 秒降到了 100 毫秒以内体验提升非常明显。6. 这套方案适合什么场景不适合什么场景6.1 适合的场景浏览器端嵌入模型最适合的场景是隐私敏感的文本处理。比如用户在本地笔记应用里做语义搜索笔记内容不想上传到服务器那在浏览器里直接算嵌入向量就是最优解。数据不出本地没有隐私泄露风险也没有服务端成本。另一个适合的场景是离线可用的应用。PWA 配合模型缓存用户第一次加载后后续即使断网也能正常使用语义检索功能。这对于一些现场作业、移动办公的场景很有价值。还有就是低延迟要求的交互。服务端推理虽然可以水平扩展但网络往返的延迟是省不掉的。浏览器端推理没有网络延迟对于需要实时反馈的场景体验会好很多。6.2 不适合的场景如果你的用户设备普遍性能较弱比如低端手机或老旧电脑浏览器端推理的体验会很差。600MB 的模型加载加上推理时的内存占用对低端设备是很大的负担。这种情况下服务端推理反而是更务实的选择。大批量文本处理也不适合浏览器端。比如你要对一万篇文档做嵌入在浏览器里跑可能要几十分钟而服务端用 GPU 批量推理可能几分钟就搞定了。浏览器端推理的优势在于单次、低延迟、隐私敏感的场景不在于吞吐量。还有就是需要频繁更新模型的场景。浏览器端模型缓存虽然能加速加载但模型更新后用户需要重新下载。如果你的模型迭代很频繁缓存策略需要仔细设计否则用户体验会很割裂。6.3 混合架构的折中方案实际项目中纯浏览器端或纯服务端往往都不是最优解。我比较推荐混合架构默认走浏览器端推理当检测到设备性能不足或模型加载失败时自动降级到服务端推理。这样既保证了大多数用户的隐私和延迟体验又为少数异常情况提供了兜底。实现上可以在初始化时做一个简单的性能探测跑一次预热推理如果耗时超过阈值比如 3 秒就标记为低性能设备后续请求走服务端。这个逻辑不复杂但能显著提升整体体验的稳定性。7. 一些值得记录的实操心得模型缓存一定要做。没有缓存的浏览器端推理每次打开页面都要重新下载 600MB用户根本不会用第二次。Cache API 是最简单的方案配合版本号做缓存失效基本够用。WebGPU 的兼容性比想象中好。Chrome 113、Edge 113 都已经默认支持 WebGPUSafari 从 18 开始也支持了。所以如果你的用户群主要是现代浏览器WebGPU 是可以作为默认后端的。WASM 作为降级方案保留即可。分词器的版本锁定很重要。跨端推理时分词器版本不一致导致的嵌入差异很难排查因为报错不会告诉你分词结果不同只会表现为检索效果变差。所以一定要在两端显式指定相同的 tokenizer revision。预热推理是提升体验的性价比最高的手段。几行代码就能把首次推理的延迟从秒级降到百毫秒级。如果你的应用对交互流畅度有要求这个步骤不能省。内存管理要主动做。ONNX Runtime Web 不会自动释放模型 buffer需要手动置空引用。另外推理过程中产生的中间张量也会占内存如果发现内存持续增长检查一下是否有未释放的 tensor 引用。最后说一个我踩过的坑ONNX Runtime Web 的 WASM 文件路径配置。如果你用打包工具比如 Vite 或 WebpackWASM 文件可能不会自动复制到输出目录导致运行时找不到文件。解决办法是在打包配置里把ort-wasm目录作为静态资源复制过去或者用 CDN 路径。这个问题在开发环境可能不会暴露但生产构建后就会报错建议提前配置好。