把 ONNX 模型放进浏览器跑,本地开发一切正常,部署到线上就挂。下面四个坑我全踩过,共同点是报错信息指向的位置和真正的原因隔了一层。每条给出实际报错、根因和修法。
坑一:只传了 .wasm,没传 .mjs
报错:
no available backend found.
ERR: [wasm] TypeError: Failed to fetch dynamically imported module:
https://example.com/models/ort-wasm-simd-threaded.mjs
根因:ONNX Runtime Web 加载 WASM 是两段式的。ort-wasm-simd-threaded.wasm 是运算二进制,但它由一个同名的 .mjs 胶水模块负责实例化,运行时通过动态 ESM import 去取。只部署 .wasm,运行时找不到任何可用后端。
容易漏是因为直觉上”模型 + 运行时 js + wasm”三个文件听起来已经齐了。
修法:把 npm 包 dist/ 目录下与你所用后端对应的 .mjs 一并部署,版本必须和 ort.min.js 严格一致。核对方式是比对 .wasm 的 sha256 与 CDN 上同版本的是否一致,避免版本错配。
坑二:nginx 不认识 .mjs
症状:文件确实传上去了,curl 返回 200,浏览器里还是同一条报错。
根因:nginx 1.24 的 mime.types 里没有 .mjs 条目。文件会以 application/octet-stream 返回,而浏览器拒绝执行非 JavaScript 类型的模块脚本。.js 和 .wasm 都在默认映射里,唯独 .mjs 不在。
$ grep -nE "wasm|javascript" /etc/nginx/mime.types
8: application/javascript js;
55: application/wasm wasm;
# 没有 mjs
修法:加一条规则。注意不要用内层 types { } 块——它会替换而不是补充继承来的映射表,容易把其他类型一起弄坏。用 default_type 更安全,因为它只在映射表查不到时生效:
location ~* \.mjs$ {
default_type text/javascript;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
验证:只看 HTTP 200 不够,必须看类型。
curl -sI https://example.com/models/ort-wasm-simd-threaded.mjs \
| grep -i content-type
# 必须是 text/javascript 或 application/javascript
坑三:配置比读它的脚本晚到
症状:功能入口是灰的,没有任何报错。
根因:这是 WordPress 的坑,但同类问题在任何有依赖顺序的打包体系里都会出现。wp_localize_script 把配置挂在某一个 handle 上,配置的 <script> 会紧挨着那个 handle 打印。如果读配置的模块是它的依赖,依赖必然先加载——此刻配置还不存在。
// 模块顶层读取 —— 此时 window.myConfig 还是 undefined
var CFG = window.myConfig || {};
var PATHS = CFG.paths || {};
window.MyModule = {
available: !!(PATHS.runtime && PATHS.model) // 永远是 false
};
实测页面里的实际顺序:ai.js 在字节位置 70843,配置在 70998,ui.js 在 75007。依赖比配置早 155 字节。
修法:不要在加载时读配置,改成调用时读;把布尔值改成 getter,让它在被访问的那一刻求值:
function paths() {
return (window.myConfig || {}).paths || {};
}
Object.defineProperty(api, 'available', {
get: function () {
var p = paths();
return !!(p.runtime && p.model && window.WebAssembly);
}
});
坑四:多线程需要跨源隔离
症状:设了 numThreads > 1,控制台报 SharedArrayBuffer is not defined,或者静默退回单线程。
根因:WASM 多线程依赖 SharedArrayBuffer,而它要求页面处于跨源隔离状态——服务端必须同时发送:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
问题是 require-corp 会波及页面上所有跨源资源:第三方字体、统计脚本、广告、CDN 图片,全都必须带上 Cross-Origin-Resource-Policy 或走 CORS,否则直接被拦。对一个普通内容站点来说,为了推理快一点而给全站加这两个头,代价通常不划算。
修法:接受单线程,显式写死,别让它去尝试再退回:
ort.env.wasm.numThreads = 1;
ort.env.wasm.simd = true; // SIMD 无需跨源隔离,保留
ort.env.wasm.wasmPaths = MODEL_DIR;
SIMD 和多线程是两回事,SIMD 不需要任何额外响应头,该开。
坑五:大文件传输被静默截断
症状:InferenceSession.create() 抛出协议解析错误,或者干脆卡住。服务器上文件大小看着对,curl 也返回 200。
根因:模型动辄几十上百 MB。传输链路上任何一环中断——ssh 掉线、CDN 超时、代理掐连接——都可能留下一个大小不对但存在的文件,而 HTTP 状态码依然是 200。我这次部署一个 84 MB 的模型,脚本里的验证 curl 设了 180 秒超时,读回来只有 25 MB 就返回了成功。
脚本报告: isnet-anime-fp16.onnx -> 200 25524967B
服务器实际: 88070593 bytes
如果只看那行 200,会以为部署成功了。
修法:大文件用 rsync 传,它支持断点续传且自带校验;部署后用哈希核对而不是大小:
rsync -h --partial --inplace --timeout=120 \
-e "ssh -o ServerAliveInterval=15" "$src" "$remote:$dest"
# 部署后核对(注意超时要给足,84MB 在弱网下可能要几分钟)
LOCAL=$(shasum -a 256 model.onnx | cut -d" " -f1)
REMOTE=$(curl -s --max-time 900 "$BASE/model.onnx" | shasum -a 256 | cut -d" " -f1)
[ "$LOCAL" = "$REMOTE" ] || echo "传输不完整"
顺带一个容易忽略的点:校验脚本本身的超时值也是 bug 来源。上面那个 180 秒不是”网络问题”,是我给的时间不够,而 curl 超时后仍然以 0 退出并报告已下载的字节数。
关于模型体积的一点权衡
这几个坑都绕开之后,还有一个决策要做:模型用什么精度。它直接决定用户首次使用要下载多少。
同一个分割模型的三个版本:
fp32 167.9 MB
fp16 84.0 MB WASM 加载 79 ms
int8 42.1 MB WASM 加载 337 ms
反直觉的是体积最小的 int8 加载最慢——量化图需要额外解析量化参数、插入反量化节点,这部分开销在会话初始化时付掉。推理耗时三者接近(单线程 WASM 下都在 7 秒量级),所以 int8 唯一的优势就是下载量。
而 int8 的代价可能很大:在我的实测里,它对模型不确定的输入会严重退化。如果你的模型要面对多样的真实输入,fp16 通常是更稳的选择——体积是 int8 的两倍,但精度基本无损。
部署前的核对清单
这四个坑的共同点是本地开发环境不会暴露它们——本地 dev server 通常自带正确的 MIME 映射,打包器会把依赖顺序理顺,静态资源目录是整个拷贝的。所以核对必须在真实线上环境做:
# 每个运行时资源都要看状态码 + 类型,不能只看状态码
for f in ort.min.js ort-wasm-simd-threaded.wasm ort-wasm-simd-threaded.mjs; do
curl -sI "$BASE/$f" | grep -iE "^HTTP/|^content-type"
done
# 大文件要核对字节完整性,弱网下截断不会报错
curl -s "$BASE/model.onnx" | sha256sum
最后一条尤其重要:我遇到过多次 curl 返回 200、内容却被截断的情况。只有哈希能证明传输完整,状态码不能。