过去一年多,我把相当多的时间花在一个问题上:怎么让浏览器扩展在端侧跑起模型,并且跑得稳。所谓“端侧 AI 推理”,就是把原本要发到服务端的模型计算,全部放在用户本机浏览器里完成;而“扩展环境”又给它加了一层浏览器自身的权限和生命周期约束。很多人一开始以为这是把推理库塞进 popup 那么简单,实际落地后你会发现,后面牵连的是系统架构、执行环境划分、内存水位控制、消息协议设计,甚至模型量化策略。这篇文章就围绕“端侧 AI 推理系统架构”和“工程实现规范”展开,适合正在做浏览器 AI 扩展、想在扩展里引入离线推理能力的同学,也适合那些已经被各种“跑通了的 demo”烦到不行的架构负责人。
1. 扩展做端侧推理,解决的不只是省 API 调用费
很多团队把端侧推理当作“省钱方案”,因为云端推理要按 token 计费,模型越大越贵。但从架构角度看,端侧推理真正带来的价值是两个词:延迟和上下文。
云端 API 的推理通常有几百毫秒网络往返,算上服务端排队,交互体验很难低于 1 秒。端侧推理则完全不同——模型和输入都在本地同一进程,省去了网络开销,响应速度肉眼可见。尤其是流式输出场景,打字机效果的 token 一个个往外吐,云端推流会被网络抖动打断,端侧几乎不存在这个问题。
第二个词是“上下文”。一个浏览器扩展,最值钱的地方在于它能拿到用户当前正在浏览页面的结构、选中文本、甚至整个标签页的内容。如果走云端推理,你得把这些内容打包上传,先做脱敏、截断,再交给模型,这既增加延迟又扩大隐私风险。端侧推理天然把“就地取材”和“就地计算”结合在一起,数据不出设备一整条链路就结束了。
1.1 网页内嵌模型与扩展级推理是两种产品
你可能会说,普通网页应用不也能在浏览器里做端侧推理吗?确实能,但两者是两种产品形态。
普通网页应用里,模型推理只发生在当前标签页内,用户打开页面时模型下载,标签页一关,整个推理会话随之结束。这种模式适合单页工具、适合演示,但很难成为日常助手。扩展则不同:右键菜单能发起任务,工具栏弹窗能展示状态,内容脚本能读取任意网页的结构化信息,后台 service worker 能跨标签页调度,甚至可以持有隐藏的宿主页面做常驻推理。这些能力组合到一起,才让“按下快捷键 → 梳理当前页面 → 本地推理 → 把结果插入页面”成为可能。
我习惯用“临时工”和“常驻服务”来类比这两种形态。网页应用是在页面生命周期里临时干一票,扩展则是围绕浏览器本身提供持续服务。持续服务天然需要三层结构:最外面是交互层,中间是调度层,最里面是执行引擎。这也是本文题目里“系统架构”的由来。
1.2 端侧约束比云端 API 苛刻得多:这些决策点绕不开
端侧推理看着自由,实际上约束条件比云端 API 苛刻得多,而且每一项都直接决定架构怎么走:
- 资源墙:浏览器标签页有内存压力,扩展宿主页面也会被浏览器回收。模型权重动辄几百 MB,不做量化根本放不下。
- 算力墙:用户设备的 GPU 能力参差不齐。同一套模型,在高端独立显卡上跑得飞快,在核显设备上可能慢到不可用。
- 生命周期墙:Manifest V3 的 service worker 会被浏览器随时休眠,弹窗关闭即销毁。推理任务一旦跑到一半被销毁,结果就丢了。
- 兼容墙:WebGPU 还没有在所有浏览器默认开放,WASM 作为兜底又偏慢,不同系统下的行为差异十分明显。
这些约束决定了整个系统不能设计成“模型 + 调用”两层结构,而必须引入调度层、模型管理层和设备适配层。前端开发者很容易忽略这件事,因为他们习惯了浏览器帮自己管理一切资源;但到了扩展环境,浏览器只保证“不让你把浏览器搞崩”,并不保证“你的模型一直都在”。
2. 执行环境决定架构:管理面、调度面、执行面怎么拆分
浏览器扩展在 Manifest V3 下有几种执行环境,每种环境的生命周期和权限都不一样。架构的本质,就是把任务安排到最合适的环境里去执行。
2.1 三个执行环境的边界能力
我先把最常打交道的三类执行环境列出来:
| 执行环境 | 生命周期 | 能做什么 | 不能/不宜做什么 |
|---|---|---|---|
| 内容脚本 | 与页面同生共死,页面刷新即重新执行 | 读 DOM、选文本、监听用户操作、把结果写回页面 | 跑不了长任务,不能跨标签页,不适合直接持有模型 |
| 后台 service worker | 全局单例,事件驱动、空闲即休眠 | 管理状态、路由消息、处理右键菜单事件、调用扩展 API | 不能长时间占用 CPU,不适合跑大模型推理 |
| 扩展页面 / offscreen document | 显式创建,未被主动关闭前持续存在 | 加载模型、跑推理、维持 worker 线程、管理 IndexedDB 缓存 | 不能直接注入网页,页面开多了也会带来内存开销 |
这里有个背景要交代清楚:Manifest V3 已经让“后台常驻脚本”退场了。如果你在旧时代习惯了把模型直接塞到后台页里,到 MV3 必须改掉。后台 service worker 会休眠,事件驱动,不适合跑长任务。具体推理逻辑要放到能稳定存活的宿主页面中去。
2.2 完整推理请求的闭环链路
我以最常见的场景——“在右键菜单点‘总结本页’”——来拆解一次推理请求的闭环链路:
- 用户点击右键菜单项。
- chrome.contextMenus 事件唤醒后台 service worker。
- 后台向当前标签页的内容脚本发送“采集正文”消息。
- 内容脚本提取页面正文、去噪、截断到 token 预算,把纯文本回传。
- 后台把请求写入调度队列,转发给 offscreen document 里的执行 worker。
- 执行 worker 从 IndexedDB 缓存加载模型(已加载则复用),执行推理,把生成的 token 流式发回后台。
- 后台逐段转发给内容脚本,内容脚本通过 DOM 插入总结面板。
这条链路里最关键的是第 5~6 步的隔离。如果跳过后台,让内容脚本直接找模型执行,会出现两个问题:内容脚本的进程跟网页一起刷新,模型状态被频繁打断;每个标签页都复制一份模型实例,内存直接爆掉。集中到一个执行面,才能保证“全局只加载一份模型、一次只处理一个任务”。
2.3 为什么执行面要放在 offscreen document
可能有同学会问:扩展有自己的选项页、侧边栏页面,为什么不选这些当执行面,非要另开一个 offscreen document?原因有三个。
第一,稳定。popup 一关,页面就销毁;选项页如果用户没打开,后台根本调不到它。offscreen document 专为“后台悄悄干活”设计,不依赖用户当前是否打开了某个界面。
第二,独立。它只作为纯计算宿主存在,内部只有 worker 线程、消息监听和模型缓存,不会和 UI 抢主线程,也不会因为用户滚动弹窗而卡顿。
第三,可控。需要推理时由调度层按需创建,用完按策略释放。相比常驻一个隐藏页面,内存占用和生命周期都更容易管控。
实际工程里,我会在 offscreen document 里再挂一个 Web Worker。模型推理放在 worker 线程,避免长时间计算阻塞 offscreen 文档的主线程——浏览器对主线程长时间忙碌几乎没有任何保护,CPU 占用一高,整个扩展的 UI 和通信都会跟着卡。
3. 推理运行时选型与模型部署:后端、引擎、量化三层决策
架构定下来之后,真正的麻烦事才开始:用哪个后端跑模型?用哪种推理引擎的封装?模型要不要量化、量到什么程度?这三个决策相互影响,必须按顺序做。
3.1 后端选型:不是越新越好
浏览器里做端侧推理,常见的执行后端有四类:WASM、WebGL、WebGPU,以及 WASM-NN 这类实验性后端。我把它们放在一起对比:
| 后端 | 现状兼容性 | 性能量级 | 适用任务 | 主要风险 |
|---|---|---|---|---|
| WASM | 所有现代浏览器可用 | CPU 执行,比 GPU 慢 2~10 倍 | 小模型、低频任务、兜底方案 | 大模型推理耗时长,设备发热明显 |
| WebGL | 老牌 GPU 入口,兼容性好 | 依赖着色器优化,收益不稳定 | 图像类算子、简单计算 | 模型算子拆分和量化迁移很痛苦 |
| WebGPU | Chrome/Edge 默认开启,Safari 18+ 跟进,Firefox 仍需开关 | GPU 加速,适合 Transformer 解码 | 文本生成、大规模 embedding | 部分设备驱动不稳,API 仍在演进 |
| WASM-NN 等实验后端 | 仅少数浏览器预研 | 借助系统级 AI 加速器 | 特定算子硬件加速 | 生态不成熟,不建议生产依赖 |
我的建议非常直接:主路径选 WebGPU,兜底路径选 WASM,WebGL 尽量少碰。原因很简单——WebGPU 是现代浏览器里唯一能把 Transformer 解码这种内存密集型循环跑出接近原生速度的通用方案;WASM 一定能跑,虽然慢,但至少不会让功能彻底不可用。WebGL 看着兼容性好,真要调到生产级,算子融合和显存管理会让你怀疑人生。
3.2 引擎选择:一体封装还是拼积木
后端决定了底层能力,但工程上你不会直接去写 GPU kernel。目前主流是两条路:用 Transformers.js 这类一体封装库,或者用 ONNX Runtime Web 自己拼管线。
我的判断标准很简单:看团队手里已经有什么模型,以及你对算子控制的精细度需求。
- Transformers.js:对语言任务做了大量预处理和后处理封装,分词器、生成循环、任务管道都内置了。如果你要快速做文本总结、翻译、信息抽取,它是最短路径。
- ONNX Runtime Web:更像工具集。模型转换、算子配置、执行 provider 都由你把控,适合已经有 ONNX 模型资产、需要跨端跑同一份模型的团队。
还有一个容易被忽略的点:输入输出协议的一致性。我会把执行引擎封装成一个统一的 ModelAdapter 接口,无论内部是 Transformers.js 还是 ONNX Runtime Web,对上层只暴露相同的方法:
export interface ModelAdapter { load(modelId: string, options?: LoadOptions): Promise<ModelHandle>; run(input: InferenceInput, options?: RunOptions): AsyncIterable<InferenceOutput>; unload(modelId: string): Promise<void>; status(): RuntimeStatus; }上层调度器只认这一套接口,引擎层可以随时替换。我在项目里换过好几轮推理引擎,接口没改过,全靠这层抽象兜着。
3.3 模型尺寸与量化精度怎么定
端侧模型不是越大越好。扩展场景要时刻记住:模型是常驻在用户浏览器里的,尺寸直接决定首次启动时间和内存水位。
一般来说,对话、摘要这类强语义任务,0.5B~1.5B 的小参数模型是甜点区;向量化、分类、信息抽取这种轻任务,300M~800M 足够。体积上,模型权重按“参数量 × 量化比特数 / 8”粗算:一个 1B 模型用 16bit 存储大约是 2GB,int8 砍到约 1GB,int4 能压到 500MB 左右。肉眼可见,不做量化根本进不了扩展的常规场景。
量化决策我有一个分级策略:
- fp16 精度:只在高端 GPU 且 WebGPU 可用时生效,用于对精度敏感的抽取任务。
- int8 量化:多数语言任务的首选,损失通常在可接受范围,广泛兼容 WASM 和 WebGPU。
- int4 量化:适合低资源设备,但要注意词表粒度和反量化开销,个别算子反而更慢。
实战里我建议把“默认 int8、可选 fp16、兜底 int4”作为发布矩阵。同时一定要在真实设备上验证质量,不能只看指标,要跑一版人工评测集,确认总结不丢关键信息、实体识别不串词。量化带来的性能收益,如果要用质量换,必须提前设好止损线。
4. 端侧推理的工程规范:内存、并发、降级与模块边界
有了架构、选型和模型,最后决定成败的是工程规范。这个环节藏得深,用户感知不到,但任何一处没兜住,产品就会在真实环境里被评价为“卡死”“闪退”“跑不出来”。
4.1 全局推理调度器
推理任务不能“谁发起谁执行”。我在代码里维护了一个全局调度器,它负责三件事:排队、去重、限流。
- 排队:所有推理请求进一个队列,按优先级排序,右键菜单的显式操作优先于页面自动触发的隐式任务。
- 去重:同一标签页、同一目标文本的连续请求,直接返回上一次结果。
- 限流:同一时间只运行一个推理任务,避免两个任务同时吃满 GPU 和 CPU。
调度器内部是一个串行任务队列,结构大致如下:
class InferenceScheduler { private queue: InferenceTask[] = []; private running = false; enqueue(task: InferenceTask, priority: Priority = Priority.NORMAL) { const idx = this.queue.findIndex((t) => t.priority < priority); if (idx === -1) this.queue.push(task); else this.queue.splice(idx, 0, task); void this.drain(); } private async drain() { if (this.running) return; this.running = true; while (this.queue.length) { const task = this.queue.shift()!; try { await task.execute(); } catch (err) { this.reportError(task, err); } } this.running = false; } }别小看这个看似简单的队列。没有它,你会在两个标签页同时请求“总结页面”时遇到不可复现的崩溃;有了它,全局 GPU 并发和上下文切换全部收敛到单点,问题可定位、可恢复。
4.2 内存治理:把 KV 缓存和上下文窗口管起来
端侧推理在大语言模型中最典型的内存问题是 KV 缓存。每生成一个 token,解码器都要为当前请求保存一份 Key/Value 缓存,上下文越长,缓存越大。扩展里最忌讳的是让用户把二十万字的小说整个丢给模型。
我的做法是三层限制:
- 固定 token 预算。不同任务类型给不同上限,摘要用 2048,问答用 4096,超出预算的文本先走“分块处理 + 逐段归纳”通道,而不是硬塞给模型。
- 单请求的 KV 缓存上限。在执行引擎里直接限定总上下文长度和生成长度,防止流式生成期间内存继续膨胀。
- 模型实例占用监控。加载新模型前检查当前已加载模型的设备占用,超过阈值就先卸载低频模型。扩展内存被浏览器回收,是生产环境偶发但致命的故障来源。
4.3 设备能力识别与降级策略
不是所有用户都有独立显卡。扩展在每次启动时要做一次设备画像,按能力分层:
type DeviceProfile = { backend: 'webgpu' | 'wasm'; gpu: boolean; maxModelSizeMB: number; speedRank: 1 | 2 | 3; };- 检测
navigator.gpu,能拿到就是候选 WebGPU; - 用 50ms 左右的空闲时间跑一轮极小的基准推理,记录耗时,划分 speedRank;
- 根据 speedRank 决定默认模型档位和最大上下文。
降级策略的核心是“别让用户死等”。我给推理请求设置超时时间,WebGPU 下超过 10 秒、WASM 下超过 30 秒仍未完成,就抛出降级提示,建议用户改用云端接口或换更小模型。宁可给一个“当前设备暂时跑不动”的诚实提示,也不要一个无限转圈的动画。
4.4 推荐目录结构与消息协议
工程规范还包含代码组织和通信契约。我的扩展项目里,至少要保持下面这样的模块边界:
src/ manager/ // 管理面:popup、侧边栏、右键菜单,只做交互 dispatcher/ // 调度面:service worker,路由消息、维护调度器 runtime/ // 执行面:offscreen document、worker、模型加载与推理 common/ // 类型定义、消息协议、常量消息协议尽量用联合类型,保证每个消息都有明确的方向和意图,避免“参数里塞一堆可选字段”的接口设计:
type RuntimeMessage = | { type: 'inference:request'; requestId: string; task: TaskKind; input: unknown } | { type: 'inference:started'; requestId: string } | { type: 'inference:delta'; requestId: string; delta: string } | { type: 'inference:done'; requestId: string; output: InferenceOutput } | { type: 'inference:error'; requestId: string; code: ErrorCode; message: string };这套协议最大的价值在于:调试时只要打印消息流,就能还原一次推理请求的完整生命周期,定位问题快得多。
5. 生产环境踩坑复盘:四个最容易翻车的细节
前面讲的是架构和规范合理性,这节专门说教训。都是实操中真实出现、并且花了不少时间才定位的问题。
5.1 反复下载模型:没有断点续传的代价
早期我做过一个天真的事:把模型文件直接打进扩展包里,或者每次启动都从网络地址重新下载。结果是扩展一升级,已经下载到本地的模型权重全部丢失,用户又得重新等几分钟。后来我把模型文件改成版本化清单,通过 IndexedDB 缓存,先比对本地哈希再决定是否下载,并对下载过程做可恢复记录。大模型文件动辄几百 MB,断点续传和文件校验对端侧扩展来说不是可选功能,是基本能力。
5.2 消息风暴:把流式输出当成同步调用
早期为了实现“打字机”效果,我对每个生成的 token 都发一条 sendMessage,结果在低配机器上消息调度开销比推理本身还大,输出出现明显顿挫。后来改成使用长连接,在 Port 上持续推送增量消息,数据吞吐和实时性才恢复正常。经验是:细粒度高频消息永远走长连接,单次低频请求才用 sendMessage。
5.3 service worker 重启把状态清空
Manifest V3 的 service worker 会被浏览器回收,之后重新唤醒。早期我把“模型是否已加载”“推理队列”都放在 worker 的全局变量里,结果一休眠,队列丢了、模型实例也没了。现在我把必不丢的元数据写入 chrome.storage.session,模型实例统一由 offscreen document 持有,worker 重启后只需要重新连接执行面,不需要重新加载模型。这就是“调度状态”和“执行状态”分开管理的意义。
5.4 不同浏览器的行为差异
同一套扩展在 Chrome 上默认开启 WebGPU,在 Safari 上部分 API 行为不同,在 Firefox 上 WebGPU 还需要手动开启 flag。如果不做分流,Safari 用户会直接落到 WASM,模型推理慢到像假死。应对方法很简单:不信任默认开关,启动时自测设备能力,并把这个能力画像同步到用户可见的“兼容性提示”中。另外,不同浏览器对扩展长连接的数量限制不一样,同时打开的标签页多时,要注意及时释放无用的 Port,否则连接会积累到触发阈值。
这些坑都不算毁灭性的大问题,但凑在一起足以毁掉产品的口碑。每次踩完我都会提醒自己:端侧推理系统不是“能跑模型”就行,而是要在用户想象不到的各种设备组合上保持稳定。做一个 demo 很简单,做生产级扩展很难,难的部分恰恰就藏在本文这些细节里。
我的收尾习惯是,在发布前故意禁用一次 WebGPU,强制走 WASM 后端跑全流程。如果 WASM 模式下核心功能还能完整走通、只是慢一点,这个版本才敢放出去。端侧推理的环境太杂,你永远不知道用户会在哪台设备上打开你的扩展,而“慢”是可接受的,“完全跑不动”才是灾难。