
在浏览器扩展里跑端侧 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 架构的 ChromebookGPU 能力从核显到独显差异巨大。这意味着你的系统架构必须考虑降级路径支持 WebGPU 的走 GPU 加速不支持的自动回退到 WASM 多线程再不行就单线程慢慢推。这个降级设计不是可选项而是架构的一部分。1.3 当前最现实的技术选型聊技术选型之前先泼一盆冷水目前想在扩展里跑模型真正成熟的路子并不多。TF.js 在浏览器里做训练和推理都活跃但它的扩展兼容性不如 ONNX Runtime WebWebLLM 这类专项方案体验好但受限于模型格式和硬件适配。我实际测试下来最稳妥的组合是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 DocumentManifest 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消息传递时明确 transferJSON 数据则保持轻量能传索引就不要传全文。推理的输入输出也尽量用 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 分别打出三个 chunkManifest 里声明各自入口互不干扰。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: Promisevoid | 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[]): PromiseFloat32Array { 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里定义好的命令字枚举禁止自由字符串。这样才能支持命令字版本兼容否则将来加一个参数新旧版本扩展对接就会出错。完整的错误码体系每个失败路径有确定的错误码和降级动作。比如模型加载失败回退 WASMWASM 也失败就给用户明确的“当前设备不支持”提示而不是一团静默。日志分三级生产环境默认只打 INFO 和 ERRORDEBUG 日志放在独立 logger 里通过环境变量开启。扩展出问题后用户导出的日志不会像火车票一样长。这套规范的价值在扩展更新迭代时体现得最充分。没有规范的时候改一次模型加载逻辑你可能要顺着调用链改五个文件有规范之后改一个loadModel函数的内部实现其余代码完全不动。5. 常见问题与排查技巧实录5.1 高频问题速查表我在多次集成测试和用户反馈里收集了一批高频问题整理成速查表遇到类似症状可以直接对照现象根本原因解决方案推理跑到一半被中断Service Worker 被回收推理逻辑移到 Offscreen Document页面滚动严重掉帧Content Script 里做了重计算计算全部迁移到 Offscreen只留 DOM 操作WebGPU 不在执行提供程序列表浏览器版本过旧或未开启硬件加速检测navigator.gpu不支持则回退 WASM模型加载报 404Manifest 缺少 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 推理在浏览器扩展里跑通真正难的从来不是模型部署而是把一个完整系统的每一层都安排明白。希望这篇文章能让你少走几周弯路。