浏览器扩展中的端侧AI推理:架构设计与工程实践
最近大半年我一直在折腾同一个方向在浏览器扩展里直接跑端侧 AI 推理。以前做扩展功能要么是请求云端接口要么是纯本地规则判断但这两年浏览器端能跑的模型越来越多WebGPU 把 GPU 调度带进了网页WASM 也能扛起更重的算子。我陆陆续续做了几个内部工具之后发现真正难的不是“模型能不能跑”而是“在扩展这个相对受限的宿主环境里怎么把模型、引擎、消息通道、任务调度和缓存体系当成一个系统去设计”。这篇文章就是我个人在这套体系上的架构笔记也是给自己的工程实现规范做一次沉淀。如果你正准备在 Chrome 或 Edge 的扩展里集成端侧模型或者只是好奇这一切怎么运转读完应该能少走不少弯路。1. 为什么要在浏览器扩展里做端侧AI推理1.1 云端推理的痒点与痛点先泼盆冷水现在市面上大多数“智能扩展”还是挂在云端接口后面你选中一段文字它发到服务器服务器跑完模型返回结果。这种架构开发确实省心但用起来有几个很现实的问题。第一是数据出域。你在网页上选中合同、邮件、病历这类敏感内容点击“智能提取”内容就离开本地了。很多用户嘴上不说心里是介意的。第二是延迟和成本。一次长网页摘要上传可能要几十KB甚至几百KB在弱网环境下转圈转得人心烦API 按次计费重度用户一个月下来不是小数目。第三是可用性。服务端一旦抖动、升级、限流扩展就瘫痪。“你的号码正在等待排队”这种提示放在浏览器插件里特别劝退。端侧推理把这些痛点一次性按下去了数据不出浏览器推理发生在本地进程不依赖网络没有按次费用。隐私、延迟、成本、可用性四件事同时改善所以它值得被认真对待。1.2 浏览器扩展端侧AI的“天然宿主”为什么偏偏要选浏览器扩展而不是单独做一个本地应用因为扩展天然具备别的端没有的“上下文”优势。我们平时说的“端侧AI”普通网页也能跑但网页受同源策略和安全沙箱限制拿不到跨域页面内容用户每次打开都要重新加载模型刷新一下就全没了。扩展不一样它由 Manifest V3 规范管理拥有独立的后台服务环境Service Worker可以持久缓存模型文件还能在主页面里通过 Content Script 读取当前正在浏览的页面 DOM。这意味着它既懂“用户正在看什么”又能在本地完成推理然后把结果回填到页面旁边。同时浏览器扩展的生态又足够开放。Chrome 和 Edge 共享同一套扩展规范写一套架构两边能用Firefox 的差异也不大无非是browser和chrome命名空间的区别。再加上 WebGPU、WebAssembly 这两年快速普及端侧推理的性能瓶颈正在被持续推高。可以说现在的扩展就是一个轻量级、有权限、有上下文、有原生 UI 承载的端侧 AI 运行壳。1.3 这些场景适合放进扩展推理哪些不适合我踩过一些坑之后总结了一个简单的判断标准推理结果是不是高频、小模型、强上下文相关。满足这三点就适合做扩展端侧推理。适合做得比较顺的场景包括页面摘要读取当前网页正文本地生成摘要不把全文传走翻译与术语解释选中文本本地翻译或解释实时性比云端好表单与文本清洗识别邮箱、电话、地址、错误命名实体轻量视觉任务截图里的文字识别、二维码读取、图片主体分类个性化推荐排序比如给浏览器书签、历史记录做本地 embedding再算相似度。不适合的也很明确大参数对话模型、需要海量知识库支撑的问答、需要固定版本迭代的大型模型都不适合塞进扩展。扩展的下载包体积、Service Worker 生命周期和内存上限决定了它只能跑“小而快”的模型。硬塞大模型用户下载体验极差浏览器也会分分钟教你做人。2. 端侧AI推理系统架构别把所有逻辑塞进一个文件2.1 四层架构先行UI层、服务层、引擎层与模型层很多初学者做扩展习惯把“抓网页、调模型、显示结果”全写在content.js里。十分钟能跑通 demo一个月之后维护起来会想哭。我后来一直用的是四层拆分UI 层、服务层、引擎层、模型层。UI 层负责与人交互包括 Popup 页面、Options 配置页以及 Content Script 渲染的浮窗。服务层是核心调度中枢一般住在 Service Worker 里负责接收请求、安排任务、广播进度。引擎层是推理引擎的封装比如 Transformers.js 或 ONNX Runtime Web 的单例。模型层就是实际的权重文件、配置文件、tokenizer以及模型元信息。这四层我用一个生活化类比来理解UI 层是餐厅的前厅点菜台服务层是传菜间和调度员引擎层是灶台模型层是食材库和预制菜。前端传菜员不直接进灶台调度员也不自己炒菜各层之间只通过约定接口协作。这么结构化的原因很简单扩展里发生异常时你必须能快速定位问题出在哪一层。如果不分层一旦模型加载报错或消息格式不对你就要在几百行混杂代码里捞针。2.2 核心模块职责划分模型、调度、缓存各管一摊在四层框架下我进一步拆出了几个独立模块每个模块只干一件事。模型管理模块负责“模型有没有”“该不该更新”“什么时候加载”它会记录本地模型版本与扩展打包版本比对决定是直接用内置模型还是从模型仓库下载。推理引擎模块是引擎层的门面只暴露init()、run()、dispose()三个方法内部什么细节都不往外漏。任务调度模块维护一个任务队列处理并发请求、优先级、超时和取消。缓存模块负责模型文件和临时推理 IPC 的读写优先用 Cache API 和 IndexedDB最后还有一个消息协议模块定义所有通信消息的格式和错误码这是全系统的“通用语”。我见过很多半途而废的扩展项目问题大多出在模块边界模糊上。比如把模型下载逻辑写在 Popup 里用户一关 Popup 下载就被中断再比如把 tokenizer 初始化写在 Content Script 里每个页面都要初始化一遍内存直接爆掉。模块职责一旦划清楚这些问题基本能从根上避免。2.3 一次推理请求的生命周期从页面点击到结果回填架构不落地就是空话所以我把一条完整的推理请求生命周期串一遍。假设用户在页面里选中一段文字点击扩展浮窗的“提取关键词”。这个动作先由 Content Script 捕获它把选中文本和上下文信息封装成一个标准消息通过chrome.runtime.sendMessage发给 Service Worker。Service Worker 收到后先做基本的校验和优先级判定把任务放进队列。如果队列前面还有任务就等待并上报一个“排队中”的状态轮到了之后调度模块把任务交给推理引擎。推理引擎先确认模型是否已加载到内存没有就触发一次初始化。初始化完成后执行推理拿到 embedding 或 token 序列再按约定封装成INFERENCE_RESULT消息回给 Content Script。Content Script 收到结果后渲染浮窗。整个链路里Model、Engine、Service、UI 四层各自只关心自己那一环。这样设计的好处是哪一步慢了、哪一步错了都能通过日志快速锁定层位置。3. 推理引擎选型与模型集成实操3.1 三大引擎怎么选ONNX Runtime Web、Transformers.js、WebLLM浏览器端能跑的推理引擎目前主流就三个方向。第一个是 ONNX Runtime Web它是把模型转成 ONNX 格式后通过 WebAssembly 或 WebGPU 后端跑推理。它的优点是模型来源广换模型不换引擎缺点是 API 偏底层需要自己处理 tokenizer、padding、后处理工程量大。第二个是 Transformers.js它把 Hugging Face Transformers 的能力搬到了浏览器端内置了文本分类、抽取、摘要、embedding、图像分类等 pipeline直接用pipeline()一行调用。它底层其实也依赖 ONNX 格式和 WebGPU/WASM但帮你把前后处理全包了。第三个是 WebLLM主打浏览器里跑大语言模型比如 4bit 量化后的 Llama、Phi 之类能生成自然语言。我自己的选型经验是做 token 级的任务比如 embedding、分类、实体抽取优先 Transformers.js手头已经有一堆 ONNX 模型想在扩展里灵活加载选 ONNX Runtime Web要做端侧对话助手并且接受几秒到几十秒的生成延迟再看 WebLLM。选型时不要被“先进”忽悠要看你最终想解决的任务类型。3.2 我最终的选型路线Transformers.js WebGPU我目前的主力方案是 Transformers.js 加 WebGPU 后端。选择它不是因为性能绝对最优而是因为工程效率最高。它对扩展场景有几个天然加分项模型参数通过device: webgpu一行切换内置了量化和缓存机制不容易写错前后处理。举个例子做文本 embedding 发给向量数据库传统 ONNX 方案要自己加载 tokenizer、手动构造 input_ids、attention_mask然后处理 hidden state。而 Transformers.js 里就是一句import { pipeline } from xenova/transformers; let extractor null; async function getEmbedder() { if (!extractor) { extractor await pipeline(feature-extraction, Xenova/bge-small-en-v1.5, { device: webgpu, dtype: q8 }); } return extractor; }这段代码里device: webgpu让它优先走 GPUdtype: q8让它加载量化模型。第一次运行会慢一些因为要下载模型并初始化之后就是复用同一个extractor实例不会再重复加载。3.3 模型转换与量化把几百MB下载体积打下来扩展场景里模型体积是生死线。你做一个 500MB 的扩展用户连安装都嫌烦。所以模型一定要做转换和量化。常规流程是这样的先从模型仓库拿到原始 PyTorch 权重通过optimum-cli转成 ONNX 格式再做量化。以我常用的 BGE-small 模型为例原始权重约 110MB转成 ONNX 后大概也是 100MB 上下量化为 int8q8后能压到 30MB 左右。这个体积对扩展来说基本可以接受配合 gzip 或 brotli 压缩后还能再小一点。量化精度损失要看任务。embedding 类任务我实测下来q8 对检索效果的影响很小也就 1% 到 2% 的分数浮动分类任务也几乎无感。真正要小心的是摘要和生成类任务量化太狠会让输出质量肉眼可见地下降所以摘要任务我倾向用 fp16而不是 q8。这个取舍建议你把自己真实语料跑一遍再定。3.4 引擎初始化与单例封装写一个不崩的推理入口很多扩展崩溃不是因为模型烂而是初始化逻辑写得稀烂。最常见的问题是每次弹窗打开都重新初始化一次模型内存直接翻倍。我把推理入口封装成单例同时处理 Service Worker 被回收后再创建的恢复逻辑。一个简化版的骨架大概是这样的class InferenceService { constructor() { this.extractor null; this.initializing null; } async getExtractor() { if (this.extractor) return this.extractor; if (!this.initializing) { this.initializing this.createExtractor().finally(() { this.initializing null; }); } return this.initializing; } async createExtractor() { const extractor await pipeline( feature-extraction, Xenova/bge-small-en-v1.5, { device: webgpu, dtype: q8 } ); this.extractor extractor; return extractor; } async run(text) { const extractor await this.getExtractor(); const output await extractor(text, { pooling: mean, normalize: true }); return Array.from(output.data); } }关键点是this.initializing这个 Promise 缓存。它保证并发请求只会触发一次初始化避免重复加载同时初始化失败后会把缓存清掉下次还能重试。这套骨架我几乎每个端侧推理项目都复用属于投入产出比极高的防御式写法。4. 工程实现规范从“能跑”变成“能上线”4.1 权限最小化与CSP合规先减小扩展的攻击面浏览器扩展一旦集成模型它要读取本地模型文件要做网络请求下载模型要在页面上下文执行脚本。权限边界要是模糊不仅审核难过也更难保证用户数据安全。我的规范第一条是权限最小化。不要一把梭地申请all_urls、webRequest、tabs这种高权限。做页面摘要一般只需要activeTab、scripting、storage三项如果模型全部打包进扩展甚至不需要网络权限。权限越少用户越不慌Chrome 商店审核也越顺利。第二条是严格设置 CSP。MV3 默认禁止eval但你要注意远程代码加载。模型仓库用 CDN 下发时必须在manifest.json的 CSP 里白名单限域。我在不少项目里看到有人为了图方便关掉安全策略这是非常危险的扩展里的 Content Script 有页面权限一旦被注入恶意脚本后果远大于一个普通网页被 XSS。4.2 消息通信协议别再用随手起的消息名内容脚本、弹窗、Service Worker 之间大量通信如果消息名随手起比如sendMsg、getResult两个版本之后绝对会乱。我把消息协议当成一个小型 API 来设计。统一消息结构是{ id: uuid-xxx, // 每次请求唯一 type: inf_req, // 消息类型枚举 payload: { task: embed, text: ... }, ts: Date.now() }响应结构也要统一{ id, ok: true, result: {...} }失败时{ id, ok: false, error: { code, message } }。错误码不要随意用字符串全部收敛成常量比如MODEL_NOT_READY、ENGINE_NOT_SUPPORTED、TASK_TIMEOUT。我给常用的消息类型做了个表放在团队规范里消息类型方向说明inf_reqContent Script → Service Worker发起推理请求inf_progressService Worker → Content Script上报排队、加载、推理进度inf_resService Worker → Content Script返回推理结果model_status任意 → Service Worker查询模型加载状态engine_error任意 → Service Worker上报引擎异常这样做的好处是日志查起来特别舒服。线上出问题翻开日志能明确看到哪个环节的类型不对、哪个 ID 没有关闭。4.3 任务调度与并发控制推理任务请排队Service Worker 里的推理任务有个硬约束它不能长时间存活也不能同时在主线程上跑太多计算。我有一次踩坑就是 Content Script 在十个页面同时发推理请求结果十个任务齐头并进加载模型几秒后内存飙到快 2GB扩展直接崩溃。后来我强制所有推理请求走队列严格串行执行。任务队列内部要有几个状态pending、running、done、failed还要支持优先级插队。比如用户手动点选的请求可以排到自动触发的请求前面。简易队列代码const queue []; let running false; async function enqueue(task, priority 0) { return new Promise((resolve, reject) { queue.push({ task, resolve, reject, priority, ts: Date.now() }); queue.sort((a, b) b.priority - a.priority || a.ts - b.ts); pump(); }); } async function pump() { if (running || queue.length 0) return; running true; const { task, resolve, reject } queue.shift(); try { resolve(await task()); } catch (e) { reject(e); } finally { running false; pump(); } }记得给每个队列任务加超时时间。推理引擎如果 30 秒还没返回基本上就是模型加载卡死或引擎内部死锁这时候主动 reject 掉给用户一个“请重试”的反馈比一直转圈好得多。4.4 三级缓存策略第二次打开扩展不再干等模型加载慢是端侧推理体验差的最大原因。用户第一次点击看到三秒以上的加载转圈可能就流失了。我试过把模型文件下到storage.local但 MV3 里storage.local存大文件有性能问题后来我改用 Cache Storage API配合包内置模型做了一个三级缓存。第一级是内存缓存模型跑起来之后就驻留在内存里后续请求直接调用。第二级是 Cache Storage模型文件第一次从模型仓库下载后写入 Cache Storage后续加载从本地缓存读不再走网络。第三级才是扩展包内置模型那部分随扩展一起安装适合体积小、必须可用的核心模型。加载顺序是有内存直接用内存没有内存查 Cache Storage没有缓存再读取内置包或请求网络。这一套下来第二次打开扩展的加载时间能从十几秒降到一两秒几乎是质变。4.5 失败降级方案不能让用户对着白屏发呆端侧推理最坏的情况不是慢而是彻底不可用。WebGPU 在老机器上可能不支持模型下载也可能失败甚至浏览器本身不支持某些 API。所以工程实现里必须设计多层降级。我的降级顺序是WebGPU → WebAssembly → 规则引擎。模型推理失败后如果还有 WASM 版本的引擎就切过去WASM 也没有的话扩展不能直接罢工而是退回基于正则或关键词的简单规则处理结果。虽然结果粗糙但至少用户在功能上还有回应。所有降级路径都要给用户一个明确的提示例如浮动角标显示“当前设备不支持 GPU已切换 CPU 模式”。不要静默降级那会让用户觉得功能不稳定。实际上大多数用户很宽容他们能接受“这个功能需要高性能设备”的诚实说明。5. 端到端实操做一个人工智能页面摘要扩展5.1 功能定义与流程设计纸上谈兵没什么意思我拿一个实际做过的页面摘要扩展当案例。功能需求很简单用户点击扩展工具栏图标扩展读取当前页面正文在本地生成一段摘要和三个关键词。交互流程我设计成四步。第一步点击图标后 Popup 发起一个summary_req请求。第二步Service Worker 通知 Content Script 抓取article或main标签下的正文文本清理掉脚本、样式和无关链接。第三步Service Worker 把文本交给推理引擎调用摘要模型生成结果。第四步结果回传 Popup 展示同时支持复制到剪贴板。这里有个细节Content Script 抓正文不能全部一股脑发给模型。模型输入长度有限通常要先做截断或分段处理。我习惯取前面 8000 字符超过部分按段落截断丢弃如果页面有标题和 meta description会一起传进去作为补充。5.2 从manifest到推理调用关键代码怎么落Manifest V3 的基础配置大约长这样{ manifest_version: 3, name: Smart Page Summarizer, version: 1.0.0, permissions: [activeTab, scripting, storage], background: { service_worker: src/background.js, type: module }, content_scripts: [ { matches: [http://*/*, https://*/*], js: [src/content.js] } ], action: { default_popup: popup.html }, web_accessible_resources: [ { resources: [models/*, wasm/*], matches: [http://*/*, https://*/*] } ] }后台拿到正文后调用摘要模型的代码大致是import { pipeline } from xenova/transformers; const summarizer await pipeline( summarization, Xenova/distilbart-cnn-6-6, { device: webgpu, dtype: q8 } ); const result await summarizer(text, { max_length: 120, min_length: 40, do_sample: false });max_length和min_length控制摘要长度。这套配置在普通办公本上一段 5000 字的网页正文推理耗时大约 3 到 6 秒。对于摘要这种非实时交互场景是可以接受的。5.3 实测性能与资源占用我实测过几台机器的数据差异很大这里给个参考范围。环境GPU支持首次冷启动推理耗时峰值内存增量Win 11 RTX 3060WebGPU8~12s2~3s300MBMacBook M1WebGPU6~10s3~4s350MB老旧Win10本核显不支持回落WASM12~18s8~15s500MB可以看到WebGPU 支持与否直接决定体验上限。所以我在工程规范里加了一条正式发布前一定要在无 GPU 的设备上跑一遍降级路径。这个很容易被开发时的主力机掩盖掉。6. 踩坑实录与调试技巧6.1 我踩过的四个坑第一个坑是 Service Worker 被冻结。MV3 的 Service Worker 不干活就会被回收我早期测试时配好所有代码把 Popup 关掉再打开发现推理请求根本没送达排查半天才发现是 Service Worker 已经“休眠”需要点击扩展图标重新激活。现在我的处理方式是所有请求都带上唤醒逻辑且在 Service Worker 里不依赖全局状态而是每次从缓存和 IndexedDB 恢复。第二个坑是 WebGPU context 丢失。页面切后台、电脑休眠、GPU 驱动重置都可能让 WebGPU 上下文失效。推理引擎抛出异常后如果不重建 pipeline后续所有请求都会失败。所以我给引擎封装加了一层错误监听异常后自动dispose()并重建实例。第三个坑是模型文件路径写错导致加载了旧的缓存模型。模型升级后用户可能还在用旧缓存行为和结果都对不上。现在我的模型管理模块会把模型版本号写进 Cache Storage 的 key 里例如bge-small-q8-v3版本变了就自然失效。第四个坑是内存持续增长。调试时发现每次调用pipeline都会在 WASM 堆里留下内存不显式释放的话几十次推理后占用高到吓人。换成单例之后问题基本消失但如果你在同一个页面频繁创建短生命周期的pipeline一定要用env.backends.onnx.wasm.proxy等机制仔细管理。6.2 调试扩展推理的实用组合拳调试扩展里的推理比普通前端麻烦但也有一套实用打法。首先是chrome://extensions页面找到你的扩展点击 Service Worker 链接能打开独立的 DevTools这里能看到后台日志、网络请求、存储内容。其次是chrome://gpu可以确认当前浏览器是否启用了 WebGPU、图形处理器型号和特性支持情况。这能帮你区分“引擎代码问题”和“硬件不支持问题”。第三是针对模型加载链路打点。我用performance.mark(model-load-start)和performance.mark(model-load-end)在关键节点打点再用performance.measure统计耗时把数据上报到调试面板。另一个隐性技巧在扩展里开启 verbose 日志开关把env.ONNX_LOG_LEVEL调整到 info能直接看到 WASM 后端的执行日志非常有助于判断算子是否走了 CPU fallback。6.3 常见问题速查表问题现象可能原因快速排查方向推理结果返回 undefined引擎没有初始化成功在 Service Worker 日志里看pipeline是否报错检查模型文件是否加载WebGPU 不可用强制回落 WASM浏览器版本或显卡驱动不支持打开chrome://gpu看 WebGPU 状态升级浏览器模型下载到一半失败网络波动或缓存 Key 冲突清掉 Cache Storage 对应 key 后重试给下载加断点续传扩展重载后推理全部失效Service Worker 全局状态丢失不在顶层缓存 extractor改为按需懒加载用 Cache API 恢复扩展内存占用持续上涨重复创建 pipeline或未释放中间结果强制单例化避免在循环里调用pipeline()这套表我直接放在扩展仓库的 README 里线上用户反馈问题时我第一句话一般是“打开 Service Worker 的 Console把报错复制给我”。有这张表兜底大部分问题靠日志就能快速定位不用下载用户环境逐台复现。最后说点个人体会。这套东西如果让我重新做一次我会把接口契约和模型版本管理放在最前面先画好消息协议再写代码先把模型体积压到最小再谈功能。做端侧 AI 扩展就像经营厨房菜刀锋利、灶台稳定、食材新鲜前厅再忙都不会乱。浏览器扩展不是一个装模型的黑盒而是一套有生命周期、有权限边界、有资源约束的应用系统端侧 AI 推理也不该是“临时调个接口”的玩具它值得按基础设施的标准来建设。如果这篇笔记能帮你少踩几个版本的坑那我这几个月的折腾就没白费。