☰
浏览器扩展端侧AI推理架构设计与工程实践
2026/10/7 5:22:08 网站建设 项目流程

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 一次完整推理请求的端到端链路

把前面几块串起来,一次推理的完整流程是这样的:

  1. 用户在页面上选中文本,content script 捕获选区,通过chrome.runtime.sendMessage发给 service worker。
  2. service worker 收到消息,先ensureOffscreen()确保推理宿主存在,然后把请求转发给 offscreen。
  3. offscreen 收到请求,检查模型是否已加载。没加载就先加载(带进度上报),加载完把任务丢进推理队列。
  4. 队列串行执行推理,结果通过消息回传给 service worker。
  5. 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,他们只关心点了之后多久出结果、会不会卡、数据安不安全。架构设计的所有取舍,最终都要回到这三个问题上。把模型加载的进度做出来、把冷启动的延迟压下去、把隐私的承诺兑现,比堆砌任何先进技术都更能留住用户。

最后分享一个我常用的小技巧:在开发阶段,给推理链路的每个阶段都打上时间戳,从消息发出到结果返回,把加载、排队、推理、回传各段的耗时都记下来。这个日志在排查性能问题时极其有用,很多时候你以为的瓶颈和实际的瓶颈完全不是一回事。等这套埋点跑顺了,你会发现优化方向一下子就清晰了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询