ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

浏览器扩展端侧AI推理实战:WebGPU与ONNX Runtime Web架构指南

浏览器扩展端侧AI推理实战:WebGPU与ONNX Runtime Web架构指南 浏览器扩展这个赛道这两年正在经历一次彻底的重构。Manifest V3 把后台页换成了 Service Worker远程代码加载被彻底封死很多过去靠云端 API 兜底的功能一下子没了退路。与此同时WebGPU 在主流浏览器上陆续转正ONNX Runtime Web 这类推理框架也把算子覆盖做得越来越全。这两条线一交汇就催生出一个很具体的问题能不能把 AI 推理整个搬到扩展里让模型在用户本机跑数据不出设备也不依赖任何外部服务我最近花了大概三周时间把一套端侧推理系统从零搭进浏览器扩展里中间踩的坑比预想的多得多。这篇文章就把整个架构设计、工程实现和那些文档里不会写的细节完整拆一遍。适合已经写过 Manifest V3 扩展、想往端侧 AI 方向走的开发者也适合做隐私敏感型工具、想把推理能力下沉到客户端的同学。读完你应该能自己搭出一套可用的端侧推理管线并且知道哪些地方最容易翻车。1. 为什么端侧推理在扩展场景里突然变得可行先说清楚这件事的动机。过去在浏览器扩展里做 AI 功能主流做法是扩展采集数据发到自己的服务器服务器调模型结果再传回来。这套架构简单但问题也很明显数据要出设备延迟受网络影响服务端成本随用量线性上涨而且一旦涉及用户隐私内容合规上就很被动。Manifest V3 的落地其实是个转折点。它强制要求所有逻辑代码必须打包进扩展禁止动态执行远程脚本。表面看是限制实际上逼着大家把能力往本地挪。而恰好在这个时间点两件事成熟了。1.1 WebGPU 从实验特性变成可用算力WebGPU 在 Chrome 113 之后默认开启Edge 跟进Safari 17 也支持了。它给 Web 环境带来了真正的 GPU 计算能力不再是 WebGL 那种为图形渲染设计的、做通用计算很别扭的接口。对推理来说最直接的好处是矩阵乘法和卷积这类操作能跑在 GPU 上速度比纯 CPU 的 WASM 后端快一个数量级。我实测过一个 30M 参数左右的小模型纯 WASM 后端单次推理大概 180ms切到 WebGPU 之后降到 25ms 上下。这个差距决定了能不能做实时交互。当然 WebGPU 也有代价首次编译着色器有冷启动开销显存管理需要自己操心后面会细说。1.2 ONNX Runtime Web 把推理这件事标准化了自己写推理引擎不现实ONNX Runtime Web 是目前最省心的选择。它支持三种后端WASM纯 CPU、WebGL老 GPU 路径、WebGPU新 GPU 路径。模型用 ONNX 格式PyTorch 和 TensorFlow 都能导出。它的 API 设计得比较克制InferenceSession.create加载模型session.run执行推理输入输出都是 Tensor学习成本不高。关键在于它把算子实现、内存管理、后端调度这些脏活都封装了。你不需要关心卷积怎么在 GPU 上分块只需要关心模型能不能被它支持。这个抽象层次对扩展开发者来说刚刚好。1.3 扩展场景的特殊约束反过来成了优势浏览器扩展有个天然优势它运行在用户的浏览器里而浏览器本身就是个沙箱。模型文件打包进扩展随扩展一起分发用户装上就有不需要额外下载。推理在本地跑数据不出设备隐私问题从架构层面就解决了。但约束也很硬。扩展的存储空间有限Chrome 网上应用店对包体积有隐性限制模型不能太大。Service Worker 有生命周期随时可能被回收长任务必须做断点续跑。这些约束决定了端侧推理在扩展里不能照搬服务端那套思路得重新设计。2. 整体架构把推理拆成四层来管直接说结论我把整个系统拆成了四层模型层、推理层、调度层、接口层。这么拆不是为了好看是因为每一层的生命周期和运行环境都不一样混在一起会很难维护。2.1 模型层模型怎么打包、怎么加载模型文件不能放在扩展包里直接读因为 Manifest V3 的 Service Worker 里没有fetch本地文件的直接路径。我的做法是把模型转成二进制放在扩展的public目录下通过chrome.runtime.getURL拿到完整 URL再用fetch读成 ArrayBuffer。// 加载模型文件 async function loadModelBuffer(modelPath) { const url chrome.runtime.getURL(modelPath); const response await fetch(url); if (!response.ok) { throw new Error(模型加载失败: ${response.status}); } return await response.arrayBuffer(); }这里有个坑模型文件如果超过几 MBfetch在 Service Worker 里可能因为生命周期被中断。我的处理是把模型加载放在一个显式的长任务里用chrome.runtime.onMessage触发并且在加载过程中定期调用chrome.runtime.getPlatformInfo之类的轻量 API 来续命。更稳妥的做法是用chrome.storage缓存模型 buffer但要注意 storage 有配额限制大模型不适合。模型格式上我强烈建议用 ONNX 的量化版本。FP32 的模型体积是 INT8 的四倍而端侧场景下 INT8 量化的精度损失通常在可接受范围内。导出的时候用torch.onnx.export配合dynamic_axes处理可变输入长度量化用 ONNX Runtime 的quantize_dynamic。2.2 推理层Session 的创建与复用InferenceSession的创建是重操作尤其是 WebGPU 后端首次创建要编译着色器可能耗时几百毫秒到几秒。绝对不能每次推理都新建 session。// 全局单例避免重复创建 let sessionPromise null; async function getSession(modelBuffer) { if (!sessionPromise) { sessionPromise ort.InferenceSession.create(modelBuffer, { executionProviders: [webgpu, wasm], graphOptimizationLevel: all, }); } return sessionPromise; }注意executionProviders的顺序WebGPU 在前WASM 兜底。如果设备不支持 WebGPUORT 会自动降级到 WASM不需要你手动判断。但有个细节降级是静默的你拿到的 session 不会告诉你实际用了哪个后端。想确认的话可以在创建后跑一次小推理对比耗时或者查session.handler的内部字段不推荐属于私有 API。Session 复用还有个内存问题。WebGPU 后端的 session 会占用显存如果同时创建多个 session显存会爆。我的做法是全局只保留一个 session模型切换时先session.release()再重建。2.3 调度层Service Worker 生命周期下的任务管理这是整个系统里最容易被低估的部分。Manifest V3 的 Service Worker 会在空闲 30 秒后被回收长推理任务如果超过这个时间会被直接掐断。我的方案是把推理任务做成可恢复的。任务状态存在chrome.storage.session里每次推理前先检查有没有未完成的任务有的话从断点继续。具体做法是把大任务切成小块每块推理完就存一次中间状态。// 任务状态管理 async function runWithCheckpoint(taskId, chunks, processFn) { const state await chrome.storage.session.get(taskId); let startIndex state[taskId]?.nextIndex ?? 0; for (let i startIndex; i chunks.length; i) { const result await processFn(chunks[i]); await chrome.storage.session.set({ [taskId]: { nextIndex: i 1, partial: result } }); } await chrome.storage.session.remove(taskId); }chrome.storage.session是 MV3 专门为这种场景设计的数据只存在内存里Service Worker 重启后还在浏览器关闭就清空。比chrome.storage.local更适合存临时状态。另外Service Worker 里不能直接用setTimeout做长延时因为 SW 可能已经休眠了。需要定时的话用chrome.alarmsAPI最小间隔是 30 秒这个限制要心里有数。2.4 接口层内容脚本与后台的通信设计内容脚本负责和页面交互后台负责推理两者通过chrome.runtime.sendMessage通信。这里有个性能陷阱消息传递是序列化的如果传大数组比如图像像素数据开销很大。我的做法是内容脚本只传必要参数比如图片的 URL 或者一个小的特征向量后台自己去取数据。如果非要传大块数据用ArrayBuffer而不是普通数组序列化效率高很多。另外消息通道有大小限制单条消息超过 64MB 会失败实际使用中建议控制在几 MB 以内。3. WebGPU 后端的性能调优与那些反直觉的细节WebGPU 是这套系统里性能提升最大的部分但也是最容易踩坑的部分。我在这上面花的时间比写业务逻辑还多。3.1 首次推理的冷启动为什么那么慢第一次调用session.run的时候你会感觉卡了很久可能一两秒。这不是模型加载慢是 WebGPU 在编译着色器。ORT 会把 ONNX 的计算图转成 WGSL 着色器然后交给 GPU 驱动编译。这个过程每个 session 只发生一次但用户第一次用的时候体验很差。我的处理是在扩展安装或者首次启动的时候主动跑一次预热推理用一个极小的输入把着色器编译提前触发。预热放在chrome.runtime.onInstalled事件里用户感知不到。// 预热用最小输入触发着色器编译 async function warmup(session) { const dummyInput new ort.Tensor( float32, new Float32Array(1 * 3 * 224 * 224), [1, 3, 224, 224] ); await session.run({ input: dummyInput }); }预热用的输入形状要和实际推理一致否则着色器还是要重新编译。这点很关键形状不一致等于白预热。3.2 输入形状固定带来的连锁反应WebGPU 后端对动态形状的支持有限。如果你的模型输入是动态的每次形状变化都可能触发重新编译。我的建议是尽量固定输入形状比如图像统一 resize 到 224x224文本统一 padding 到固定长度。如果业务上确实需要可变长度那就准备几个固定的档位比如 128、256、512输入按最近的档位 padding。这样着色器只需要编译几次而不是每次输入都编译。3.3 显存管理和 session 释放WebGPU 的显存不像 JS 堆内存那样有 GC 兜底。session 不释放显存就一直占着。我遇到过连续切换模型十几次之后浏览器标签页直接崩掉的情况就是显存泄漏。// 切换模型前必须释放 async function switchModel(newBuffer) { if (sessionPromise) { const oldSession await sessionPromise; await oldSession.release(); sessionPromise null; } sessionPromise ort.InferenceSession.create(newBuffer, { executionProviders: [webgpu], }); return sessionPromise; }release()是异步的要 await。另外Tensor 对象用完也要及时释放虽然 ORT 有自动管理但显式调用tensor.dispose()更保险。3.4 什么时候该退回 WASMWebGPU 不是万能的。小模型参数量小于 1M在 WebGPU 上可能比 WASM 还慢因为 GPU 的调度开销盖过了计算收益。我实测下来参数量在 5M 以上WebGPU 才有明显优势。另外如果推理是偶发的、间隔很长的WebGPU 的冷启动开销摊不平WASM 反而更稳。我的策略是做一个简单的基准测试在扩展首次运行时跑一次根据结果决定用哪个后端。测试逻辑很简单用同一个模型分别跑 WASM 和 WebGPU各跑三次取平均选快的那个。4. 模型选型与量化在扩展体积和推理质量之间找平衡扩展的体积是个硬约束。Chrome 网上应用店虽然没明说上限但超过 10MB 的扩展审核会变慢用户安装意愿也会下降。模型必须控制在这个量级以内。4.1 什么样的模型适合塞进扩展不是所有模型都能往扩展里塞。我的筛选标准有三条参数量在 50M 以内输入输出维度固定或档位化算子集在 ONNX Runtime Web 的支持列表内。具体到任务类型图像分类、轻量目标检测、文本嵌入、小型语言模型这几类比较合适。像 BERT-base 这种 110M 参数的模型量化后大概 25MB勉强能接受但偏大。更推荐 DistilBERT 或者 TinyBERT 这类蒸馏模型量化后能压到 5MB 以内。文本生成类的小模型比如参数量在 100M 以内的量化后大概 30-50MB这个体积对扩展来说偏大了。如果非要做可以考虑把模型放在扩展外部首次使用时下载但这又引入了网络依赖和端侧的初衷矛盾。我的建议是这类场景暂时别硬上扩展或者用更小的模型。4.2 量化到底损失了多少精度我用一个文本分类任务做了对比测试。FP32 模型准确率 92.3%INT8 动态量化后 91.8%掉了 0.5 个百分点。这个损失在大多数业务场景下可以接受。但要注意量化对某些任务的影响更大比如需要精细数值输出的回归任务或者对长尾类别敏感的分类任务。量化方式上动态量化quantize_dynamic最省事不需要校准数据直接转。静态量化需要校准数据集精度通常更好但流程复杂。端侧场景我一般先用动态量化精度不够再考虑静态。# 动态量化示例 from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputmodel_fp32.onnx, model_outputmodel_int8.onnx, weight_typeQuantType.QInt8, )4.3 模型分片加载的思路如果模型实在压不到 10MB 以内可以考虑分片。把模型按层拆成几个文件推理时按需加载。但 ONNX Runtime Web 不支持部分加载你得自己实现一个加载器把分片拼成完整 buffer 再交给 ORT。这个方案复杂度高我只在极端情况下用。更实际的做法是模型裁剪。用 ONNX 的onnx-simplifier去掉冗余节点用onnxruntime-tools做算子融合通常能再压 10%-20%。这些工具在 Python 侧跑属于构建流程的一部分。5. 工程化落地构建、调试与发布架构和模型都定了之后剩下的就是把它变成一个能发布、能维护的扩展。这部分没什么高深技术但细节特别多。5.1 构建流程怎么组织我用 Vite 做构建配合crxjs/vite-plugin处理扩展特有的入口。模型文件放在public/models下构建时原样拷贝。ONNX Runtime Web 的 WASM 文件需要单独处理因为它的.wasm文件不能被打包进 JS bundle得作为静态资源。// vite.config.js 关键配置 export default defineConfig({ plugins: [crx({ manifest })], optimizeDeps: { exclude: [onnxruntime-web], }, build: { rollupOptions: { external: [onnxruntime-web], }, }, });onnxruntime-web必须排除在打包之外否则 WASM 文件路径会错乱。它的加载逻辑依赖相对路径打包器一处理就找不到文件了。这个坑我踩了整整一个下午。5.2 调试端侧推理的实用手段端侧推理的调试比服务端麻烦因为没有日志服务器出错信息也不好收集。我的做法是在开发模式下把推理的输入输出都打到 console用console.time和console.timeEnd测各阶段耗时。console.time(preprocess); const input preprocess(rawData); console.timeEnd(preprocess); console.time(inference); const output await session.run({ input }); console.timeEnd(inference);生产环境下我会把关键指标推理耗时、后端类型、错误码通过chrome.storage.local存下来用户可以在扩展的选项页里导出诊断信息。这样出问题的时候能拿到一手数据不用靠猜。5.3 发布时的审核注意事项Chrome 网上应用店对扩展的审核越来越严尤其是涉及 AI 能力的。几个要点扩展描述里不要出现AI 生成这类可能触发额外审核的表述改成本地智能处理之类的隐私政策里要明确说明数据不离开设备如果模型是从第三方来的注意许可证兼容性。另外扩展的权限要最小化。端侧推理本身不需要任何网络权限如果你的 manifest 里申请了all_urls或者tabs权限审核会问用途。能不用就不用。6. 几个真实踩过的坑和排查过程这部分是我觉得最有价值的内容因为都是文档里不会写的。6.1 Service Worker 被回收导致推理中断现象是用户反馈有时候处理到一半就没反应了。我一开始以为是模型问题后来在日志里发现推理任务执行到一半Service Worker 被回收了任务状态丢失。排查过程先确认 Service Worker 的生命周期在chrome.runtime.onSuspend里打日志发现确实触发了。然后检查任务状态存储发现用的是内存变量SW 一回收就没了。修复方案就是前面说的用chrome.storage.session存任务状态并且把大任务切块。切块的粒度要控制好每块推理时间最好在 5 秒以内给状态存储留出时间。6.2 WebGPU 在部分设备上静默失败有用户反馈推理结果全是 NaN。这个很难查因为 WebGPU 在某些集成显卡或者旧驱动上会静默失败不报错直接输出垃圾数据。我的处理是加了一层结果校验。推理输出如果包含 NaN 或者 Inf就判定为后端异常自动降级到 WASM 重跑一次。同时记录这个事件如果某个设备频繁触发就在配置里永久禁用 WebGPU。function validateOutput(tensor) { const data tensor.data; for (let i 0; i data.length; i) { if (!Number.isFinite(data[i])) { return false; } } return true; }这个校验有性能开销大输出会慢一些。我的做法是只抽样校验比如每隔 100 个元素查一个兼顾性能和可靠性。6.3 模型加载的内存峰值问题加载一个 20MB 的模型内存峰值可能到 100MB 以上。因为 ArrayBuffer、ORT 内部拷贝、GPU 上传各占一份。在低内存设备上这会导致标签页崩溃。优化手段加载完模型后立即释放 ArrayBuffer 引用让 GC 回收用ort.env.wasm.numThreads控制 WASM 线程数线程越多内存占用越大WebGPU 后端下模型上传到 GPU 后CPU 侧的 buffer 可以释放。// 加载后释放引用 let modelBuffer await loadModelBuffer(path); const session await ort.InferenceSession.create(modelBuffer, opts); modelBuffer null; // 显式置空帮助 GC6.4 内容脚本注入时机导致的初始化失败内容脚本如果注入太早页面 DOM 还没准备好拿不到目标元素。如果注入太晚用户可能已经操作过了。我的做法是用document.readyState判断配合MutationObserver监听目标元素出现。function waitForElement(selector) { return new Promise((resolve) { const el document.querySelector(selector); if (el) return resolve(el); const observer new MutationObserver(() { const el document.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); }); }这个模式在扩展开发里很常用尤其是处理动态加载的页面。7. 端侧推理在扩展里的边界在哪里做了这一轮之后我对端侧推理在扩展里的能力边界有了比较清楚的认识。能做的轻量分类、特征提取、小型嵌入模型、简单的文本处理。这些任务模型小、推理快、对精度要求相对宽松端侧完全能扛。勉强能做的中等规模的目标检测、小型语言模型的推理。需要仔细调优对设备有要求得做好降级方案。暂时别碰的大语言模型生成、高精度图像生成、需要大量上下文的任务。模型体积和算力需求都超出了扩展能承受的范围。这个边界不是固定的会随着 WebGPU 的普及和模型压缩技术的进步往外扩。但就目前而言认清边界比盲目堆功能更重要。我见过太多扩展为了加个AI 功能塞进去一个几十 MB 的模型结果用户装完就卡体验极差。一个实用的判断标准如果模型量化后超过 15MB或者单次推理在主流设备上超过 500ms就要重新考虑这个功能是不是适合放在端侧。不适合的话要么砍掉要么换更小的模型要么接受它只能在高配设备上跑。最后分享一个我在实际项目里总结的小技巧把推理能力做成可插拔的。核心逻辑不依赖具体模型模型通过配置加载。这样换模型、调参、做 A/B 测试都很方便也方便在 WebGPU 不可用时快速切到 WASM。这个设计一开始多花点功夫后面维护起来省心很多。
RELATED READING

延伸阅读

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