做浏览器端抠图的时候,我给「极致质量」档的大模型做了分块缓存:解压后的模型按 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 是同一个请求。
于是发生了一连串覆盖:
- 9 个分块依次写进同一条记录,后一块覆盖前一块;
- 最后写的清单又把最后一块覆盖了,这条记录只剩一段 29 字节的 JSON;
- 下次加载时,开头那句不带片段的
cache.match(cacheKey)正好命中这条记录; - 这 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 切块存大文件:
- 键里不要出现
#,需要子键就用路径段。 - 写完逐块读回,数一下条数。覆盖不会报错,
keys()的数量对不上就是被覆盖了。 - 读回的字节交给解析器之前,先验魔数和体积下限,验不过就当未命中。
- 一个本该跑很久的任务很快就失败了,先怀疑它没跑到那一步,再去怀疑资源不够。
这段代码来自 抠图侠 浏览器端本地推理的模型加载部分(本地路径不上传图片;站点另有云端去背景接口,那条会传图,两者是分开的)。
利益相关:我是抠图侠的开发者。 本文部分内容包含 AI 辅助创作