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

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

浏览器里的 no available backend found 不是一个足够具体的诊断:运行时缺文件、模块 MIME 错误、配置读取过早、线程条件不满足,都可能让后端初始化失败。本文按四道检查关口排查,先确认“拿到了什么”,再判断“执行到了哪里”。这些问题也可能在本地出现,只是开发服务器往往替我们隐藏了其中一部分。

2026-09-08 修订:本次核对本站现有 ONNX Runtime Web 1.20.1 资源,实际运行四个下载对照和两个模型接口检查。纠正旧文“curl 超时仍以 0 退出”的说法,删除无法据现有材料复核的精度与速度排名。历史部署记录与本次合成实验分开,不把后者当作原事故重演。

第一关:资源是否齐全,正文是否完整

本站这一套资源包括 ort.min.js、ort-wasm-simd-threaded.mjs、ort-wasm-simd-threaded.wasm 和 isnet-anime-fp16.onnx。本次检查的确切大小如下;文件名、完整 SHA-256 与接受的响应类型见资源清单。

资源 字节数
JavaScript 入口 446,284
ESM 胶水模块 24,618
WASM 二进制 11,246,032
FP16 模型 88,070,593

模型是 88.07 MB,约 83.99 MiB;不要把十进制 MB 与二进制 MiB 混用。这份清单记录本站当前文件,不是上游签名。升级运行时时应从同一次构建选取配套文件,不能用一个 WASM 哈希代替全部资源的版本核对。其他后端或构建需要的文件可能不同,参见 ONNX Runtime 部署文档。

旧部署检查里有 curl ... || true,随后仅输出 HTTP 状态码和已收到的字节数。这里丢失的是失败退出状态,不是 curl 把超时判成成功。状态码 200 可以在响应头到达时就确定,之后正文仍可能中断。curl 手册中,18 表示部分文件传输错误,28 表示超时。

为了把这个区别变成能复跑的检查,transfer_faults.py 在回环地址临时启动 HTTP 服务,期望对象固定为 bytes(range(100))。四个请求全部返回 200,实际结果如下。这是人为故障实验,不是历史生产日志。

对照 实际结果
正文中断 声明 100 字节,仅发送 25 字节后关闭;curl 退出 18。
正文超时 发送 25 字节后停顿;1 秒上限触发退出 28。
错误对象 声明并完整发送 25 字节;curl 退出 0,但不匹配预期对象。
正确对象 完整发送 100 字节;退出 0,长度与预期哈希均匹配。

因此检查顺序是:保留退出码,确认最终响应,再核对长度与预先记录的可信哈希。下载完才给文件算一个哈希、却没有参照值,只能给这个文件命名,不能证明它正确。也不要忽略管道的失败状态,把 curl | sha256sum 最后一段成功误认为前面的下载成功。

实验包的 fetch_assets.py 下载到临时文件,保留 TLS 证书校验,仅允许 HTTPS 跳转;所有检查通过后才在同一目录改名。失败时删除该临时文件并停止。实际部署大文件也应先上传暂存路径、核对后再原子替换;直接对公开文件使用 rsync --inplace,可能让读者在上传期间读到半成品。本轮未改动站点的部署脚本。

第二关:文件存在,浏览器是否愿意执行

这一版运行时需要通过 .mjs 加载对应 WASM。缺少模块、下载到登录页或 404 页面、响应类型错误,都值得先查;同一条“动态导入失败”也可能来自路径、CORS 或 CSP,不能只凭报错断言缺文件。

ES 模块有严格的 JavaScript MIME 检查。本站此次下载的 .mjs 类型符合要求;但 Nginx 是否认识扩展名取决于实际配置及其映射文件,不能笼统断言某个 Nginx 版本一定没有条目。检查最终响应的 Content-Type,同时检查文件正文,不要只用 HEAD 的 200 作结论。参见 JavaScript 模块说明。

展开限定单个文件的 Nginx 配置示例
# Example only: confirm the actual document root before use.
location = /public-downloads/anime-matting/ort-wasm-simd-threaded.mjs {
    root /srv/personal-site/current;
    types { text/javascript mjs; }
    default_type text/javascript;
    try_files $uri =404;
}

这里故意只匹配一个模块:内层 types 会取代该作用域继承的类型映射,放到整个静态目录可能影响其他文件;default_type 只是没有匹配类型时的后备值,不会覆盖已有错误映射。合并规则前检查现有 location、root 和继承的安全头,执行 nginx -t 后再按运维流程发布。配置语义见 Nginx types。这里没有给可变文件名添加一年 immutable 缓存,也没有改变全站 MIME 或响应头。

第三关:配置在读取那一刻是否存在

本站源码中,AI 模块是 UI 脚本的依赖,而 personalSiteCharLibConfig 附着在 UI handle 上。依赖可能先执行。如果模块顶层把不存在的配置读成空对象,再保存一个布尔值,稍后配置到达不会自动更新那个布尔值。下面的完整 Node 程序保留同样的时序,不依赖 WordPress 或模型。

