1. 为什么要把模型搬进浏览器标签页
第一次认真琢磨“端侧视觉 AI 跑在浏览器里”这件事,是因为一个很具体的场景:给一个内部工具加图片识别能力,用户上传一张图,前端直接给出结果。按老思路,这活儿得后端扛——图片传到服务器,模型推理,结果回传。但真做起来问题一堆:图片涉及用户隐私,上传链路要加密、要脱敏、要合规;服务器 GPU 成本按小时烧;网络一抖动,体验直接崩。后来我换了个思路,把模型直接塞进浏览器标签页,图片压根不出本地,推理在用户自己的机器上完成。实测下来,这条路不仅走得通,而且在很多场景下比后端方案更省心。
这篇东西想聊的就是这件事的“工程真相”。不是那种“Hello World 跑通就完事”的教程,而是把端侧视觉 AI 在浏览器里落地时会遇到的真实问题摊开讲:模型怎么选、怎么转、怎么加载、怎么跑得快、怎么不把用户内存吃爆。关键词里提到的端侧视觉AI、神经网络、浏览器、WebGL、WASM,基本就是这条链路上的五个核心节点。适合谁看?前端工程师想给项目加 AI 能力、算法同学想把模型推到端上、或者单纯对“浏览器里跑神经网络”好奇的技术人,都能从里面找到能直接抄的东西。
先说清楚一个前提:浏览器里跑神经网络,不是要把训练也搬进来,那是另一回事。我们聊的是推理,也就是模型已经训练好了,拿来做前向计算。这个边界很重要,因为推理和训练对算力、内存、精度的要求完全不是一个量级。把边界划清楚,后面的选型和优化才有意义。
2. 浏览器里跑推理,到底靠什么在算
2.1 JavaScript 直接算:能跑,但别当真
最朴素的做法是用纯 JavaScript 实现前向传播。矩阵乘法用嵌套循环,卷积用滑动窗口。我早期试过一个很小的手写数字识别模型,参数量不到十万,在桌面浏览器上单张推理大概几十毫秒,勉强能用。但一旦换成稍微像样的视觉模型,参数量上到百万级,纯 JS 的循环开销就压不住了,单张图几秒起步,页面直接卡死。
纯 JS 的问题不在于语言本身慢,而在于它没有针对数值计算做底层优化。矩阵乘法这种操作,在原生环境里可以用 SIMD 指令、可以用多线程、可以用缓存友好的内存布局,而 JS 的数组是对象,每次访问都有额外的类型检查和边界检查。所以纯 JS 方案只适合教学演示或者极小的模型,真上生产环境,得换路子。
2.2 WASM:把 C++ 的算力搬进浏览器
WASM(WebAssembly)是第一个真正能打的方案。它的思路是:把用 C/C++ 或 Rust 写的高性能推理代码编译成浏览器能执行的字节码,运行在浏览器的沙箱里,速度接近原生。像 ONNX Runtime Web、TensorFlow.js 的 WASM 后端,底层都是这个逻辑。
WASM 的优势在于通用性。它不依赖 GPU,纯 CPU 就能跑,兼容性极好,老设备、低端机都能用。我实测过一个量化后的 MobileNet 变体,WASM 后端在普通笔记本上单张推理能压到几十毫秒,比纯 JS 快了一个数量级。而且 WASM 支持多线程(需要浏览器开启对应的跨源隔离头),能把多核 CPU 利用起来,进一步提速。
但 WASM 也有天花板。CPU 的并行能力有限,遇到大模型或者高分辨率输入,延迟还是会上去。这时候就得上 GPU。
2.3 WebGL:借显卡的并行算力
WebGL本来是给网页做 3D 渲染的,但它本质上是一套能操作 GPU 的接口,而神经网络的卷积、矩阵乘法恰好是高度并行的计算,天然适合 GPU。把卷积运算映射成着色器程序,让 GPU 的几千个核心同时干活,速度能比 WASM 再快好几倍。
WebGL 方案的关键在于“怎么把神经网络的计算表达成图形渲染的管线”。卷积可以看成是滑动窗口的加权求和,矩阵乘法可以看成是纹理采样和累加。TensorFlow.js 的 WebGL 后端就是干这个的,它把每一层运算翻译成对应的着色器,数据在 GPU 的纹理里流转。我实测过一个中等规模的卷积网络,WebGL 后端比 WASM 快了大概三到五倍,输入分辨率越高,优势越明显。
不过 WebGL 的坑也不少。GPU 内存有限,模型太大或者中间特征图太多,会直接爆显存;不同显卡、不同驱动对浮点精度的支持不一致,偶尔会出现结果对不上的情况;还有上下文丢失的问题,页面切到后台或者系统资源紧张时,WebGL 上下文可能被回收,得做恢复处理。
2.4 WebGPU:下一代方案,但还不到全面铺开的时候
WebGPU是 WebGL 的继任者,提供了更底层、更现代的 GPU 访问能力,支持计算着色器,写神经网络推理比 WebGL 自然得多。它的性能上限更高,内存管理也更合理。但目前浏览器支持还不统一,部分平台还在实验阶段,生产环境用起来得做降级方案。我的建议是:现在可以关注、可以试,但主力还得放在 WASM 和 WebGL 上。
把这几条路放一起对比,选型逻辑就清楚了:
| 方案 | 适用场景 | 优势 | 主要限制 |
|---|---|---|---|
| 纯 JS | 教学、极小模型 | 零依赖、易调试 | 速度慢,大模型不可用 |
| WASM | 通用推理、低端设备 | 兼容性好、CPU 多线程 | 大模型延迟高 |
| WebGL | 中大型视觉模型 | GPU 加速、速度快 | 显存限制、精度差异 |
| WebGPU | 新项目、追求性能 | 性能上限高、API 现代 | 浏览器支持不统一 |
实际项目里,我通常的做法是优先 WebGL,失败降级 WASM,再失败降级纯 JS。这样既能在好设备上拿到性能,又能在差设备上保证可用。
3. 模型从训练到浏览器,中间要过几道关
3.1 格式转换:ONNX 是绕不开的中间站
训练出来的模型,不管是 PyTorch 还是 TensorFlow,都不能直接丢进浏览器。浏览器端推理引擎认的格式主要是ONNX和TensorFlow.js 自有格式。ONNX 的好处是生态中立,PyTorch 可以导出 ONNX,TensorFlow 也可以,然后 ONNX Runtime Web 直接加载。
转换这一步最容易出问题。我踩过的坑包括:算子不支持(某些自定义层 ONNX 没有对应实现)、动态维度处理不当(输入尺寸写死导致换分辨率就报错)、精度丢失(导出时默认 float32,但量化后对不上)。转换完一定要做数值校验,拿同一张输入图,对比原模型和转换后模型的输出,误差在可接受范围内才算过关。
3.2 量化:把模型压小,但别压垮精度
浏览器端的内存和带宽都有限,原始 float32 模型动辄几十上百兆,加载慢、占内存。量化就是把权重和激活从 float32 降到 int8 甚至更低,模型体积能缩小到四分之一,推理速度也能提升。
但量化不是免费的午餐。int8 量化会引入精度损失,对分类任务影响可能不大,但对检测、分割这种对位置敏感的任务,掉点会很明显。我的经验是:优先用量化感知训练(QAT),在训练阶段就模拟量化误差,让模型适应;如果只能用训练后量化(PTQ),一定要在验证集上仔细评估,掉点超过阈值就放弃。
还有一个细节:浏览器端量化模型的加载,需要推理引擎支持对应的量化算子。ONNX Runtime Web 对量化支持不错,但也不是所有算子都覆盖,转换后要实测。
3.3 模型切分与懒加载:别让首屏等模型
一个几十兆的模型,如果首屏就同步加载,用户得盯着白屏等好几秒。我的做法是按需加载:页面先渲染出来,模型在后台异步下载,下载完再初始化推理引擎。如果模型特别大,还可以做分片加载,把权重切成几块,先加载主干网络,让用户能先用上基础功能,细节层再慢慢补。
这里有个容易忽略的点:模型文件的缓存。浏览器对静态资源的缓存策略要配好,用 Cache Storage 或者 Service Worker 把模型缓存到本地,第二次访问就不用重新下载了。我见过有人每次刷新都重新拉模型,流量和体验双输。
4. 让推理跑得稳:内存、线程与首帧延迟
4.1 内存管理:浏览器标签页不是无限资源
浏览器标签页的内存是有上限的,尤其是移动端。一个视觉模型加载进来,权重占一块,中间特征图占一块,输入输出缓冲区再占一块,加起来很容易上百兆。如果页面还有其他重资源,直接触发标签页崩溃。
我的做法是:推理完立刻释放中间张量。很多推理引擎默认会保留中间结果以便反向传播,但推理场景不需要,得显式关掉。另外,输入图片要先缩放到模型需要的尺寸,别拿原图直接喂,一张 4K 图转成张量就是几十兆,纯属浪费。
还有一个隐蔽的坑:WebGL 纹理不释放。用 WebGL 后端时,每次推理都会创建纹理,如果不主动清理,显存会越用越多,最后上下文丢失。TensorFlow.js 提供了dispose()方法,用完的张量一定要调。
4.2 多线程与 Worker:别阻塞主线程
推理是计算密集型任务,放在主线程会卡住 UI,用户点什么都没反应。正确做法是把推理放进Web Worker,主线程只管交互和渲染。Worker 里跑 WASM 或 WebGL,算完把结果 postMessage 回主线程。
WASM 多线程需要浏览器支持 SharedArrayBuffer,而 SharedArrayBuffer 要求页面开启跨源隔离(COOP/COEP 响应头)。这个配置在部署时经常被忽略,导致多线程用不起来。如果没法改响应头,就只能单线程跑,性能会打折扣。
4.3 首帧延迟:用户等不了三秒
端侧推理的体验瓶颈往往不在单张推理速度,而在首次推理的延迟。模型加载、引擎初始化、着色器编译、第一次前向计算,这些加起来可能好几秒。优化手段包括:预热(加载完先跑一次空推理,把着色器编译好)、模型缓存、以及把非关键路径的初始化延后。
我实测过一个方案:页面加载时先显示占位图,模型在后台加载,加载完自动跑一次预热推理,用户真正上传图片时,推理已经是“热”的,延迟从两三秒降到几百毫秒。这个体验差距是巨大的。
5. 那些文档里不会写的坑
5.1 不同浏览器的行为差异
同样是 Chrome,桌面版和移动版对 WebGL 的支持就不一样;Safari 对 WebGL 的浮点纹理支持有历史包袱,某些精度格式用不了;Firefox 在 WASM 多线程上的表现又和 Chrome 不同。我做过一个兼容性矩阵,发现同一个模型在不同浏览器上的推理结果偶尔会有微小差异,原因是浮点运算顺序不同导致的累积误差。
应对办法是:在目标浏览器上做完整回归测试,别只在开发机上跑通就上线。对于精度敏感的任务,可以在推理后做一次结果校验,超出阈值就降级到 WASM 后端重算。
5.2 模型版本与缓存失效
模型更新是常事,但浏览器缓存会导致用户还在用旧模型。我的做法是给模型文件名带上版本号或哈希,更新时改文件名,强制浏览器重新拉取。同时保留一个版本检查接口,页面启动时问一下服务端最新版本,不一致就触发更新。
5.3 输入预处理的性能陷阱
很多人把注意力全放在模型推理上,忽略了输入预处理的开销。把一张图片从<img>或<canvas>转成模型需要的张量格式,涉及缩放、归一化、通道重排,这些操作如果写得不好,可能比推理本身还慢。我的经验是用OffscreenCanvas在 Worker 里做预处理,避免主线程阻塞,同时尽量用 GPU 做缩放,别用 JS 逐像素循环。
6. 一个可复现的最小落地路径
如果你现在就想动手,我给一条我实际用过的路径:
- 选模型:从 MobileNet、YOLO-nano 这类轻量视觉模型起步,别一上来就上大模型。
- 导出 ONNX:用 PyTorch 的
torch.onnx.export,注意固定输入尺寸,导出后做数值校验。 - 量化:用 ONNX Runtime 的量化工具做 int8 量化,在验证集上确认掉点在可接受范围。
- 前端集成:用 ONNX Runtime Web,优先 WebGL 后端,配置 WASM 降级。
- 放进 Worker:推理逻辑全部在 Worker 里,主线程只负责 UI。
- 加缓存:用 Service Worker 缓存模型文件,带版本号管理。
- 预热与释放:加载完跑一次预热推理,每次推理后释放中间张量。
这套流程我在几个内部工具上跑过,桌面端单张推理能稳定在百毫秒级,移动端稍慢但可用。关键是把降级和缓存做扎实,别让用户因为设备差就用不了。
最后分享一个我踩过的坑:有一次模型在开发机上跑得好好的,上线后部分用户反馈结果全错。排查了半天,发现是那批用户的显卡驱动对某个浮点纹理格式支持有问题,导致 WebGL 计算结果异常。后来加了结果合理性校验,异常时自动切到 WASM 后端,问题就消失了。端侧推理的复杂性,很多时候不在模型本身,而在这些千奇百怪的运行环境上。