機器學習模型現在已經能在瀏覽器裡執行了。而且不是玩具——一個把歌曲拆成人聲和伴奏的音源分離模型,一個帶聲音克隆的文字轉語音引擎,兩者都編譯成 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 ——在瀏覽器端完成人聲分離、降噪和文字轉語音,沒有一個會上傳你的檔案。如果你想看的是訊號處理這一側、而不是分發這一側, 為什麼相位抵消會把低音和人聲一起帶走 講的就是這個。