1. 端侧 AI 推理与浏览器扩展的碰撞点在哪
浏览器扩展这个赛道,过去十年基本被两类东西占据:一类是广告拦截、密码管理这种轻量工具,另一类是爬虫辅助、页面注入这种灰产边缘的脚本。但最近一年我注意到一个明显的变化——越来越多的开发者开始把端侧 AI 推理往扩展里塞。这个趋势背后有几个很实在的驱动力。
第一是隐私合规的压力。以前做文本摘要、图片识别、语义搜索这类功能,最省事的做法是把数据传到云端 API,但这两年不管是企业内训场景还是个人用户,对“数据不出本机”的要求越来越硬。你做一个会议纪要总结的扩展,如果每次都要把会议内容传到远端,很多公司 IT 部门直接就不让装了。
第二是WebGPU 和 WASM SIMD 的成熟。以前在浏览器里跑模型基本是玩具级别,现在 WebGPU 在主流浏览器上的覆盖率已经相当可观,配合 ONNX Runtime Web 或者 Transformers.js 这类运行时,在扩展的 background service worker 或者 offscreen document 里跑一个量化后的小模型,延迟已经能压到可接受的范围。
第三是Manifest V3 的架构倒逼。MV3 把 background page 换成了 service worker,生命周期变得很短,这对需要持续加载模型权重的 AI 推理来说是个麻烦事。但反过来想,这种限制也逼着开发者去思考更合理的架构——模型该放哪、推理该在哪触发、状态怎么保持,这些问题在 MV2 时代很多人是糊弄过去的。
这篇文章我想聊的就是这套东西:在 Manifest V3 的约束下,怎么把端侧 AI 推理系统合理地搭起来。不是那种“跑个 demo 就完事”的教程,而是从架构分层、模型部署、通信机制到工程踩坑的完整梳理。适合已经写过扩展、想往 AI 方向走的开发者,也适合做端侧推理但对浏览器环境不熟的人。
2. 整体架构设计:为什么不能把模型直接塞进 service worker
2.1 MV3 生命周期对推理系统的致命影响
先把这个最核心的矛盾讲清楚。Manifest V3 的 background 是service worker,它的生命周期由浏览器控制——没有事件的时候会被挂起,通常 30 秒到 5 分钟不等。这意味着什么?如果你把模型权重加载在 service worker 的全局变量里,用户切个标签页、发个呆,worker 被回收,下次事件来了重新启动,模型得重新加载。
一个量化后的 BERT 小模型大概 20-40MB,从 IndexedDB 或者 Cache Storage 读出来再初始化 ONNX Runtime 的 session,冷启动轻松超过 1 秒。用户每次点扩展图标都要等一秒多,这个体验是没法接受的。
所以架构设计的第一个决策就是:推理执行环境不能依赖 service worker 的常驻状态。我试过几种方案,下面这张表是我实际对比下来的结果。
| 方案 | 模型加载位置 | 冷启动延迟 | 内存占用 | 适用场景 |
|---|---|---|---|---|
| Service Worker 内直接推理 | SW 全局变量 | 800ms-2s | 低 | 极轻量模型(<5MB) |
| Offscreen Document 常驻 | Offscreen 页面 | 首次 1-2s,后续 <50ms | 中高 | 中等模型,需要频繁推理 |
| 独立推理页面 + 消息通信 | 扩展页面 | 首次 1-2s | 中 | 需要 UI 交互的推理 |
| WASM 在 content script | 页面上下文 | 每次注入 | 高 | 不推荐,污染宿主页面 |
2.2 分层架构:把职责切干净
我最终采用的架构是四层分离,这个划分方式参考了传统端侧推理系统的思路,但针对浏览器环境做了调整。
第一层是模型管理层。这一层不负责推理,只负责模型的获取、缓存、版本管理和完整性校验。模型文件放在扩展包内还是运行时下载,这是个需要权衡的问题。打包进扩展的话,CRX 体积会暴涨,Chrome Web Store 对包体积有隐性限制(超过 100MB 审核会变慢),而且模型更新要重新发版。运行时下载的话,需要处理网络失败、缓存失效、版本迁移这些问题。我的做法是:小模型(<10MB)打包,大模型运行时下载并缓存到 Cache Storage。
第二层是推理执行层。这一层是真正跑模型的地方,我选择用offscreen document来承载。Offscreen document 是 MV3 引入的一个特殊页面,它没有 UI,但生命周期比 service worker 长得多,只要你不主动关闭,它可以一直活着。这就解决了模型常驻的问题。创建方式是在 service worker 里调用chrome.offscreen.createDocument,指定理由为WORKERS或者BLOBS。
第三层是通信协调层。service worker 作为消息中枢,负责在 content script、popup、offscreen document 之间转发消息。这里有个坑:offscreen document 和 service worker 之间的通信用的也是chrome.runtime.sendMessage,但消息的 target 需要明确指定,否则会广播到所有上下文。
第四层是 UI 交互层。popup、side panel、content script 注入的浮层都属于这一层。它们不直接碰模型,只发请求、收结果。
2.3 为什么选 Offscreen Document 而不是别的
有人会问,为什么不用 SharedWorker 或者直接开一个隐藏的扩展页面?SharedWorker 在扩展环境里的支持一直不太稳定,而且它和 service worker 的通信要走 MessageChannel,调试起来很痛苦。隐藏扩展页面(chrome-extension://xxx/hidden.html)倒是能用,但它会出现在浏览器的标签页管理里,用户可能误关,而且每个窗口都会开一个实例,内存浪费。
Offscreen document 的好处是:全局唯一、无 UI、生命周期可控、支持完整的 DOM 和 Web API。这意味着你可以在里面用 WebGPU、WebAssembly、甚至 Web Worker 嵌套。我实测下来,在 offscreen document 里跑一个 30MB 的量化模型,常驻内存大概 150-200MB,对于现代设备来说是可以接受的。
注意:offscreen document 同时只能存在一个,创建前要先调
chrome.offscreen.hasDocument()检查,否则会报错。而且它不支持chrome.tabs等部分 API,别把不该放的逻辑塞进去。
3. 模型部署与推理引擎的工程细节
3.1 模型格式选择:ONNX 还是别的
浏览器里跑推理,模型格式的选择直接决定了你能用哪些运行时。目前主流的路子有这么几条:
- ONNX + ONNX Runtime Web:生态最成熟,支持 WebGPU 和 WASM 后端,量化工具链完整。缺点是 ORT 的 wasm 文件本身就有几 MB,首次加载有开销。
- TensorFlow.js:适合 TF 生态的模型,但 WebGPU 后端还在实验阶段,性能不如 ORT 稳定。
- Transformers.js:底层其实也是 ONNX Runtime,但封装了 Hugging Face 的模型加载流程,适合 NLP 任务快速上手。
- 自定义 WASM:用 Rust 或 C++ 编译自己的推理内核,性能最好但开发成本极高。
我的建议是:除非你有极强的性能定制需求,否则直接用 ONNX Runtime Web。它的 WebGPU EP 在 Chrome 113+ 上已经比较稳定,WASM SIMD 后端作为兜底也能跑。
模型导出这块,PyTorch 转 ONNX 用torch.onnx.export,注意 opset 版本别太低,建议 17 以上,否则一些 attention 相关的算子可能不支持。导出后一定要用onnxruntime的 Python 版跑一遍验证输出一致性,我踩过好几次导出后数值对不上的坑,最后发现是某个算子在不同 opset 下行为有差异。
3.2 量化:端侧推理的必修课
浏览器环境内存和算力都有限,不做量化基本没法用。量化的核心思路是把 FP32 的权重压缩成 INT8 甚至 INT4,模型体积能降到原来的 1/4 到 1/8,推理速度也能提升 2-3 倍。
ONNX Runtime 提供了几种量化方式:
- 动态量化:权重离线量化,激活值运行时量化。最简单,
quantize_dynamic一行搞定,适合 LSTM、BERT 这类模型。 - 静态量化:需要校准数据集,精度损失更小,但流程复杂。
- QAT(量化感知训练):在训练阶段就模拟量化,精度最好,但需要重新训练。
对于浏览器扩展场景,我一般用动态量化就够了。实测一个 110M 参数的 BERT,FP32 是 440MB,INT8 动态量化后 110MB,再配合模型剪枝能压到 60MB 左右。当然扩展里不会用这么大的模型,一般用 6 层的小模型或者蒸馏版本。
量化后的精度损失要实测。我做过一个文本分类任务,INT8 量化后准确率从 92.3% 掉到 91.1%,这个损失在大多数场景下可以接受。但如果你的任务是实体识别这种对边界敏感的任务,建议做静态量化或者保留 FP16。
3.3 模型缓存策略:Cache Storage 的正确用法
运行时下载的模型要缓存,不然每次冷启动都重新下载,用户流量和等待时间都受不了。浏览器扩展里可用的缓存方案有 IndexedDB、Cache Storage 和 OPFS(Origin Private File System)。
Cache Storage 是最合适的,因为它本来就是为存储 Response 对象设计的,模型文件作为 fetch 的响应存进去,读取时直接cache.match拿到 ArrayBuffer,非常自然。IndexedDB 存二进制也可以,但 API 更繁琐,而且大文件读写性能不如 Cache Storage。
具体做法是:给每个模型版本建一个 cache name,比如model-cache-v1.2.0,模型文件用固定的 URL 路径作为 key。更新模型时创建新的 cache,旧的在确认新版本可用后删除。这里要注意Cache Storage 的配额,Chrome 对扩展的存储配额大概是可用磁盘空间的 60%,但单个 origin 有上限,模型文件别超过几百 MB。
// 模型缓存的核心逻辑 async function getModelBuffer(modelUrl, version) { const cacheName = `model-cache-${version}`; const cache = await caches.open(cacheName); let response = await cache.match(modelUrl); if (!response) { response = await fetch(modelUrl); if (!response.ok) throw new Error(`模型下载失败: ${response.status}`); // 克隆一份存入缓存,原响应返回给调用方 await cache.put(modelUrl, response.clone()); } return await response.arrayBuffer(); }实操心得:模型下载一定要做分片和断点续传。我遇到过一次用户网络不稳定,200MB 的模型下了三次都失败,最后加了 Range 请求分片下载才解决。另外下载过程中要给用户进度反馈,不然用户以为扩展卡死了。
4. 通信机制与状态管理的实战方案
4.1 Service Worker 与 Offscreen 的消息通道
前面说了 service worker 是消息中枢,但这里有个细节很多人会踩坑:service worker 被挂起后,之前建立的 MessagePort 会失效。所以不能用长连接的方式,每次通信都要重新建立通道。
标准的做法是 service worker 收到请求后,先确保 offscreen document 存在,然后通过chrome.runtime.sendMessage发消息,offscreen 那边监听chrome.runtime.onMessage处理。但这里有个问题:sendMessage是广播的,popup、content script 都会收到,需要在消息里加target字段做过滤。
// service worker 侧:转发推理请求 async function ensureOffscreen() { const exists = await chrome.offscreen.hasDocument(); if (!exists) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['WORKERS'], justification: '运行端侧AI推理' }); } } async function runInference(payload) { await ensureOffscreen(); return chrome.runtime.sendMessage({ target: 'offscreen', type: 'INFERENCE', data: payload }); } // offscreen 侧:处理推理请求 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.target !== 'offscreen') return false; if (msg.type === 'INFERENCE') { handleInference(msg.data) .then(result => sendResponse({ ok: true, result })) .catch(err => sendResponse({ ok: false, error: err.message })); return true; // 保持通道开放以支持异步响应 } });注意return true这行,这是异步sendResponse的关键,忘了写的话消息通道会立即关闭,调用方收到 undefined。
4.2 推理任务的队列与并发控制
端侧推理是计算密集型任务,同时跑多个推理会把 CPU/GPU 打满,导致浏览器卡顿。我见过一个扩展没做并发控制,用户快速点击几次按钮,直接触发了 5 个推理任务并行,页面直接卡死。
解决方案是在 offscreen document 里维护一个任务队列,串行执行推理。队列的实现很简单,用一个 Promise 链或者数组加标志位就行。但要注意任务超时和取消,用户可能等不及想取消,这时候要能中断推理。ONNX Runtime 的 session run 本身不支持中断,但可以在队列层面做逻辑取消——任务还没开始执行就标记为取消,执行中的任务只能等它跑完。
class InferenceQueue { constructor() { this.queue = []; this.running = false; } async enqueue(task) { return new Promise((resolve, reject) => { this.queue.push({ task, resolve, reject, cancelled: false }); this.process(); }); } async process() { if (this.running || this.queue.length === 0) return; this.running = true; const item = this.queue.shift(); if (item.cancelled) { item.reject(new Error('任务已取消')); this.running = false; this.process(); return; } try { const result = await item.task(); item.resolve(result); } catch (err) { item.reject(err); } finally { this.running = false; this.process(); } } }4.3 状态同步:模型加载状态怎么让 UI 知道
模型加载是个耗时操作,UI 层需要知道当前状态——是未加载、加载中、还是就绪。这个状态不能存在 service worker 的全局变量里,因为会被回收。我的做法是把状态存在chrome.storage.session里,这是 MV3 专门为会话级状态设计的存储,service worker 重启后数据还在,浏览器关闭才清空。
状态对象大概长这样:
{ modelStatus: 'loading', // idle | loading | ready | error modelVersion: '1.2.0', loadProgress: 0.65, lastError: null }UI 层通过chrome.storage.session.onChanged监听变化,实时更新界面。这里有个细节:storage.session默认对 content script 不可见,需要在manifest.json里设置"access_level": "TRUSTED_AND_UNTRUSTED_CONTEXTS",否则 content script 读不到状态。
踩坑记录:
storage.session有 10MB 的配额限制,别把模型权重或者大数组往里塞,只存状态元数据。我一开始把推理中间结果也存进去了,结果大文件直接写入失败,排查了半天。
5. 性能优化与常见问题排查
5.1 WebGPU 后端的启用与降级
WebGPU 是端侧推理性能的关键,但它的可用性不是 100%。Chrome 113+ 默认开启,但有些企业策略会禁用,Firefox 和 Safari 的支持情况也不一样。所以必须做能力检测和降级。
检测逻辑是:先看navigator.gpu是否存在,存在的话尝试requestAdapter(),拿到 adapter 再创建 device。任何一步失败就降级到 WASM 后端。ONNX Runtime Web 支持在创建 session 时指定 execution provider 列表,它会自动选择可用的。
async function createSession(modelBuffer) { const providers = []; if (navigator.gpu) { try { const adapter = await navigator.gpu.requestAdapter(); if (adapter) providers.push('webgpu'); } catch (e) { console.warn('WebGPU 不可用,降级到 WASM'); } } providers.push('wasm'); return ort.InferenceSession.create(modelBuffer, { executionProviders: providers, graphOptimizationLevel: 'all' }); }实测数据:同一个模型,WebGPU 后端推理耗时 45ms,WASM SIMD 后端 180ms,差距大概 4 倍。对于实时性要求高的场景(比如输入即推理),WebGPU 是必须的;对于后台批处理任务,WASM 也能接受。
5.2 内存泄漏的排查与规避
端侧推理最容易出的问题就是内存泄漏。浏览器扩展的内存不像原生应用那么好管理,泄漏积累到一定程度,标签页直接崩溃。
常见的泄漏点有这么几个:
- Tensor 对象没释放:ONNX Runtime 的 Tensor 底层是 WASM 内存,虽然 JS 有 GC,但 WASM 堆的释放有时滞后。大量推理后要手动调
tensor.dispose()。 - 事件监听器没移除:offscreen document 里如果给 DOM 加了监听器,页面销毁时要清理。
- 闭包持有大对象:推理结果如果被闭包引用,GC 回收不掉。建议推理完成后把中间变量置 null。
排查工具就用 Chrome DevTools 的 Memory 面板,对 offscreen document 做 heap snapshot,对比推理前后的对象数量。我一般会跑 100 次推理,看内存是否稳定在某个水位,如果持续上涨就是有泄漏。
5.3 常见问题速查表
下面这张表是我在实际开发和用户反馈中整理出来的高频问题,基本覆盖了 80% 的故障场景。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理请求无响应 | offscreen 未创建或已销毁 | 检查hasDocument() | 每次请求前确保 offscreen 存在 |
| 首次推理特别慢 | 模型冷加载 | 看加载日志时间戳 | 提前预热,扩展启动时预加载 |
| 推理结果乱码 | 输入张量形状不对 | 打印 tensor dims | 核对模型输入签名 |
| 内存持续增长 | Tensor 未释放 | Heap snapshot 对比 | 手动 dispose,置空引用 |
| WebGPU 初始化失败 | 浏览器策略禁用 | 检查navigator.gpu | 降级到 WASM 后端 |
| 消息发送失败 | service worker 已挂起 | 看 SW 控制台 | 重试机制 + 状态检查 |
| 模型下载中断 | 网络不稳定 | 看 fetch 错误码 | 分片下载 + 断点续传 |
| 扩展包体积过大 | 模型打包进 CRX | 看构建产物 | 改为运行时下载 |
5.4 冷启动优化的几个实用技巧
冷启动是端侧推理体验的命门。用户点开扩展,等 2 秒才出结果,这个体验基本就废了。我总结了几个有效的优化手段。
第一是预加载。在扩展安装或者浏览器启动时,就触发模型加载。chrome.runtime.onInstalled事件里可以启动 offscreen document 并开始加载模型,等用户真正用的时候模型已经就绪。这个做法会占用一些内存,但对于高频使用的扩展是值得的。
第二是模型分片加载。如果模型很大,可以先加载一部分(比如 embedding 层),让用户能开始输入,后面的层在后台继续加载。这个需要模型本身支持分阶段执行,实现起来复杂一些,但对大模型场景很有效。
第三是结果缓存。相同的输入没必要重复推理,用 LRU 缓存存最近 N 条推理结果。对于文本分类、意图识别这类任务,用户重复输入的概率不低,缓存命中能直接省掉推理时间。
第四是降低精度换速度。如果 WebGPU 可用,用 FP16 而不是 FP32,速度能提升 30% 左右,精度损失很小。ONNX Runtime 支持在 session 创建时指定精度。
6. 工程化落地的一些经验之谈
6.1 构建流程:模型和代码要分开管理
扩展的构建流程里,模型文件不应该和代码走同一套打包逻辑。我的做法是:代码用 Vite 或者 Webpack 打包,模型文件单独放在public/models/目录,构建时只做拷贝不做处理。模型版本用单独的 JSON 文件管理,包含版本号、文件列表、哈希值。
{ "version": "1.2.0", "models": [ { "name": "text-classifier", "path": "models/classifier-int8.onnx", "size": 25165824, "sha256": "a1b2c3..." } ] }哈希值用于完整性校验,下载后比对,防止文件损坏或者被篡改。这个在安全敏感场景下很重要。
6.2 调试技巧:怎么在 offscreen 里打断点
Offscreen document 没有 UI,不能像普通页面那样右键检查。调试方法是:在chrome://extensions里找到你的扩展,点击 “service worker” 链接打开 SW 的 DevTools,然后在 Console 里执行chrome.offscreen相关命令,或者直接在 SW 的 Sources 面板里找到 offscreen.html 对应的上下文。
更简单的办法是在manifest.json里临时给 offscreen 页面加一个可见的入口,比如在 popup 里放一个按钮,点击后chrome.tabs.create打开 offscreen.html。这样就能用常规的 DevTools 调试了。调试完记得把这个入口去掉。
6.3 版本兼容:不同浏览器内核的差异
Chrome 和 Edge 都是 Chromium 内核,扩展 API 基本一致,但 Edge 在某些 API 的实现上有细微差别。比如chrome.offscreen在 Edge 的早期版本里支持不完整,需要做特性检测。Firefox 的扩展体系是另一套(WebExtensions),MV3 的支持还在推进中,offscreen document 在 Firefox 上根本没有对应实现。
所以如果你的扩展要跨浏览器,必须做能力检测和降级方案。Firefox 上可以降级到用 background page(MV2)或者隐藏的扩展页面来跑推理。这个工作量不小,但如果目标用户覆盖 Firefox,就得做。
6.4 安全考量:模型和数据的边界
端侧推理的一个核心卖点是隐私,但前提是你真的做到了数据不出本机。有几个点要注意:
- 模型文件本身可能包含敏感信息。有些模型在训练时可能记住了训练数据,虽然概率很低,但在安全敏感场景下要考虑。
- 推理中间结果不要外传。有些开发者为了“优化”,把推理的中间特征传到远端做后处理,这就破坏了端侧的隐私承诺。
- 扩展的权限要最小化。只申请必要的权限,
host_permissions别写<all_urls>,按需申请。
经验:如果你的扩展要上架商店,审核时会对权限和网络请求做严格检查。端侧推理的扩展如果还带着一堆远端请求,很容易被拒。把网络请求限制在模型下载这一个用途上,审核会顺利很多。
6.5 用户反馈里最常被问到的几个问题
做了一段时间后,用户反馈里高频出现的问题其实就那么几个。一个是“为什么第一次用这么慢”,这个前面说了,预加载能解决大部分。另一个是“能不能支持更大的模型”,这个受限于浏览器内存和扩展包体积,只能引导用户理解端侧的边界。还有一个是“为什么有时候结果不准”,这个往往是量化精度损失导致的,需要在模型选择和量化策略上做权衡。
我个人的体会是,端侧 AI 推理在浏览器扩展里落地,技术难点不在模型本身,而在工程约束的平衡。你要在包体积、内存占用、推理延迟、精度损失这几个维度里找平衡点,没有银弹,只有针对具体场景的取舍。一个文本摘要的扩展和一个图片识别的扩展,最优架构可能完全不同。多测、多调、多听用户反馈,比一开始就追求完美架构更实际。