1. 端侧推理为何突然成了浏览器扩展的刚需
浏览器扩展这个行当,过去十年基本就是干三件事:改页面样式、拦网络请求、往页面里塞按钮。逻辑简单,打包出来几百KB,扔到应用商店就完事。但从去年开始,我明显感觉到风向变了——越来越多的需求开始往扩展里塞模型,而且是那种动辄几十MB甚至上百MB的模型。
这个变化的推手其实很实在。一方面,用户对隐私的敏感度上来了,把数据传到远端服务器再拿结果,很多人心里不踏实,尤其是涉及截图、文档、聊天记录这类内容。另一方面,端侧硬件确实争气,现在的笔记本和手机,NPU、GPU 的算力放在五年前是不可想象的,跑个几亿参数的小模型完全够用。再加上 WebGPU 在主流浏览器里逐步铺开,扩展这个原本只能做做 DOM 操作的沙盒环境,突然有了直接调用图形算力的能力。
但真动手做,你会发现坑比想象中多得多。Manifest V3 把后台页面换成了 Service Worker,这东西随时可能被浏览器回收,你没法像以前那样在后台常驻一个推理会话。扩展的各个运行环境——content script、service worker、offscreen document、popup——彼此隔离,模型放哪、算力在哪、结果怎么传,每一步都要重新设计。我见过不少团队,模型在本地跑得好好的,一塞进扩展就各种内存溢出、会话丢失、首帧延迟爆炸。
这篇东西就是把我这段时间踩过的坑、验证过的方案、以及一些不太方便写在官方文档里的经验,系统地捋一遍。核心围绕浏览器扩展、端侧 AI 推理、Manifest V3、WebGPU、ONNX Runtime这几个关键词展开。不管你是刚打算在扩展里加个翻译、摘要、图像处理功能,还是已经在做但被性能问题卡住,应该都能从里面找到能直接抄的作业。我尽量说人话,把每个选择背后的账算清楚,而不是甩一堆 API 名字让你自己猜。
2. 整体架构设计:模型到底该放在哪个运行环境
2.1 先搞清楚扩展里到底有几个"房间"
做端侧推理,第一件事不是选模型,而是想清楚模型住在哪。浏览器扩展不是一个单一进程,它更像一套合租房,每个房间的权限、生命周期、能访问的资源都不一样。你得先摸清这套房子的户型,才能决定把床(模型)放哪。
主要房间有这么几个:
- Service Worker(后台):Manifest V3 里的后台脚本,负责事件调度、消息中转。它最大的问题是不常驻,浏览器觉得你闲着就把你干掉,下次事件来了再唤醒。你没法在这里维持一个长时间的推理会话。
- Content Script(内容脚本):注入到网页里的脚本,能直接操作 DOM,但运行在页面的上下文里,权限受限,而且每个标签页一份,模型重复加载会要命。
- Offscreen Document(离屏文档):这是 Manifest V3 专门为这类需求开的口子。它本质上是一个隐藏的 HTML 页面,有完整的 DOM 和 Web API 访问权限,生命周期比 Service Worker 稳定得多,可以常驻。端侧推理的主力环境基本就是它。
- Popup / Side Panel(弹窗/侧边栏):用户交互界面,生命周期跟着用户操作走,开开关关很频繁,不适合放重资源。
- Web Worker:可以挂在 offscreen document 下面,把推理计算从主线程剥离,避免卡 UI。
我一开始图省事,把 ONNX Runtime 的会话直接建在 Service Worker 里,结果测试的时候好好的,用户用一会儿就报"会话已销毁"。后来才反应过来,Service Worker 被回收了,会话自然就没了。这个坑非常典型,几乎每个新手都会踩。
2.2 推荐的分层架构
经过几轮折腾,我目前比较稳定的架构是这样的:
| 层级 | 运行环境 | 职责 | 是否常驻 |
|---|---|---|---|
| 交互层 | Popup / Side Panel | 用户输入、结果展示 | 否 |
| 调度层 | Service Worker | 消息路由、任务队列、权限校验 | 否 |
| 推理层 | Offscreen Document | 模型加载、会话管理、推理执行 | 是 |
| 计算层 | Web Worker | 张量预处理、后处理、耗时计算 | 是 |
| 注入层 | Content Script | 页面数据采集、结果回填 | 否 |
这个分层的核心逻辑是:把重资源和长生命周期的东西集中在 offscreen document,把轻量的、事件驱动的东西放在 Service Worker,把纯计算放在 Web Worker。
为什么这么分?因为 Service Worker 被回收是浏览器的既定行为,你对抗不了,只能顺应。把模型和会话放在 offscreen document,它不会被随意回收,推理的连续性就有保障。而 Web Worker 负责把 CPU 密集的预处理(比如图像 resize、tokenize)从主线程挪走,避免 offscreen document 的 UI 线程被阻塞。
2.3 通信链路的设计要点
分层之后,通信就成了关键。扩展里各个环境之间只能靠chrome.runtime.sendMessage和chrome.runtime.connect通信,前者是一次性的,后者是长连接。推理这种场景,我强烈建议用长连接(Port),原因有两个:
一是推理请求往往需要流式返回结果,比如逐 token 输出,一次性消息满足不了。二是长连接能感知对方是否还活着,Service Worker 被回收时端口会断开,你可以据此做重连和状态恢复。
具体做法是:offscreen document 启动时,主动向 Service Worker 建立一个命名端口,比如叫inference-port。Service Worker 收到请求后,通过这个端口转发给 offscreen。结果也走同一个端口回来。这样即使 Service Worker 中途被回收,offscreen 这边的端口断开后可以重新发起连接,模型会话不受影响。
注意:offscreen document 的创建有数量限制,一个扩展同时只能有一个。所以别想着给每个任务开一个 offscreen,老老实实做任务队列。
3. 模型选型与 ONNX Runtime 的落地细节
3.1 为什么是 ONNX Runtime Web
端侧推理的运行时选择其实不少,TensorFlow.js、WebLLM、ONNX Runtime Web 都能用。我最后选 ONNX Runtime Web,主要基于几个现实考量。
第一是模型格式的通用性。ONNX 已经成了模型交换事实上的中间格式,PyTorch、TensorFlow 训练出来的模型都能导出成 ONNX。这意味着你的模型供应链不会被某个框架绑死,今天用 HuggingFace 上的模型,明天换成自己微调的,导出流程基本一致。
第二是后端切换的灵活性。ONNX Runtime Web 支持 WebGPU、WebGL、WASM 三种执行后端,而且可以在运行时根据环境能力动态选择。用户机器支持 WebGPU 就用 GPU 加速,不支持就退回 WASM,兼容性和性能兼顾。
第三是算子覆盖和社区活跃度。我实测下来,主流的 Transformer 类模型、CNN 类模型,ONNX Runtime Web 的算子支持都比较完整,遇到不支持的算子也能通过自定义算子扩展。
TensorFlow.js 的问题在于模型转换链路相对封闭,WebLLM 则偏向大语言模型场景,通用性不如 ONNX Runtime。当然这只是我的选择,你要是只做 LLM,WebLLM 也值得考虑。
3.2 模型体积与量化策略
扩展是要分发到用户机器上的,模型体积直接决定了安装包大小和首次加载体验。一个 FP32 的 BERT-base 大概 400MB,这显然不现实。量化是必须的。
我一般按这个顺序做量化决策:
- FP16:体积减半,精度损失极小,WebGPU 原生支持,首选。
- INT8:体积再减半,精度损失可控,适合对体积敏感的场景,但要注意 WebGPU 对 INT8 的支持不如 FP16 成熟,可能需要走 WASM 后端。
- INT4:体积最小,但精度损失明显,只适合对精度要求不高的场景,比如简单的文本分类。
实测数据供参考:一个 1.1 亿参数的模型,FP32 约 440MB,FP16 约 220MB,INT8 约 110MB。对于扩展来说,超过 100MB 的模型就要慎重了,用户下载和加载的耐心是有限的。
我的建议是优先 FP16,在 WebGPU 上跑,性能和精度平衡得最好。如果模型实在太大,再考虑 INT8,但要接受可能退回 WASM 后端的性能损失。
3.3 模型加载的时机与缓存
模型加载是端侧推理最耗时的环节,几百 MB 的文件从磁盘读进来、解析、初始化会话,冷启动几秒到十几秒都正常。这个体验必须优化。
我的做法是懒加载 + 缓存。扩展安装后不立即加载模型,等用户第一次触发推理功能时再加载。加载完成后,会话保持在 offscreen document 里,后续请求直接复用。同时利用浏览器的 Cache API 把模型文件缓存起来,第二次启动就不用重新下载了。
// offscreen document 中的模型加载逻辑 async function loadModel(modelUrl) { const cache = await caches.open('model-cache-v1'); let response = await cache.match(modelUrl); if (!response) { response = await fetch(modelUrl); // 只缓存成功的响应 if (response.ok) { await cache.put(modelUrl, response.clone()); } } const modelBuffer = await response.arrayBuffer(); const session = await ort.InferenceSession.create(modelBuffer, { executionProviders: ['webgpu', 'wasm'], graphOptimizationLevel: 'all' }); return session; }这里有个细节:executionProviders数组的顺序很重要,ONNX Runtime 会按顺序尝试,WebGPU 不可用就自动退回 WASM。这样你不需要自己写环境检测逻辑。
实操心得:模型文件建议放在扩展包外,通过
chrome.runtime.getURL或者远端 CDN 加载。放在扩展包里会导致安装包巨大,而且应用商店审核对包体积有要求。但放远端就要考虑首次加载的网络延迟,所以缓存策略必须做好。
4. WebGPU 加速的实操与性能调优
4.1 WebGPU 在扩展里的可用性现状
WebGPU 是这一轮端侧推理性能提升的关键。它让扩展能直接调用 GPU 做通用计算,而不像 WebGL 那样要绕着图形渲染的弯子走。但它的可用性有几个现实约束,你得心里有数。
首先是浏览器版本。Chrome 从 113 开始默认开启 WebGPU,Edge 跟进得也快,Firefox 和 Safari 相对滞后。所以你的扩展必须做好降级,不能假设所有用户都有 WebGPU。
其次是扩展环境的限制。WebGPU 在 offscreen document 里是可以用的,但在 Service Worker 里不行。这也是为什么推理必须放在 offscreen document 的另一个原因。
最后是硬件差异。不同 GPU 对 WebGPU 的支持程度不一样,有些老显卡虽然浏览器版本够,但驱动层面支持不完整,跑起来可能比 WASM 还慢。所以运行时探测 + 动态降级是必须的。
4.2 运行时能力探测
我一般用这段代码做探测:
async function detectWebGPU() { if (!navigator.gpu) return false; try { const adapter = await navigator.gpu.requestAdapter(); if (!adapter) return false; // 检查是否支持推理需要的特性 const device = await adapter.requestDevice(); return !!device; } catch (e) { return false; } }探测通过就用 WebGPU,不通过就退回 WASM。这里要注意,requestAdapter和requestDevice都是异步的,而且可能因为权限或资源问题失败,所以一定要包在 try-catch 里。
4.3 性能调优的几个关键点
WebGPU 跑起来之后,性能调优主要围绕这几个方向:
批处理大小(batch size)。端侧推理和服务器不一样,服务器追求吞吐,端侧追求延迟。批处理太大会增加单次推理延迟,太小又浪费 GPU 算力。我的经验是,交互式场景 batch size 设为 1,后台批处理场景可以设到 4 或 8,具体要看模型和硬件。
输入形状固定化。WebGPU 对动态形状的支持不如静态形状好,如果模型输入形状经常变化,会触发重新编译,性能断崖式下跌。所以尽量把输入 padding 到固定长度,比如文本模型统一 pad 到 128 或 256。
显存管理。GPU 显存是有限的,模型权重、中间激活值、输入输出张量都占显存。如果显存不够,WebGPU 会报错或者悄悄退回 CPU。我遇到过一个大模型在 4GB 显存的机器上跑,batch size 稍微大一点就崩,最后只能限制 batch size 并做显存监控。
避免频繁的 CPU-GPU 数据搬运。每次把数据从 CPU 传到 GPU 或反过来,都有开销。所以预处理尽量在 GPU 上做,或者至少把多次小传输合并成一次大传输。
4.4 一个真实的性能对比
我在一台配备集成显卡的笔记本上做过对比测试,模型是一个 1.1 亿参数的文本分类模型,输入长度 128:
| 后端 | 首次推理 | 后续平均 | 内存占用 |
|---|---|---|---|
| WASM (单线程) | 3.2s | 850ms | 420MB |
| WASM (多线程) | 2.8s | 420ms | 480MB |
| WebGPU | 1.5s | 95ms | 620MB |
可以看到 WebGPU 在后续推理上有数量级的优势,但首次推理因为要编译着色器,优势没那么明显,而且显存占用更高。这个数据说明,如果你的场景是高频推理,WebGPU 收益巨大;如果是低频、一次性的,WASM 也够用,还省显存。
5. Manifest V3 生命周期管理的坑与解法
5.1 Service Worker 被回收的真实影响
Manifest V3 最让人头疼的就是 Service Worker 的短生命周期。浏览器会在空闲 30 秒左右回收它,有事件来了再唤醒。这个机制对普通扩展没影响,但对推理场景是致命的。
我遇到过的具体问题包括:推理任务进行到一半,Service Worker 被回收,消息通道断开,结果丢失;模型会话状态存在 Service Worker 的全局变量里,回收后状态清零;定时任务被中断,批处理队列丢失。
这些问题的根源都是把不该放 Service Worker 的东西放进去了。解法就是前面说的分层架构,把状态和会话放到 offscreen document,Service Worker 只做无状态的消息中转。
5.2 保活策略的正确姿势
有人会想,那我让 Service Worker 一直活着不就行了?网上确实有一些保活技巧,比如定时发消息、播放静音音频、开 WebSocket 长连接。但我要泼盆冷水:这些方法要么已经被浏览器封堵,要么违反应用商店政策,风险很大。
正确的做法不是对抗回收,而是设计成可恢复的。具体来说:
- 所有推理状态持久化到
chrome.storage.session或 IndexedDB,Service Worker 唤醒后能恢复。 - 消息通道断开后,offscreen document 主动重连。
- 任务队列用持久化存储,Service Worker 重启后继续处理未完成的任务。
这样即使 Service Worker 被回收一百次,用户体验也不受影响。
5.3 offscreen document 的生命周期管理
offscreen document 虽然比 Service Worker 稳定,但也不是永远活着。它会在扩展被禁用、浏览器关闭时销毁。而且创建它有明确的理由限制,Manifest V3 规定只有特定原因才能创建 offscreen document,比如DOM_PARSER、BLOBS、WORKERS等。
推理场景一般用WORKERS这个理由,因为你要在里面跑 Web Worker。创建代码如下:
async function ensureOffscreenDocument() { const existingContexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }); if (existingContexts.length > 0) return; await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['WORKERS'], justification: 'Run AI inference in a persistent context' }); }注意getContexts这个 API 是较新版本才有的,老版本要用clients.matchAll或者自己维护标志位。我建议用新 API,然后对老版本做兼容。
踩坑记录:offscreen document 创建是异步的,而且可能因为并发调用创建出多个。一定要加锁或者用单例模式,我见过因为并发创建导致两个 offscreen 同时跑推理,内存直接爆掉的案例。
6. 常见问题排查与实战避坑清单
6.1 推理结果不稳定或报错
这是最常见的问题,原因通常有几类:
模型输入格式不对。ONNX 模型对输入张量的形状、类型、名称都有严格要求。我建议在加载模型后,先打印session.inputNames和session.outputNames,确认输入输出的名称和顺序,再构造输入张量。很多人栽在输入名称对不上,报错信息又很隐晦。
数据类型不匹配。ONNX 模型期望的输入类型可能是float32、int64、int32,你传错了就会报错或者结果异常。特别是int64,JavaScript 里没有原生 64 位整数,要用BigInt64Array,这个很容易忽略。
张量形状动态变化。前面提过,WebGPU 对动态形状支持不好。如果输入形状每次都不同,会反复触发编译。解法是固定形状,或者用 ONNX Runtime 的动态形状支持(但性能会打折)。
6.2 内存泄漏与崩溃
端侧推理是内存大户,稍不注意就泄漏。我总结的几个高发点:
- 会话未释放。每次创建
InferenceSession都会占内存,如果反复创建不释放,很快就爆了。会话应该复用,实在要重建,先调用session.release()。 - 张量未释放。ONNX Runtime 的张量是手动管理的,用完要
tensor.dispose()。虽然现在有自动回收机制,但显式释放更保险。 - 事件监听器未移除。在 offscreen document 里注册的监听器,如果组件销毁时没移除,会一直持有引用,导致内存泄漏。
我一般会在开发阶段用 Chrome 的 Memory 面板做堆快照对比,跑几十次推理后看内存是否持续增长。如果增长,基本就是上面几个问题之一。
6.3 首帧延迟过长
用户第一次用功能,等十几秒才出结果,体验很差。优化方向有几个:
模型预热。扩展启动后,在后台悄悄加载模型并跑一次空推理,把着色器编译、内存分配这些一次性开销提前做掉。用户真正用时就是热启动,快很多。
分阶段加载。如果模型很大,可以先加载一个小的、能快速响应的模型,同时后台加载大模型,大模型就绪后切换。这样用户至少能立刻看到反馈。
进度反馈。加载过程中给用户明确的进度提示,哪怕只是"正在加载模型 45%",也比干等着强。进度可以通过fetch的Content-Length和已读字节数计算。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 会话创建失败 | 模型文件损坏或格式不对 | 检查 ONNX 文件完整性 | 重新导出模型,验证 opset 版本 |
| 推理结果全为 0 | 输入未正确归一化 | 检查预处理逻辑 | 按模型要求做归一化 |
| WebGPU 报错退回 WASM | 显存不足或驱动问题 | 查看控制台 GPU 错误 | 降低 batch size 或改用 WASM |
| Service Worker 频繁重启 | 空闲被回收 | 查看扩展后台日志 | 状态持久化,设计成可恢复 |
| 内存持续增长 | 张量或会话未释放 | 堆快照对比 | 显式 dispose,复用会话 |
| 首帧延迟超过 10s | 模型太大或未预热 | 测量各阶段耗时 | 预热 + 分阶段加载 + 进度反馈 |
6.5 几个不太方便写在文档里的经验
别在 content script 里做推理。content script 运行在页面上下文,每个标签页一份,模型重复加载会拖垮浏览器。而且页面可能随时关闭,推理中途被打断。所有推理都应该在 offscreen document 里做,content script 只负责采集数据和回填结果。
模型版本管理要提前规划。扩展更新时,模型可能也要更新。如果模型缓存在用户本地,要考虑版本兼容和清理策略。我一般给模型 URL 带上版本号,比如model-v2.onnx,新版本用新 URL,旧缓存定期清理。
测试要覆盖低端设备。开发机往往配置很好,跑得飞快,但用户的设备可能很老。我建议至少在一台 4GB 内存、集成显卡的机器上测一遍,看看会不会崩、会不会卡。很多问题只有在这种设备上才暴露。
注意应用商店的政策。不同商店对扩展包体积、远端代码加载、权限申请都有要求。端侧推理涉及大模型和 WebGPU,可能触发额外审核。提前了解政策,别等提交了被打回来。
7. 写在最后的一点个人体会
这套架构我前后迭代了三四版,从最初把模型塞 Service Worker 的天真方案,到现在分层清晰、可恢复、可降级的稳定实现,中间踩的坑基本都写在上面的章节里了。如果只让我留一句话给准备入坑的人,那就是:端侧推理在浏览器扩展里的核心矛盾,不是算力不够,而是生命周期不可控。把这个问题想明白,架构就顺了。
另外提一个后续可以扩展的方向:现在这套方案是单模型的,如果你要做多模型协同,比如一个小模型做粗筛、一个大模型做精排,可以在 offscreen document 里维护多个会话,用任务队列调度。这个我还在试验阶段,等跑稳了再单独写一篇。