浏览器跨域存储API在Transformers.js中的应用
Hugging Face展示Web标准API在本地化ML库中的前沿应用,对前端开发者有技术参考价值。
Hugging Face展示Web标准API在本地化ML库中的前沿应用,对前端开发者有技术参考价值。
Transformers.js 通过针对特定任务的 pipeline,为 Web 开发者提供了一种简单的方式,让他们能够在 Web 应用中利用 Transformer 的强大能力。要在浏览器中运行推理,开发者需要创建一个 pipeline() 实例,并指定希望该 pipeline 执行的任务。下面是一个具体示例,展示了如何设置自动语音识别(ASR)pipeline。
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0';
const asr = await pipeline(
'automatic-speech-recognition',
'Xenova/whisper-tiny.en',
{ device: 'webgpu' },
);
const result = await asr('jfk.wav');
console.log(result);
你会注意到,我在源代码中将 Xenova/whisper-tiny.en 指定为模型。对于常见的英语自动语音识别任务来说,这是一个非常不错的选择。事实上,根据所链接摘录中介绍的 Transformers.js 默认模型解析机制,它甚至就是默认模型。
在浏览器中运行这个示例时,Transformers.js 会自动下载并缓存相关的模型资源和 Wasm 文件。下面的截图展示了访问该应用后,Chrome DevTools 中的 Cache Storage 部分。重新加载页面时,资源将从 Cache API 提供,模型几乎可以立即返回结果。
然而,Xenova/whisper-tiny.en 是一个热门模型,而且如前所述,它甚至还是 Transformers.js 中 ASR 任务的默认模型,因此完全可以想象,你访问的不止一个应用都会使用它。为了模拟这种情况,下面仍然是之前的示例应用,但它由另一个源提供服务。当你访问这个不同源上的应用时,它无法像之前那样几乎立即可用,浏览器反而必须重新下载并缓存所有模型资源,即使这些资源与之前的版本逐字节完全相同。即使在这个玩具示例中,重复下载和存储的数据也达到了 177 MB,你可以在 Chrome DevTools Application 面板的 Storage 部分查看。可以想象,这些数据很快就会累积起来。
Wasm 运行时资源
但情况还会变得更糟。让我们为这个玩具示例添加第二个 pipeline:情感分析。情感分析默认使用 Xenova/distilbert-base-uncased-finetuned-sst-2-english 模型。如果不指定模型,Transformers.js 的默认模型解析机制就会自动为你选择它。
const classifier = await pipeline('sentiment-analysis');
const sentiment = await classifier(result.text);
console.log(sentiment);
这是两个完全不同的 AI 模型,但它们都依赖同一个大小为 4,733 kB 的 ort-wasm-simd-threaded.asyncify.wasm WebAssembly(Wasm)运行时文件。这个文件来自 Transformers.js 底层所基于的 ONNX Runtime 库。在另一个源上打开扩展后的演示,你会在 Network 选项卡中看到,Wasm 运行时也会被再次下载和缓存。
因此,即使你运行的应用并不共享相同的 AI 模型,浏览器仍然会为你已经拥有的共享 Wasm 资源发起冗余请求,除此之外还会再次缓存这些资源,占用硬盘空间。
默认情况下,AI 模型资源来自 Hugging Face Hub,并最终由 Hugging Face CDN 提供。浏览器会请求类似 https://huggingface.co/Xenova/distilbert-base-uncased-finetuned-sst-2-english/resolve/main/config.json 的资源,然后在这个示例中,它会被重定向到最终的 CDN URL,例如 https://huggingface.co/api/resolve-cache/models/Xenova/distilbert-base-uncased-finetuned-sst-2-english/0b6928efcb76139cae2c6881d49cda67fe119f42/config.json?%2FXenova%2Fdistilbert-base-uncased-finetuned-sst-2-english%2Fresolve%2Fmain%2Fconfig.json=&etag=%223c36342ef1f74de2797d667c68c6b7b988d0b87c%22。
默认情况下,Wasm 运行时资源由 jsDelivr CDN 提供。例如,在撰写本文时,ort-wasm-simd-threaded.asyncify.wasm 来自 https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0-dev.20260416-b7804b056c/dist/ort-wasm-simd-threaded.asyncify.wasm。
现在你可能会说,即使不同的应用运行在不同的源上,只要它们最终都从相同的 CDN URL 获取资源,并且最终 URL 相同,缓存就不应该成为问题。遗憾的是,浏览器缓存很久以前就不是这样工作的了。《通过缓存分区提升安全性和隐私》一文详细介绍了其中的所有细节,但本质上,为了防止时序攻击,缓存会按源隔离:网站响应 HTTP 请求所花费的时间,可能暴露浏览器过去是否访问过同一资源,从而使浏览器容易遭受安全和隐私泄露。
具体实现可能因浏览器而异,但在 Chrome 中,除了资源 URL 之外,缓存资源还会使用网络隔离键(Network Isolation Key)作为键的一部分。网络隔离键由顶层站点和当前 frame 所属站点组成。以前面托管在 https://googlechrome.github.io 和 https://rawcdn.rawgit.net 两个源上的玩具示例为例。如果它们都使用来自 https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0-dev.20260416-b7804b056c/dist/ort-wasm-simd-threaded.asyncify.wasm 的 Wasm 运行时,那么它们的缓存键将如下表所示。
https://googlechrome.github.io
https://googlechrome.github.io
https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0-dev.20260416-b7804b056c/dist/ort-wasm-simd-threaded.asyncify.wasm
https://rawcdn.rawgit.net
https://rawcdn.rawgit.net
https://cdn.jsdelivr.net/npm/onnxruntime-web@1.26.0-dev.20260416-b7804b056c/dist/ort-wasm-simd-threaded.asyncify.wasm
因此,即使资源 URL 完全相同,由于网络隔离键不匹配,也不会命中缓存,这意味着资源会被重复下载和重复存储。这正是跨源存储提案旨在解决的挑战。
跨源存储 API 登场
💡 注意:跨源存储 API 仍是一个尚未定稿的早期提案。虽然任何浏览器目前都尚未原生实现这项拟议中的 API,但你无需等待即可进行试验。安装 Cross-Origin Storage 扩展,即可在所有页面中注入 navigator.crossOriginStorage polyfill,并测试完整流程。
拟议中的跨源存储(Cross-Origin Storage,COS)API 引入了专用的 navigator.crossOriginStorage 接口,Web 应用可以通过该接口跨越源边界存储和检索大型文件。这些文件并非通过 URL 标识,而是通过加密哈希标识。
最后这一点,也就是加密哈希,非常关键。由于 COS 根据文件的哈希而不是 URL 或来源来识别文件,因此,无论两个源分别从哪里获取文件,你在访问 https://googlechrome.github.io 时下载的 ort-wasm-simd-threaded.asyncify.wasm Wasm 运行时,都会被识别为与 https://rawcdn.rawgit.net 即将请求的文件完全相同。下面的代码片段展示了基本流程。
const hash = {
algorithm: 'SHA-256',
value: '8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4',
};
try {
const handle = await navigator.crossOriginStorage.requestFileHandle(hash);
// Cache hit! Get the file as a Blob and use it directly.
const fileBlob = await handle.getFile();
} catch {
// Cache miss. Download from network, then store for next time.
const fileBlob = await fetch('https://cdn.jsdelivr.net/.../ort-wasm-simd-threaded.asyncify.wasm')
.then(r => r.blob());
const handle = await navigator.crossOriginStorage.requestFileHandle(
hash,
{ create: true, origins: '*' },
);
const writableStream = await handle.createWritable();
await writableStream.write(fileBlob);
await writableStream.close();
}
如果资源位于 COS 中,你会获得一个 FileSystemFileHandle,并可以通过 getFile() 直接读取 Blob(返回的 File 继承自 Blob)。如果资源不在 COS 中,则回退到网络获取,并将资源写入 COS,供下一个需要它的应用使用。这个应用可能是你自己的应用,也可能是另一个毫不相关、甚至位于完全不同源上的应用。
该 API 的设计有意仿照 File System Standard 中的 FileSystemDirectoryHandle.getFileHandle(),你可能已经通过源私有文件系统(Origin Private File System,OPFS)API 熟悉了它。hash 参数在这里扮演的角色与 OPFS 中的 name 参数相同:唯一标识一个资源。options.create 标志的工作方式也相同:缺省或设为 false 表示只读访问;打算写入时则设为 true。
控制谁可以读取哪些内容
并非所有资源都应该在全局范围内共享。存储文件时,COS 允许开发者通过 origins 选项精确控制其可见性。
将 origins: '*' 设置为星号,会使文件在全局范围内可用。任何源都可以通过哈希找到它。对于 Transformers.js 示例中的 AI 模型资源或 Wasm 运行时来说,这是正确的选择:其核心目的就是让 Web 上的每个应用都能受益于同一份缓存副本。
传入明确的来源列表,例如 origins: ['https://write.example.com', 'https://calculate.example.com'],会将访问权限限制在这些网站。这非常适合在公司自有站点之间共享、且不应被其他任何人发现的专有资源,例如商业办公套件中使用的专有校对 AI 模型。
如果完全省略 origins,文件将仅对同站来源可用。对于需要在一个组织的所有子域名之间共享、但不应跨越组织边界的资源,这是一个合理的默认设置。
有一条重要规则:可见性只能升级,绝不能降级。如果一个文件已经全局可用,之后尝试使用受限的 origins 列表存储该文件时,这次尝试会被静默忽略。这可以防止恶意行为者重新存储公共资源并缩小其可用范围。反向操作则是允许的:最初使用受限 origins 列表存储的文件,之后可以放宽访问权限。任何网站——不只是最初的存储者——都可以针对同一个哈希调用 requestFileHandle()(哈希并不是秘密),并传入 create: true 和范围更广的 origins 值;只要浏览器验证哈希匹配,从那一刻起,该资源就会对更广泛的受众开放。请注意,执行升级的网站仍然必须通过返回的句柄写入完整文件。这项要求是为了防止网站利用升级路径作为侧信道,探测某个特定文件是否已经存储在 COS 中。
COS 有一个细微但非常重要的特性:写入文件时,浏览器会验证哈希。如果写入的数据与声明的哈希不匹配,写入操作会失败并报错。这让完整性校验自动完成:从 COS 读取文件的应用可以确信,自己获得的正是预期的字节。这与应用通过网络下载后自行计算哈希所获得的保障相同。
事实证明,这在 Transformers.js 场景中有双重价值。如今,下载模型权重后,大多数应用实际上无法验证 CDN 是否提供了正确的字节。借助 COS,无论文件来自哪里——官方 Hugging Face CDN,还是某个随机网站自行托管的镜像——存储中的每个文件都会在写入时得到隐式验证。
当然,跨来源共享缓存会从相反方向引出与分区 HTTP 缓存相同的问题:如果任何网站都能通过哈希探测某个文件是否存在,那么攻击者是否可以通过检查某个游戏引擎的 Wasm 模块是否已被缓存,来获知用户浏览历史中的某些信息?
COS 通过两种互补机制解决这个问题:
第一种是 origins 字段:不应被全局探测的专有资源,就不应该使用 origins: '*' 存储。通过开发者教育,我们鼓励开发者在适当的场景中认真考虑这一点。
第二种是可用性门控:即使文件被声明为全局可用,如果浏览器尚未在足够多的不同来源中遇到该文件,也可能拒绝确认它的存在。只出现在一两个网站上的文件仍可能充当跨站标识符,因此无论文件是否真实存在于磁盘上,浏览器都可能像它根本不存在一样返回错误。在 Chrome 团队内部,我们意识到不常见资源可能导致的隐私泄露,并计划总体上通过限制可缓存的具体资源来缓解这一问题。具体的缓解措施仍在完善之中。
关键在于,这意味着错误并不是一个确定的答案。它可能表示“尚未存储”,也可能表示“已经存储,但浏览器不告诉你”。应用应始终以相同方式处理:回退到网络请求。
回到前面的玩具示例:ort-wasm-simd-threaded.asyncify.wasm 运行时大小为 4,733 kB,并且所有由 Transformers.js 驱动的应用都会共享它,无论应用使用的是哪个 AI 模型。借助 COS,第一个加载它的应用只需下载一次,然后使用其 SHA-256 哈希并设置 origins: '*' 进行存储。之后的每个应用,无论位于 https://googlechrome.github.io、https://rawcdn.rawgit.net 还是其他任何来源,都可以立即在 COS 中找到它。那份重复下载、大小为 177 MB 的 Whisper 模型权重呢?也是一样:Xenova/whisper-tiny.en 只需下载一次,第二次就能通过哈希识别出来,并在几毫秒内从 COS 提供。当然,Xenova/distilbert-base-uncased-finetuned-sst-2-english 也同样如此。
Transformers.js 本身已经在库级别试行 COS API。拉取请求 #1549 引入了一个实验性的 COS 缓存后端,通过一个选择性启用的标志进行控制。在设置 pipeline 之前,只需添加一行即可启用:
import { env, pipeline } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.2.0";
// 👇 Opt in to the experimental Cross-Origin Storage cache backend.
env.experimental_useCrossOriginStorage = true;
const asr = await pipeline('automatic-speech-recognition', 'Xenova/whisper-tiny.en', { device: 'webgpu' });
const result = await asr('jfk.wav');
console.log(result);
请注意,该标志带有 experimental_ 前缀。这是有意为之,表示底层浏览器 API 尚未标准化,可能会在没有主版本号升级的情况下发生变化。设置该标志后,Transformers.js 会通过获取原始 Xet 指针(原始指针文件示例)并提取其中的 oid sha256: 字段,解析每个由 Xet 跟踪的模型文件(即大型 ONNX 权重文件)的 SHA-256 哈希。随后,它会使用该哈希作为 navigator.crossOriginStorage 的键。如果模型已经存在于 COS 中——因为另一个网站之前已将其存入——就会立即提供模型,无需经过网络往返。如果不存在,它会回退到常规下载,并将结果存入 COS,供下一个调用方使用。对于这个玩具示例,实际优势在于:Xenova/whisper-tiny.en、Xenova/distilbert-base-uncased-finetuned-sst-2-english(当然还有 ort-wasm-simd-threaded.asyncify.wasm)无论被多少个不同来源请求,都只需要通过网络传输一次。
这个玩具示例使用 Xenova/whisper-tiny.en 就能很好地工作,但如果用户的 COS 缓存中已经有其他 Whisper 变体,你当然也可能愿意直接使用。例如,用户可能已经拥有 Xenova/whisper-large-v3,顾名思义,它比 tiny 变体大得多。Transformers.js 的 Model Registry 让应用可以灵活选择模型。如果你知道应用的需求可以由 Xenova/whisper-tiny.en、whisper-medium.en 或 Xenova/whisper-large-v3 中的任意一个满足,就可以在 registry 中查询每个模型的关联文件,探测它们是否存在于 COS 缓存中(缓存中可能包含所需模型资源的一部分或全部),然后决定最终选择哪个模型。ModelRegistry.is_pipeline_cached() API 直接集成了 COS(当然也集成了 Cache API),因此这项操作非常符合人体工程学。
COS API 尚未在任何浏览器中原生实现,但你不必等到那时才开始试用。安装 Cross-Origin Storage 扩展,即可在所有页面中注入 navigator.crossOriginStorage polyfill,并测试完整流程。查看该扩展的源代码,并按照使用说明开始操作。
安装扩展后,现在就可以尝试完整的端到端体验:打开启用了 COS 的第一个玩具示例,让它加载 Xenova/whisper-tiny.en,然后从第二个来源打开启用了 COS 的玩具示例。此时不会再像之前那样重新下载 177 MB 的内容,而是会在几毫秒内从 COS 提供模型。打开扩展的弹出窗口后,你可以看到 COS 的实际工作情况。如果选择 View by Resource,就能看到 SHA-256 哈希为 950978b1dbcbf250335358c1236053ba19a7f7849b33dc777f4421b72b7626fa 的资源在 https://googlechrome.github.io 和 https://rawcdn.rawgit.net 之间共享。虽然看起来可能并不明显,但通过与 Hugging Face 上的 SHA-256 哈希进行比较,你可以验证自己看到的正是 https://huggingface.co/Xenova/whisper-tiny.en/blob/main/onnx/decoder_model_merged.onnx。目前,该扩展主要面向像你这样的高级用户。等浏览器原生实现后,浏览器的 Settings 页面中将提供更友好的集成。下方截图展示了扩展的弹出窗口,其中 View by Resource 标签页处于启用状态,你可以看到共享资源、它的哈希,以及在 COS 缓存中拥有该资源的两个来源。
如果你正在构建自己的 Transformers.js 应用,行动方式很简单:在首次调用 pipeline() 前添加 env.experimental_useCrossOriginStorage = true,安装扩展程序,然后观察 Network 标签页中重复的下载请求逐一消失。每多一个网站选择启用该功能,其他网站用户的使用体验都会变得更快、成本更低。选择启用完全没有风险:如果由于用户未安装 COS 扩展程序而无法支持 COS API,代码只会回退到默认路径(Cache API)。
Transformers.js 并不是唯一一个尝试使用 COS 的项目。WebLLM(需主动启用,参见文档)和 wllama(自动启用,参见 PR)同样对这项拟议中的 API 充满期待。
Chrome 团队正在考虑在浏览器中原生实现 COS API。作为一项尚处于早期阶段的提案,我们欢迎大家就 API 以及提案本身的设计形式提供反馈。你可以在 Cross-Origin Storage 仓库中提交 issue、表达支持或发起 PR。
本文提及的模型 3
博客中的更多文章
如何在 Chrome 扩展程序中使用 Transformers.js
Transformers.js v4:现已发布至 NPM!
· 注册或登录后发表评论
本文提及的模型 3