浏览器扩展中端侧AI推理的架构设计与工程实践
在浏览器扩展里跑端侧 AI 推理这件事,最近半年被问到的次数明显变多了。不少人拿着 ONNX Runtime Web 或者 Transformers.js 的示例代码,往 Manifest V3 扩展里一套,发现要么模型加载不出来,要么 Service Worker 一休眠整个推理任务就断掉,要么内容脚本里塞了太多计算导致页面卡成幻灯片。这些问题不完全是 API 用不对,更多是架构层面没想清楚:浏览器扩展根本不是为长驻推理设计的运行环境,它的生命周期、内存策略、消息机制都和传统前端页面完全不同。
这篇文章我打算聊透一件事:在现代浏览器扩展环境下,端侧 AI 推理系统应该怎么设计架构,以及工程实现上应该定哪些规范。我会用一套我在实际项目中沉淀下来的方案为例,覆盖 Service Worker、Offscreen Document、Content Script 三个角色的分工,推理引擎挂载位置的选择,模型加载与缓存策略,数据流和内存治理,还有一整套可以直接抄的目录结构和代码骨架。适合正在做浏览器插件、端侧模型部署、或者想把 AI 能力塞进扩展里的前端工程师和算法工程师参考。
1. 为什么说浏览器扩展是端侧 AI 推理的特殊战场
1.1 扩展环境的约束,比 Web 页面要严苛得多
很多人以为浏览器扩展就是普通网页加了个 API 壳,实际上现代扩展(尤其是 Manifest V3)的运行模型跟传统网页差异巨大。Service Worker 不是常驻进程,它会被浏览器随时回收,过了 30 秒空闲直接挂起,这跟 AI 推理动不动就推几百毫秒甚至几秒的需求天然冲突。Content Script 虽然有 DOM 访问能力,但它运行在页面隔离环境里,一跑重计算就会导致页面主线程卡死。最麻烦的是扩展的内存配额:一个标签页或者 Service Worker 的可用内存有限,你在后台跑一个大模型,浏览器根本不会给你像 Node.js 那样宽松的堆空间。
这就带来一个核心矛盾:端侧 AI 推理需要相对稳定的计算生命周期和可控内存,而扩展机制恰恰在时段、线程、内存上都给得捉襟见肘。所以做这类系统,第一件事不是选模型,而是设计一套能让推理任务在碎片化生命周期里跑完的调度架构。
1.2 端侧推理进入扩展场景的价值
那为什么还要在扩展里跑 AI?答案很实际:用户不需要打开一个独立页面,也不需要安装 Python 环境,只要装了插件,AI 能力就藏在浏览器里随时待命。比如选中一段网页文字做摘要,右键菜单一点,模型就在本地跑完;比如在侧边栏对当前页面做内容分类、敏感词扫描、 OCR 识别;再比如做无障碍阅读时对图片生成 alt 描述。这些功能如果走云端,有隐私、延迟、成本三重问题,而走端侧刚好完美避开。
但端侧 AI 推理进入扩展场景,难点不像原生 APP 那样是“算力不够”,而是“环境碎片化”。用户用的可能是 Windows / macOS / Linux 上的 Chrome、Edge,也可能是 ARM 架构的 Chromebook,GPU 能力从核显到独显差异巨大。这意味着你的系统架构必须考虑降级路径:支持 WebGPU 的走 GPU 加速,不支持的自动回退到 WASM 多线程,再不行就单线程慢慢推。这个降级设计不是可选项,而是架构的一部分。
1.3 当前最现实的技术选型
聊技术选型之前先泼一盆冷水:目前想在扩展里跑模型,真正成熟的路子并不多。TF.js 在浏览器里做训练和推理都活跃,但它的扩展兼容性不如 ONNX Runtime Web;WebLLM 这类专项方案体验好,但受限于模型格式和硬件适配。我实际测试下来,最稳妥的组合是:ONNX Runtime Web 作为推理后端,优先走 WebGPU EP,同时保留 WASM EP 作为降级方案。
为什么选它?首先 ONNX Runtime Web 的 API 设计特别简洁,几行代码就能把模型加载起来,Session 创建、输入输出封装都处理好了;其次它对 ONNX 格式的算子覆盖最全,模型从 PyTorch 或者 HuggingFace 导出后基本不用做太多转换;最关键的是它默认支持 WebGPU EP,这是目前浏览器里能拿到的最高推理性能路径。相比之下,纯 WASM 推理在 CPU 上也能跑,但遇到卷积或者 Transformer 结构时速度会明显下降。
注意:WebGPU 在 Chrome 113 之后才默认开启,如果你面向的是企业客户或者教育场景,浏览器版本可能比较旧,降级路径必须做扎实。
2. 系统架构设计:三层角色分工与数据流
2.1 现代扩展的三个关键角色
Manifest V3 下,扩展的空间主要分成三块,每块都有不同的生命周期和权限边界。
- Service Worker:扩展的大脑,处理浏览器事件、维护状态、分发任务。它是事件驱动的,休眠后所有内存释放,所以不适合放模型。
- Content Script:注入到网页里的脚本,能操作页面 DOM,但权限很小,和扩展其他部分只能通过消息通信,长期处于页面上下文中。
- Offscreen Document:Manifest V3 新增的隐藏文档,可以用标准 DOM API 做音频播放、DOM 解析等后台工作,它是常驻的,适合放推理引擎。
我见过的很多失败项目,核心错误就是把模型推理塞进了 Service Worker。刚开始跑得好好的,一旦用户切标签页几分钟,Service Worker 被浏览器回收,正在进行的推理直接中断。这类 bug 极其难以复现,用户报“插件用着用着就没反应了”,你排查半天发现是生命周期的问题。
正确的分工是:Service Worker 只做控制面,Offscreen Document 做数据面(推理),Content Script 做采集面。三者各司其职,才能把扩展的性能和稳定性做到位。
2.2 推理引擎挂载点选在 Offscreen Document
我在架构里把推理引擎放在 Offscreen Document。原因有四个:
- Offscreen Document 在扩展生命周期内常驻,只要你不主动关闭它,它就不会被浏览器回收。Service Worker 休眠后,推理任务依然能继续跑完。
- 它拥有独立的 DOM 和 JavaScript 执行环境,你可以在里面创建 OffscreenCanvas 做图像预处理,用
createImageBitmap做解码,不污染页面主线程。 - 它的生命周期由扩展自己管理,你可以主动创建和销毁,这意味着模型占用的内存可以在任务结束后彻底释放。
- 它天然隔离了页面脚本和扩展脚本,推理相关的复杂对象不会暴露给 Content Script,安全边界清晰。
架构上一共就两条数据链路。采集链路是:网页数据 → Content Script → 结构化消息或二进制 Buffer → Service Worker → Offscreen Document → 推理引擎。结果链路就反过来,推理结果 → Offscreen Document → Service Worker → Content Script → 回填页面或展示到侧边栏。
2.3 数据流设计的关键细节
数据流听起来是又短又简单的概念,实际上最容易踩坑。比如从 Content Script 拿一个 48MB 的图片 Buffer 传给 Service Worker,如果消息机制里没有指定transferable,浏览器会做一次结构化克隆,内存瞬间翻一倍,在低端设备上直接 OOM。
我一般这样处理:图片等大块二进制数据优先转成ArrayBuffer,消息传递时明确 transfer;JSON 数据则保持轻量,能传索引就不要传全文。推理的输入输出也尽量用 TypedArray 而不是普通对象,减少序列化开销。你想象一条管道里跑着两种数据:小体积的指令消息走 JSON,大块的二进制走 transfer,两种流互不干扰,这就是扩展内部的“小交换机”思路。
模型权重也不是每次推理都从磁盘加载。扩展启动时把模型二进制 fetch 到内存,首次推理完成后把 Session 留在 Offscreen Document 中,后续请求直接复用。只有扩展更新或者用户主动清理时才重新加载,这能把冷启动时间从几秒压缩到几百毫秒。
2.4 消息通信的可靠性设计
另一个容易忽略的点是消息通道的可靠性。Service Worker 休眠后,Content Script 发消息会经历一个“唤醒—处理—可能再次休眠”的过程,如果代码里没有做消息确认和超时重试,用户遇到的可能就是“点了按钮没反应”。
我在设计时给每条消息定义了请求 ID、命令字、payload 和超时兜底。Offscreen Document 收到推理请求后,先把任务放进队列,再逐条执行,完成后回传结果;如果超过 15 秒没有响应,Service Worker 侧就认为超时,向 UI 返回错误并尝试重建推理会话。这套机制本质上和分布式系统的消息可靠性是一个思路:确认、重试、超时、幂等。
3. 工程实现规范:目录、模型与推理的持久化治理
3.1 项目目录结构规范
架构想清楚了,落到工程上,第一件事是把目录定死。我用了大半年的目录结构长这样,稳定,可扩展:
extension-ai/ ├── manifest.json ├── src/ │ ├── background/ │ │ ├── index.ts # Service Worker 入口 │ │ ├── dispatcher.ts # 消息分发与状态管理 │ │ └── lifecycle.ts # 会话创建/销毁策略 │ ├── offscreen/ │ │ ├── index.ts # Offscreen 入口 │ │ ├── inference.ts # ONNX Runtime Session 管理 │ │ ├── preprocess.ts # 图像/文本预处理 │ │ └── queue.ts # 推理任务队列 │ ├── content/ │ │ ├── index.ts # 页面采集脚本 │ │ └── dom.ts # DOM 提取与注入 │ └── shared/ │ ├── types.ts # 消息类型定义 │ ├── protocol.ts # 命令字与错误码 │ └── logger.ts # 分级日志 ├── models/ │ ├── manifest.json # 模型清单 │ └── quantized/ ├── build/ │ └── esbuild.config.mjs这套目录最大的好处是严格区分了三个运行时角色的代码,每个角色的构建产物完全独立。你绝不能把 Offscreen 的代码打包进 Content Script,那样会增加页面负担,也会把模型加载逻辑暴露给网页脚本。构建时我用 esbuild 分别打出三个 chunk,Manifest 里声明各自入口,互不干扰。
3.2 模型管理的几个硬指标
模型放哪、怎么放、什么时候更新,这些看似细节的问题,在扩展场景里被放大得很厉害。我把模型管理的规范总结为“三个一”:
- 一份模型清单:
models/manifest.json里登记模型名称、版本、量化类型、输入输出尺寸、适用的推理后端。每次模型更新必须递增版本号,避免 Service Worker 和 Offscreen 之间出现“加载了一半旧模型,一半新模型”这种灵异问题。 - 一个加载入口:所有模型统一通过
loadModel(name)函数加载,内部处理缓存、版本校验、后端选择。禁止在业务代码里直接写fs.readFile或fetch模型文件。 - 一套量化标准:默认使用 INT8 量化模型,体积是 FP32 的四分之一,推理速度提升 2 到 4 倍。对精度依懒性强的场景(比如 OCR 识别生僻字、医疗影像),才保留 FP16 模型作为双轨。
还有一个常被忽略的指标是冷启动时间。我给项目定的目标是:模型已缓存的情况下,从用户点击到首帧推理结果出来不超过 2 秒。为了达到这条线,启动流程做了三层缓存:浏览器 HTTP 缓存放模型文件、Offscreen 内存缓存放 Session、全局态缓存放推理配置。每一层都服务于不同频率的访问诉求。
3.3 推理参数配置与算力调度
ONNX Runtime Web 的推理参数不是一成不变的,需要按设备能力动态调整。我维护了一个getRuntimeConfig函数,内部检测 WebGPU 是否可用、CPU 核数多少、设备内存大小,然后动态决定三件事:
- 执行后端:有 WebGPU 优先 GPU,否则 WASM 多线程,再不行 WASM 单线程。
- 线程数:WASM 后端线程数设置为
navigator.hardwareConcurrency - 1,但上限 4。线程太多不但没有加速,反而因为线程间同步开销导致推理更慢。 - 批处理大小:对于文本摘要这类任务,每次只推一个样本;对于批量图像分类,如果显存充足可以一次性塞 4 到 8 张图。批处理过大会造成显存溢出,过小又浪费 GPU 并行能力,需要实机测试找到拐点。
这个调度的原理不难理解:浏览器端能用的算力是共享的,GPU 同时还要渲染页面,如果推理占满了所有执行单元,用户滑动页面都会掉帧。所以我的调度策略永远保留一部分算力给浏览器本身。
3.4 生命周期治理与后台限流
扩展里做推理,最不能忽视的是生命周期。Chrome 对扩展的后台运行有严格限制,Service Worker 空闲即回收,这是平台规则,你没法绕过,只能让系统架构去适应它。我在这套架构里加了一个“任务队列 + 限流”的组合机制。
任务队列负责把推理请求按优先级排好,紧急的用户交互请求插队,批量后台任务让路。限流则采用令牌桶思路:每 5 秒允许执行一次大模型推理,如果超过这个频率就把请求缓存到队列里,下个时间窗口再执行。这样既保证用户体验,又不会让 GPU 长时间满载导致整机发热。我曾经见过一个插件没做限流,后台每 10 秒跑一次模型,十分钟后用户的风扇狂转,笔记本温度直接飙到 80 度,这种产品体验属于灾难级别。
4. 实操过程与完整实现示例(最小可跑闭环)
4.1 扩展初始化与权限声明
现在我们把架构落成代码。先建manifest.json,这是扩展的身份证。为了跑推理,权限声明很关键:
{ "manifest_version": 3, "name": "Local AI Assistant", "version": "1.0.0", "background": { "service_worker": "dist/background/index.js", "type": "module" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["dist/content/index.js"], "run_at": "document_idle" } ], "permissions": [ "offscreen", "storage", "activeTab" ], "host_permissions": [ "https://models.example.com/*" ], "action": { "default_title": "Local AI" } }三个字段值得认真解释。offscreen权限是使用 Offscreen Document 的前提,Manifest V3 下没有这个权限你根本创建不了隐藏文档;storage用来保存模型缓存的元数据;host_permissions里加模型托管域名是为了让 Service Worker 和 Offscreen 都能 fetch 到模型文件,如果你把模型打包进扩展内部,这个字段就不需要。
4.2 Service Worker 中的推理入口
Service Worker 只做一件事:接收请求,转交给 Offscreen,等待结果,返回响应。
// src/background/index.ts import { MessageProtocol, InferenceRequest, InferenceResponse } from '../shared/protocol'; let offscreenReady: Promise<void> | null = null; async function ensureOffscreen() { if (!offscreenReady) { offscreenReady = chrome.runtime.getContexts({ contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT] }).then((contexts) => { if (contexts.length === 0) { return chrome.offscreen.createDocument({ url: 'dist/offscreen/index.html', reasons: ['BLOBS'], justification: 'Run on-device AI inference' }); } }); } return offscreenReady; } chrome.runtime.onMessage.addListener( (request: InferenceRequest, _sender, sendResponse) => { if (request.command === MessageProtocol.INFER) { ensureOffscreen() .then(() => { chrome.runtime.sendMessage(request).then(sendResponse); }) .catch((err) => { sendResponse({ ok: false, error: err.message }); }); return true; // 异步响应标记 } } );看到return true了吧,这是 Chrome 消息机制里必须写的“按住电话线”标记,告诉浏览器这个响应是异步的,别提前关闭通信通道。忘了它会收到 “The message port closed before a response was received” 的报错。
4.3 Offscreen Document 中加载推理核心
Offscreen Document 里加载 ONNX Runtime Web 并跑推理的代码长这样:
// src/offscreen/inference.ts import * as ort from 'onnxruntime-web'; let session: ort.InferenceSession | null = null; export async function loadModel(modelPath: string, backend: 'webgpu' | 'wasm' = 'webgpu') { const options: ort.InferenceSessionOptions = { executionProviders: [], graphOptimizationLevel: 'all' }; if (backend === 'webgpu') { options.executionProviders.push({ name: 'webgpu' }); } else { options.executionProviders.push({ name: 'wasm' }); options.numThreads = Math.min(navigator.hardwareConcurrency - 1, 4); } session = await ort.InferenceSession.create(modelPath, options); } export async function runInference(input: Float32Array, dims: number[]): Promise<Float32Array> { if (!session) throw new Error('Model not loaded'); const feeds = { input: new ort.Tensor('float32', input, dims) }; const results = await session.run(feeds); return results.output.data as Float32Array; }这段代码有四个细节。executionProviders.push({ name: 'webgpu' })是启用 GPU 加速的唯一入口;graphOptimizationLevel: 'all'让 ONNX Runtime 对计算图做全量优化,实测能提升 10% 到 20%;WASM 降级路径手动限制线程数,是为了避免低端设备线程爆炸;Session 只创建一次,复用同一份权重,这是性能和内存的平衡点。
4.4 端侧 AI 推理的五个固定工程规范
架构和代码都有了,最后收口的是一套工程规范。我在项目里把它固化成了 CICD 检查项,每次提交代码必须通过:
- 模型资源不进 JS 包:模型文件统一放
models/目录,通过版本管理发布,由构建脚本拷贝到dist/models/。谁都不允许import model from './model.onnx',那会让 JS 包体积爆炸。 - Content Script 保持贫血:它只做 DOM 读取和消息转发,不做任何计算。遇到需要解析大段文本的场景,把原始内容传给 Offscreen,绝不自己在页面上下文里开循环。
- 统一消息命令字:所有通信必须走
protocol.ts里定义好的命令字枚举,禁止自由字符串。这样才能支持命令字版本兼容,否则将来加一个参数,新旧版本扩展对接就会出错。 - 完整的错误码体系:每个失败路径有确定的错误码和降级动作。比如模型加载失败回退 WASM,WASM 也失败就给用户明确的“当前设备不支持”提示,而不是一团静默。
- 日志分三级:生产环境默认只打 INFO 和 ERROR,DEBUG 日志放在独立 logger 里,通过环境变量开启。扩展出问题后,用户导出的日志不会像火车票一样长。
这套规范的价值在扩展更新迭代时体现得最充分。没有规范的时候,改一次模型加载逻辑,你可能要顺着调用链改五个文件;有规范之后,改一个loadModel函数的内部实现,其余代码完全不动。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
我在多次集成测试和用户反馈里收集了一批高频问题,整理成速查表,遇到类似症状可以直接对照:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 推理跑到一半被中断 | Service Worker 被回收 | 推理逻辑移到 Offscreen Document |
| 页面滚动严重掉帧 | Content Script 里做了重计算 | 计算全部迁移到 Offscreen,只留 DOM 操作 |
| WebGPU 不在执行提供程序列表 | 浏览器版本过旧或未开启硬件加速 | 检测navigator.gpu,不支持则回退 WASM |
| 模型加载报 404 | Manifest 缺少 host_permissions | 在host_permissions里声明模型托管域名 |
| 消息无响应,控制台报 port closed | 忘记return true | 异步消息必须显式返回 true |
| 推理结果内存线性增长 | Session 没有复用,每次新建 | 全局单例 Session,用完不销毁 |
| 扩展更新后模型版本错乱 | 缺少版本校验 | 在模型清单里递增版本,加载时比对缓存版本 |
5.2 一次内存失控的完整排查实录
分享一次我在集成测试中遇到的真实问题。当时我用扩展反复分析一篇文章,连续跑 20 次推理后,整个浏览器标签页崩溃,提示 “Out of memory(内存不足)”。我第一反应是图片 buffer 没释放,检查后发现图片确实都被 transfer 掉了,没有残留。然后我用 Memory 面板快照对比了第一次和第十次推理后的堆对象,发现 Offscreen Document 的 JS 堆里多了一堆ORT.InferenceSession对象。
原因很清晰:我的代码里每次推理都调用了create()方法新建 Session,而不是复用。虽然 ONNX Runtime 对 Session 做了缓存优化,但每个 Session 在 GPU 上都会分配独立的显存和预计算资源,几十个 Session 堆积,内存自然爆炸。修复方式是让 Session 变成 Offscreen Document 里的单例,并且每次推理结束后主动清理临时张量:
const results = await session.run(feeds); Object.values(feeds).forEach((tensor) => tensor.dispose()); return results.output.data;这个坑的经验教训是:端侧推理遇到内存问题,先查 Session 数量和生命周期,这比排查业务代码更优先。Session 是系统级资源,不是你随手 new 一把就能丢的对象。
5.3 调试工具与性能分析实战
调试扩展推理和调试普通网页差别很大。我整理三个最实用的手段:
第一,chrome://gpu页面确认 WebGPU 是否真的在工作。有时候你觉得走了 GPU,实际上因为驱动问题浏览器悄悄回退到了软件渲染,推理速度慢了一半还不知道原因。
第二,DevTools 的 Memory 面板做堆快照对比。在 Offscreen Document 的上下文里先拍一张基线快照,跑完一轮推理再拍一张,对比新增对象就能定位是哪个环节泄漏了。
第三,性能分析器记录推理耗时。ONNX Runtime Web 的 Session 上有一个startProfiling方法,能导出每个算子的耗时明细,用它来定位是哪一层算子拖慢了整体速度。
最后一个实战技巧:如果你调试时发现模型加载灰常慢,先看网络面板里模型文件的体积。ONNX 模型动辄几十上百 MB,要是没开 gzip 压缩,传输时间就会占据整个加载流程的大部分。我的实践是先对模型做一次体积优化,去掉不需要的元数据,再用onnxruntime的优化工具做常量折叠,通常能把模型体积压缩 20% 以上。
6. 经验收官:扩展端侧推理的三个心态建设
文档写到这里,正文部分已经完整了。按照惯例,最后我再聊几句这几年积累的感悟,这些话对刚入门的开发者尤其有参考价值:
第一,不要贪心。第一个版本不要追求“什么模型都能跑”,而是选一个具体场景做到极致。比如先做一个网页摘要功能,模型小、速度快、反馈直接,用户感知最明显。等这条链路完整跑通,再逐步扩展 OCR、翻译、分类这些能力,你会发现架构只需要小幅调整,不用推翻重来。
第二,不要跟浏览器的生命周期对抗。Service Worker 会被回收是平台设计,不是 bug。你花再大力气保活,也敌不过 Chrome 更新版本后收紧策略。顺应它,把重活挪到 Offscreen Document,这才是正道。
第三,不要忽略降级路径。“我的用户大部分都是 Chrome 最新版”这句话会害死你。现实是总有用户停在旧版本,总有企业锁定了旧内核。把降级路径做得和主路径一样认真,这是端侧技术的基本素养。
端侧 AI 推理在浏览器扩展里跑通,真正难的从来不是模型部署,而是把一个完整系统的每一层都安排明白。希望这篇文章能让你少走几周弯路。