1. 端侧 AI 推理为什么开始往浏览器扩展里塞
这两年浏览器扩展的玩法变了。以前大家写个扩展,无非是改改页面样式、拦拦广告、做个书签管理,逻辑轻、依赖少,一个content script加个popup就能交差。但现在越来越多人在问同一件事:能不能把模型直接跑在扩展里,不依赖后端,不上传数据,用户点一下就在本地出结果。这个需求背后其实有三股力量在推。
第一股是隐私。用户越来越在意自己的文本、图片、浏览记录被传到哪去了。端侧推理最直接的价值就是数据不出设备,这对做笔记总结、网页翻译、内容分类这类场景特别有吸引力。第二股是成本。你只要跑过带模型的后端就知道,推理的算力成本是实打实的,用户量一上来账单就压不住。把推理放到客户端,服务器只做分发和更新,边际成本能压到很低。第三股是延迟。本地推理没有网络往返,交互反馈是即时的,这对“划词即翻译”“选中即总结”这种高频轻交互体验是决定性的。
但浏览器扩展这个运行环境,说实话并不是为跑模型设计的。它有一套自己的约束:Manifest V3 之后后台从常驻的background page变成了会被回收的service worker,生命周期短、不能长期持有大内存;扩展的各个部分(popup、content script、offscreen document、service worker)运行在相互隔离的上下文里,通信要靠消息传递;再加上跨域、CSP、存储配额这些限制,直接把一个推理 runtime 塞进去,坑是一个接一个。
所以这篇东西我想聊的不是“怎么调个 API”,而是当你真的决定在浏览器扩展里做端侧 AI 推理时,整套系统架构该怎么设计、工程上要注意什么。核心会围绕 Manifest V3 的生命周期约束、推理任务的调度与隔离、模型文件的加载与缓存、以及跨上下文通信这几个最容易翻车的地方展开。适合已经写过扩展、想往端侧 AI 方向走的人,也适合做客户端架构、正在评估“模型到底放端上还是放云上”的工程师参考。
我自己的判断是:端侧推理在扩展里不是“能不能做”的问题,而是“哪些场景值得做、架构怎么搭才不别扭”的问题。下面按我实际踩过的顺序,一层层拆。
2. 整体架构设计与方案选型思路
2.1 先想清楚哪些推理该放端上
不是所有模型都适合塞进扩展。我一般用一个很朴素的判断标准:模型体积、调用频率、隐私敏感度这三者决定它该不该端侧化。
如果一个能力调用频率极高、单次输入输出都不大、而且数据敏感,那它几乎是端侧推理的完美候选,比如划词翻译、短文本情感分类、网页正文摘要。反过来,如果模型动辄几个 G、调用频率又低、对结果质量要求极高,那放端上就是给自己找罪受,用户下载模型的时间成本就劝退了。
我见过有人一上来就想把一个大语言模型整个塞进扩展,结果模型文件几百兆,首次加载卡到用户以为扩展坏了。合理的做法是分层:轻量任务(分类、embedding、小模型摘要)放端侧,重任务(长文本生成、复杂推理)走云端,端侧只做预处理和结果缓存。这样既拿到了隐私和延迟的好处,又不至于把扩展做成一个臃肿的下载器。
2.2 推理 runtime 的选型逻辑
端侧推理在浏览器里目前主流就几条路:WebAssembly(配合 SIMD 和线程)、WebGPU、以及基于它们的上层框架(比如 ONNX Runtime Web、Transformers.js 这类)。选型时我主要看三个维度。
兼容性:WebGPU 性能好,但覆盖还没到“闭眼用”的程度,尤其是老设备和某些平台。WebAssembly 基本是兜底方案,几乎所有现代浏览器都支持,配合 SIMD 能拿到不错的性能。我的常规策略是优先 WebGPU,回退 WASM,运行时探测能力再决定走哪条路。
模型格式:ONNX 是目前跨 runtime 最通用的中间格式,工具链成熟,量化方案也多。如果你的模型是从 PyTorch 或 TensorFlow 导出的,转 ONNX 再量化(INT8 或 INT4)基本是标准流程。量化这件事后面会单独讲,它对体积和速度的影响是数量级的。
包体积:runtime 本身也是有体积的。ONNX Runtime Web 的 wasm 文件加上各种算子,压缩后也有几兆。这对扩展的审核和加载都有影响,所以要么把 runtime 做成按需加载,要么用更轻的专用 runtime。这一点很多人一开始不算账,等到打包出来发现扩展体积爆炸才回头改。
2.3 Manifest V3 带来的架构约束
这是整个设计里最容易被低估的部分。Manifest V3 把后台从常驻页面改成了service worker,它有几个硬约束你必须接受:
- 会被随时回收:空闲一段时间后 service worker 就被终止,内存里的状态全丢。你不能指望它像以前那样常驻内存持有模型。
- 不能访问 DOM:service worker 里没有
document,很多依赖 DOM 的 API 用不了。 - 有执行时间限制:单个事件处理不能无限跑,长任务会被打断。
这意味着模型不能常驻在 service worker 里。那模型放哪?答案是offscreen document。这是 Manifest V3 专门为“需要 DOM 或需要长时间运行的任务”设计的隐藏页面,它可以持有较大的内存、可以跑长时间任务、可以访问完整的 Web API。把推理引擎放在 offscreen document 里,service worker 只做调度和消息中转,这是目前最稳的架构。
我画不出图(也不打算用图),但你可以这样理解数据流:content script采集用户输入 → 发给service worker→ service worker 转发给offscreen document→ offscreen 里的推理引擎跑模型 → 结果原路返回。每一跳都是异步消息,每一跳都可能失败,所以错误处理和超时机制必须做扎实。
2.4 存储与缓存的分层设计
模型文件动辄几十上百兆,不能每次用都重新下载。浏览器扩展能用的存储有几层,各有各的脾气:
| 存储层 | 容量 | 特点 | 适合放什么 |
|---|---|---|---|
| chrome.storage.local | 默认约 10MB(可申请 unlimitedStorage) | 键值对,异步,跨上下文可读 | 配置、小状态、模型元信息 |
| IndexedDB | 受配额限制,通常较大 | 支持二进制大对象,事务性 | 模型权重分片、缓存结果 |
| Cache Storage | 受配额限制 | 类 HTTP 缓存,适合静态资源 | 模型文件、wasm 资源 |
| 内存(offscreen) | 受设备内存限制 | 最快,但易失 | 已加载的模型实例 |
我的常规做法是:模型权重放 IndexedDB 或 Cache Storage,配置和元信息放 chrome.storage.local,加载后的模型实例常驻 offscreen 内存。这里有个关键点——模型文件要分片存储,因为 IndexedDB 单条记录过大时写入会失败或极慢,通常按几 MB 一片切开,加载时再拼回去。
3. 核心细节解析与实操要点
3.1 offscreen document 的创建与生命周期管理
offscreen document 不是你想创建就能随便创建的,它需要声明offscreen权限,并且在 manifest 里指定用途。用途是有限枚举的,比如WORKERS、BLOBS、DOM_PARSER等。跑推理通常归到需要长时间计算或需要完整 Web API 的场景,具体用途字段要按你实际用到的能力来选,选错了审核或运行时会出问题。
创建逻辑一般放在 service worker 里,而且要做幂等。因为 service worker 会被回收重启,重启后 offscreen document 可能还在,也可能没了,你得先检查再创建:
async function ensureOffscreen() { const existing = await chrome.offscreen.hasDocument(); if (existing) return; await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['WORKERS'], justification: 'Run local AI inference' }); }注意:
hasDocument()这个检查不能省。我踩过的坑就是没检查直接 create,结果 service worker 重启后重复创建报错,整个推理链路直接断掉。
offscreen document 本身也有生命周期。它不会像 service worker 那样频繁被回收,但浏览器在内存紧张时仍可能干掉它。所以推理引擎的初始化要做成懒加载 + 可重建:第一次收到推理请求时再加载模型,加载失败或 document 被销毁后能自动重建。
3.2 模型加载与量化:体积和速度的平衡
模型加载是端侧推理里最耗时的一环。一个量化后的几十兆模型,从 IndexedDB 读出来、反序列化、初始化 runtime,冷启动可能要几秒。用户第一次用的时候如果没有任何反馈,会以为扩展卡死了。所以加载进度必须可见,这是体验底线。
量化方面,我一般按这个顺序试:
- FP32 原始模型:只在模型极小时用,否则体积和内存都吃不消。
- INT8 量化:体积约为 FP32 的四分之一,精度损失通常可接受,是通用首选。
- INT4 量化:体积进一步减半,但精度损失明显,适合对结果容忍度高的分类任务。
量化的具体做法取决于你的工具链。以 ONNX 为例,可以用 ONNX Runtime 提供的量化工具做动态量化,也可以用训练后量化(PTQ)配合校准数据集。这里的关键经验是:量化后一定要用真实数据回归测试,别只看体积降了就上线。我遇到过 INT8 量化后某个类别几乎全错的情况,原因是校准数据分布和实际输入差太远。
模型分片存储的实现大致是这样:把模型文件按固定大小(比如 4MB)切片,每片存成 IndexedDB 里的一条记录,key 里带上分片序号。加载时按序读出、拼成完整的 ArrayBuffer,再交给 runtime。分片大小要权衡:太小则记录数多、读取开销大;太大则单次写入可能超时。4MB 到 8MB 是我实测比较稳的区间。
3.3 跨上下文通信的消息协议设计
扩展里各个上下文之间的通信,是 bug 的高发区。popup 发给 service worker、service worker 发给 offscreen、content script 发给 service worker,每条链路都可能因为上下文被销毁而失败。我的做法是定义一套统一的消息协议,而不是到处写裸的sendMessage。
协议里至少要有这几个字段:type(消息类型)、requestId(请求唯一标识)、payload(数据)、timeout(超时时间)。响应也要带requestId,这样在并发请求时才能正确配对。为什么强调这个?因为端侧推理是异步的,用户可能连续触发多次,如果没有 requestId,结果就会串。
// 统一的请求封装 function sendInferenceRequest(payload, timeout = 30000) { const requestId = crypto.randomUUID(); return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('inference timeout')), timeout); const listener = (msg) => { if (msg.requestId !== requestId) return; clearTimeout(timer); chrome.runtime.onMessage.removeListener(listener); msg.error ? reject(new Error(msg.error)) : resolve(msg.result); }; chrome.runtime.onMessage.addListener(listener); chrome.runtime.sendMessage({ type: 'INFER', requestId, payload }); }); }提示:超时时间不要设太短。模型冷启动加上首次推理,几秒到十几秒都正常。我一般给 30 秒兜底,同时在 UI 上给进度反馈,而不是让用户干等。
还有一个容易忽略的点:消息大小限制。Chrome 的消息传递对单条消息大小是有限制的,如果你把整个模型或者大段文本塞进消息里传,可能直接被截断或报错。大数据的传递应该走 IndexedDB 或 Cache Storage,消息里只传引用(比如一个 key)。
3.4 推理任务的调度与并发控制
端侧推理吃 CPU/GPU,如果用户同时触发多个任务,设备会卡。所以并发控制是必须的。我的做法是在 offscreen document 里维护一个任务队列,串行执行推理,或者限制最大并发数为 1 到 2。
为什么倾向串行?因为大多数端侧模型在单次推理时已经能吃满可用的算力,并发跑多个只会互相抢资源,总吞吐不一定提升,反而让每个任务的延迟都变长。串行 + 队列能让每个任务的可预期延迟更稳定。
队列还要处理优先级。比如用户主动触发的划词翻译,优先级应该高于后台的批量摘要。实现上可以用两个队列,高优先级队列先出队。同时要支持取消:用户切换了页面或者关掉了 popup,之前的推理任务应该能被取消,避免浪费算力。
class InferenceQueue { constructor() { this.queue = []; this.running = false; } enqueue(task, priority = 0) { return new Promise((resolve, reject) => { this.queue.push({ task, priority, resolve, reject }); this.queue.sort((a, b) => b.priority - a.priority); this.drain(); }); } async drain() { if (this.running) return; this.running = true; while (this.queue.length) { const { task, resolve, reject } = this.queue.shift(); try { resolve(await task()); } catch (e) { reject(e); } } this.running = false; } }4. 实操过程与核心环节实现
4.1 从零搭建扩展骨架
先把目录结构定下来,这决定了后面各模块怎么协作。我的常规结构是这样:
extension/ manifest.json background.js // service worker,调度中枢 offscreen.html // 推理宿主页面 offscreen.js // 推理引擎逻辑 content.js // 页面注入,采集输入 popup.html / popup.js // 用户交互 lib/ // runtime 与工具 models/ // 模型元信息(权重走 IndexedDB)manifest 里要声明的权限和字段,我列一下关键的:
{ "manifest_version": 3, "name": "Local AI Extension", "version": "1.0.0", "permissions": ["offscreen", "storage", "unlimitedStorage"], "background": { "service_worker": "background.js" }, "content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"] }], "action": { "default_popup": "popup.html" } }注意:
unlimitedStorage这个权限值得申请,否则 IndexedDB 的配额可能不够放模型。但申请了也要注意,用户看到权限列表里多这一条可能会犹豫,所以扩展的说明里要讲清楚为什么需要。
4.2 推理引擎在 offscreen 里的初始化
offscreen.js 是整个系统的核心。它的初始化流程我拆成几步:探测能力、加载 runtime、加载模型、预热。
能力探测主要看 WebGPU 是否可用:
async function detectCapability() { if ('gpu' in navigator) { try { const adapter = await navigator.gpu.requestAdapter(); if (adapter) return 'webgpu'; } catch (e) { /* fall through */ } } return 'wasm'; }runtime 加载要按需。如果走 WASM,wasm 文件本身也要从扩展资源里读出来,这一步可以用fetch配合扩展内的相对路径。加载完 runtime 再加载模型权重,权重从 IndexedDB 分片读出后拼接。
预热这一步很多人省掉,但我强烈建议做。所谓预热,就是拿一条极短的假输入跑一次推理,让 runtime 完成算子编译、内存分配等一次性开销。这样用户真正用的时候,第一次推理的延迟会明显降低。预热的代价是扩展启动时多花一点时间,但可以放在空闲时做。
4.3 模型分片存储与加载的完整实现
存储侧,我写一个简单的分片写入函数:
const CHUNK_SIZE = 4 * 1024 * 1024; async function saveModel(db, modelId, arrayBuffer) { const total = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); const tx = db.transaction('models', 'readwrite'); const store = tx.objectStore('models'); for (let i = 0; i < total; i++) { const start = i * CHUNK_SIZE; const chunk = arrayBuffer.slice(start, start + CHUNK_SIZE); await store.put({ key: `${modelId}_${i}`, chunk, index: i, total }); } await tx.done; }加载侧反过来,按 index 排序后拼接:
async function loadModel(db, modelId) { const tx = db.transaction('models', 'readonly'); const store = tx.objectStore('models'); const all = await store.getAll(); const chunks = all .filter(r => r.key.startsWith(`${modelId}_`)) .sort((a, b) => a.index - b.index); const totalBytes = chunks.reduce((s, c) => s + c.chunk.byteLength, 0); const result = new Uint8Array(totalBytes); let offset = 0; for (const c of chunks) { result.set(new Uint8Array(c.chunk), offset); offset += c.chunk.byteLength; } return result.buffer; }这里有个性能细节:getAll()会把所有记录一次性读进内存,如果模型很大,这一步本身就很吃内存。更稳的做法是分批读,比如每次读 8 片,拼完再读下一批。我在模型超过 100MB 的场景下会改成流式拼接,避免内存峰值过高导致 offscreen 被干掉。
4.4 一次完整推理请求的端到端链路
把前面几块串起来,一次推理的完整流程是这样的:
- 用户在页面上选中文本,content script 捕获选区,通过
chrome.runtime.sendMessage发给 service worker。 - service worker 收到消息,先
ensureOffscreen()确保推理宿主存在,然后把请求转发给 offscreen。 - offscreen 收到请求,检查模型是否已加载。没加载就先加载(带进度上报),加载完把任务丢进推理队列。
- 队列串行执行推理,结果通过消息回传给 service worker。
- service worker 把结果转发回 content script,content script 渲染到页面上。
这条链路里,进度上报是体验的关键。加载模型时,offscreen 要定期把进度发给 service worker,再转发给 popup 或 content script,让用户看到“模型加载中 45%”这样的反馈。没有这个,用户大概率会在加载到一半时以为卡死然后卸载扩展。
4.5 参数选择与性能调优的实测记录
性能调优这块,我记录几个实测下来影响最大的参数。
线程数:WASM 多线程能显著提速,但线程数不是越多越好。一般设成navigator.hardwareConcurrency的一半到全部之间,具体要看设备。我实测在四核设备上,2 到 4 线程的收益最明显,再多收益递减还增加调度开销。
批大小:如果模型支持批处理,批大小要按输入规模动态调整。单条输入时批大小为 1,批量处理时再增大。固定一个大批大小会让单条请求也付出批处理的算力代价。
量化精度:前面说过,INT8 是通用首选。但如果你的任务对精度极敏感,可以只对部分层做量化,保留关键层的 FP32。这种混合量化能兼顾体积和精度,代价是工具链配置更复杂。
缓存策略:相同输入的推理结果应该缓存。比如同一段文本的翻译,用户重复触发时直接返回缓存结果。缓存 key 用输入的哈希,存在 IndexedDB 里,设一个合理的过期时间。这个优化对高频重复场景的体验提升非常明显。
5. 常见问题与排查技巧实录
5.1 service worker 被回收导致推理中断
这是最高频的问题。表现是:用户触发推理,等了一会儿没反应,再触发又好了。原因就是 service worker 在等待期间被回收,消息链路断了。
排查思路:先看 service worker 的日志有没有“terminated”之类的记录。解决上,一是缩短空闲时间,在推理进行中通过定期发心跳消息保持 service worker 活跃;二是让 offscreen 承担更多状态,因为 offscreen 比 service worker 稳定,把推理状态放在 offscreen 里,service worker 只做无状态转发,被回收了重建也不影响。
提示:心跳不要发太频繁,几秒一次就够,太频繁反而增加开销。而且心跳本身也可能失败,要做好失败重试。
5.2 模型加载失败或加载后推理报错
这类问题通常有几个来源:分片存储损坏、量化模型与 runtime 版本不匹配、内存不足。
排查顺序我一般这样走:先确认分片是否完整(记录数和总大小对不对),再确认 runtime 版本和模型导出时的版本是否兼容,最后看内存。内存不足在移动端或低配设备上很常见,表现是加载到一半 offscreen 被销毁。解决办法是降低模型精度、减小分片读取的批量大小,或者干脆换更小的模型。
5.3 跨域与 CSP 相关的坑
扩展的 CSP 比普通网页严格,eval、内联脚本这些默认都被禁。如果你的 runtime 内部用了这些,会直接报错。解决办法是选一个不依赖eval的 runtime 构建版本,或者调整 CSP 配置(但能不动 CSP 就不动,动了审核和安全性都麻烦)。
跨域方面,模型文件如果放在远程服务器,需要在 manifest 里声明对应的 host 权限。但更推荐把模型打包进扩展或者从可信的静态资源加载,减少运行时的不确定性。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 推理无响应 | service worker 被回收 | 查看后台日志 | 心跳保活 + 状态放 offscreen |
| 首次推理极慢 | 未预热、冷启动 | 计时各阶段耗时 | 空闲时预热、进度反馈 |
| 加载到一半失败 | 内存不足、分片损坏 | 检查内存与分片完整性 | 降精度、分批读取 |
| 结果串台 | 消息未配对 | 检查 requestId | 统一消息协议 |
| 扩展体积过大 | runtime + 模型未优化 | 分析打包产物 | 按需加载、量化、分片 |
| 移动端崩溃 | 内存峰值过高 | 监控内存曲线 | 流式拼接、限制并发 |
5.5 几个我踩过的独家坑
第一个坑是在 popup 里直接跑推理。popup 一关就销毁,推理跑到一半用户点了别处,任务就没了。所以推理一定要放 offscreen,popup 只做展示。
第二个坑是忽略消息大小限制。我曾经把一段很长的文本直接塞进消息里传,结果被静默截断,推理结果驴唇不对马嘴。后来改成大数据走存储、消息只传 key,问题消失。
第三个坑是量化后没做回归测试。前面提过,INT8 量化后某个类别全错,上线后用户反馈才发现。现在我的流程里,量化后必须跑一遍标注好的测试集,指标达标才允许打包。
第四个坑是并发没控制。早期没做队列,用户快速连续触发,设备直接卡死。加了串行队列后,虽然单次延迟没变,但整体体验稳定多了。
6. 端侧推理扩展的边界与后续演进
聊到这,我想说点更宏观的判断。端侧 AI 推理在浏览器扩展里,目前的能力边界其实很清楚:它擅长的是轻量、高频、隐私敏感的任务,不擅长重模型和复杂推理。认清这个边界,比盲目追求“把大模型塞进扩展”要重要得多。
从工程演进的角度,我看到几个值得关注的方向。一是WebGPU 的普及会让端侧推理的性能上限明显抬高,现在很多需要回退 WASM 的场景未来可以直接走 GPU。二是模型小型化和蒸馏技术的成熟,让同样能力的模型体积持续下降,端侧能承载的任务会越来越多。三是扩展与本地其他进程的协作,比如扩展负责交互、本地服务负责重推理,这种混合架构在桌面端会越来越常见。
但无论技术怎么演进,有几条工程原则我觉得不会变:状态要放在稳定的上下文里,通信要有统一协议,加载要有进度反馈,并发要有控制,量化要有回归测试。这些不是某个框架的特性,而是端侧推理这个场景本身的约束决定的。
我自己在做这类项目时,最大的体会是:别把端侧推理当成一个“技术炫技”,它首先是一个产品体验问题。用户不关心你用的是 WASM 还是 WebGPU,他们只关心点了之后多久出结果、会不会卡、数据安不安全。架构设计的所有取舍,最终都要回到这三个问题上。把模型加载的进度做出来、把冷启动的延迟压下去、把隐私的承诺兑现,比堆砌任何先进技术都更能留住用户。
最后分享一个我常用的小技巧:在开发阶段,给推理链路的每个阶段都打上时间戳,从消息发出到结果返回,把加载、排队、推理、回传各段的耗时都记下来。这个日志在排查性能问题时极其有用,很多时候你以为的瓶颈和实际的瓶颈完全不是一回事。等这套埋点跑顺了,你会发现优化方向一下子就清晰了。