做浏览器扩展里的端侧 AI 推理,和做服务端推理完全是两个物种。服务器场景里你能随便开几百兆内存、默认 GPU 随便用,但进了扩展环境,你面对的是 Service Worker 的休眠机制、标签页之间互相挤占资源、以及用户随时可能关掉页面跑路的事实。我最近把一个“页面内容理解 + 摘要生成”的完整推理链路塞进了一个扩展,模型跑在本机浏览器里,从捕获页面内容到推理结果渲染,除了首次加载模型的等待之外,后续调用基本都在秒级以内完成,全程没有上传任何用户数据。这篇文章我会把从架构设计到工程落地的完整思路、参数权衡和我实际踩过的坑都摊开写,适合想在扩展里加端侧 AI 能力、或者正在被“扩展一重新加载模型就被清掉”这种问题折磨的人。
1. 整体架构设计:把浏览器扩展当成微型分布式系统来拆
1.1 先分清四个角色的职责,再谈其他
很多人写扩展里的 AI 功能时,习惯把所有代码一股脑塞进background.js:监听消息、加载模型、跑推理、回传结果,甚至更新 UI 也放那里。数据量小的时候能用,一旦模型体积上去、并发请求变多,基本必然翻车。原因很简单:扩展的各个部分在浏览器里是被隔离的,权限、生命周期、执行环境都不一样,硬要把它们揉成一个整体,等于自己给自己下套。
我习惯把扩展拆成四个角色:
- Popup / 页面 UI:负责用户交互,展示状态和结果。它的生命周期随用户点击而存在,用户一关 Popup,里面的状态就全没了,所以绝对不能作为数据或模型的所有者。
- Content Script:负责跟具体页面接触,读取 DOM、捕获页面选择内容、截取可见区域图像。它跟页面共享 DOM,但同时又是一个隔离环境,不合适做重计算。
- Background / Service Worker:整个扩展的调度中心,负责消息路由、状态汇总和任务分发。它不会长期驻留,但所有模块之间要通信,都得经过它来统一协调。
- Inference Worker:真正跑模型和推理的地方。用 Web Worker 隔离,避免长时间推理阻塞 UI 线程;模型加载、推理 session、预处理资源都放这里。
这四个角色的关系,很像分布式交换机架构里的“控制面 / 数据面”分离逻辑。Service Worker 是控制面,只负责指挥;Inference Worker 是数据面,只负责把推理任务跑完;Content Script 相当于各个端口的接入层,负责把外部信号采集进来。把控制面和数据面混在一起,后续想优化并发或者提高内存利用率都会变得很难。
1.2 消息流转与调度逻辑
一次典型推理请求会这样走:
- 用户在页面里点了“分析这个区域”,Content Script 采集 DOM 信息和截图数据。
- Content Script 把数据通过
chrome.runtime.sendMessage打包发给 Service Worker。 - Service Worker 收到请求后,把任务塞进一个请求队列,并转发给 Inference Worker。
- Inference Worker 调用端侧推理引擎,完成模型推理,把结果回传给 Service Worker。
- Service Worker 再把结果返回给 Content Script,由 Content Script 在页面里渲染。
这套链路里最容易被忽略的是“队列”。很多人直接做同步转发,消息一到就塞给推理引擎,结果多个标签页同时触发任务时,推理引擎内部还在处理上一个任务,新的请求就排不上队,最后要么超时,要么直接丢消息。所以我在 Service Worker 里维护一个带优先级的任务队列,比如用户手动触发的任务优先级高于页面自动检测产生的任务,同一标签页的请求尽量合并处理。实现这个队列本身不复杂,但它决定了突发请求下整个扩展还能不能有条理地工作。
2. 动手之前先做“体检”:能力探测与运行时选型
2.1 设备能力探测清单
做服务端推理时,你通过查机器配置文档就能知道有多少 CPU、多少显存。端侧推理完全不一样,用户的设备差异可能比服务器集群还大,而且你没法预知目标设备跑在什么浏览器、什么操作系统上。要解决这个问题,第一步不是写推理代码,而是给用户设备做一次“体检”。这就好比在 Ubuntu 上准备部署环境,先敲一句uname -m看系统架构,再用lscpu看核心数和指令集,几分钟就能确定目标平台特性。浏览器端没有 lscpu 这个命令,但有对等的“体检 API”,我会在扩展启动时统一探测一次。
我在项目里实际用的探测清单长这样:
// capabilities.ts function detectCapabilities() { const webgpuSupported = !!globalThis.navigator?.gpu; const wasmSupported = typeof WebAssembly !== 'undefined'; const cores = navigator.hardwareConcurrency ?? 4; const deviceMemory = (globalThis.navigator as any).deviceMemory ?? 4; // 单位 GB const isMobile = /Android|iPhone|iPad/.test(navigator.userAgent); return { webgpuSupported, wasmSupported, cores, deviceMemory, isMobile, }; }这里有两个容易踩的细节。第一,deviceMemory只有 Chromium 系浏览器会返回,其他浏览器可能没有这个字段,所以必须给默认值,而且可以保守一点,比如给 4GB。第二,能力探测不等于真实可用,后面真正调用navigator.gpu.requestAdapter()依旧可能失败,比如驱动被禁用、隐私模式、或者浏览器版本有兼容问题。所以“探测”只用于初步决定候选策略,真正选择哪个运行时,还要在初始化时做一次真实尝试。
2.2 推理后端选型与回退策略
端侧推理引擎到了浏览器里,现实就是没有银弹。常见后端主要就这几类:
- WebAssembly + SIMD + 多线程:兼容性最好,哪儿都能跑。速度受限于 CPU,但对中小模型,比如几十 MB 的视觉模型或文本分类模型,体验完全够用。通过 SharedArrayBuffer 可以把多核用起来。
- WebGPU:能调用 GPU 加速,推理速度比 WASM 快上一个档次,尤其适合图像和 Transformer 这类计算密集的模型。但 WebGPU 在不同浏览器之间的适配进度差异很大,同一台设备换个浏览器可能直接从“可用”变成“不可用”。
- WebGL:历史遗留方案,能跑但精度和兼容性处理成本高,除非维护旧项目,否则新代码不推荐直接选它作为主力。
我的策略是:默认首选 WebGPU,初始化失败自动回退到 WASM 多线程,再失败回退到单线程 WASM。整个过程在扩展的 warm-up 阶段完成,不要让用户在一个失败窗口里干等。选型时,我建议别自己直接调底层 API,尽量还是在 ONNX Runtime Web 或 Transformers.js 这一层做后端切换。它们把 WebGPU、WASM、CPU 后端的切换封装得比较完善,自己造轮子的收益不大。尤其是 Transformers.js 做文本类任务的 API 很顺手,模型预下载、tokenizer 处理、pipeline 输出都帮你解决了。
2.3 模型大小与内存预算
端侧推理最大的敌人往往不是算力,是内存。浏览器扩展的内存配额虽然不像手机端那么死板,但你让一个扩展常驻 500MB 内存,用户迟早会因为卡顿把你卸载。所以动手前,先算一笔账。
模型参数的基本计算方式是:参数量 × 每个参数字节数 ≈ 模型文件大小。如果我用 B 表示十亿参数的数量,一个 1.4B 的模型,用 fp32 存储是 4 字节每参数,就是大约 5.6GB;量化到 int8 是 1 字节每参数,变成 1.4GB;量化到 int4 大约是 0.7GB。这还只是参数本身,没算推理时的中间激活值、KV Cache、预处理临时缓冲区。在实际浏览器端侧,1B 以上的模型能跑起来已经很不容易,内存很容易翻一倍以上。
所以我建议分档管理。扩展首次安装时,先用能力探测结果给用户一个“预期档位”:内存 8GB 以上且 WebGPU 可用的设备,可以跑 3B 以下的小型模型,量化到 int8 或 int4;普通设备只跑几十 MB 到几百 MB 的专用小模型,比如 OCR、文本分类、向量嵌入这些离散任务;内存紧张的设备就只开 CPU 单线程推理,甚至允许用户关掉自动分析功能。档位信息存进chrome.storage.local,之后推理调度直接按档位决定这只设备最多能加载多大的模型,避免用户设备被直接跑爆。
3. 工程实现规范:模块边界、生命周期和协议设计
3.1 别和 Service Worker 的休眠机制对抗
用过 Manifest V3 的人应该都吃过这个教训:后台 Service Worker 空闲几十秒就会被浏览器杀掉。以前 MV2 的常驻后台页可以长期挂在一个页面里跑任务,现在不行了。如果你把加载模型、跑推理这个流程整个放在 Service Worker 里,很可能推理还没跑完,Worker 就被休眠了,整个流程就断了。
几种思路可以绕开这个问题。轻量任务直接在推理 Worker 里完成,Service Worker 只做任务转发和状态记录;如果你有持续的、较重的任务,比如视频帧流式分析,就需要引入 Offscreen Document 这类“常驻后台页”的方案。它本质上是一个隐藏页面,能绕过 Service Worker 的休眠限制,但需要用户手势触发,并且在 Manifest 里配置权限。
工程上的核心原则是:把 Service Worker 当成调度器用,不要放任何重量级状态。模型实例只能保存在 Inference Worker 里,因为 Worker 有独立的完整生命周期,不会因为 Service Worker 销毁就被回收。Service Worker 被休眠之后再次唤醒,它只负责重新找到对应的 Worker 并恢复连接,状态都在 Worker 端,这样就能避免“扩展每次重载之后模型就要重新下载”的尴尬场景。
3.2 消息协议设计:一次性消息还是长连接
一次性消息chrome.runtime.sendMessage用起来最简单,但它有个天然局限:没法主动推送中间状态,也没法做增量结果。端侧推理这个场景里,模型加载可能要十几秒,推理可能要几秒,界面总需要“排队中”“正在推理”“已完成”这类状态反馈。如果只用一次性消息去模拟这种过程,只能靠定时轮询,体验差且逻辑乱。
所以我更推荐用runtime.Port建立长连接。请求方发一个“启动推理”的事件,Worker 把状态分阶段推回来,界面上可以画进度条,也支持取消操作。消息结构本身要统一,我在项目里用类似这样的字段:
interface InferenceRequest { requestId: string; type: 'ocr' | 'summarize' | 'embedding'; source: { tabId: number; url?: string }; payload: any; // 具体业务数据 }一条消息必须带上唯一requestId,这是整个异步错误排查的基础。服务端返回结果时也带requestId,前端无论什么时候收到响应,都能知道是哪一次请求的;没有 requestId 时,多个并发请求的返回结果无法区分,页面上的结果互相覆盖,排查起来让人崩溃。这个细节看着小,实际项目里遇到一次就再也忘不掉了。
3.3 并发控制和回压
端侧推理引擎通常不是为“无限并发”设计的。多个请求同时进来,如果全部直接塞给模型,内存会瞬间涨起来,推理时间也会明显变长。我在 Service Worker 里做了两层并发控制:第一层按任务类型分流,OCR、摘要、嵌入各自维护队列,互不干扰;第二层每个队列内部最多并发 1 个推理任务,其他请求排队。
单并发设计看起来很保守,但对端侧推理来说,收益反而最明显。浏览器里的推理任务一般很短,几十到几百毫秒,单个队列顺序执行,用户几乎感觉不到排队延迟。更重要的是,单并发能让推理 Worker 复用同一个 session,避免反复创建和销毁带来的高昂初始化开销。如果某个模型确实很慢,再考虑单独开 worker 池,那是后话,没必要在一开始就过度设计。
回压处理同样要想清楚。队列无限堆积,用户等不到结果时会觉得扩展卡死。所以队列长度超过一定阈值后,新请求直接拒掉或者做合并。比如 OCR 任务排队超过 5 个,就丢弃最老的帧,只保留最新一帧。这类策略用一个简单函数就能实现,但在实际体验里的价值远超想象。
3.4 用可转移对象和 SharedArrayBuffer 管理内存
端侧推理的性能瓶颈,很多时候不在模型本身,而在数据拷贝。比如页面截图是一张 ImageData,Byte 量级可能在好几兆,如果用普通消息传给 Worker,默认会做结构化克隆,内存拷贝一次,图像再处理一次,开销一下子就上去了。更合理的做法是使用 Transferable 对象的transfer参数,把 ArrayBuffer 的所有权直接转移给 Worker,实现零拷贝。
worker.postMessage( { requestId, imageBuffer: imageArrayBuffer }, [imageArrayBuffer] // 转移所有权 );SharedArrayBuffer 在扩展环境里用起来限制比较多,需要跨源隔离条件,Manifest V3 的跨域策略和 CSP 都可能拦它。我的建议是,如果目标用户主要在 Chromium 系浏览器,可以先查扩展的 CSP 配置能不能支持;不能支持就用 Transferable 方案,也足够解决大部分性能问题,别强行上共享内存。
4. 推理核心模块的关键实现
4.1 引擎接入:Transformers.js 与 ONNX Runtime 的初始化
我现在的项目里,文本类模型用 Transformers.js,视觉类模型走 ONNX Runtime Web。两个都有各自的 pipeline API,接入方式很接近。以文本摘要为例:
// inference-worker.ts import { pipeline } from '@xenova/transformers'; let summarizer; async function init() { summarizer = await pipeline('summarization', 'Xenova/distilbart-cnn-6-6'); // 预热:跑一次空输入,让模型填充缓存 await summarizer('warmup', { max_length: 50 }); }这段代码背后有几个工程细节需要注意:
第一,模型名要精确到repo/model-name,Transformers.js 默认从 Hugging Face 拉权重,你需要在扩展的host_permissions或 CSP 里放行对应域名,否则下载请求会直接失败。第二,首次加载很慢,但我不建议让用户感知到完整的下载等待。可以在扩展安装后,利用chrome.alarms或 Offscreen Document 在后台静默预下载一次模型,这样用户真正使用的时候,模型可能已经在本地缓存里了。第三,pipeline返回的是同一个 session,后续请求要复用同一个实例,不要每次推理都重新创建。
4.2 前处理和后处理的取舍
端侧推理的前处理藏着大量性能坑,最典型的就是图像缩放。页面截图像素普遍在 1920×1080,直接塞给模型既占内存又慢,合理的做法是在 Content Script 里先缩放到模型输入尺寸,而且把缩放放在 Canvas 或 WebGPU 的纹理通道里完成,不要先取到 CPU 像素数组再缩放,那样会产生一次不必要的内存拷贝。
文本任务的后处理也经常被忽略。模型解码出来的 token 列表如果直接展示,用户看到的是一段没有标点、没有分段的原始序列。所以我在 Worker 里做后处理,把推理结果变成结构化的 JSON:摘要文本、关键短语、置信度、耗时。UI 层只负责渲染 JSON,不负责解析模型输出。这样业务层和推理层之间就有一层稳定边界,后续换模型只需要改 Worker 内部实现,UI 层完全不用动。
4.3 推理 session 的缓存与预热
模型推理最贵的时间,基本花在首次加载和首次推理上。首次加载要下载权重、初始化 session,首次推理要暖缓存、触发编译优化。这个问题不处理,用户在第一次使用时会等十几秒甚至几十秒,体验直接劝退。
我的方案是两步:安装后预下载 + 预热推理。预下载把“网络下载”的等待时间转移到“安装后静默阶段”,真正使用时的模型加载会直接读本地缓存,快很多。预热推理则是把初始化的编译开销提前跑掉,让第一个用户可见请求直接走已经热好的 pipeline。实测下来,不预热时首次推理可能需要 2 到 3 秒,预热之后,由于缓存的优化路径已就位,实际首推时间能降到几百毫秒以内,体感差距非常明显。
4.4 模型的版本管理与分发
如果你把模型作为扩展资源打包,扩展一更新模型就得重新分发,安装包体积会直接失控。如果你在用户端动态下载,就要处理“模型版本更新”这件事,否则用户可能长期用旧版本。我采用的办法是在扩展里放一个models.config.json,记录每个模型的目标 URL 和版本号。扩展启动时先读配置文件,和本地缓存版本对比,不一致才重新下载。
用户端缓存放在IndexedDB,不要放在chrome.storage,因为chrome.storage的单条存储有大小限制,模型权重动辄几十 MB 根本放不进去。缓存文件要按文件名分区保存,下载中断要支持续传。这一套逻辑其实跟下载类扩展的任务调度很像,比如你在常见下载管理器的浏览器扩展里看到的任务队列、断点续传、完整性校验,模型分发本质上就是一个受限版本的下载系统,完全可以借鉴这类成熟方案的思路。
5. 常见问题与排查速查表
这一节我把实际开发中反复遇到的典型问题整理成一个速查表,后面再挑三个印象最深的细节展开讲。
| 症状 | 可能根因 | 排查顺序 |
|---|---|---|
| 推理请求发出后没有返回,十几秒后超时 | Service Worker 休眠或消息丢失 | 查 Service Worker 日志;确认是否收到请求;查请求队列是否堆积 |
| 扩展页面变卡,内存涨到 300MB 以上 | 推理 session 重复创建,或模型量化精度不到位 | 打开任务管理器看扩展进程内存;检查 worker 是否每次请求都重建 session |
| 首次推理特别慢,每次都要再等一遍 | 预热未执行,或模型缓存未命中 | 检查首次加载日志;确认预下载流程是否完整 |
WebGPU 设备提示requestAdapter failed | adapter 探测失败,驱动或浏览器兼容问题 | 单独调用navigator.gpu.requestAdapter();查浏览器版本 |
| 页面截图数据传不到 Worker | ImageData 被结构化克隆,内存翻倍 | 尝试转成 ArrayBuffer 并transfer;确认 CSP 是否允许 |
| 扩展在部分浏览器上直接白屏 | 代码直接用navigator.gpu未做保护 | 给能力探测加默认值;回退方案必须有兜底 |
Service Worker 休眠导致的丢请求。这个坑非常隐蔽。我在扩展里挂了一个定时任务,想每两分钟触发一次自动摘要,结果经常没反应。排查半天才发现 Service Worker 空闲时间一长就被杀掉,定时任务触发时 Worker 还在“唤醒”过程中,消息发出去没有人接收。解决办法是把这类周期性任务放到 Offscreen Document 或独立 Worker 中,Service Worker 只做消息桥接。
WebGPU 适配性崩溃。用户反馈扩展在 Chrome 上能跑,但在某些浏览器上一点就崩。排查后发现是navigator.gpu.requestAdapter()返回了 null,而我没处理这个情况,后续创建 buffer 的地方全都抛异常。修复很简单:返回 null 时直接走 WASM 回退,而且回退要在初始能力探测阶段就准备好,不要等到崩溃之后再补救。
CSP 导致的模型加载失败。扩展的 CSP 默认很严格,从外部域名加载模型文件容易被拦截。如果模型权重域名不在host_permissions允许列表里,或者没配置正确的 connect-src,加载请求会被静默失败。排查这类问题时,不要只看网络面板,还要看控制台里的 CSP 报错。前置把允许域名写在配置里,比事后到处加白名单省事得多。
6. 从浏览器扩展到更大的端侧推理版图
写了这么多,我想把一句话说透:浏览器扩展里的端侧推理,真的不是被压缩的云服务,它更接近嵌入式系统里的推理约束。比如你在 STM32 这类 MCU 上做端侧推理,要考虑的是 FLASH 容量、RAM 上限、功耗和实时性;到了浏览器扩展里,约束换成了 Service Worker 生命周期、WASM 内存上限和 GPU 兼容性,但思考方式是相通的——先约束能力边界,再把任务拆成可调度的单元,最后用回退策略兜住各种异常。这个思路不是某一种框架能教给你的,它是把工程抠出细节之后沉淀下来的东西。
我自己比较喜欢的一个验证方法,是新扩展版本上线前,先在低配设备上开“仿生模式”测试。把 CPU 核数调低、禁用 WebGPU、限制内存,看系统在回退路径下是不是还能给出一个可用的最小功能。能用才上线,不能就继续修。这个过程虽然花时间,但每次都能发现几个平时想不到的边界问题。
最后分享一个小做法:把推理相关的所有日志打点集中在同一个地方,记录请求 ID、耗时、内存变化和最终状态。哪怕是用户反馈扩展失灵,拉一条日志按 requestId 一链就能定位到到底卡在采集、加载、推理还是 UI 渲染。别小看这个习惯,后期排查的时间能省掉一大半。端侧 AI 推理系统也正因为有了这样的可观测性,才真正变成一个可以被维护的工程系统,而不是一个黑盒脚本。