浏览器跑 ONNX 的四个部署坑:本地全对,线上全错
浏览器跑 ONNX 的四个部署坑:本地全对,线上全错
站内搜索
直接问 AI

浏览器跑 ONNX 的四个部署坑:本地全对,线上全错

把 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、内容却被截断的情况。只有哈希能证明传输完整,状态码不能。

发表回复

向下探索