多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

浏览器扩展端侧AI推理架构设计与工程实践

浏览器扩展端侧AI推理架构设计与工程实践 1. 端侧 AI 推理为什么开始往浏览器扩展里塞这两年浏览器扩展的玩法变了。以前大家写个扩展无非是改改页面样式、拦拦广告、做个书签管理逻辑轻、依赖少一个content script加个popup就能交差。但现在越来越多人在问同一件事能不能把模型直接跑在扩展里不依赖后端不上传数据用户点一下就在本地出结果。这个需求背后其实有三股力量在推。第一股是隐私。用户越来越在意自己的文本、图片、浏览记录被传到哪去了。端侧推理最直接的价值就是数据不出设备这对做笔记总结、网页翻译、内容分类这类场景特别有吸引力。第二股是成本。你只要跑过带模型的后端就知道推理的算力成本是实打实的用户量一上来账单就压不住。把推理放到客户端服务器只做分发和更新边际成本能压到很低。第三股是延迟。本地推理没有网络往返交互反馈是即时的这对“划词即翻译”“选中即总结”这种高频轻交互体验是决定性的。但浏览器扩展这个运行环境说实话并不是为跑模型设计的。它有一套自己的约束Manifest V3 之后后台从常驻的background page变成了会被回收的service worker生命周期短、不能长期持有大内存扩展的各个部分popup、content script、offscreen document、service worker运行在相互隔离的上下文里通信要靠消息传递再加上跨域、CSP、存储配额这些限制直接把一个推理 runtime 塞进去坑是一个接一个。所以这篇东西我想聊的不是“怎么调个 API”而是当你真的决定在浏览器扩展里做端侧 AI 推理时整套系统架构该怎么设计、工程上要注意什么。核心会围绕 Manifest V3 的生命周期约束、推理任务的调度与隔离、模型文件的加载与缓存、以及跨上下文通信这几个最容易翻车的地方展开。适合已经写过扩展、想往端侧 AI 方向走的人也适合做客户端架构、正在评估“模型到底放端上还是放云上”的工程师参考。我自己的判断是端侧推理在扩展里不是“能不能做”的问题而是“哪些场景值得做、架构怎么搭才不别扭”的问题。下面按我实际踩过的顺序一层层拆。2. 整体架构设计与方案选型思路2.1 先想清楚哪些推理该放端上不是所有模型都适合塞进扩展。我一般用一个很朴素的判断标准模型体积、调用频率、隐私敏感度这三者决定它该不该端侧化。如果一个能力调用频率极高、单次输入输出都不大、而且数据敏感那它几乎是端侧推理的完美候选比如划词翻译、短文本情感分类、网页正文摘要。反过来如果模型动辄几个 G、调用频率又低、对结果质量要求极高那放端上就是给自己找罪受用户下载模型的时间成本就劝退了。我见过有人一上来就想把一个大语言模型整个塞进扩展结果模型文件几百兆首次加载卡到用户以为扩展坏了。合理的做法是分层轻量任务分类、embedding、小模型摘要放端侧重任务长文本生成、复杂推理走云端端侧只做预处理和结果缓存。这样既拿到了隐私和延迟的好处又不至于把扩展做成一个臃肿的下载器。2.2 推理 runtime 的选型逻辑端侧推理在浏览器里目前主流就几条路WebAssembly配合 SIMD 和线程、WebGPU、以及基于它们的上层框架比如 ONNX Runtime Web、Transformers.js 这类。选型时我主要看三个维度。兼容性WebGPU 性能好但覆盖还没到“闭眼用”的程度尤其是老设备和某些平台。WebAssembly 基本是兜底方案几乎所有现代浏览器都支持配合 SIMD 能拿到不错的性能。我的常规策略是优先 WebGPU回退 WASM运行时探测能力再决定走哪条路。模型格式ONNX 是目前跨 runtime 最通用的中间格式工具链成熟量化方案也多。如果你的模型是从 PyTorch 或 TensorFlow 导出的转 ONNX 再量化INT8 或 INT4基本是标准流程。量化这件事后面会单独讲它对体积和速度的影响是数量级的。包体积runtime 本身也是有体积的。ONNX Runtime Web 的 wasm 文件加上各种算子压缩后也有几兆。这对扩展的审核和加载都有影响所以要么把 runtime 做成按需加载要么用更轻的专用 runtime。这一点很多人一开始不算账等到打包出来发现扩展体积爆炸才回头改。2.3 Manifest V3 带来的架构约束这是整个设计里最容易被低估的部分。Manifest V3 把后台从常驻页面改成了service worker它有几个硬约束你必须接受会被随时回收空闲一段时间后 service worker 就被终止内存里的状态全丢。你不能指望它像以前那样常驻内存持有模型。不能访问 DOMservice worker 里没有document很多依赖 DOM 的 API 用不了。有执行时间限制单个事件处理不能无限跑长任务会被打断。这意味着模型不能常驻在 service worker 里。那模型放哪答案是offscreen document。这是 Manifest V3 专门为“需要 DOM 或需要长时间运行的任务”设计的隐藏页面它可以持有较大的内存、可以跑长时间任务、可以访问完整的 Web API。把推理引擎放在 offscreen document 里service worker 只做调度和消息中转这是目前最稳的架构。我画不出图也不打算用图但你可以这样理解数据流content script采集用户输入 → 发给service worker→ service worker 转发给offscreen document→ offscreen 里的推理引擎跑模型 → 结果原路返回。每一跳都是异步消息每一跳都可能失败所以错误处理和超时机制必须做扎实。2.4 存储与缓存的分层设计模型文件动辄几十上百兆不能每次用都重新下载。浏览器扩展能用的存储有几层各有各的脾气存储层容量特点适合放什么chrome.storage.local默认约 10MB可申请 unlimitedStorage键值对异步跨上下文可读配置、小状态、模型元信息IndexedDB受配额限制通常较大支持二进制大对象事务性模型权重分片、缓存结果Cache Storage受配额限制类 HTTP 缓存适合静态资源模型文件、wasm 资源内存offscreen受设备内存限制最快但易失已加载的模型实例我的常规做法是模型权重放 IndexedDB 或 Cache Storage配置和元信息放 chrome.storage.local加载后的模型实例常驻 offscreen 内存。这里有个关键点——模型文件要分片存储因为 IndexedDB 单条记录过大时写入会失败或极慢通常按几 MB 一片切开加载时再拼回去。3. 核心细节解析与实操要点3.1 offscreen document 的创建与生命周期管理offscreen document 不是你想创建就能随便创建的它需要声明offscreen权限并且在 manifest 里指定用途。用途是有限枚举的比如WORKERS、BLOBS、DOM_PARSER等。跑推理通常归到需要长时间计算或需要完整 Web API 的场景具体用途字段要按你实际用到的能力来选选错了审核或运行时会出问题。创建逻辑一般放在 service worker 里而且要做幂等。因为 service worker 会被回收重启重启后 offscreen document 可能还在也可能没了你得先检查再创建async function ensureOffscreen() { const existing await chrome.offscreen.hasDocument(); if (existing) return; await chrome.offscreen.createDocument({ url: offscreen.html, reasons: [WORKERS], justification: Run local AI inference }); }注意hasDocument()这个检查不能省。我踩过的坑就是没检查直接 create结果 service worker 重启后重复创建报错整个推理链路直接断掉。offscreen document 本身也有生命周期。它不会像 service worker 那样频繁被回收但浏览器在内存紧张时仍可能干掉它。所以推理引擎的初始化要做成懒加载 可重建第一次收到推理请求时再加载模型加载失败或 document 被销毁后能自动重建。3.2 模型加载与量化体积和速度的平衡模型加载是端侧推理里最耗时的一环。一个量化后的几十兆模型从 IndexedDB 读出来、反序列化、初始化 runtime冷启动可能要几秒。用户第一次用的时候如果没有任何反馈会以为扩展卡死了。所以加载进度必须可见这是体验底线。量化方面我一般按这个顺序试FP32 原始模型只在模型极小时用否则体积和内存都吃不消。INT8 量化体积约为 FP32 的四分之一精度损失通常可接受是通用首选。INT4 量化体积进一步减半但精度损失明显适合对结果容忍度高的分类任务。量化的具体做法取决于你的工具链。以 ONNX 为例可以用 ONNX Runtime 提供的量化工具做动态量化也可以用训练后量化PTQ配合校准数据集。这里的关键经验是量化后一定要用真实数据回归测试别只看体积降了就上线。我遇到过 INT8 量化后某个类别几乎全错的情况原因是校准数据分布和实际输入差太远。模型分片存储的实现大致是这样把模型文件按固定大小比如 4MB切片每片存成 IndexedDB 里的一条记录key 里带上分片序号。加载时按序读出、拼成完整的 ArrayBuffer再交给 runtime。分片大小要权衡太小则记录数多、读取开销大太大则单次写入可能超时。4MB 到 8MB 是我实测比较稳的区间。3.3 跨上下文通信的消息协议设计扩展里各个上下文之间的通信是 bug 的高发区。popup 发给 service worker、service worker 发给 offscreen、content script 发给 service worker每条链路都可能因为上下文被销毁而失败。我的做法是定义一套统一的消息协议而不是到处写裸的sendMessage。协议里至少要有这几个字段type消息类型、requestId请求唯一标识、payload数据、timeout超时时间。响应也要带requestId这样在并发请求时才能正确配对。为什么强调这个因为端侧推理是异步的用户可能连续触发多次如果没有 requestId结果就会串。// 统一的请求封装 function sendInferenceRequest(payload, timeout 30000) { const requestId crypto.randomUUID(); return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(inference timeout)), timeout); const listener (msg) { if (msg.requestId ! requestId) return; clearTimeout(timer); chrome.runtime.onMessage.removeListener(listener); msg.error ? reject(new Error(msg.error)) : resolve(msg.result); }; chrome.runtime.onMessage.addListener(listener); chrome.runtime.sendMessage({ type: INFER, requestId, payload }); }); }提示超时时间不要设太短。模型冷启动加上首次推理几秒到十几秒都正常。我一般给 30 秒兜底同时在 UI 上给进度反馈而不是让用户干等。还有一个容易忽略的点消息大小限制。Chrome 的消息传递对单条消息大小是有限制的如果你把整个模型或者大段文本塞进消息里传可能直接被截断或报错。大数据的传递应该走 IndexedDB 或 Cache Storage消息里只传引用比如一个 key。3.4 推理任务的调度与并发控制端侧推理吃 CPU/GPU如果用户同时触发多个任务设备会卡。所以并发控制是必须的。我的做法是在 offscreen document 里维护一个任务队列串行执行推理或者限制最大并发数为 1 到 2。为什么倾向串行因为大多数端侧模型在单次推理时已经能吃满可用的算力并发跑多个只会互相抢资源总吞吐不一定提升反而让每个任务的延迟都变长。串行 队列能让每个任务的可预期延迟更稳定。队列还要处理优先级。比如用户主动触发的划词翻译优先级应该高于后台的批量摘要。实现上可以用两个队列高优先级队列先出队。同时要支持取消用户切换了页面或者关掉了 popup之前的推理任务应该能被取消避免浪费算力。class InferenceQueue { constructor() { this.queue []; this.running false; } enqueue(task, priority 0) { return new Promise((resolve, reject) { this.queue.push({ task, priority, resolve, reject }); this.queue.sort((a, b) b.priority - a.priority); this.drain(); }); } async drain() { if (this.running) return; this.running true; while (this.queue.length) { const { task, resolve, reject } this.queue.shift(); try { resolve(await task()); } catch (e) { reject(e); } } this.running false; } }4. 实操过程与核心环节实现4.1 从零搭建扩展骨架先把目录结构定下来这决定了后面各模块怎么协作。我的常规结构是这样extension/ manifest.json background.js // service worker调度中枢 offscreen.html // 推理宿主页面 offscreen.js // 推理引擎逻辑 content.js // 页面注入采集输入 popup.html / popup.js // 用户交互 lib/ // runtime 与工具 models/ // 模型元信息权重走 IndexedDBmanifest 里要声明的权限和字段我列一下关键的{ manifest_version: 3, name: Local AI Extension, version: 1.0.0, permissions: [offscreen, storage, unlimitedStorage], background: { service_worker: background.js }, content_scripts: [{ matches: [all_urls], js: [content.js] }], action: { default_popup: popup.html } }注意unlimitedStorage这个权限值得申请否则 IndexedDB 的配额可能不够放模型。但申请了也要注意用户看到权限列表里多这一条可能会犹豫所以扩展的说明里要讲清楚为什么需要。4.2 推理引擎在 offscreen 里的初始化offscreen.js 是整个系统的核心。它的初始化流程我拆成几步探测能力、加载 runtime、加载模型、预热。能力探测主要看 WebGPU 是否可用async function detectCapability() { if (gpu in navigator) { try { const adapter await navigator.gpu.requestAdapter(); if (adapter) return webgpu; } catch (e) { /* fall through */ } } return wasm; }runtime 加载要按需。如果走 WASMwasm 文件本身也要从扩展资源里读出来这一步可以用fetch配合扩展内的相对路径。加载完 runtime 再加载模型权重权重从 IndexedDB 分片读出后拼接。预热这一步很多人省掉但我强烈建议做。所谓预热就是拿一条极短的假输入跑一次推理让 runtime 完成算子编译、内存分配等一次性开销。这样用户真正用的时候第一次推理的延迟会明显降低。预热的代价是扩展启动时多花一点时间但可以放在空闲时做。4.3 模型分片存储与加载的完整实现存储侧我写一个简单的分片写入函数const CHUNK_SIZE 4 * 1024 * 1024; async function saveModel(db, modelId, arrayBuffer) { const total Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); const tx db.transaction(models, readwrite); const store tx.objectStore(models); for (let i 0; i total; i) { const start i * CHUNK_SIZE; const chunk arrayBuffer.slice(start, start CHUNK_SIZE); await store.put({ key: ${modelId}_${i}, chunk, index: i, total }); } await tx.done; }加载侧反过来按 index 排序后拼接async function loadModel(db, modelId) { const tx db.transaction(models, readonly); const store tx.objectStore(models); const all await store.getAll(); const chunks all .filter(r r.key.startsWith(${modelId}_)) .sort((a, b) a.index - b.index); const totalBytes chunks.reduce((s, c) s c.chunk.byteLength, 0); const result new Uint8Array(totalBytes); let offset 0; for (const c of chunks) { result.set(new Uint8Array(c.chunk), offset); offset c.chunk.byteLength; } return result.buffer; }这里有个性能细节getAll()会把所有记录一次性读进内存如果模型很大这一步本身就很吃内存。更稳的做法是分批读比如每次读 8 片拼完再读下一批。我在模型超过 100MB 的场景下会改成流式拼接避免内存峰值过高导致 offscreen 被干掉。4.4 一次完整推理请求的端到端链路把前面几块串起来一次推理的完整流程是这样的用户在页面上选中文本content script 捕获选区通过chrome.runtime.sendMessage发给 service worker。service worker 收到消息先ensureOffscreen()确保推理宿主存在然后把请求转发给 offscreen。offscreen 收到请求检查模型是否已加载。没加载就先加载带进度上报加载完把任务丢进推理队列。队列串行执行推理结果通过消息回传给 service worker。service worker 把结果转发回 content scriptcontent script 渲染到页面上。这条链路里进度上报是体验的关键。加载模型时offscreen 要定期把进度发给 service worker再转发给 popup 或 content script让用户看到“模型加载中 45%”这样的反馈。没有这个用户大概率会在加载到一半时以为卡死然后卸载扩展。4.5 参数选择与性能调优的实测记录性能调优这块我记录几个实测下来影响最大的参数。线程数WASM 多线程能显著提速但线程数不是越多越好。一般设成navigator.hardwareConcurrency的一半到全部之间具体要看设备。我实测在四核设备上2 到 4 线程的收益最明显再多收益递减还增加调度开销。批大小如果模型支持批处理批大小要按输入规模动态调整。单条输入时批大小为 1批量处理时再增大。固定一个大批大小会让单条请求也付出批处理的算力代价。量化精度前面说过INT8 是通用首选。但如果你的任务对精度极敏感可以只对部分层做量化保留关键层的 FP32。这种混合量化能兼顾体积和精度代价是工具链配置更复杂。缓存策略相同输入的推理结果应该缓存。比如同一段文本的翻译用户重复触发时直接返回缓存结果。缓存 key 用输入的哈希存在 IndexedDB 里设一个合理的过期时间。这个优化对高频重复场景的体验提升非常明显。5. 常见问题与排查技巧实录5.1 service worker 被回收导致推理中断这是最高频的问题。表现是用户触发推理等了一会儿没反应再触发又好了。原因就是 service worker 在等待期间被回收消息链路断了。排查思路先看 service worker 的日志有没有“terminated”之类的记录。解决上一是缩短空闲时间在推理进行中通过定期发心跳消息保持 service worker 活跃二是让 offscreen 承担更多状态因为 offscreen 比 service worker 稳定把推理状态放在 offscreen 里service worker 只做无状态转发被回收了重建也不影响。提示心跳不要发太频繁几秒一次就够太频繁反而增加开销。而且心跳本身也可能失败要做好失败重试。5.2 模型加载失败或加载后推理报错这类问题通常有几个来源分片存储损坏、量化模型与 runtime 版本不匹配、内存不足。排查顺序我一般这样走先确认分片是否完整记录数和总大小对不对再确认 runtime 版本和模型导出时的版本是否兼容最后看内存。内存不足在移动端或低配设备上很常见表现是加载到一半 offscreen 被销毁。解决办法是降低模型精度、减小分片读取的批量大小或者干脆换更小的模型。5.3 跨域与 CSP 相关的坑扩展的 CSP 比普通网页严格eval、内联脚本这些默认都被禁。如果你的 runtime 内部用了这些会直接报错。解决办法是选一个不依赖eval的 runtime 构建版本或者调整 CSP 配置但能不动 CSP 就不动动了审核和安全性都麻烦。跨域方面模型文件如果放在远程服务器需要在 manifest 里声明对应的 host 权限。但更推荐把模型打包进扩展或者从可信的静态资源加载减少运行时的不确定性。5.4 常见问题速查表现象可能原因排查方向解决手段推理无响应service worker 被回收查看后台日志心跳保活 状态放 offscreen首次推理极慢未预热、冷启动计时各阶段耗时空闲时预热、进度反馈加载到一半失败内存不足、分片损坏检查内存与分片完整性降精度、分批读取结果串台消息未配对检查 requestId统一消息协议扩展体积过大runtime 模型未优化分析打包产物按需加载、量化、分片移动端崩溃内存峰值过高监控内存曲线流式拼接、限制并发5.5 几个我踩过的独家坑第一个坑是在 popup 里直接跑推理。popup 一关就销毁推理跑到一半用户点了别处任务就没了。所以推理一定要放 offscreenpopup 只做展示。第二个坑是忽略消息大小限制。我曾经把一段很长的文本直接塞进消息里传结果被静默截断推理结果驴唇不对马嘴。后来改成大数据走存储、消息只传 key问题消失。第三个坑是量化后没做回归测试。前面提过INT8 量化后某个类别全错上线后用户反馈才发现。现在我的流程里量化后必须跑一遍标注好的测试集指标达标才允许打包。第四个坑是并发没控制。早期没做队列用户快速连续触发设备直接卡死。加了串行队列后虽然单次延迟没变但整体体验稳定多了。6. 端侧推理扩展的边界与后续演进聊到这我想说点更宏观的判断。端侧 AI 推理在浏览器扩展里目前的能力边界其实很清楚它擅长的是轻量、高频、隐私敏感的任务不擅长重模型和复杂推理。认清这个边界比盲目追求“把大模型塞进扩展”要重要得多。从工程演进的角度我看到几个值得关注的方向。一是WebGPU 的普及会让端侧推理的性能上限明显抬高现在很多需要回退 WASM 的场景未来可以直接走 GPU。二是模型小型化和蒸馏技术的成熟让同样能力的模型体积持续下降端侧能承载的任务会越来越多。三是扩展与本地其他进程的协作比如扩展负责交互、本地服务负责重推理这种混合架构在桌面端会越来越常见。但无论技术怎么演进有几条工程原则我觉得不会变状态要放在稳定的上下文里通信要有统一协议加载要有进度反馈并发要有控制量化要有回归测试。这些不是某个框架的特性而是端侧推理这个场景本身的约束决定的。我自己在做这类项目时最大的体会是别把端侧推理当成一个“技术炫技”它首先是一个产品体验问题。用户不关心你用的是 WASM 还是 WebGPU他们只关心点了之后多久出结果、会不会卡、数据安不安全。架构设计的所有取舍最终都要回到这三个问题上。把模型加载的进度做出来、把冷启动的延迟压下去、把隐私的承诺兑现比堆砌任何先进技术都更能留住用户。最后分享一个我常用的小技巧在开发阶段给推理链路的每个阶段都打上时间戳从消息发出到结果返回把加载、排队、推理、回传各段的耗时都记下来。这个日志在排查性能问题时极其有用很多时候你以为的瓶颈和实际的瓶颈完全不是一回事。等这套埋点跑顺了你会发现优化方向一下子就清晰了。
返回列表