
我去年年中接了个活儿做一个浏览器扩展用户划词时调用本地模型快速判断“这段话是不是广告软文”是就标个记号全程不出浏览器、不上传文本。听着不难结果一上来我把 ONNX Runtime Web 塞进 Service Worker 打算直接跑推理随即被 Chrome 教做人——Service Worker 在推理进行到一半时被回收模型加载三次崩一次。后来我把整个架构推倒重来才意识到一个核心问题现代浏览器扩展环境下的端侧 AI 推理从来不是“塞个模型进去”那么简单。它牵扯到扩展生命周期、推理引擎选型、跨上下文通信、内存控制、降级策略等一系列系统架构与工程实现规范问题。这篇文章就打算把这些约束、取舍、踩坑和经验一次性讲透适合想在 Chrome/Edge 扩展里做本地摘要、翻译、敏感信息识别、图像分类、语音转文字等功能的开发者。1. 扩展环境为什么不适合直接跑推理四个真实约束很多人第一次在扩展里跑端侧 AI都是拿普通网页的思路套。普通页面里script加载一个 Transformers.js 或者 ONNX Runtime Webawait一个pipeline()然后推理完事。扩展环境完全不是这样至少有四个约束会直接影响你的架构设计。1.1 Service Worker 生命周期短命且会被随时回收Manifest V3 之后扩展的后台逻辑统一跑在 Service Worker 里。Chrome 对它的策略是“按需唤醒、空闲回收”事件处理完大约 30 秒后浏览器就有权把整个 Worker 杀掉释放内存。如果你在 SW 里直接加载一个几百 MB 的模型加载过程本身要花好几秒甚至十几秒中间只要有一次事件没续住整个进程就被回收下次再进来又得重新加载。而且扩展 Service Worker 里跑长任务Chrome 的节流机制会介入。早期版本 5 秒不处理事件就可能被杀现在虽然放宽到 30 秒但模型推理一个样本可能就要几十秒比如大一点的语音模型你根本没有办法保证它能在 SW 内跑完。结论Service Worker 不是跑推理的地方它是管调度的地方。1.2 上下文隔离扩展不是“一个页面”一个普通扩展同时存在好几种执行环境popup 弹窗、content script 内容脚本、background service worker、option 页面如果主动创建还有 Offscreen Document离屏文档和 Web Worker。它们各有各的 DOM、各有各的全局对象彼此之间只能用postMessage或chrome.runtime.sendMessage通信。这意味着模型只能放在某一个上下文里然后所有“想用 AI 的模块”都得通过消息跟它交互。你没法像普通网页那样一个import完事。上下文之间传数据还有序列化开销Float32Array在某些情况下会被拷贝而不是转移卡起来真要命后面第 6 章详细说。1.3 CSP 和扩展权限限制扩展默认的 Content Security Policy 很严格eval、new Function这类动态执行基本被禁远程代码也默认不允许。很多 AI 库在初始化时喜欢动态生成代码或加载远程 wasm 片段在扩展里会被拦。另外如果你用的是webview或者想跨域拉模型文件都需要在host_permissions里显式声明否则请求直接失败。1.4 内存预算扩展不是浏览器的主人一个扩展的可用内存不是一个常量但浏览器对扩展的预算远没有对普通页面那么大方。尤其是当你同时开着多个标签页、每个页面都注入 content script、每个 content script 又各自持有一份模型时内存瞬间就爆了。端的本质是“用户设备本地跑”但设备资源和浏览器资源都不是无限供应的。所以在扩展里做端侧推理第一件事不是选模型而是先确认边界模型放哪个上下文谁负责加载谁负责推理谁负责卸载谁负责报告失败这些不先定好后面每加一个功能都是在给自己埋雷。2. 推理引擎与模型选型先定路线再写代码有了边界下一步是选路线。我在这个项目里同时对比过几条主流方案最后选了“ONNX Runtime Web 为主、WebGPU 优先、WASM 兜底”的组合。下面把这套选型逻辑拆开讲。2.1 推理后端对比WebGPU、WebNN、WASM 怎么选端侧推理的底层执行主要看后端backend。我把它们放在一起测过一轮结果如下后端速度兼容性内存占用典型场景备注WebGPU快GPU 并行度高Chrome 113、Safari 18Firefox 未稳定支持较高需显存/共享内存中等模型、实时性要求高扩展里需要 Offscreen Document 持有上下文WebNN接入硬件加速器NPU/DirectMLEdge 支持较好仍在新版迭代视设备而定追求能效比、移动端API 还不够稳建议封装一层WASM SIMD/Threads中等CPU 多核可跑几乎所有现代浏览器较高WASM 线性内存小模型、兼容优先无 GPU 时的兜底方案JS 纯计算慢全兼容低几乎不考虑仅做功能验证时用如果你只面向 Chrome/Edge 用户WebGPU 是性价比最高的。注意 WebGPU 有一个很恶心的特性GPU 上下文可能因为驱动重置、系统内存压力、切换分辨率等原因丢失context lost。扩展里一旦发生你的模型权重可能全部作废必须重新初始化。所以架构上必须把“重新初始化”当成一等公民来支持。2.2 运行时库别直接裸调底层 APITransformers.js封装度高HuggingFace 生态的模型基本都能跑适合快速验证。但封装度高也意味着你不太好精细控制生命周期而且它默认从 HF Hub 拉权重扩展离线场景要自定义env.localModelPath。ONNX Runtime Web底层引擎提供InferenceSession可以精细管理模型加载、输入输出、后端切换。适合做生产级扩展缺点是代码量大一些。WebLLM主打 LLM 流式生成走 WebGPU适合做本地对话、文档问答模型体积大内存压力也大。MediaPipe Tasks适合纯视觉或音频任务和文本模型生态割裂。我的选择逻辑很简单如果你的任务是“模型需要灵活换、参数需要调”直接用 ONNX Runtime Web 自己封装一层。如果你要快速出 Demo先用 Transformers.js 验证准确率再在公司里换成 ONNX Runtime Web 做工程化。不推荐先散兵游勇地用一堆库最后每个库各自加载一份 runtime内存直接翻倍。2.3 模型格式与量化q8 是一个甜点模型格式优先选 ONNX。开源生态里很多模型已经转好了如果没有可以用optimum-cli或者onnxruntime工具链把 PyTorch 权重转成 ONNX。参数规模建议控制在 100M-500M 之间扩展场景几乎不要想着跑 7B 级别的模型加载时间和内存开销都不现实。量化级别我做了几组实测精度模型大小以 300M 参数为例速度相对 f32质量损失适用场景f32约 1.2GB基准无基本不适合扩展f16约 600MB相近或略快可忽略GPU 内存充足时可用q8int8约 300MB略慢一些轻微可接受扩展里的甜点选择q4int4约 150MB更快较明显只做粗粒度分类q8 在内存和质量之间最平衡。我做广告识别时从 f16 切到 q8 之后准确率只掉了约 0.3%但内存从 620MB 降到 330MB体感非常明显。另一个细节模型输出也要看是 Dense 还是 Sparse。分类任务输出一个向量没问题但如果是抽取式 QA、序列标注输出尺寸和输入序列长度挂钩你需要把模型设计成固定最大长度比如max_seq_len512否则动态 shape 会让扩展的内存峰值不可控。2.4 模型从哪来本地打包还是远程拉取扩展有一个很现实的问题Chrome Web Store 对包体有要求模型大了之后必须考虑远程获取。我的方案是“小模型随扩展包走大模型远程按需下载”。随包走模型文件放在models/目录用chrome.runtime.getURL()拿地址。离线可用安装即用体验最好。远程拉取放自己的对象存储或 GitHub Releases第一次用得等下载。这里必须做两个事一是SHA-256 校验防止文件损坏或被篡改二是版本管理模型文件要带版本号扩展更新时不能因为缓存导致新旧模型混用。提示远程模型千万别裸用 CDN万一你改版了模型或 CDN 回源有问题用户端的推理结果会变得不可复现。至少加一层version字段和校验和。3. 扩展应用架构三层解耦 消息协议设计选定引擎后我把扩展架构明确分成三层这也是目前我觉得最适合扩展环境的模式。3.1 三层职责划分表现层Popup / Content Script / 页面 UI只负责用户交互和页面 DOM 处理。Content Script 挖到文本后封装成一个“任务”发给后台不在自己这里加载模型。控制层Service Worker负责生命周期管理、消息路由、任务队列、模型版本管理。它不跑推理但决定“什么时候加载 Offscreen Document”“什么时候卸载”。执行层Offscreen Document Worker真正的推理温床。Offscreen Document 负责持有 WebGPU 上下文和 UI 无关的 DOM 能力Worker 负责跑 ONNX Runtime 的 CPU/GPU 计算。为什么不直接让 Offscreen Document 跑因为推理循环里如果有长时间同步计算会卡住 Offscreen Document 的事件循环导致它无法响应心跳消息。多套一层 Worker能保证生命周期诊断和管理通道永远畅通。数据流大概是这样Content Script 捕获到用户划词 → 构造任务{taskId, type, text}→ 发给 Service Worker → SW 检查执行层是否就绪 → 就绪后将任务转发给 Offscreen Document → Offscreen Document 把结果回传 → 最终原路返回给 Content Script 渲染标记。3.2 消息协议给任务一个“生命周期”扩展里最容易乱的是消息满天飞每个模块各写各的。我建议把所有推理消息统一成一个带状态的协议任务有pending、running、done、failed四个状态。每个请求带唯一的requestId任何一层都只管处理对应状态的事件。这样出错时能快速定位是“没发出去”“被 SW 丢弃”“GPU 初始化失败”还是“模型结果异常”。对于流式推理比如本地生成式摘要协议要支持推送不能让调用方干等一个Promise挂半小时。用事件通道inference:progress、inference:complete、inference:error。这一层做好之后后续加语音识别、图像分类等功能都只是加任务类型不用重写通信。3.3 关键代码Offscreen Document 的创建与 Worker 挂载Offscreen Document 不是普通页面得有理由才能创建。Chrome 在后续版本里新增了chrome.offscreen.Reason.WORKERS正好可以用于挂载 Worker 跑推理。// background/service_worker.ts import { Reason } from chrome-types; async function ensureOffscreenDocument() { const existing await chrome.offscreen.hasDocument(); if (existing) return; await chrome.offscreen.createDocument({ url: offscreen.html, reasons: [Reason.WORKERS], justification: Host the on-device inference worker and WebGPU context, }); }Offscreen Document 内部的 Worker 初始化逻辑// offscreen.ts const worker new Worker(inference-worker.js, { type: module }); worker.onmessage (event) { const { requestId, status, payload } event.data; if (status ready) { postMessage({ type: offscreen-ready }); } // 将结果原路转发回 Service Worker chrome.runtime.sendMessage({ requestId, status, payload }); };这里有个细节Offscreen Document 和 Service Worker 之间通信优先用postMessage而不是chrome.runtime.sendMessage因为 runtime 消息要过浏览器内部序列化延迟高、对大数据量不友好。如果你的执行层在 Offscreen Document 里SW 可以直接postMessage给document的引用链路短一截。4. 模型生命周期管理与内存控制的实战策略生命周期问题在普通网页里几乎不用考虑——页面关了模型自然释放。扩展里不行SW 会被回收、Offscreen Document 可能被浏览器判定为“无用途”而关闭、GPU 上下文可能丢失。所以必须主动设计。4.1 加载策略懒加载 预热模型不要一上来就加载扩展安装后立刻加载 300MB 模型用户会明显感觉到浏览器变卡严重时直接被 Chrome 标记为“拖慢启动”。我采用的策略是首次触发才加载用户第一次实际触发推理任务时SW 检查执行层状态没有就创建 Offscreen Document、加载模型。加载期间任务排队把并发任务放进 FIFO 队列加载完成后再逐个执行避免在加载过程中又来一次请求导致二次初始化。高频场景预热如果是翻译、摘要这类高频功能用户首次交互后立即在后台加载模型不等待用户真正提交内容。预热的时机选在浏览器空闲时段用requestIdleCallback或 SW 里监听访问历史后延迟 5 秒触发。4.2 卸载策略不使用的模型就是负债模型加载后如果不卸载一个 300MB 的 q8 模型会一直占着内存用户多开几个标签页直接卡爆。需要设计两个层面的卸载模型级卸载连续 10 分钟没有推理请求就session.release()释放模型。再触发时重新加载。执行层卸载模型释放后关闭 Offscreen Document 和 Worker让 WebGPU 上下文、WASM 内存彻底还给系统。听起来简单真正难的是“什么时候判定不使用了”。浏览器没有直接给扩展一个“用户空闲”的完美信号。我给了一个折中方案以最后一次推理完成时间为基准超过阈值就降级到“未初始化”状态。下次再推理时用户会多等一次加载但换来的是日常使用的流畅。提示如果你做了自动卸载一定要在 UI 上给用户可见的状态。我就遇到过一个 bug用户第二天回来第一次点按钮界面白转了 8 秒因为模型在卸载后重新加载。后来我在 popup 上加了一个“首次加载可能稍慢”的提示就没人抱怨了。4.3 推理并发与 Worker 池端侧模型尤其是跑在 GPU 上的并发数是 1 最好。同一个 WebGPU 上下文里两个推理请求同时跑要么排队要么互相抢占显存没有任何收益。我的做法是 SW 里维护一个全局任务队列执行层永远只处理当前一个任务。推理中如果有新请求进来直接排队。如果你的扩展要同时跑两个不同类型的小模型比如文本分类 关键词提取可以考虑维护两个 Worker 实例各自加载一个小模型。但记住不要为每个任务都 new 一个 WorkerWorker 的启动成本和模型加载成本都比任务本身贵得多。4.4 内存观测与泄漏排查扩展里查内存泄漏非常痛苦因为你不能像普通页面那样打开 DevTools 的 Memory 面板看每个堆快照。我现在用的方式定期采样performance.memory.usedJSHeapSizeChrome 支持每秒记一次存到内存环形队列里。推理前后对比每次推理完成记录模型内存快照和推理后内存快照如果峰值持续增长而不是回到基线说明有泄漏。监听异常事件webgpu context lost、worker error都要上报很多时候泄漏不是 JS 的问题是 GPU 上下文没释放。我用这套方法抓住过一个真泄漏最初我在每次推理后都主动session.release()但 WebGPU 的 pipeline cache 没有被释放连续推理 20 次后显存占用爬升了 200MB。后来改成“保留 session、释放输入输出 Tensor”并且每隔 30 分钟重建一次执行层内存曲线才算稳定。5. 工程实现规范构建、打包、降级与测试架构和生命周期定好之后剩下的就是工程实现规范。这一章偏向“工业化”但恰恰是很多人做扩展 AI 时最容易忽略的部分。5.1 推荐目录结构我用 Vite TypeScript wxt 搭建目录最终长这样extension-ai/ ├── src/ │ ├── entrypoints/ │ │ ├── background/ │ │ │ └── index.ts # Service Worker 控制层 │ │ ├── content/ │ │ │ └── index.tsx # 页面内容提取与标记 │ │ ├── popup/ │ │ │ └── index.tsx # 用户面板 │ │ └── offscreen/ │ │ ├── index.html │ │ ├── offscreen.ts # Offscreen Document 宿主 │ │ └── worker.ts # 推理 Worker │ ├── core/ │ │ ├── types.ts # 任务协议类型 │ │ ├── message.ts # 消息路由工具 │ │ └── taskQueue.ts # 任务队列 │ ├── engines/ │ │ ├── onnx.ts # ONNX Runtime Web 封装 │ │ └── fallback.ts # WASM 降级路径 │ └── models/ │ ├── ad-classifier-q8.onnx │ └── tokenizer.json ├── wxt.config.ts ├── package.json └── tsconfig.json最关键的是core/types.ts和core/message.ts。它们定义了任务的协议边界所有入口文件都从这两个模块导消息类型避免在 SW、Content、Popup、Offscreen 四个地方各自维护一份消息结构。5.2 构建与打包的几个坑WASM 文件要单独处理ONNX Runtime Web 的.wasm文件在构建时容易被打包器忽略。需要在构建配置里把ort-wasm-simd-threaded.jsep.wasm这类文件复制到最终产物目录否则运行时 404。不依赖远程 CDN扩展的 CSP 默认不允许远程脚本而且端侧推理本来就有离线场景。把 runtime 的 wasm 和模型全部打成本地文件。包体超限怎么办Chrome Web Store 对扩展包体有限制模型过大时建议拆分成“核心扩展几 MB 模型按需下载包”的方式。远程下载模块要做断点续传和完整性校验。5.3 降级与兼容WebGPU 不可用时的优雅回退你永远不知道用户用的是什么年代的设备。有些 Windows 老机器 WebGPU 支持很差甚至扩展跑在 Chrome 112 及以下版本。所以必须设计降级链。我的降级链是首选 WebGPUort.env.backendFlags开启webgpu。如果初始化失败或 context lost 多次切到 WASM SIMD Threads。如果 WASM 也失败比如老设备内存不足干脆禁用 AI 功能只在 UI 上告诉用户“当前设备不支持本地推理”。降级不能光靠 catch 错误要主动做特性检测async function detectCapability() { // 1. 检测 WebGPU if (navigator.gpu) { const adapter await navigator.gpu.requestAdapter(); if (adapter) return webgpu; } // 2. 检测是否支持 WebAssembly threads if (WebAssembly.SIMD) return wasm; return unsupported; }注意navigator.gpu存在不意味着真的能用requestAdapter()可能返回 null。这个检测结果要缓存到chrome.storage.session每次扩展启动或 Offscreen 重启后重新检查一次不要每次推理都查。5.4 测试与监控验证正确性比性能更重要端侧推理在扩展里有个隐性问题相同模型在不同后端上输出可能有微小差异。WebGPU 的算子实现和 WASM 不同float 累加顺序不同最终 logits 可能有细微差别。这不影响分类但对置信度阈值敏感的任务影响就会显现。我的测试规范是差异性测试同一组输入分别跑 WASM当基准和 WebGPU对比输出向量最大误差。如果误差超过 1e-2说明 GPU 实现有问题要换算子配置。集成测试用 Playwright 加载扩展模拟真实用户划词断言最终 DOM 标记是否出现。内存回归每次发版前跑一轮“连续 50 次推理”的自动化脚本观察内存是否符合基线曲线。监控方面我把推理耗时、后端类型、模型版本、失败原因用chrome.storage.local做成环形日志只保留最近 200 条。用户反馈“AI 不好用”时我能远程拉这些日志定位而不是靠猜。6. 我在扩展里跑模型踩过的三个坑完整排查链路最后分享三个印象最深的坑。每个都不是文档里直接写清楚的排查过程也很有代表性。6.1 Service Worker 被杀推理任务直接消失现象功能偶尔可用偶尔不可用。用户反馈“点了一下没反应再点一下就好了”。我本地用 DevTools 调试时永远复现不了因为 DevTools 打开时 SW 不会被杀。排查过程先在 SW 里加日志记录所有事件的时间线。发现推理任务发出后没有任何inference:done或inference:error事件。怀疑是消息丢了检查chrome.runtime.sendMessage的返回Promise发现一直 pending。最后在 SW 里加console.log配合chrome://serviceworker-internals看进程状态发现进程在推理开始后约 20 秒被杀。根因当时我把模型加载和推理全写在 SW 里占用的 CPU 时间太长Chrome 判定 SW 没有在合理时间内完成事件处理强制回收。解决把所有推理逻辑迁到 Offscreen Document Worker。迁移后问题从“随机失效”变成“可预期加载时长”再也没被 SW 回收。6.2 Offscreen Document 的 WebGPU 上下文丢失现象用户全屏看视频时扩展推理会偶发报错。重启浏览器后恢复过一阵又出现。排查过程错误信息只有WebGPU context lost没有堆栈。最初以为是代码里 WebGPU 上下文创建参数不对反复尝试不同powerPreference无效。加了一个contextlost事件监听发现事件触发在用户切换显示器分辨率之后。同时注意到很多设备的内存压力过大时浏览器会主动重置 GPU 上下文。根因WebGPU 上下文是一个脆弱的资源系统层面显卡驱动重置、显存不足和应用层面都可能让 GPU 进程重启。扩展必须把它当“会随时断开的连接”来对待。解决设计“恢复”机制——监听contextlost一旦触发释放旧的 Offscreen Document重新创建执行层重新加载模型。当前正在跑的任务标记为failed并返回给上层“请重试一次”。const canvas document.createElement(canvas); const adapter await navigator.gpu.requestAdapter(); const context canvas.getContext(webgpu); context.addEventListener(contextlost, (event) { event.preventDefault(); onContextLost(); // 重建执行层 });经验以后凡是涉及 WebGPU 的扩展功能第一版就要把恢复逻辑写进去因为你永远不知道用户会在什么样的情况下使用你的扩展。6.3 大数组跨上下文传递导致页面卡顿现象Content Script 做文本处理时页面滚动明显变卡尤其是选中大段文字后。排查过程单独测推理 Worker 本身的耗时完全正常。在 Content Script 里测postMessage之后的耗时发现发送一个 20000 维的浮点数组到后台竟然卡了 800ms。查 MDN 才发现postMessage的第二个参数如果没用transfer浏览器会对数据进行结构化克隆structured clone相当于深拷贝。数据越大越慢而且在大页面主线程上执行直接卡 DOM。根因我的 Content Script 拿到了模型输出的完整Float32Array然后原样传回 Content Script 做可视化。这个传输过程是拷贝不转移。解决两个优化。第一推理结果只返回 Top-K 结果比如 Top 5 分类标签而不是全量向量数据量从 20000 降到 5。第二确实需要回传大数组时使用transfer转移所有权worker.postMessage({ requestId, data: floatArray.buffer }, [floatArray.buffer]);经验扩展架构里能少传数据就少传数据。跨上下文的每一条数据都有真实成本不只是网络带宽那种成本是同时占用两个上下文内存和主线程时间的成本。设计消息协议时第一原则是“最小载荷”。最后的体会从前面的踩坑经验里我自己的体会是在扩展里做端侧 AI本质上是把“模型服务”的整套运维问题搬到浏览器里重做一遍——生命周期、健康检查、版本管理、降级、监控一个都不能少。前期多花一天把架构边界画清楚后期至少省一周排查疑难杂症。另外一个小技巧模型版本和扩展版本千万不要绑死模型单独带版本号出现问题时先把模型回滚到旧版本而不是逼用户升级扩展。这套架构后来我又复用到了本地摘要、关键词提取、违规内容标记等多个功能上基本只写任务处理逻辑不重做基础设施。如果你正准备在扩展里上端侧模型建议照着这个思路先画清楚边界、定义消息、写死降级链再碰模型代码。