Cache API 会丢掉 URL 里的 #:我的 9 个模型分块一直写在同一条缓存上

做浏览器端抠图的时候,我给「极致质量」档的大模型做了分块缓存:解压后的模型按 32MB 一块切开,分别 cache.put 进 Cache API,最后再写一份清单,记下块数和总字节数。下次打开页面,先读清单,把块拼回来,再交给 onnxruntime-web 建会话。

这个档位上线后的表现是:勾上「极致质量」,等一会儿,提示失败,退回标准档。我一直以为是内存不够,大模型加上解压缓冲,16GB 的机器确实有点紧。后来查下去才发现,它从上线起就没成功过一次,原因和内存没有关系。

当时的写法

js 复制代码
// 写:每块 32MB,最后写清单
for (let i = 0; i < count; i++) {
  const slice = buf.slice(i * CHUNK, Math.min(total, (i + 1) * CHUNK));
  await cache.put(`${cacheKey}#${i}`, new Response(slice));
}
await cache.put(`${cacheKey}#meta`, new Response(JSON.stringify({ count, total })));

// 读:先试整条,读不到再按清单拼
const cached = await cache.match(cacheKey);
if (cached) return new Uint8Array(await cached.arrayBuffer());

key#0、key#1......key#meta,一个前缀下挂一串子键,写的时候完全没觉得有问题。

Cache API 不认 #

Cache API 比较请求时,会先把 URL 的片段(# 后面那部分)去掉再比,put 也是这样。所以在它眼里,key#0、key#1、key#meta 和不带片段的 key 是同一个请求。

于是发生了一连串覆盖:

  1. 9 个分块依次写进同一条记录,后一块覆盖前一块;
  2. 最后写的清单又把最后一块覆盖了,这条记录只剩一段 29 字节的 JSON;
  3. 下次加载时,开头那句不带片段的 cache.match(cacheKey) 正好命中这条记录;
  4. 这 29 字节被当成模型交给 ORT,报 ERROR_CODE: 7, Failed to load model because protobuf parsing failed。

29 字节能对上账。在生产站上直接读那条缓存,内容是:

json 复制代码
{"count":9,"total":272200717}

272200717 字节按 32MB(33554432 字节)一块切,向上取整正好 9 块;这串 JSON 本身是 29 个字符。

片段被忽略这件事也能当场复现。我在生产站上另开了一个测试 cache,依次写 x#0、x#1、x#meta,keys() 只剩一条,三个片段读回来都是最后写进去的那个值。想自己试的话,控制台里大概是这样:

js 复制代码
const c = await caches.open("frag-test");
await c.put("/x#0", new Response("0"));
await c.put("/x#1", new Response("1"));
await c.put("/x#meta", new Response("meta"));
(await c.keys()).length;                 // 只有一条
await (await c.match("/x#0")).text();    // 读回来是最后写的 "meta"

为什么我一直以为是内存

回头看,最早该注意的是时间。一个要拼几百 MB、建会话要好几秒的流程,如果几秒内就失败退回,说明它根本没走到吃内存的那一步。同一份模型字节,在同一个页面里手工建会话(graphOptimizationLevel: "all"、8 线程),8.4 秒建好,推理也正常。

修法

改了两处。

一是分块键改用路径段,不再用片段:

js 复制代码
const chunkKey = (cacheKey, part) => `${cacheKey}/part/${part}`;

二是缓存读回来以后,先确认它还是一个 ONNX 模型,再交给 ORT:

js 复制代码
const looksLikeOnnx = (bytes) => bytes.byteLength > 1024 * 1024 && bytes[0] === 0x08;

ONNX 文件是 protobuf,ModelProto 的第一个字段是 ir_version(field 1,varint),编码后首字节就是 0x08。我拿线上标准档模型的前 64KB 解压看了一眼,开头是 08 09,也就是 ir_version = 9。再加一条大于 1 MiB 的体积下限,29 字节 JSON 这种情况就直接挡掉了。

验不过就删掉那条缓存,当作没缓存,重新下载:

js 复制代码
const cached = await cache.match(cacheKey);
if (cached) {
  const bytes = new Uint8Array(await cached.arrayBuffer());
  if (looksLikeOnnx(bytes)) return bytes;
  await cache.delete(cacheKey).catch(() => {});
}

第二道检查不只是为了这个 bug。浏览器会截断或驱逐缓存,读回来的字节不一定完整。在入口处验一下,比让 ORT 报一个 protobuf 解析错误、再回头猜原因省事得多。

最后在项目的逻辑测试里加了断言:分块键必须是路径段,源码里不能再出现 ${cacheKey}# 这种写法,整条和分块两条读回路径都要先过 looksLikeOnnx。

自查清单

如果你也在用 Cache API 切块存大文件:

  1. 键里不要出现 #,需要子键就用路径段。
  2. 写完逐块读回,数一下条数。覆盖不会报错,keys() 的数量对不上就是被覆盖了。
  3. 读回的字节交给解析器之前,先验魔数和体积下限,验不过就当未命中。
  4. 一个本该跑很久的任务很快就失败了,先怀疑它没跑到那一步,再去怀疑资源不够。

这段代码来自 抠图侠 浏览器端本地推理的模型加载部分(本地路径不上传图片;站点另有云端去背景接口,那条会传图,两者是分开的)。

利益相关:我是抠图侠的开发者。 本文部分内容包含 AI 辅助创作

相关推荐
keyipatience2 小时前
TypeScript学习(1)
前端·javascript·学习·typescript·html·css3
xifangge20252 小时前
基于 JavaScript 实现 CNC 数控切削参数与螺纹公差纯前端核算引擎
开发语言·前端·javascript
honkun63 小时前
vue 表格组件 vxe-table 修改滚动条样式,修改滚动条样式,统一宽度、颜色与自动隐藏
前端·javascript·vue.js
FRing033 小时前
JavaScript入门到精通二十四(正则表达式、元字符、修饰符)
开发语言·前端·javascript·正则表达式
大明二代4 小时前
从 RAG 到有记忆的 Agent:Mastra Memory 与 PostgreSQL 实战
前端·javascript·人工智能
小羊没烦恼!5 小时前
C#综合揭秘——Entity Framework 并发处理详解
java·开发语言·前端·javascript·数据库·mysql·c#
OpsEye5 小时前
模型能力波动怎么办?网关侧做模型质量监控与自动切换
javascript·ai编程
西红柿炖牛腩3548 小时前
机房电脑不能联网,怎么离线把课件里的 3D 模型压小?Draco / Meshopt 参数清单
前端·javascript·3d·性能优化·大屏端
爱喝水的小周8 小时前
实验十三 Vue 过渡与动画—电商后台消息中心实现
前端·javascript·vue.js