展开完整 config_order.cjs 反例
'use strict';
const assert = require('node:assert/strict');
const page = {};
const earlyPaths = (page.config || {}).paths || {};
const frozen = { available: Boolean(earlyPaths.runtime && earlyPaths.model) };
const lazy = {
  get configured() {
    const paths = (page.config || {}).paths || {};
    return Boolean(paths.runtime && paths.model);
  }
};
assert.equal(frozen.available, false);
assert.equal(lazy.configured, false);
page.config = { paths: { runtime: '/ort.min.js', model: '/model.onnx' } };
assert.equal(frozen.available, false);
assert.equal(lazy.configured, true);
console.log(JSON.stringify({ frozen_after_config: frozen.available,
  lazy_after_config: lazy.configured, session_creation_tested: false }));
console.log('CONFIG_ORDER_OK');

运行结果是 frozen_after_config: false、lazy_after_config: true。getter 解决的是“稍后读取当前配置”,并不证明 URL 可访问、模型兼容或会话能创建。因此示例把属性叫 configured,而不是把它当作“推理可用”。本站现有 AI 模块也已采用延迟读取,本文未更改其代码。

另一种修法是用 wp_add_inline_script($consumer_handle, $code, 'before'),把配置绑定到实际读取它的脚本前,并使用 wp_json_encode 序列化数据。不是简单把它放在最终 UI 脚本前。涉及 async、defer 或模块时还需核对实际执行顺序,HTML 字节位置本身不是执行时序证明。接口细节见 WordPress 内联脚本。

第四关:线程、SIMD 与页面隔离分别检查

本站现有模块在创建会话前显式设置 ort.env.wasm.numThreads = 1,本次模型检查也使用一个线程。浏览器启用 WASM 多线程需要运行环境支持,并满足跨源隔离条件;SIMD 是另一个能力,不因单线程就必须关闭,也不因设置了开关就保证旧浏览器或任意构建支持。配置要求见 ONNX Runtime 环境选项。

常见隔离配置是 COOP same-origin 与 COEP require-corp。后者会限制不符合要求的跨源嵌入;具体是否需要 CORS 或 CORP,取决于请求方式与资源策略,不能概括成每个第三方文件都必须添加同一个头。为内容站启用隔离前,应在独立环境检查字体、登录弹窗、统计与广告等集成,不为单次推理测速直接改变全站策略。

排查记录应分别保留页面的 crossOriginIsolated、运行时版本、线程设置、后端选择、会话创建结果和第一次推理结果。仅 WebAssembly 存在或资源下载成功,仍不能替代最后两步。本文的 Node 检查不验证浏览器页面隔离,也不代表手机浏览器可用。

当前模型实际验证到了哪一步

本次在 Apple M3、arm64 macOS、Node 26.4.0 下,使用Web 版运行时的 WASM 后端,不是 onnxruntime-node 的原生 CPU 后端。先核对四个资源哈希,再创建会话。模型输入名为 img,类型 float32,形状 [1,3,1024,1024];输出 mask 为 float32 [1,1,1024,1024]。

两个输入均直接构造张量,不经过图片解码或 Canvas。第一个全部为零;第二个对坐标 0…1023 定义 R=x/1023、G=y/1023、B=(x+y)/2046,按 NCHW 存储。两次输出都包含 1,048,576 个有限数,且在 [0,1] 内。零输入最大值约 0.004766,均值约 0.0000277725;RGB 渐变最大值约 0.004996,均值约 0.0000171765。

原始记录同时保存环境、输入与输出哈希。该次会话创建约 273 ms,第一次推理约 7.234 s,第二次约 6.991 s;下载及本地模型读取不在计时内,创建阶段包括 WASM 初始化,没有重复计时试验。不能把这些数叫作浏览器加载时间,也不能据此排名 FP16、FP32 与 INT8。

实验包保存两个完整输出,每个解压后为 4,194,304 字节的小端 float32。compare_outputs.py 逐值比较,分别报告逐位相同与最大绝对差。均值和最大值相同不等于张量逐位一致。这两种合成输入没有人物与人工标注,不能证明抠图准确、FP16 无损、浏览器预处理正确或真实用户性能。

下载与复跑

下载ONNX 部署实验包,先运行不需要模型的两个反例。Python 仅使用标准库;需要本机有 curl 和 Node,输出目录须尚不存在。

python3 transfer_faults.py --output transfer-output
node config_order.cjs

# Optional: download about 95.16 MiB and run the existing model.
python3 fetch_assets.py --output assets
node probe_model.cjs assets probe-output
python3 compare_outputs.py reference probe-output

成功标记依次包括 TRANSFER_FAULTS_OK、CONFIG_ORDER_OK、ASSET_DOWNLOAD_OK、MODEL_CONTRACT_OK 和 OUTPUT_COMPARISON_OK。推理的工作内存会超过模型文件大小,不建议用资源紧张的手机复跑 Node 实验。完整说明见实验说明。

这份检查能回答“这组文件是否完整、这两个输入能否执行、错误是否被检查脚本漏掉”,不能回答模型精度是否足够。现有材料只有 FP16 文件;没有原始失败图、FP32/INT8 对照输出和计时日志时,旧文的精度与速度比较不能在本包中复核。先把部署证据与模型质量证据分开,后续实验才不会在错误的层面反复调参。

发表回复

向下探索