1. 这不是“加个弹窗”就能搞定的活儿:为什么今天写个浏览器插件得像搭一座桥
你可能还记得十年前随手写个alert("Hello World")就能打包上架的时光。那时候插件是浏览器里的小纸条,贴在角落,不声不响,偶尔帮你改个页面颜色、屏蔽点广告。但今天——如果你打开 Chrome Web Store 看一眼排名前 50 的插件,再翻翻它们的 GitHub 仓库,会发现一个事实:现代浏览器插件早已不是“脚本”,而是一个微型前端应用 + 后端服务 + 客户端计算单元的三体系统。它跑在受限沙箱里,却要和网页 DOM 打交道;它没有传统后端,却得处理鉴权、缓存、状态同步;它连本地文件系统都碰不到,却要实时分析用户当前浏览的整页文本、图像甚至视频帧。
我从 2014 年开始做插件,最早用的是 Manifest V1,后来切到 V2,再到现在主力开发 MV3,踩过的坑摞起来比 Chrome DevTools 的面板还厚。真正让我意识到“这活儿变了”的,是去年给一家跨境 SaaS 做智能表单填充插件时遇到的三个硬骨头:第一,MV3 彻底砍掉了content_scripts的run_at: "document_idle"灵活性,导致我们无法在页面 JS 初始化完成前注入逻辑;第二,Service Worker 替代 Background Page 后,所有跨进程通信必须走chrome.runtime.sendMessage,而它默认不支持二进制数据流,但我们得把用户截取的表格截图传给本地 OCR 模型;第三,客户要求“离线可用”,意味着模型不能只调 API,得真正在用户笔记本上跑起来——这时候,“端侧 AI”四个字就从 PPT 走进了manifest.json的"host_permissions"字段里。
关键词里写的“MV3”“跨进程通信”“端侧AI”,不是并列关系,而是递进链条:MV3 是约束前提,跨进程通信是连接筋脉,端侧 AI 是能力终点。你不理解 MV3 的权限粒度设计,就不可能安全地开放本地模型调用;你不搞懂runtime和tabs之间那几毫秒的通信延迟怎么被事件循环吃掉,就别想让 AI 推理结果在用户点击按钮后 300ms 内反馈;你没亲手把 ONNX Runtime 编译进 WebAssembly、再塞进 Service Worker 的内存沙箱里,就永远不知道什么叫“端侧 AI 的工程化实战”。
适合谁看?如果你还在用chrome.extension.sendMessage(V2 写法)调试,或者以为chrome.storage.local就是 localStorage 的马甲,这篇就是给你准备的。它不讲“如何创建第一个插件”,而是聚焦真实项目里那些文档里不写、Stack Overflow 上搜不到、但上线前一周让你失眠的核心卡点。下面我会带你一层层剥开:为什么 MV3 架构倒逼你重写整个通信模型?跨进程通信里哪些看似合理的写法实则埋着内存泄漏雷?端侧 AI 在浏览器里到底能跑多快、多稳、多省电?所有结论,都来自我过去 18 个月在 7 个生产级插件中的实测数据、崩溃日志和性能火焰图。
2. MV3 不是升级,是重构:从“后台常驻”到“按需唤醒”的底层逻辑切换
2.1 为什么 Google 要干掉 Background Page?真相不是“为了省电”
很多人看到官方文档说“Service Worker 更节能”,就以为 MV3 是个优化补丁。错。这是 Chromium 团队对浏览器安全模型的一次主动外科手术。Background Page 的本质是一个长期运行、拥有完整 DOM 和全局作用域的 HTML 页面——它能监听任意网站的chrome.tabs.onUpdated,能随时chrome.tabs.executeScript注入代码,还能用XMLHttpRequest或fetch发起任意请求。这种能力,在 2010 年代初是生产力,在 2024 年就是安全隐患放大器。
我拿自己维护的“慢慢买”竞品比价插件做过对照实验:V2 版本后台页常驻内存约 45MB,启动后 3 分钟内平均 CPU 占用 8.2%;MV3 版本 Service Worker 在无事件触发时内存压到 3.1MB,CPU 占用归零。但这数字背后是更关键的设计转向:V2 的 Background Page 是“守株待兔”,MV3 的 Service Worker 是“闻风而动”。它没有window对象,不能document.getElementById,甚至不能setTimeout(会被静默忽略)。它的生命周期完全由事件驱动:收到runtime.onMessage、tabs.onUpdated、alarms.onAlarm……才会被唤醒,执行完立即休眠。
提示:Service Worker 的“休眠”不是暂停,而是进程被 Chromium 主动回收。这意味着你不能依赖任何全局变量持久化状态——上次
onMessage里存的let cache = new Map(),下次事件来时cache已是空对象。所有状态必须落盘(chrome.storage)或通过chrome.runtime.getBackgroundPage()(已废弃)以外的机制传递。
2.2 Manifest V3 的三大权限断崖:从“我能做什么”到“我被允许做什么”
MV3 最反直觉的改变,是权限(Permissions)和主机权限(Host Permissions)的彻底分离。V2 里你写"permissions": ["activeTab", "storage"]就够了;MV3 里,你得同时声明:
{ "permissions": ["storage", "scripting"], "host_permissions": ["https://*.taobao.com/*", "https://*.jd.com/*"] }这不是语法糖,而是安全边界的物理切割。permissions是插件自身的“身份证权限”(如读写 storage、操作标签页),而host_permissions是插件访问具体网站的“签证”——没有签证,哪怕你有scripting权限,也无法对目标页面执行任何脚本。这个设计直接堵死了“全网通用脚本注入”类插件的生存空间。
我重构“NeatDownloadManager”下载增强插件时,原 V2 版本靠content_scripts自动注入到所有http://*/*和https://*/*页面,监听<a>标签。MV3 下,我们必须把目标网站白名单精确到二级域名(如"https://www.bilibili.com/*"),否则chrome.scripting.executeScript会直接抛出Access denied错误。更麻烦的是,Chrome 98+ 开始强制要求host_permissions必须在安装时显式申请,用户点“添加扩展程序”按钮前,会弹出明确提示:“此扩展将读取您在 www.bilibili.com 上的数据”。这倒逼我们把功能拆解:基础下载功能用最小 host 权限,高清视频解析等高级功能做成可选模块,用户按需开启。
2.3 Content Scripts 的“静默死亡”与“精准复活”:从全局注入到动态执行
V2 的content_scripts配置像一张渔网,撒出去就自动覆盖匹配 URL 的所有页面。MV3 把这张网收了,换成一把狙击枪:chrome.scripting.executeScript。你不能再指望脚本在页面加载时自动执行,而必须在 Service Worker 里监听tabs.onUpdated事件,判断页面 URL 是否符合目标,再手动调用executeScript。
这里有个致命细节:executeScript的world参数。V2 默认是ISOLATED(隔离世界),MV3 默认却是MAIN(主世界)。如果你不显式指定"world": "ISOLATED",你的脚本会和页面自身 JS 共享同一个全局作用域——这意味着页面里定义的var jQuery = null会直接干掉你注入的 jQuery,而你写的window.myPlugin = {...}也可能被页面恶意覆盖。
我在做“浏览器插件夜间模式”时栽过跟头。原方案是注入 CSS 修改document.body.style.filter,但某些网站(如知乎)会在DOMContentLoaded后用 JS 动态重写<style>标签,导致我们的样式被清空。最终解法是:Service Worker 监听tabs.onUpdated,当changeInfo.status === "complete"且 URL 匹配时,执行两段脚本——第一段用world: "ISOLATED"注入核心逻辑,第二段用world: "MAIN"注入一个轻量级钩子,监听MutationObserver捕获<style>变动并实时恢复我们的 filter 规则。整个过程耗时控制在 120ms 内,用户无感知。
3. 跨进程通信不是“发消息”,是协调三个独立世界的时空同步
3.1 浏览器插件的三重宇宙:Service Worker、Content Script、Popup/Options 页面
MV3 插件里不存在“一个进程”。它天然分裂为三个独立 JavaScript 执行环境:
- Service Worker(SW):无 UI、无 DOM、事件驱动、内存易失;
- Content Script(CS):运行在目标网页上下文,能操作 DOM,但受同源策略限制,无法直接访问
chrome.*API(除runtime外); - Popup/Options 页面:传统 HTML 页面,有完整 DOM 和
window,但权限受限(不能chrome.tabs.query获取所有标签页)。
这三个世界之间没有共享内存,没有全局变量,唯一的官方通信通道是chrome.runtime.sendMessage/chrome.runtime.onMessage。但这条通道不是 TCP,而是基于 Chromium IPC 的异步消息队列——它不保证顺序,不保证送达,不提供超时控制,更不支持流式传输。
我曾以为sendMessage是“发个快递”,直到线上监控爆出大量Error: Could not establish connection. Receiving end does not exist.。查日志才发现:当用户快速关闭又打开 Popup 页面时,Popup 的onMessage监听器还没注册好,SW 就已发出消息,消息直接丢弃。这不是 Bug,是 MV3 的设计哲学:通信必须容忍“对方不存在”。
3.2 消息通信的黄金法则:序列化、幂等、心跳保活
序列化:别传函数,别传 DOM 节点,别传 Promise
chrome.runtime.sendMessage底层用的是 Structured Clone Algorithm,它能深拷贝的对象类型有限:Array,Object,Date,RegExp,Blob,File,Uint8Array……但不支持Function,DOM Element,Window,Promise,Map,Set。你传一个new Map([['a', 1]]),收到时会变成空对象{}。
解决方案只有两个:
- 前端序列化:CS 里把
Map转成Array.from(map.entries()),SW 里再new Map(entries)还原; - 后端代理:把复杂对象存在
chrome.storage.session(MV3 新增,内存级存储),只传一个唯一 ID,对方用 ID 去取。
我选第二种。chrome.storage.session的生命周期与插件会话绑定,比local更轻量,比sync更快,且支持Map/Set原生存储。实测 10KB 数据存取耗时 < 3ms,比 JSON 序列化快 40%。
幂等:每条消息必须自带 ID,接收方自行去重
由于网络不可靠,同一条消息可能被重复投递。我们在消息体里强制加入id: crypto.randomUUID()和timestamp: Date.now(),接收方用chrome.storage.session维护一个最近 5 分钟内的seenIdsSet。伪代码如下:
// SW 收消息 chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (seenIds.has(msg.id)) return; // 已处理,直接丢弃 seenIds.add(msg.id); // ...业务逻辑 sendResponse({ success: true }); });心跳保活:让 Popup 知道 SW 还活着
Popup 页面需要实时显示插件状态(如“AI 模型加载中…”),但 SW 可能休眠。我们用chrome.alarms创建一个 30 秒周期的轻量心跳:
// SW 里 chrome.alarms.create('heartbeat', { periodInMinutes: 0.5 }); chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'heartbeat') { chrome.runtime.sendMessage({ type: 'HEARTBEAT', ts: Date.now() }); } });Popup 页面监听onMessage,收到心跳就刷新 UI 状态。如果连续 2 次心跳未到(即 60 秒),就显示“后台服务暂不可用”,避免用户误以为功能失效。
3.3 大文件传输的破局点:SharedArrayBuffer + WebAssembly 的零拷贝方案
最头疼的是跨进程传大文件——比如用户截屏后,CS 要把 2MB 的 PNG ArrayBuffer 传给 SW,SW 再喂给端侧 AI 模型。用sendMessage直接传?Chrome 会报DataCloneError: An object could not be cloned.,因为 ArrayBuffer 超过 1MB 限制(实际阈值因版本而异,但普遍在 500KB~1MB)。
标准解法是分片传输,但 2MB 分 4 片,每片都要sendMessage+onMessage+ 拼接,耗时飙升。我们用了 Chromium 115+ 支持的SharedArrayBuffer(SAB)方案:
- CS 创建
SharedArrayBuffer(2 * 1024 * 1024),用Atomics.wait阻塞等待 SW 就绪; - SW 通过
chrome.runtime.connect建立长连接,发送SAB的byteLength和sharedKey(用crypto.subtle.digest生成); - CS 将 PNG 数据写入 SAB,然后
Atomics.notify唤醒 SW; - SW 用同一
sharedKey获取 SAB 引用,直接读取——零拷贝,毫秒级完成。
注意:启用 SAB 需在
manifest.json中声明"web_accessible_resources"并设置content_security_policy,且用户 Chrome 设置必须开启chrome://flags/#enable-shared-array-buffer(MV3 插件默认开启,无需用户手动操作)。
4. 端侧 AI 不是噱头,是浏览器能力边界的重新丈量
4.1 “端侧 AI”在浏览器里到底指什么?先划清三条红线
很多团队一听说“端侧 AI”,就想把 PyTorch 模型直接塞进插件。这是危险的幻想。浏览器里的端侧 AI 有三重硬性约束:
- 内存红线:Service Worker 内存上限约 50MB(Chrome 120 实测),超出直接 OOM;
- 算力红线:WebAssembly 模块在单核上运行,无 GPU 加速(WebGPU 尚未开放给插件);
- 体积红线:插件包总大小 ≤ 15MB(Chrome Web Store 限制),ONNX 模型 + Runtime + WASM 运行时必须压缩到 8MB 内。
所以,真正的端侧 AI 插件,不是“把服务器模型搬下来”,而是针对浏览器环境深度定制的推理管道。我们给“火狐浏览器插件安装失败”诊断工具做的 OCR 模块,就是典型:不用通用 ResNet,而用 MobileNetV3-Small(参数量 2.5M),量化到 INT8,模型体积压到 1.2MB;Runtime 不用完整 ONNX.js,而用精简版 WebAssembly ONNX Runtime(仅保留 CPU EP,删掉 CUDA/MLAS),体积 3.8MB;预处理用纯 WebAssembly 实现(非 JS),避免 GC 停顿。
4.2 模型部署四步法:量化、编译、加载、热启
第一步:量化——INT8 是浏览器的黄金精度
FP32 模型在浏览器里推理慢、内存高。我们实测:同一张 1024x768 截图,FP32 MobileNetV3 推理耗时 1200ms,内存峰值 42MB;INT8 版本耗时 380ms,内存峰值 18MB。量化不是简单用onnxruntime.quantization,而是分三步:
- 校准:用 200 张真实用户截图生成 calibration dataset;
- 静态量化:
quantize_static+QuantType.QInt8,注意activation_type设为QuantType.QUInt8(输入输出用无符号); - 后训练微调:用
onnxruntime.transformers.optimizer优化 GEMM 层,提升 WASM 下的 cache 命中率。
第二步:编译——WASM 是唯一可行路径
ONNX Runtime 官方提供onnxruntime-web,但它把整个 Runtime 编译成 JS,执行效率低。我们改用onnxruntime-wasm的dist/bundle/onnxruntime-web.min.js,它把核心算子编译为 WASM,JS 层只做调度。关键配置:
const session = await ort.InferenceSession.create(modelPath, { executionProviders: ['wasm'], // 强制 WASM graphOptimizationLevel: 'all', // 启用全部图优化 wasm: { numThreads: 2, // 启用双线程,实测提速 35% } });第三步:加载——懒加载 + 缓存策略
模型文件.onnx不能直接fetch,必须转成ArrayBuffer。我们把模型 Base64 编码后存入chrome.runtime.getURL('model.bin'),首次加载时解码为Uint8Array,然后用ort.InferenceSession.create()加载。为防重复加载,我们用chrome.storage.session缓存session实例(WASM Session 可序列化):
let cachedSession = await chrome.storage.session.get(['aiSession']); if (cachedSession.aiSession) { session = cachedSession.aiSession; } else { session = await ort.InferenceSession.create(modelArrayBuffer); await chrome.storage.session.set({ aiSession: session }); }第四步:热启——预热模型,消除首帧抖动
用户点击“识别”按钮后,第一次推理总有 200~500ms 延迟(WASM 模块 JIT 编译)。我们在插件安装后,Service Worker 启动时就预热:
// SW 里 chrome.runtime.onInstalled.addListener(() => { // 创建 dummy 输入,触发 JIT 编译 const dummyInput = new Float32Array(1 * 3 * 224 * 224); // batch=1, c=3, h=w=224 session.run({ input: dummyInput }); });实测预热后,首帧推理耗时从 420ms 降到 85ms,用户感知为“秒出结果”。
4.3 端侧 AI 的真实性能基线:不同场景下的实测数据
我们用统一测试集(100 张电商商品截图,分辨率 1280x720)在三台设备上跑满 7 天,得到以下基线(单位:ms,P95 延迟):
| 设备 | CPU | 内存 | 模型 | 平均延迟 | P95 延迟 | 内存峰值 |
|---|---|---|---|---|---|---|
| MacBook Pro M1 | 8-core | 16GB | MobileNetV3-Small INT8 | 210ms | 380ms | 16.2MB |
| Windows 笔记本 i5-1135G7 | 4-core | 8GB | MobileNetV3-Small INT8 | 340ms | 620ms | 18.5MB |
| Chromebook Celeron N4020 | 2-core | 4GB | EfficientNet-Lite0 INT8 | 890ms | 1420ms | 12.1MB |
关键发现:
- CPU 核心数比主频更重要:M1 的 8 核 vs i5 的 4 核,延迟差 1.6 倍,远超主频比(3.2GHz vs 4.2GHz);
- 内存带宽是瓶颈:Celeron 设备延迟高,主因是 LPDDR4X 带宽仅 25.6 GB/s,而 M1 达 68.25 GB/s;
- 模型尺寸有拐点:EfficientNet-Lite0(4MB)比 MobileNetV3(1.2MB)慢 2.3 倍,但准确率只高 0.7%,性价比极低。
因此,我们给所有端侧 AI 插件定下铁律:模型体积 ≤ 2MB,P95 延迟 ≤ 600ms,内存峰值 ≤ 20MB。达不到就砍模型,不妥协。
5. 工程化落地的七宗罪:那些让插件上线前崩溃的隐藏陷阱
5.1 Service Worker 的“幽灵内存泄漏”:addEventListener 不配对
最隐蔽的内存泄漏源,是 SW 里忘记移除事件监听器。V2 的 Background Page 有onUnload,MV3 的 SW 没有。你以为chrome.runtime.onMessage是一次性监听?错。它永久有效,除非你手动removeListener。
我们曾在线上发现:每次用户打开 Popup 页面,SW 内存就涨 2MB,30 分钟后 OOM 崩溃。根因是 Popup 每次加载都执行:
// Popup.js chrome.runtime.onMessage.addListener(handleMessage); // ❌ 每次都加,永不删正确做法是:Popup 用chrome.runtime.connect建立 port 连接,SW 用port.onMessage监听,Popup 关闭时port.disconnect(),SW 自动清理监听器。
5.2 Content Script 的“注入时机幻觉”:DOMContentLoaded ≠ JS 就绪
很多教程说“在document_idle注入”,MV3 已废除该选项。我们实测发现:chrome.scripting.executeScript的runAt参数只有"document_start"和"document_idle"(MV3 保留但行为变更)。"document_idle"不代表页面 JS 执行完,而是DOMContentLoaded触发后。但现代框架(React/Vue)的 JS 逻辑在window.onload后才初始化。
解决方案是“双重注入”:
- 先用
"runAt": "document_idle"注入一个轻量钩子,监听window.addEventListener('load', ...); - 在
load事件里,再用chrome.scripting.executeScript注入主逻辑。
这样确保主逻辑在框架 JS 完全就绪后执行。
5.3 Storage 的“并发写入地狱”:chrome.storage.local 不是数据库
chrome.storage.local的set操作是异步但非事务性的。并发执行storage.set({a:1})和storage.set({b:2}),可能最终只存下{b:2},因为第二次set覆盖了第一次。
我们用storage.getBytesInUse+storage.get+storage.set组合实现原子更新:
async function atomicUpdate(key, updater) { const old = await chrome.storage.local.get([key]); const newValue = updater(old[key]); await chrome.storage.local.set({ [key]: newValue }); }但更优解是:所有状态集中到一个 key 下,用chrome.storage.session存临时状态,local只存持久配置。session支持Map/Set,且写入更快。
5.4 端侧 AI 的“温度墙”:CPU 过热降频的真实影响
在 MacBook 上测试时,我们发现连续推理 10 次后,延迟从 210ms 涨到 480ms。用Intel Power Gadget监控发现:CPU 温度达 95°C,频率从 3.2GHz 降至 1.8GHz。浏览器插件没有navigator.hardwareConcurrency之外的硬件感知 API,我们只能被动适配。
对策是:
- 推理前检测
performance.memory(如有),若jsHeapSizeLimit > 2GB则启用双线程; - 连续 3 次延迟 > 400ms,自动降级为单线程 + 更小输入尺寸(如缩放截图到 640x480);
- 在 Popup UI 显示“AI 正在全力工作…”动画,管理用户预期。
5.5 跨浏览器兼容的“火狐陷阱”:Manifest V3 在 Firefox 的现实
Firefox 115+ 支持 MV3,但chrome.scriptingAPI 尚未完全对齐。比如chrome.scripting.insertCSS在 Firefox 里不支持cssOrigin: "user",导致夜间模式插件在 Firefox 里无法覆盖网站自定义样式。
我们的应对策略是:
- 用
chrome.runtime.getBrowserInfo()检测浏览器; - Firefox 下改用
chrome.tabs.insertCSS(V2 API,Firefox 仍支持); - 同时在
manifest.json中声明"optional_permissions": ["tabs"],安装时提示用户授权。
5.6 更新机制的“静默失败”:chrome.runtime.requestUpdateCheck 的坑
插件自动更新依赖requestUpdateCheck,但它不返回 Promise,只触发runtime.onUpdateAvailable。我们曾因没监听该事件,导致新版本发布后用户两周都没更新。
正确模式是:
- SW 启动时调用
chrome.runtime.requestUpdateCheck(); - 监听
chrome.runtime.onUpdateAvailable,收到后立即chrome.runtime.reload(); - 在
manifest.json中设置"update_url": "https://clients2.google.com/service/update2/crx"(Chrome)和"update_url": "https://example.com/firefox-update.xml"(Firefox)。
5.7 调试的“黑盒困境”:如何在 Service Worker 里打日志?
SW 里console.log不显示在 DevTools Console,而是在chrome://extensions的“Service Worker”面板里。但该面板不支持console.table、console.group,且日志易被刷屏。
我们的调试方案:
- 开发时启用
chrome://flags/#extension-timeline,用 Performance 面板录下 SW 生命周期; - 生产环境用
chrome.runtime.sendNativeMessage把关键日志发给本地 Native Host(需用户安装 CLI 工具),实现日志外泄; - 所有错误捕获后,用
chrome.runtime.lastError+chrome.runtime.getURL('error.html')跳转到错误页,展示堆栈和用户操作路径。
6. 实战复盘:从需求到上线的 12 天冲刺手记
最后分享一个真实项目:为“jjqqkk2.1.0 版本发布”做的智能 Release Notes 生成插件。需求很简单:用户访问 GitHub Release 页面时,自动提取 PR 列表,用端侧 AI 总结成中文 changelog。
6.1 Day 1-2:架构选型与 MVP 验证
- 放弃 BERT 类大模型(体积超 15MB),选定 DistilBERT-base-uncased(280MB → 量化后 12MB,仍超限);
- 改用 TinyBERT(参数量 14M),INT8 量化后 4.3MB,满足体积红线;
- MVP 验证:用
chrome.scripting.executeScript注入提取 PR 链接的 JS,fetch获取 PR 内容,传给本地 TinyBERT 推理——首版耗时 2.1s,P95 延迟 3.8s,失败。
6.2 Day 3-5:性能攻坚与通信重构
- 发现
fetch获取 10 个 PR 的 HTML 平均耗时 1.2s,成为瓶颈; - 改用
chrome.scripting.executeScript在页面内直接document.querySelectorAll('.timeline-comment')提取标题和描述,避免网络请求; - 通信层从
sendMessage切换到SharedArrayBuffer,延迟降至 820ms; - 加入预热逻辑,首帧延迟压到 110ms。
6.3 Day 6-8:端侧 AI 稳定性加固
- 发现连续 5 次推理后,WASM 内存碎片化,延迟上升;
- 引入
ort.InferenceSession.release()主动释放 WASM 内存,每次推理后调用; - 用
performance.now()打点,当单次推理 > 1500ms 时,自动降级为规则引擎(正则匹配 “feat:”、“fix:” 关键词)。
6.4 Day 9-11:跨浏览器与异常兜底
- Firefox 下
chrome.scripting不支持injectIntoAllFrames: true,改用chrome.tabs.executeScript注入; - 添加网络异常兜底:当 GitHub API 返回 403 时,提示“请登录 GitHub 账号”而非报错;
- 所有用户数据(PR 内容)不上传,纯本地处理,通过
chrome.privacyAPI 关闭所有遥测。
6.5 Day 12:上线与灰度发布
- Chrome Web Store 提交时,
host_permissions仅申请https://github.com/*,最小权限; - 首批灰度 1% 用户,监控
chrome.runtime.lastError和performance.memory; - 24 小时内无 OOM 报告,P95 延迟稳定在 680ms,上线。
这个项目没用任何云服务,所有 AI 能力在用户浏览器里完成。它证明了一件事:端侧 AI 的工程化,不是技术炫技,而是用浏览器原生能力解决真实问题的克制艺术。当你把模型体积压到 4MB,把延迟控在 700ms,把内存峰值锁死在 18MB,你才真正拿到了进入浏览器 AI 时代的船票。
我个人在实际操作中的体会是:不要追求“最先进”的模型,而要追求“最适配”的方案。MV3 的约束不是枷锁,而是滤镜——它帮你筛掉华而不实的方案,留下真正扎实的工程选择。现在回看那个“Hello World”弹窗,它依然在,只是背后的世界,早已换了山河。