浏览器扩展这个赛道,这两年正在经历一次彻底的重构。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(); }这里有个坑:模型文件如果超过几 MB,fetch在 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 兜底。如果设备不支持 WebGPU,ORT 会自动降级到 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 什么时候该退回 WASM
WebGPU 不是万能的。小模型(参数量小于 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_input='model_fp32.onnx', model_output='model_int8.onnx', weight_type=QuantType.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。这个设计一开始多花点功夫,后面维护起来省心很多。