ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

浏览器扩展内的端侧AI推理:多上下文架构设计与工程规范

浏览器扩展内的端侧AI推理:多上下文架构设计与工程规范 从浏览器“小工具”到“本地推理宿主”这是这两年很值得关注的一条技术路径。现代浏览器扩展环境已经不只是用来改页面样式、拦截请求而是能直接承载端侧 AI 推理的完整运行时。把模型放到扩展里本地执行不传数据、不依赖云服务在隐私敏感的场景里优势非常明显。但这套东西的系统架构和工程实现规范远比想象中复杂MV3 的 Service Worker 生命周期、Offscreen Document 的上下文隔离、WebGPU 的可用性判断、WASM 的加载与 CSP 限制任何一个环节没想清楚项目都会陷入调试地狱。这篇内容我尽量用实际踩坑的视角把现代浏览器扩展环境下做端侧 AI 推理的架构设计和工程规范完整拆一遍适合正在做效率工具、内容分析插件或者想把 AI 能力低成本嵌入浏览器的开发者参考。先解释清楚一个根本问题为什么要把 AI 推理放到扩展里做很多人第一反应是“浏览器里跑模型性能肯定不行”。这话放在五年前成立但现在 WebGPU 已经稳定WASM 性能逼近原生再加上量化模型的普及端侧推理的可用性已经跨越了临界点。更重要的是扩展这种宿主形态有天然的“伴随性”——它就在用户浏览上下文里能拿到当前页面的内容、选中文本、图片 URL这种上下文接入能力是独立 App 无法比拟的。你可以在用户右键菜单里直接加一个“本地总结”点一下扩展在后台加载模型、跑推理、把结果回填到浮层全程数据不出设备。这个体验和“复制内容→打开某个工具→粘贴→等云端返回”完全不是一个量级。1. 端侧 AI 与浏览器扩展的结合点1.1 从“扩展工具”到“推理宿主”的形态变化传统上浏览器扩展的角色是“操作浏览器和网页”去广告、改样式、抓取信息、模拟点击。它主要处理的是 DOM 和数据流几乎不涉及计算密集型的任务。但端侧 AI 推理引入后扩展的角色发生了一个根本性变化——它变成了一个“局部小型的推理服务端”。一个典型的场景就能说明这个变化你在看一篇英文论文想选中一段做本地翻译或者术语解释。以前的做法是调用在线翻译 API把文本发送到服务器结果再传回来。现在换成端侧方案后扩展加载一个 100MB 左右的量化翻译模型在本地声明的 Offscreen Document 里创建推理会话。用户选中文本后扩展从页面上下文拿到选区内容转发给后台脚本后台脚本再把任务路由给推理会话推理完成后原路返回。整个过程用户无感断网也能运行而且没有任何中间服务器隐私边界非常清晰。这背后不是一个简单的 API 调用而是一套完整的小型系统有任务入口右键菜单、快捷键、页面按钮、有消息路由不同上下文之间的通信、有计算资源管理什么时候加载模型、什么时候释放、有任务队列避免并发推理把内存打爆、有错误回退GPU 不可用时切到 CPU。这套系统的架构设计和工程实现规范就是我下面重点拆解的内容。1.2 端侧推理相比云端方案的核心收益我在实际项目里最直接的感受是三个字不依赖。不依赖网络、不依赖服务可用性、不依赖用户是否登录。云端推理方案看起来实现简单但一旦推广到真实用户环境问题会接踵而至API Key 的管理、计费、限流、跨国网络延迟、数据合规声明。而端侧推理把这些全部绕开了。从工程指标看端侧推理在延迟上也有优势。单次推理在 WebGPU 加速下一个轻量分类模型通常在几十毫秒到一两百毫秒内完成这涵盖了数据预处理和后处理。而云端方案哪怕网络状况很好一次往返也要 300ms 以上如果模型比较大首次推理还要排队冷启动。当然端侧方案的劣势也很明显模型要打包进扩展里体积会增大不同用户的硬件差异大低端设备上的推理时间可能差好几倍。所以架构设计必须把“可用性”和“可回退”作为第一原则——这才是工程实现规范的价值所在。2. 系统架构设计的核心拆解2.1 MV3 多上下文模型每个 Worker 都有自己的职责Chrome 扩展的架构基础是多上下文隔离不同上下文有各自的权限边界和生命周期。在 Manifest V3MV3下主要上下文包括background Service Worker、Content Script、Popup 页面、Options 页面以及一个经常被忽略但非常重要的 Offscreen Document。Service Worker 是扩展的大脑但它有几个硬性限制无法直接访问 DOM、生命周期受事件驱动、空闲时会被浏览器休眠。Content Script 运行在网页上下文里可以操作 DOM但它拿不到扩展完整 API且受页面 CSP 影响。Popup 和 Options 页面的生命周期非常短用户一关闭就销毁。真正适合承载大量计算任务的是 Offscreen Document——这是 Chrome 专门为扩展提供的“隐藏页面”它有完整的 DOM 和 Canvas 访问能力也支持 WebGPU、Audio、Clipboard 等 SW 中不可用的 API生命周期可以由扩展自己控制受创建理由限制。所以架构的第一个决策就是把“推理引擎”放到 Offscreen Document 里而不是直接塞进 Service Worker。理由很实际WebGPU 需要 DOM 元素或 GPU 设备上下文Service Worker 里根本获取不到WASM 的某些能力也受 SW 环境限制。把推理引擎单独放进 Offscreen还能把松耦合做出来——推理模块可以独立加载、独立销毁不污染主流程。2.2 推理任务应该放在哪个上下文执行我在很多讨论里看到有人问“能不能直接在 Content Script 里跑模型”。技术上可以但工程上非常不建议。Content Script 和网页共享渲染进程执行推理这种耗时的计算会直接卡住页面轻则滚动掉帧重则触发浏览器的“页面无响应”机制。更严重的是Content Script 的安全上下文受网页环境影响一个大模型推理会话如果被恶意网页探测到可能造成攻击面扩大。比较合理的分工是页面交互在 Content Script 或 Popup任务编排和路由在 Service Worker真正的推理执行在 Offscreen Document。这样Content Script 只负责收集输入和渲染输出Service Worker 只做消息转发和状态管理Offscreen Document 只做模型加载和推理。三个角色各管一块问题的边界也变得非常清晰——推理出错了只需要看 Offscreen Document 的日志不需要在页面上下文里到处排查。2.3 消息传递与任务编排别把推理跑成阻塞调用扩展的多上下文架构带来的副作用是通信变复杂了。从 Content Script 发消息到 Offscreen Document不能直接调用中间必须经过 Service Worker。而且在实际场景里同一个模型可能同时被多个入口请求比如用户快速选中了十次文本或者同时触发了图片检测和文本摘要。如果每次请求都直接创建新的推理会话内存会立刻爆炸。所以我设计了轻量级的任务编排层所有推理请求统一到后台的路由器路由器维护一个任务队列按顺序把任务派发给 Offscreen Document 的推理引擎。单个任务的执行流程包括解析请求类型和数据、预处理转张量、归一化、推理、后处理解码、映射、结果结构化返回。整个调用链是异步的用消息事件驱动不阻塞任何 UI 操作。这里有一个很关键的原则请求要有幂等性设计。同一个任务如果因为超时被重试不能让推理引擎创建重复会话也不能让结果重复返回。我的做法是给每个请求分配一个唯一的 taskIdOffscreen 推理引擎检测到相同 taskId 时会直接丢弃重复请求。这个细节在大并发场景下非常有用。2.4 内存与生命周期管理扩展的内存限制不是硬性的但浏览器对单个扩展的驻留内存有隐性惩罚长期高占用会导致进程频繁被回收。模型加载进来后往往占总内存的大头尤其是加载 float32 原始模型时一个 200MB 的模型在运行时可能要扩展成 800MB 的中间表示。生命周期管理上我建议按需创建和销毁 Offscreen Document。Chrome 的限制是“同一时刻只能存在一个 Offscreen Document”创建时机必须匹配特定理由。常用的做法是在扩展启动或首次推理前创建 Offscreen在模型闲置超过一定时间后主动关闭释放 GPU 和内存资源。但要注意Offscreen 的关闭不能太激进——频繁重建模型会话的耗时比推理本身还长反而得不偿失。我一般把闲置阈值设在 35 分钟实测下来能在资源占用和冷启动延迟之间取得平衡。3. 端侧推理引擎选型与模型部署细节3.1 主流推理引擎对比ONNX Runtime Web、Transformers.js 与原生 WebGPU端侧推理引擎的选择直接决定了开发效率的上限。目前主流的方案有三个方向ONNX Runtime Web、Transformers.js、直接写 WebGPU 计算管线。三个我都实际用过各有优劣。ONNX Runtime Web 是微软开源的把 ONNX 格式的模型跑在浏览器里底层支持 WASM 和 WebGPU 后端。它最大的优势是生态成熟PyTorch 导出的模型几乎可以无缝转换而且算子覆盖度高遇到不支持的算子时还有清晰的错误提示。Transformers.js 是建立在 ONNX Runtime Web 之上的高层封装对 HuggingFace 生态极度友好加载模型只需要一行代码适合做文本分类、摘要、向量嵌入这类场景。缺点是抽象层比较厚自定义预处理和后处理时需要往下钻到原生的 ORT API。直接写 WebGPU 计算管线适合有 GPU 编程经验的团队性能和可控性最高但工作量也最高。除非你要跑的模型非常固定且简单否则我建议不要一开始就走这条路。多数项目用 ONNX Runtime Web 或者 Transformers.js 就能覆盖 90% 的需求。3.2 WebGPU 与 WASM硬件加速的取舍与回退选型之后要解决的是执行后端的问题。Chrome 和 Edge 从 113 版本开始默认支持 WebGPU但用户的浏览器版本、系统 GPU、驱动状态都不同不能假设 WebGPU 一定可用。工程上必须做可用性检测并提供 WASM 回退路径。检测代码比较简单判断navigator.gpu是否存在即可但真正的坑在于即使navigator.gpu存在请求适配器时也可能返回 null这种情况多见于无 GPU 的虚拟机、远程桌面会话、显卡驱动异常。我的策略是分层回退先尝试用 WebGPU 创建推理会话如果失败把 execution provider 降级为 WASM。这里的细节是同一个 ONNX 模型在 WebGPU 和 WASM 下的预处理可能不一样比如归一化参数、张量排布需要做一层抽象。回退不能只做一次——如果用户中途切换了显卡策略或者浏览器更新导致 GPU 上下文重建推理会话可能失效。此时需要监听 WebGPU 上下文丢失事件在后台重新创建会话。3.3 模型量化与加载策略模型体积是端侧部署的另一道坎。以 MiniLM 文本嵌入模型为例float32 版本约 90MB而 int8 量化后能压到 25MB 左右推理速度还有可能提升。量化是目前端侧推理必然要做的步骤。工具链上我习惯在 PyTorch 里做动态量化或者静态量化导出 ONNX 时开启 opset 17然后直接用onnxruntime的量化 API 在 Python 侧完成。模型加载也有两种路径一种是把模型文件直接打包进扩展目录用户安装扩展时模型就存在本地另一种是首次使用时从远程下载缓存到扩展私有存储。前者安装包太大不利于快速迭代后者有首次加载延迟和断点续传的问题。我推荐的折中方案是默认把主模型打包进扩展把可选的增强模型比如更大更精准的版本做成按需下载并且始终用 IndexedDB 缓存下载结果。下载时用分块读取加断点记录避免网络中断后从头再来。3.4 manifest 配置与 CSP 的特殊约束扩展的 manifest.json 和页面 CSP 设置比普通 Web 应用严格得多。MV3 要求所有脚本必须是静态引用禁止 eval 和动态执行字符串。这意味着 ONNX Runtime Web 的 WASM 文件必须放在扩展包内引用不能从 CDN 动态加载。我遇到过一个典型的坑把 ort.wasm.wasmPaths 指向了 CDN结果浏览器直接拒绝加载报错信息是 “Refused to load the script ... because it violates Content Security Policy directive”。正确的做法是把 wasm 文件拷贝到扩展目录例如vendor/wasm/然后在代码里设置ort.env.wasm.wasmPaths chrome.runtime.getURL(vendor/wasm/)。同时如果模型需要从远程下载你还需要在manifest.json里面配置 hosts 权限或者使用后台 Service Worker 执行 fetch避开页面 CSP 的约束。CSP 的extension_pages段需要添加wasm-unsafe-eval否则 WASM 初始化会被浏览器拦截。这些都是不跑通一遍很难发现的细节。4. 实操过程搭建一个端侧图片分类扩展4.1 整体文件结构与 manifest 配置理论部分聊完落到实际工程。我以一个“右键图片→本地分类”的例子来演示全流程。整个扩展的结构分四块后台 Service Worker 负责路由、Offscreen 页面负责推理、Content Script 负责页面交互、静态资源存放模型和 WASM 文件。my-extension/ ├── manifest.json ├── background.js ├── offscreen/ │ ├── offscreen.html │ └── offscreen.js ├── content/ │ └── content.js ├── vendor/ │ ├── ort.min.js │ └── wasm/ └── models/ └── image_classifier.onnxmanifest.json 的配置要点有三个声明offscreen权限、后台 Service Worker 使用 module 类型、设置 extension_pages 的 CSP。offscreen权限必须在 manifest 里显式声明Service Worker 里才能调用chrome.offscreen.createDocument。CSP 那段要特别注意必须允许 WASM 才能让 ONNX Runtime 正常跑 WASM 后端。4.2 在 Offscreen 页面初始化推理引擎Offscreen 页面和普通页面一样有 HTML 文件但不可见。初始化逻辑写在offscreen.js里核心代码是加载 ONNX Runtime Web、指定 wasm 路径、读取模型文件、创建推理会话。初始化时机要慎重。Chrome 不允许随意创建 Offscreen Document官方限定了几种理由。我的做法是在 Service Worker 收到首次推理请求时触发创建使用DISPLAY_MEDIA或DOM_PARSER作为 reason。创建完成后通知 Offscreen 执行初始化初始化和首次推理之间用消息事件串起来。冷启动流程大概是用户点击右键菜单→后台创建 Offscreen→加载 wasm→读取模型→创建会话→返回可用状态。这一整套流程首次大约耗时 38 秒后续就很快了。// offscreen.js 核心片段 import * as ort from ../vendor/ort.min.js; ort.env.wasm.wasmPaths chrome.runtime.getURL(vendor/wasm/); const modelUrl chrome.runtime.getURL(models/image_classifier.onnx); let session null; const inputName input; const outputName output; async function initModel() { const response await fetch(modelUrl); const modelBuffer await response.arrayBuffer(); session await ort.InferenceSession.create(modelBuffer, { executionProviders: [webgpu, wasm], }); }4.3 消息协议设计与调用链多上下文之间通信需要一个稳定的协议。我按类型把消息分成INIT_MODEL、INFER_REQUEST、INFER_RESPONSE、TASK_ERROR、PING几类。每个请求都带taskId和type响应也带taskId和status。协议格式统一后Service Worker 路由逻辑会非常干净只是透传不改数据。调用链完整走一遍是这样用户在页面右键点击图片Content Script 监听到了contextMenus.onClicked事件在后台注册事件Content 与后台通过 message 通信获取图片的 blob URL转成 ArrayBuffer通过chrome.runtime.sendMessage发给 Service Worker。Service Worker 收到后检查 Offscreen 是否存在如果不存在就创建然后向 Offscreen 发送INFER_REQUEST。Offscreen 收到消息后把图片 ArrayBuffer 转成张量执行 session.run把输出映射成分类标签通过chrome.runtime.sendMessage返回给 Service Worker再由 Service Worker 转发回 Content ScriptContent Script 在页面浮层里显示结果。4.4 任务队列、超时与异常处理没有任务队列的扩展在用户连续操作时会出大问题。用户可能同时选了三张图、点了两次“总结”这些请求如果同时进入推理引擎WebGPU 的session.run会互相抢占导致延迟急剧上升甚至资源泄漏。我用一个简单的 Promise 链把推理任务串行化确保任意时刻只有一个 session.run 在执行。队列之外还要有超时机制。模型推理本身可能因为 GPU 繁忙卡住不能无限等待。我给每个推理任务设置了 15 秒的超时上限超时后把任务标记为失败并通知 Service Worker 重建会话。异常处理不能只放在 Offscreen 层Service Worker 和 Content Script 也要有兜底——比如 Content Script 收到错误后要清掉页面上的浮层给用户一个明确的提示而不是让界面僵死。4.5 完整链路从右键点击到分类结果展示实际操作时调试这种多上下文扩展比较痛苦。我的经验是把日志分级Content Script 层用console.info输出交互事件Service Worker 层用console.warn输出路由状态Offscreen 推理层用console.error输出模型执行错误。浏览器扩展后台页面可以分别查看各部分日志但 Service Worker 的日志偶尔会被清空所以关键链路事件我还会写入 chrome.storage.session方便事后追溯。单次推理的性能数字也值得关注。用 WebGPU 跑一个 MobileNet 级别的图像分类模型输入 224×224预处理约 20ms模型推理约 30ms后处理约 1ms全链路从收到消息到返回结果稳定在 60ms 以内。这个数字如果用 WASM 回退会放大到 200ms 左右但仍在可接受范围。对比云端一张图片的往返至少 400ms端侧体验有明显优势。5. 常见问题与排查技巧实录5.1 典型问题速查表问题症状根因解决方案WASM 加载失败控制台出现 CSP violationextension_pages 未加wasm-unsafe-eval修改 manifest CSP 配置Offscreen 创建失败报 “Only a single offscreen document may be created”前一个 Offscreen 未关闭创建前先 close或检查关闭结果WebGPU 不可用session.run 报 GPU 相关错误系统无 GPU / 驱动异常 / 浏览器版本旧检测navigator.gpu做 WASM 回退模型加载慢首次冷启动 10 秒以上大模型直接从扩展目录读取缓存到 IndexedDB二次启动走本地缓存Content Script 拿不到结果浮层无响应控制台无日志消息转发链路中断检查 Service Worker 生命周期避免休眠并发推理卡死多次快速请求后无响应推理会话被并发占用引入串行任务队列5.2 WebGPU 上下文丢失与降级恢复WebGPU 的稳定性比我们期望的要差一些。实测中发现Remote Desktop 连接、显卡切换、休眠唤醒后GPU 设备会失效推理会话直接抛错。这个问题只靠检测navigator.gpu是不够的因为初始化时检查是正常的运行一段时间后才出问题。我的处理方式是两层第一层是在创建会话时记录 execution provider 类型错误发生后判断是否因为 GPU 失效第二层是主动做销毁重建如果 WebGPU 连续重试两次都失败直接降级为 WASM并且记住这个状态后续不再尝试 WebGPU。降级的代价是推理时间变长但至少功能可用这符合端侧算力的“服务降级不中断”原则。5.3 模型加载卡住与缓存一致性模型文件如果放在扩展包里安装时就会被浏览器处理不存在网络问题。但一个 100MB 的模型会让扩展包无比臃肿而且每次更新扩展版本用户都要重新下载整个安装包。所以实际项目里我更倾向“小壳模型外置”的策略扩展本体只保留一个轻量评估模型完整模型首次使用时从自己的服务器或对象存储下载存入 IndexedDB。这样做引出了一致性问题模型在服务端更新了扩展缓存里的旧模型不能自动失效。我给模型加版本号和哈希校验每次初始化时查一下本地缓存记录如果版本落后就重新下载。下载过程中还有“用户停止操作让后台休眠”的场景所以要实现断点分块下载在 Service Worker 的storage.session里记录已下载的字节偏移被唤醒后继续。5.4 跨浏览器兼容性注意事项Chrome 的完整 API 体系在 Firefox 和 Safari 里并不通用。Firefox 目前对 Offscreen Document 的支持有限Safari 的 WebGPU 还在实验阶段。如果你的目标用户不完全在 Chrome/Edge 上工程上必须做特性检测和功能降级。我的方案是维护一个能力矩阵支持 Offscreen WebGPU 的走完整推理路径只支持后台 Service Worker 的走 WASM 推理路径什么都不支持的扩展可以照常安装但设置页里明确显示“当前浏览器不支持端侧推理”。这三档渐进增强在 manifest 里不强行区分但在代码初始化阶段就要分流不能让用户装了一个永远无法完成初始化的扩展。6. 工程实现规范与项目治理6.1 版本管理、构建与兼容性矩阵端侧 AI 扩展和普通扩展的版本管理有区别模型文件、推理引擎、扩展代码三者需要独立版本化。模型升级不应强迫用户更新扩展安装包而扩展代码的升级也不应影响正在运行的长驻模型。我建议把模型版本号和扩展版本号拆开在 manifest 里只放最小必要模型完整模型用配置文件model-config.json管理里面记录模型 URL、版本、哈希、量化类型。构建流程上ONNX Runtime Web 的 dist 文件很大直接塞进扩展包会导致代码审查困难。我习惯用构建工具把ort.min.js和 wasm 文件从 npm 包里拷贝到 vendor 目录再打 zip 压缩包。每次发版前出一份兼容性矩阵Chrome 113 完整支持、Edge 113 完整支持、Firefox 116 WASM 降级、Safari 17 降级。这份矩阵写进 README版本迭代时可以快速判断影响范围。6.2 性能指标采集与质量看板没有监控的端侧运行时等于盲飞。浏览器扩展里能做监控的方式比较受限但至少可以做到三点一是在推理请求里埋点记录 from 到 to 的耗时、模型名称、execution provider、成功失败状态二是把性能数据先写入chrome.storage.session再定期异步上报到自己的数据服务有条件的话三是离线场景下把性能数据缓存到 IndexedDB等网络恢复再上报。关键指标我一般看五个冷启动耗时扩展安装后首次推理、单次推理 p50/p95、模型加载耗时、内存峰值、GPU 上下文丢失率。其中 p95 比平均值更能反映用户真实体感因为平均值会被极快的前几次推理“美化”。采集这些数据的目的不是为了做报表而是为了发现“什么场景下模型质量不可接受”。6.3 灰度发布与动态策略浏览器扩展没法像服务端那样随意灰度但可以通过远程配置实现类似效果。我在后台 Service Worker 启动时会请求一份策略 JSON内容包含不同用户分组应该加载哪个模型版本、是否开启 WebGPU、推理任务超时阈值是多少、是否启用某些实验特性。策略下发后缓存在本地默认使用兜底配置网络不可达时不影响正确性。灰度发布时我倾向于先放 5% 的无感知用户观察性能指标和错误日志确认异常率低于阈值再逐步放量。还有一个容易被忽略的细节扩展市场的审核有时间差不能依赖“审一次发一次”的节奏来修复线上问题。所以所有远程策略都必须向后兼容即使扩展代码很旧只要策略服务可控也能做到快速止损。端侧 AI 推理在扩展这一层的落地现在还处于早期像“任务队列 Offscreen Document WebGPU WASM 回退”这种组合在大多数文档里没有一个完整的参考。我踩过很多坑最深刻的一条体会是不要为了追求“浏览器里跑深度学习”的炫酷感而牺牲工程底线。模型可以不大功能可以简单但生命周期管理、回退路径、任务编排这三件事从一开始就必须写进设计文档。真正能把端侧 AI 体验做好的全是细节的胜利。最后再分享一个小技巧给 Offscreen 页面和 Service Worker 之间的请求链路打上 trace 日志用performance.now()记录每个节点的耗时调试时你会发现很多性能问题不需要猜测直接看时间线就能定位到“卡在预处理”还是“卡在 session.run”。这套基础设施从第一天就搭好后面省下来的排查时间远超想象。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进