机器学习模型现在已经能在浏览器里运行了。而且不是玩具——一个把歌曲拆成人声和伴奏的音源分离模型,一个带声音克隆的文字转语音引擎,两者都编译成 WebAssembly,在访客自己的处理器上执行。不用上传,没有推理服务器,也没有 GPU 账单。
推理本身反倒是容易的那一半。把 130 MB 的模型放上静态托管、再让浏览器同意运行它,前后踩了四个坑。其中三个不给你任何可用的报错信息,一个给你的结论与事实正好相反。
下面所有数字都是在已部署的产物上实测的,不是估算。
背景
pthreads 版的 WebAssembly 构建需要 SharedArrayBuffer。而浏览器只把它开放给 跨源隔离 的页面,也就是说需要三个响应头:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-origin
有意思的是第三个,大多数文章也正是在这里写错的。在 COEP: require-corp下,Emscripten 的 pthreads 工作线程,以及引擎再派生出来的嵌套 Worker,会从浏览器视为不透明来源的地方去拉取各自的子资源。 Cross-Origin-Resource-Policy: same-origin 会把这些请求挡掉。它必须设成 cross-origin,这是一个比直觉所想 更宽松 的取值,而且只作用在需要它的那一个路径前缀上、不要整站套用,因为 cross-origin 同时也允许任何网站嵌入这些资源。
在 Cloudflare Pages 上,这些要写在 _headers 文件里。第一个坑就在这里。
坑 1:Pages 会合并同名响应头,而 COOP 只能是单个令牌
为了保险,我把目录本身和它下面的所有路径都各声明了一遍:
/ai-text-to-speech/
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
/ai-text-to-speech/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
两条规则都匹配这个文档。Pages 不会 让更具体的那条规则胜出——它会把同名响应头合并成一个用逗号连接的值:
cross-origin-opener-policy: same-origin, same-origin
cross-origin-embedder-policy: require-corp, require-corp
COOP 和 COEP 都是单令牌的结构化响应头。这个值解析失败,而浏览器对解析不了的响应头采取了最糟糕的做法:它回退到 unsafe-none 而且悄无声息。控制台里没有任何关于这个响应头的警告。你得到的,反而是来自完全不相干的地方的这样一条:
DataCloneError: SharedArrayBuffer transfer requires self.crossOriginIsolated
这条消息里没有任何东西指向响应头,更别说指向一个重复了的响应头。
有两点值得记住:
*也会匹配空字符串。/prefix/*已经匹配了/prefix/本身。多加的那条精确路径规则并不是双保险,它本身就是问题所在。- 合并对
Cache-Control来说无害,因为它本来就是一串指令。但对任何语法上只有一个令牌的响应头来说,合并是致命的。
我也正是靠这个重复证明了第一点:每个响应头都恰好出现两次,只有两个模式都匹配时才可能发生。
现在,只要有文件被两条设置同一响应头的规则同时匹配,我的部署脚本就会让构建失败。这个检查只要二十行,本可以替我省下一整天。
坑 2:HTTP 缓存存的不只是响应体,还有响应头
修好 _headers 文件并部署之后,还是失败。同一个页面,同一类错误,只是换了个地方:Worker 根本起不来。那个 ErrorEvent 的 message为空, filename也为空,行号是 0。它在 Worker 创建后约 4 ms 就触发了。
原因是:浏览器的 HTTP 缓存里存着一份 tts-worker.js ,是在隔离响应头出现
之前 拉取的。缓存的响应会带着它被存下时的响应头,而那条缓存的 max-age 是四个小时。跨源隔离的页面会拒绝运行自身脚本缺少
COEP: require-corp 的 Worker——而且拒绝时不给任何细节。
所以服务器是对的,网络面板看起来也是对的,而页面加载的却是一份四小时前的、并不对的响应。
最终定位问题的诊断方法,值得留着备用:
const headers = async (url, cache) =>
[...(await fetch(url, { cache })).headers]
.filter(([k]) => k.startsWith('cross-origin'));
console.log('fresh ', await headers(url, 'no-store'));
console.log('cached', await headers(url, 'force-cache'));
如果这两者对不上,你面对的是一条被污染的缓存,而不是服务器的问题。我这里 fresh 带着全部三个响应头,而 cached 一个都没有。
两个修法都要做,单做任何一个都不够:
Cache-Control: no-cache用在前端源文件上。它们只有 13–31 KB;每次重新验证的开销,跟一场任何服务端改动都结束不了的四小时故障相比,微不足道。- 给 Worker 的 URL 加上版本号(
tts-worker.js?v=…),这样已经被污染的缓存条目就能绕过去。 每次改动隔离响应头,都要把它加一。
还有一个衍生问题,花掉的时间比这个故障本身还多:我的本地开发服务器会把任何带 ?v= 的 URL 当作 immutable来缓存。于是我一加版本号,本地就再也拿不到 Worker 的修改了——更糟的是,本地和线上恰好在我正在排查的这一块表现不一致了。
让这件事变难的,是本地与线上缓存行为的不一致,而不是缓存本身。
坑 3:单文件 25 MiB 的墙
Cloudflare Pages 会拒绝任何超过 25 MiB 的单个文件。最大的模型文件有 82.0 MiB,另一个有 37.9 MiB。
切分是显而易见的;值得写下来的是以谁为准。切分工具把每一片的路径、字节数和 SHA-256 记进一份清单,于是客户端信任的是这份清单——而不是原始文件:
models/flow_lm_main_delta_attn_flow_int8.onnx 82.0 MiB → 4 parts
models/mimi_encoder.onnx 37.9 MiB → 2 parts
Worker 里的重组过程本身平平无奇,只有两条断言值得一提——开发过程中它们都触发过:
async function fetchAsset(entry, onDelta) {
if (!entry.parts?.length) return await fetchBytes(`${BASE}${entry.path}`, onDelta);
const out = new Uint8Array(entry.bytes);
let off = 0;
for (const p of entry.parts) {
const buf = await fetchBytes(`${BASE}${p.path}`, onDelta);
if (off + buf.length > out.length) throw new Error(`${entry.path}: parts exceed the declared size`);
out.set(buf, off);
off += buf.length;
}
if (off !== entry.bytes) throw new Error(`${entry.path}: assembled ${off} of ${entry.bytes} bytes`);
return out;
}
截断的模型在加载时并不会抛异常,它只会产出看上去挺像回事的垃圾音频。一定要断言字节数。
坑 4:托管平台不压缩真正要紧的那些字节,而 HEAD 请求还对此撒谎
Cloudflare 按内容类型决定是否压缩响应。 .wasm, .js, .css 和
.html 会被自动处理。 .onnx、模型分片和声音嵌入文件都是
application/octet-stream,这类内容发出去时是 不压缩的。
这正是负载的大头。所以这些字节在打包时就预先压缩好,以原来的文件名存放,并把 Content-Encoding: br 逐个文件声明在 _headers 文件里。
逐个文件声明,不要用通配符。 压缩器会跳过所有压不出明显效果的文件,而一个没压缩过的文件如果带着 Content-Encoding: br 发出去,就是彻底损坏的。一条 /models/* 规则会把从没压缩过的文件也算进去。打包工具按实际压缩了的文件逐个生成规则——依据的是结果,而不是意图。
接下来是浪费了一个下午的部分。要检查它有没有生效,最顺手的命令是:
curl -sI https://example.net/models/big.onnx | grep -i content-encoding
它显示 content-encoding: br ,每次都是。 这是错的。 Cloudflare 对 HEAD 请求会返回这个响应头,而对于一个大的 application/octet-stream
,真正的 GET 请求根本没有压缩。读响应头得到的,恰恰是与事实完全相反的结论。
唯一可靠的检查,是数一次 GET 实际拿到的字节数:
U=https://example.net/models/big.onnx
echo "wire $(curl -s -H 'Accept-Encoding: br' "$U" | wc -c) decoded $(curl -s --compressed "$U" | wc -c)"
用管道接 wc -c,不要用 %{size_download} ——那个数的是线上传输的字节,不是解码后的字节,所以它会悄悄附和你希望得到的任何答案。
--compressed 需要一个编译时带 brotli 的 curl (curl -V | grep -i brotli).
压缩到底值多少
在已部署的分片上实测:
| 字节 | ||
|---|---|---|
| 一个 20.5 MiB 的 int8 ONNX 分片,原始 | 21,507,048 | |
| 同一分片,brotli -q 11 | 8,752,904 | 40.7% |
| 整个部署,原始 | 267.6 MiB | |
| 整个部署,实际存储 | 184.9 MiB | 省下 82.7 MiB |
40.7% 这个数比看上去有意思。那个分片里 40% 的字节都是
0x00,它的零阶熵是每字节 4.19 比特——这意味着 任何只看符号频率的编码器都不可能压到 52.4% 以下。Brotli 压到了 40.7%,稳稳低于这条下限,这具体证明了它的上下文建模是在跨字节地发现结构,而不仅仅是在数频率。量化后的权重张量里满是这种结构:成串的零、重复的指数模式、近乎重复的数据块。
如果你正在通过网络分发量化模型、却还没测过这个,这很可能是你能拿到的最便宜的收益。
还有一个,不单列一节
有一段时间,Worker 其实 一直在 报告自己的失败,只是我看不到。这些消息走的是 console.debug,而 Chrome 在默认的控制台级别下会把它隐藏。Emscripten 的标准错误输出和每一次路由失败,都被记到了一个没人会看的级别里。诊断所依赖的任何东西,都应该打在 warn 或更高的级别上。
检查清单
如果你要把一个 pthreads WASM 模型放上静态托管:
- 永远不要让两条
_headers规则在同一路径上设置同一个响应头。 在构建里断言这一点。*也会匹配空字符串。 - 设置
CORP: cross-origin只用在需要隔离的前缀上,别处都不要。 - 前端源文件用
no-cache,并给 Worker 的 URL 加版本号。 缓存下来的响应会带着它当初的响应头。 - 保持本地和线上的缓存行为一致。 把一个小时的问题拖成一天的,正是两者的不一致。
- 断言重组后的字节数。 截断的模型会产出结果,而不是抛出异常。
- 声明
Content-Encoding要逐个文件来,依据是实际压缩了哪些文件。 - 通过数 GET 拿到的字节数来验证压缩。 HEAD 会对你撒谎。
- 诊断日志至少要打在
warn或更高级别。
这些经验来自的工具是 Audiofilter ——在浏览器端完成人声分离、降噪和文字转语音,没有一个会上传你的文件。如果你想看的是信号处理这一侧、而不是分发这一侧, 为什么相位抵消会把低音和人声一起带走 讲的就是这个。