我一直觉得“浏览器扩展”和“端侧AI推理”这两个词摆在一起,既别扭又带劲。别扭是因为扩展本身是个轻量宿主,内存、CPU甚至生命周期都紧巴巴的,谁没事往里塞一个几百MB的大模型?带劲是因为一旦真塞进去了,以前那些只能靠云端API实现的智能能力,就能在用户眼皮底下静默跑起来,不传隐私、不付账单、还能离线干活。
这篇文章源于我最近重构一个内容摘要扩展的完整经历。核心是想清楚了一件事:在Chromium扩展环境下,端侧AI推理不是一个“调个模型就跑”的玩具,而是一套完整的分层系统架构,外加一堆必须写进团队规范里的工程细节。从后台Service Worker到内容脚本再到推理引擎,每一层都有它的脾气。无论你是想给扩展加本地翻译、页面摘要、结构化数据抽取,还是想在扩展里跑一个轻量分类器,这套方案都能直接拿来当脚手架。
我先说结论:扩展里的端侧AI,架构比模型重要,规范比算法重要。下面我把从选型到落地的全过程拆开讲,所有代码和配置都是实际验证过的版本。
1. 整体架构拆解:扩展里的AI推理为什么不能“想当然”
1.1 先看清场景,再决定要不要端侧
我见过太多人一上来就喊“我要在浏览器里跑大模型”,结果做完发现用户根本不买账。端侧推理在扩展里真正能站住脚的场景,无外乎下面四种:
- 隐私敏感数据。比如在本地网页里提取病历、工资单、合同条款,用户绝不愿意把这些内容发给云端做分析。端侧推理意味着数据不出浏览器,这个卖点对特定人群是致命的。
- 低延迟交互。页面实时翻译、输入框即时纠错这类场景,如果走云端API,一次往返至少几百毫秒,加上网络抖动就卡顿。本地推理只需要几十毫秒,体验完全两码事。
- 离线可用。差旅、内网环境、网络被屏蔽的场景下,扩展照样能干活。这是云API永远给不了的能力。
- 成本控制。我认识一个做内容分类插件的团队,每天几十万次云端请求,账单打到崩溃。后来把模型量化到8bit塞进扩展,一个月云成本归零,本地跑得好好的。
但端侧不是银弹。模型体积、内存占用、推理速度跟设备性能强相关。如果你的模型超过500MB,或者目标用户都是两三年前的入门机型,那老老实实走云侧反而更稳。架构选型的第一步不是选引擎,而是判断这个功能到底适不适合端侧。
1.2 扩展宿主环境的特殊约束
浏览器扩展不是普通的Web页面,它在架构上有一堆绕不开的硬约束。尤其Manifest V3(MV3)普及之后,很多以前能钻的空子都没了。
第一,后台逻辑从持久页面变成了Service Worker。MV3里background script默认是事件驱动型Service Worker,空闲几秒钟就被浏览器杀掉,任何长时间运行的任务都得拆成“一段一段”的状态机,靠事件唤醒继续跑。这意味着你不能在后台开一个for循环去处理批量数据,也不能用全局变量长期保存模型实例。模型必须能快速加载、快速初始化、随时准备被重置。
第二,内容脚本(Content Script)和扩展上下文(Extension Context)完全隔离。内容脚本能操作DOM,但它运行在页面的JS世界里,直接访问扩展后台的变量会碰壁。你要做的是把“页面数据”和“模型推理”拆成两个独立进程,中间用消息通道传数据。
第三,CSP策略卡住了很多动态执行路径。MV3默认CSP不允许unsafe-eval,你在推理引擎里如果用eval或者new Function,直接给你报错。WASM模块也需要在CSP里显式放行wasm-unsafe-eval,否则浏览器拒绝编译。这些细节我后面会在Manifest配置里专门讲。
第四,内存预算极其有限。一个扩展标签页能分到的内存不比普通网页多多少,尤其内容脚本如果长期驻留,还要跟宿主页面抢资源。一旦模型加载后内存峰值超过设备能承受的范围,用户的浏览器直接卡死或白屏,这种事故一次就能把口碑做没。
1.3 分层架构总览
我在实际重构中把整个扩展拆成了五层,每层职责单一,层与层之间只通过标准接口通信。
- UI层:负责扩展弹窗、设置页、注入到页面里的浮层面板。它不直接碰模型,只发起请求和展示结果。
- 内容脚本层:负责从当前页面抓取目标数据,比如网页正文、选中的文本、表单内容。它运行在页面上下文里,拿到的都是第一手DOM数据。
- 扩展后台层:负责接收内容脚本的推理请求,调度模型加载与执行,维护任务队列,管理生命周期。这一层是端侧AI的“指挥中心”。
- 推理引擎层:负责实际的模型加载、前向传播、后处理。它被后台层调用,但自身不感知任何页面细节。
- 数据存储层:负责模型权重、推理历史、用户配置的持久化。躲在这个层后面的,通常是IndexedDB加Cache API的组合。
这五层的数据流是单向的:页面数据从内容脚本流到后台,再流向推理引擎;推理结果原路返回。控制流则是双向的,因为用户随时可能取消任务、切换设置。
为什么要这么严格?因为一旦让内容脚本直接加载模型,或者让UI层直接操作存储,整个扩展的并发、内存、权限问题会瞬间失控。我之前第一版就是偷懒让内容脚本直接调用推理库,结果三个标签页同时触发摘要,直接搞崩了用户的浏览器。分层不是形式主义,是端侧资源紧张下的自救。
2. 推理引擎选型与模型落地方案
2.1 主流引擎横向对比
选推理引擎是端侧AI的第一步。目前跑得通浏览器环境的选手就三个:Transformers.js、ONNX Runtime Web、TensorFlow.js。我做了一个对比表,把关键差异直接列清楚。
| 引擎 | 底层执行 | GPU支持 | 生态模型数量 | 动态形状支持 | 适合场景 |
|---|---|---|---|---|---|
| Transformers.js | WebGPU / WASM | 优秀 | 极多(HuggingFace原生) | 支持 | NLP为主、快速原型、Multimodal模型 |
| ONNX Runtime Web | WebGPU / WASM | 优秀 | 多(需转换) | 中等 | 自定义模型、生产环境部署 |
| TensorFlow.js | WebGL / WASM | 一般 | 老模型较多 | 较强 | 已有TF生态、图像分类 |
我的建议是按这个逻辑判断:如果你用的是HuggingFace上的现成Transformer模型,比如BERT、GPT-2、Whisper、CLIP,直接选Transformers.js,因为它连tokenizer、preprocessing、postprocessing都帮你封装好了,写几十行代码就能跑出结果。如果你有自己的ONNX模型,或者对推理性能极致敏感,选ONNX Runtime Web,它的算子覆盖更广,量化支持也做得更精细。TensorFlow.js除非你有历史包袱,否则别碰,WebGL执行效率在端侧并不占优势。
从架构角度看,更聪明的做法是在后台把推理引擎封装成统一接口,内部实现可以随时替换。我在项目里先用了Transformers.js跑通流程,后来发现某个NLP任务瓶颈在WASM的CPU推理上,就切换成ONNX Runtime Web并接了WebGPU,只改了引擎适配层,上层完全无感。这就是分层架构带来的红利。
2.2 模型获取与量化压缩
模型权重是扩展里最重的资产。以我用的一个文本摘要模型为例,原始PyTorch权重300MB,直接塞进扩展包等于劝退所有用户。正确姿势是先用optimum-cli把它导出成ONNX格式再做量化。
optimum-cli export onnx --model 你的模型名 model_onnx/导出之后,我会用动态量化把FP32权重压到INT8。命令大概长这样:
python -m onnxruntime.quantization.quantize \ --input model_onnx/model.onnx \ --output model_onnx/model_quantized.onnx \ --quantize_dynamic动态量化对NLP模型几乎无损,文本摘要这种任务精度抖动控制在1%以内,但模型体积能砍掉75%。我那个模型从300MB直接压到70MB,内存占用也从400MB降到120MB,这个差距在扩展场景里就是能跑和不能跑的区别。
除了量化,还要做形状裁剪。很多导出的ONNX模型默认支持动态序列长度,这在浏览器里是灾难,因为内存分配在长序列时容易失控。我的做法是把序列长度固定到128或者256,然后把模型的Dynamic Axes关掉。这样推理速度能提升30%,内存峰值也变得更加可控。
2.3 WebGPU回退策略:不能只看性能
WebGPU是端侧推理性能的王炸,但它不是所有设备都有。Chrome从113版本开始稳定支持WebGPU,Firefox还在打磨,低端安卓WebView更是直接缺席。架构上必须默认走WASM回退路径。
我的检测逻辑很简单:
function isWebGPUSupported(): boolean { const gpu = (navigator as any).gpu; return !!gpu && typeof gpu.requestAdapter === "function"; }能拿到adapter不代表能用,我会再跑一次adapter.requestDevice(),失败就降级到WASM。WASM本身也要分级:4线程并行、2线程、单线程,依据navigator.hardwareConcurrency动态调整。
这里有个重要的架构决策:引擎初始化是异步的,且初始化之后要缓存设备实例。每次推理任务进来时,如果检测到设备状态变化,比如GPU上下文丢失,就重新初始化并回退。我的做法是把引擎生命周期封装成一个createInferenceSession()的工厂函数,内部维护状态机:idle -> initializing -> ready -> failed -> fallback。任务调度器只认状态,不认底层细节。
2.4 模型缓存的版本管理
模型下载一次几十上百MB,让用户每次开浏览器都重新下载,等于产品自杀。我用IndexedDB存二进制权重,用Cache API存其他静态资源,双轨并行。
IndexedDB里我维护一个models表,主键是模型名+版本号,记录三个字段:版本号、权重ArrayBuffer、元信息。加载逻辑采用cache first策略:先查IndexedDB,有就直接加载;没有就走网络下载,下载完写入IndexedDB再加载。
版本管理最容易踩坑的是“旧版本变孤儿”。我每次发布新版本模型都会带一个MODEL_VERSIONS清单,后台初始化时对比清单,删除本地不存在于清单里的旧权重。不删的话,扩展升级几次之后,用户磁盘上的模型垃圾能膨胀到几个GB。
3. 消息通道与任务调度:让每个标签页都“会排队办事”
3.1 定义扩展内网通信协议
浏览器扩展里的消息通道,本质上是两个隔离JS世界之间的IPC。如果没有一套统一协议,你会陷入“参数乱传、回调地狱、错误无处安放”的泥潭。我在项目里直接用TypeScript定义了一套内部协议,核心是一个可辨识联合类型。
type InferenceRequest = | { type: "summarize"; text: string; options?: { maxLength: number } } | { type: "classify"; text: string; labels: string[] } | { type: "extract_entities"; text: string }; type InferenceResponse = | { ok: true; requestId: string; data: unknown; latencyMs: number } | { ok: false; requestId: string; errorCode: string; message: string };所有从内容脚本发给后台的消息,必须走同一个postMessage封装,消息体里带上requestId。为什么需要requestId?因为消息是异步的,你发出去之后不能假设“后发的响应一定在后”。有了ID,任务调度器就能精确地把响应匹配回发起者,哪怕是多个标签页同时请求也不会混乱。
3.2 跨上下文数据流设计
内容脚本抓到的是页面DOM数据,比如网页正文文本。如果直接把一个几十万字符的字符串通过chrome.runtime.sendMessage发给后台,消息序列化开销会大得惊人。数据量超过几十KB,就该考虑用索引DB中转或者Transferable对象。
先说Transferable:ArrayBuffer是真正可以“零拷贝转移”的数据类型,发送时所有权直接从发送方转给接收方,发送方那边变量变成空,不会有复制成本。但文本字符串本身不是Transferable,得先编码成ArrayBuffer再转。
我的推荐做法是分两档:数据小于1MB,直接结构化克隆发送;数据大于1MB,内容脚本先把数据块写入IndexedDB,然后只把索引Key发给后台,后台再按Key读取。虽然多了一次DB读写,但避免了长时间阻塞UI线程。内容脚本里做一个大对象的JSON.stringify,如果对象很大,页面滚动都会卡。
3.3 推理任务队列与并发控制
多个标签页同时触发推理请求,是扩展端侧AI最经典的崩溃场景。模型只有一个,内存只有一份,GPU上下文只有一个,你不能让5个请求同时冲进推理引擎。我维护了一个任务队列,同一时刻只允许一个推理任务在执行,其余按FIFO排队等待。
class InferenceQueue { private queue: InferenceRequest[] = []; private running = false; async push(request: InferenceRequest): Promise<InferenceResponse> { return new Promise((resolve, reject) => { this.queue.push({ request, resolve, reject }); this.drain(); }); } private async drain() { if (this.running) return; this.running = true; while (this.queue.length > 0) { const { request, resolve, reject } = this.queue.shift()!; try { const response = await runInference(request); resolve(response); } catch (err) { reject(err); } } this.running = false; } }有人可能会问:队列串行化之后,会不会导致第二个标签页等太久?会。所以我在协议里加了priority字段,页面可见且用户正在交互的请求优先级更高,后台预加载类任务优先级最低。排队是底线,优先级是体验。
3.4 状态反馈:不能让用户干等
推理是个耗时的异步操作,尤其WASM回退路径下一个中等模型跑一次摘要可能要3到5秒。用户如果没有反馈,第一反应是“插件坏了”,然后直接关掉。
状态反馈有三个层次。最轻量的是扩展工具栏的Badge,显示一个转圈的动画字符或者进度百分比;中间层次是注入到页面里的浮动UI,能实时显示“加载模型中30%”“正在处理第2段”;最重的是流式输出,一段一段地把摘要结果吐出来,用户能感知到系统在干活,焦虑感直接减半。
流式输出在架构上要求推理引擎支持iterable输出,Transformers.js的生成类模型天生支持这个特性。做成AsyncIterator<{ delta: string }>之后,后台每收到一个增量就通过消息通道推给内容脚本,脚本再实时渲染到浮层里。实测下来,流式输出比一次性输出的主观等待时间短了将近一半,强烈建议做。
4. 工程实现规范:可维护性与发布安全
4.1 项目目录与构建体系
端侧AI扩展比普通扩展复杂一个量级,如果目录不规划好,一个月后自己都找不到代码。我目前的结构是这样:
src/ background/ # Service Worker 入口,调度器、队列 content/ # 内容脚本,页面数据提取与UI注入 ui/ # 弹窗、设置页 engine/ # 推理引擎封装,含Transformers.js/ONNX适配层 types/ # 跨上下文共享的TS类型定义 core/ # 协议、队列、存储等基础设施构建用Vite加@crxjs/vite-plugin,开发模式自动热重载,打包时自动按目录生成MV3清单。TypeScript开严格模式,noImplicitAny必须开。跨上下文消息协议这种地方,一旦类型对不上,运行时踩雷的概率极高,严格检查能挡掉一大半低级错误。
4.2 Manifest配置细节
MV3的Manifest是整个架构的第一道关卡,关键字段一个都不能错。我贴一个完整的基础配置,重点看CSP和权限:
{ "manifest_version": 3, "name": "Local AI Assistant", "version": "1.0.0", "background": { "service_worker": "background.js" }, "permissions": ["storage", "indexedDB", "activeTab"], "host_permissions": [], "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ], "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" } }这里有个必须注意的坑:很多WASM推理引擎在编译时依赖WebAssembly.compile,MV3默认CSP不允许,你必须在extension_pages策略里显式加上'wasm-unsafe-eval',否则模型死活加载不出来。但这也要谨慎,只在加载WASM的扩展页面用这个策略,别图省事加到所有页面。
host_permissions我直接留空。为什么?因为大多数推理功能的输入数据来自用户主动触发的当前页面,activeTab权限就够了。非要声明<all_urls>,扩展商店审核会盯着你问“为什么需要访问所有网站”,回答不好就下架。最小权限原则在扩展里不是安全口号,是生存之道。
4.3 异常处理与降级策略
端侧AI的系统性风险在于“不确定性”太多:模型可能下载失败、WebGPU可能初始化失败、设备内存可能不足、引擎可能崩溃。我建议把所有异常分成三类,分别处理。
第一类是可恢复错误,比如网络超时、下载中断。这类走重试机制,最多重试3次,每次退避递增。 第二类是环境不支持错误,比如设备没有WebGPU、硬件太老。这类不重试,直接降级能力:有GPU走GPU,只有CPU就走WASM单线程,再不行就提示用户该功能需要更高配置。 第三类是内部错误,比如模型推理结果异常,这类记录详细日志,展示给用户一个友好的错误提示,同时允许用户上报日志。
分钟级监测也很重要。我在后台统计每个任务的latencyMs、errorCode、deviceKind,本地只保留最近100条,用户在设置页可以一键导出诊断信息。这在实际维护里帮了大忙,很多用户反馈“推理失败”时,导出的诊断日志一眼就能定位到是不是WebGPU上下文丢失。
4.4 权限最小化与安全架构
浏览器扩展是安全敏感地带,因为代码跑在用户浏览器里,手里还可能握着用户的各种权限。你的扩展一旦被XSS注入或者被恶意第三方扩展通信,整台机器数据都可能被拖走。我的安全红线有下面几条。
内容脚本永远不直接调用推理引擎,也不允许直接读取后台的模型权重存储。它的能力被锁死在“提取页面数据”和“渲染结果”两个动作上。
对从页面拿到的数据做完整性校验。网页是用户可控的,恶意页面可以注入超大文本、畸形JSON、或者“看起来像指令的字符串”。进入模型之前,先做长度上限校验和类型检查,防止你的扩展变成任意代码执行的跳板。
绝对不能在content script里使用innerHTML渲染模型输出。模型生成的文本可能包含HTML片段,如果直接拼进页面会被执行。我在浮层UI里只使用textContent渲染。别嫌麻烦,这是安全底线。
4.5 调试、日志与商店审核经验
调试端侧AI扩展比调试普通网页复杂得多,主要是上下文隔离导致你没法一笔一划看全貌。我的调试三板斧是:后台页面开DevTools,看SQLite式的问题排查快照;内容脚本在页面里开一个隐藏调试通道,把日志postMessage转发到后台控制台;再就是多加日志,按级别标记INFO/WARN/ERROR。
发布到Chrome Web Store时有两个高频拒审点。第一个是隐私政策,只要扩展包含任何推理处理,商店都会要求你有隐私政策页面并声明数据如何被处理。第二个是远程代码禁令,MV3禁止扩展执行远程托管代码,但模型权重文件不属于“代码”,你可以从自己CDN远程加载权重,前提是服务器响应带正确的CORS头。千万别在扩展里动态import()远程JS,那必拒无疑。
5. 常见问题排查实录
5.1 模型下载失败或加载超时
现象:用户装了扩展,第一次点功能时卡在“加载模型中”很久,最后直接失败。
排查路径:先看后台日志里fetch请求的HTTP状态,如果是CORS错误,大概率CDN头部没配Access-Control-Allow-Origin。如果是请求超时,说明模型文件太大或者用户网络不稳,我在设计里把模型下载分成4MB的分片,每片断点续传,失败只重传该分片。另外,首次下载时我会在UI上给出真实的进度百分比而不是转圈动画,用户等待的心理阈值完全不一样。
5.2 Service Worker被休眠导致推理中断
现象:用户切走标签页一分钟再切回来,发现推理任务无响应了。
原因就是MV3的Service Worker被浏览器回收,内存中的模型实例和任务上下文全部清零。我的解法是双层保障。任务执行前,把请求参数持久化到IndexedDB,标注状态为pending;推理完成后更新状态为done。Service Worker每次唤醒先检查有没有pending任务,有就恢复执行而不是重新入队。同时推理任务如果即将超过空闲阈值,就在每段时间发一个keepalive信号,用chrome.alarms周期性唤醒自己检查任务状态。这套机制上线后,中断率直接从15%降到1%以内。
5.3 WebGPU在黑屏和异常之间横跳
现象:部分Windows用户反馈GPU推理时页面白屏,或者扩展弹出后设备丢失。
WebGPU在这两年迭代里稳定性还有瑕疵,特别是驱动的device.lost事件处理不好就会白屏。我的做法是在requestDevice()失败或设备丢失的事件里,自动回退到WASM模式,并弹一个不可关闭的提示条告诉用户“当前设备GPU不可用,已切换到CPU模式”。同时把回退状态写入存储,以后每次初始化都先看上次的GPU状态,避免每次都在同一个坑里反复踩。
5.4 内容脚本拿不到动态渲染页面的DOM
现象:很多SPA页面是异步渲染的,脚本在document_idle时执行,结果页面内容还没生成,提取到的数据是空的。
我踩过这个坑之后改成了两段式提取:先同步执行一次轻量提取,如果拿不到目标节点,就用MutationObserver监听DOM变化,等目标出现后再提取一次。同时给整个提取过程设了5秒超时,超时就放弃,避免在僵尸页面上无限等待。
5.5 内存占用持续增长
现象:扩展用了一段时间后,浏览器变卡,任务管理器里扩展内存直线上升。
大多数情况是模型实例没有正确释放。Transformers.js和ONNX Runtime的WASM内存一旦分配,通常不会自动还给系统,除非你把实例设为null并触发GC。我写了一个主动内存管理策略:推理完成后空闲30秒,就把模型实例置空,只保留权重缓存;下次推理时如果实例不存在,从缓存重新初始化。虽然每次唤醒模型会增加一两秒延迟,但内存暴涨问题彻底解决了,我觉得值。
根据我个人这几轮踩坑经验,浏览器扩展里的端侧AI推理,真正决定成败的不是模型多聪明,而是你有多懂浏览器的“脾气”。Service Worker会杀后台,WebGPU会闹脾气,内存永远不够,你还得时刻想着商店审核的规矩。把这些约束当成系统设计的一部分,而不是事后的补丁,你的扩展就能稳定支撑真实用户场景。后续我打算继续往多浏览器适配和分级模型加载这两个方向深挖,到时候有新经验再回来分享。