
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 worker2.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低极轻量模型5MBOffscreen 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 参数的 BERTFP32 是 440MBINT8 动态量化后 110MB再配合模型剪枝能压到 60MB 左右。当然扩展里不会用这么大的模型一般用 6 层的小模型或者蒸馏版本。量化后的精度损失要实测。我做过一个文本分类任务INT8 量化后准确率从 92.3% 掉到 91.1%这个损失在大多数场景下可以接受。但如果你的任务是实体识别这种对边界敏感的任务建议做静态量化或者保留 FP16。3.3 模型缓存策略Cache Storage 的正确用法运行时下载的模型要缓存不然每次冷启动都重新下载用户流量和等待时间都受不了。浏览器扩展里可用的缓存方案有 IndexedDB、Cache Storage 和 OPFSOrigin 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 后端推理耗时 45msWASM 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 的扩展体系是另一套WebExtensionsMV3 的支持还在推进中offscreen document 在 Firefox 上根本没有对应实现。所以如果你的扩展要跨浏览器必须做能力检测和降级方案。Firefox 上可以降级到用 background pageMV2或者隐藏的扩展页面来跑推理。这个工作量不小但如果目标用户覆盖 Firefox就得做。6.4 安全考量模型和数据的边界端侧推理的一个核心卖点是隐私但前提是你真的做到了数据不出本机。有几个点要注意模型文件本身可能包含敏感信息。有些模型在训练时可能记住了训练数据虽然概率很低但在安全敏感场景下要考虑。推理中间结果不要外传。有些开发者为了“优化”把推理的中间特征传到远端做后处理这就破坏了端侧的隐私承诺。扩展的权限要最小化。只申请必要的权限host_permissions别写all_urls按需申请。经验如果你的扩展要上架商店审核时会对权限和网络请求做严格检查。端侧推理的扩展如果还带着一堆远端请求很容易被拒。把网络请求限制在模型下载这一个用途上审核会顺利很多。6.5 用户反馈里最常被问到的几个问题做了一段时间后用户反馈里高频出现的问题其实就那么几个。一个是“为什么第一次用这么慢”这个前面说了预加载能解决大部分。另一个是“能不能支持更大的模型”这个受限于浏览器内存和扩展包体积只能引导用户理解端侧的边界。还有一个是“为什么有时候结果不准”这个往往是量化精度损失导致的需要在模型选择和量化策略上做权衡。我个人的体会是端侧 AI 推理在浏览器扩展里落地技术难点不在模型本身而在工程约束的平衡。你要在包体积、内存占用、推理延迟、精度损失这几个维度里找平衡点没有银弹只有针对具体场景的取舍。一个文本摘要的扩展和一个图片识别的扩展最优架构可能完全不同。多测、多调、多听用户反馈比一开始就追求完美架构更实际。