把神经网络塞进一个浏览器标签页这件事,听起来像是个玩笑,但这两年我确实一直在干这个——把各种视觉模型推到浏览器里跑端侧推理,从最早的人脸检测、手势识别,到后来给工业现场的质检设备做 Web 端辅助判定。今天不聊论文,也不贴大段理论,就讲讲把“神经网络”这个庞然大物硬塞进浏览器标签页时,那些实际工程里绕不开的坑、选型和真相。
先说结论:这件事能成,但跟你想象的可能不太一样。它跟你在 Python 里跑一套 PyTorch 模型完全是两种思维——在浏览器里,推理不是瓶颈,内存和数据搬运才是。而且,很多时候瓶颈反而不在模型本身,而在框架和浏览器之间的“翻译层”:图像解码、张量拷贝、canvas 绘制、纹理上传……每一步都在偷偷吃掉你的性能预算。这篇文章会围绕“端侧视觉 AI 在浏览器中的真实工程路径”展开,适合已经在尝试把模型部署到 Web、或者准备从零开始做 Web 端 AI 应用的开发者参考。你会看到我从模型选型、推理后端、内存调优到真实踩坑的完整记录。
1. 为什么浏览器能跑神经网络:从 WebGL 到 WebGPU 的底层逻辑
1.1 浏览器里的“并行计算”竟然靠的是显卡
很多人第一次听说“在浏览器里跑神经网络”时的第一反应是:JavaScript 这种单线程语言,凭什么跑得动深度学习模型?答案是:不靠 JavaScript 本身,靠的是 GPU。神经网络的核心计算是矩阵乘法和卷积,这两类操作天然适合大规模并行。浏览器虽然不能直接访问系统 GPU,但通过 WebGL 或 WebGPU 这样的图形接口,可以间接地、成规模地调用 GPU 做并行计算。
WebGL 从诞生起就是为图形渲染设计的,但它的可编程着色器(Shader)本质上就是一个“跑在 GPU 上的独立小程序”。于是在深度学习框架流行起来之前,就已经有人尝试把神经网络的算子翻译成着色器语言。你可以把 WebGL 的纹理(Texture)当作一个多维数组来用:一张 256x256 的纹理,就能存一个 256x256 的矩阵,而 fragment shader 天然是“每个像素并行执行一次”,恰好就是矩阵运算的并行模式。TensorFlow.js 早期版本就是靠这套思路跑起来的——把一组权重编码进纹理,把输入数据也编码成纹理,然后通过一次 draw call 触发 GPU 执行 shader 里的计算逻辑。
到了 WebGPU,事情顺畅了很多。WebGPU 是一套更底层的、贴近现代 GPU API 的抽象,能直接管理缓冲区、计算管线(compute pass),不需要再乔装成“画图”。如果你的模型支持 WebGPU 后端,推理速度通常比 WebGL 路径快不少。但实际的浏览器支持情况,比想象的复杂:Chrome 和 Edge 已经默认支持 WebGPU 了,Safari 虽然是“部分支持”,但根据我的实测,iOS 17 以后的设备表现尚可,桌面 Safari 的 WebGPU 支持依旧不太稳定。所以做工程时,我一般会按 WebGPU → WebGL → CPU 的顺序做自动降级。
1.2 为什么同一台设备,浏览器里的推理比原生慢得多
这里得说一句实话:即便有 GPU 加速,浏览器端推理速度往往还是比原生 App 慢。最核心的原因是数据链路过长。原生推理时,摄像头帧数据从传感器到内存,再进模型张量,中间拷贝次数很少。而在浏览器里,你要先从摄像头拿到视频流,把视频帧绘制到 canvas 上,再把 canvas 像素数据读出来,转成 RGBA 序列,交到推理框架手里,框架还要把这些数据上传为 GPU 纹理。这一步流程,在移动端浏览器上尤其容易造成卡顿。
我自己的项目里曾经做过一次性能剖析,一个 MobileNetV3-Small 模型在桌面 Chrome 上跑一次推理只需要 12ms,但整个“取帧→预处理→推理→后处理”完整流水线跑下来,平均耗时却高达 47ms。多出来的 35ms 中,有 20ms 花在了 canvas 像素读取和 RGBA 到 RGB 转换上,剩下 15ms 花在了纹理上传和数据拷贝上。也就是说,模型计算本身只占了整个链路的大约四分之一。这跟我们平时在服务器端优化推理时“重模型、轻数据”的习惯完全不同,在浏览器里,数据链路优化往往是性价比最高的一步。
1.3 浏览器端“推荐什么”和“能跑什么”是两个问题
这里先给新手提个醒:选模型时,不要只看它在 ImageNet 上的精度和算力。在端侧视觉 AI 场景里,内存占用、算子兼容性、输入分辨率这三个因素通常比理论计算量更致命。一个在服务器上精度很高的 ResNet-101,转成 ONNX 后动辄 150MB 以上,浏览器里连加载都费劲。而且它的标准卷积在 WebGL 后端可能只是个“勉强能跑”的状态,算子都被降级到 CPU 执行了,速度直接掉一个数量级。浏览器端的模型选型,首要原则是“轻量、算子简单、对输入尺寸敏感度低”,MobileNet 系列、EfficientNet-Lite、SqueezeNet 这类模型往往比大模型实用得多。
2. 工具链选型与模型转换:ONNX Runtime Web 与 TensorFlow.js 的实际差异
2.1 运行时选型对比:宏观路径而非微观算子
浏览器端推理目前能走的路主要有两条:TensorFlow.js 和 ONNX Runtime Web。这两条我都踩过,说下真实感受。
TensorFlow.js 的好处是生态丰富,Python 端训练的 Keras 模型可以直接转成 tfjs 格式,官方文档和社区案例也特别多,新手照葫芦画瓢基本能跑通。但它有个让我比较难受的点:版本兼容性问题。TensorFlow 主版本一升级,tfjs-converter 经常需要同步升级,而且有些自定义层或 PyTorch 训练的模型不好转换,得绕路。而且 tfjs 的 WebGL 后端在算子融合上做得一般,某些结构复杂的模型,跑起来会有明显的“算子调度开销”。
ONNX Runtime Web 则是另一条路。ONNX 本身是跨框架的中间格式,PyTorch、TensorFlow、PaddlePaddle 训练出来的模型都能转。ORT Web 支持 WebGL 和 WebGPU 双后端,算子覆盖度做得很全,并且在 x86 设备上还可以使用 WASM 的 SIMD 指令做 CPU 推理加速。如果你已经有 ONNX 模型,或者你的模型主要来自 PyTorch,ORT Web 通常会更顺。
我现在的项目基本默认走“PyTorch 训练 → 导出 ONNX → ORT Web 推理”这条链路。原因有三:其一,ONNX 格式的中间层规避了框架锁定问题;其二,ORT Web 在算子支持上更可靠;其三,WASM CPU 后端给了我一个可靠的兜底方案,WebGPU/WebGL 不可用时不至于彻底趴窝。
下面这张表是我实际使用过程中的感受总结,供大家参考:
| 对比项 | TensorFlow.js | ONNX Runtime Web |
|---|---|---|
| 模型来源 | 主要适合 TensorFlow/Keras 生态 | 适合 PyTorch、TensorFlow、PaddlePaddle 等主流框架 |
| 转换复杂度 | 需要过 tfjs-converter,部分算子需人工处理 | torch.onnx.export 后基本一步到位,算子兼容性考察需提前做 |
| GPU 加速方式 | WebGL 为主,新版本逐步适配 WebGPU | WebGL + WebGPU 双后端,另有 WASM CPU 后端兜底 |
| 适合场景 | 快速原型、Keras 老用户、前端团队主导的项目 | 跨框架、跨平台、算子复杂度高的生产级项目 |
| 对新手友好度 | 文档多、案例多 | 依赖 ONNX 生态,语义上需要一点模型转换经验 |
2.2 模型转换:不只是改个格式这么简单
ONNX 转换看着简单,实则处处是坑。我最常遇到的一类问题是:模型在 PyTorch 里跑得好好的,导出 ONNX 后在浏览器里输出结果就“飘”了。原因通常集中在算子不兼容和动态维度上。
动态维度是重灾区。很多 PyTorch 模型导出时如果不固定 batch 和输入尺寸,ONNX 里就会保留动态轴。ONNX Runtime Web 的 WebGL 后端对动态 shape 的支持很弱,轻则性能骤降,重则直接报错。我现在的习惯是导出前就固定好 batch=1 和具体输入尺寸,比如 224x224 或 256x256。这会牺牲一点灵活性,但能换来稳定性和推理速度。不要想着在浏览器里跑“任意分辨率输入”,除非你的模型本身就是全卷积结构且做好了全局池化,否则基本是在给自己挖坑。
另外,torch.onnx.export里有个opset_version参数,我建议尽量用较新的算子集版本,但不要盲目追最新——因为 Web 后端的算子支持往往滞后于新标准。我自己一般锁定 opset 11~14 之间,既能覆盖大多数模型,又不会引入太新的、Web 后端还没适配的算子。遇到个别转换失败的层,优先考虑“替换算子”而不是“死磕转换”。比如 upsample 类算子,在 ONNX 里可能有多种表达方式,换成更基础的形式往往能绕开兼容性问题。
2.3 量化与压缩:浏览器端模型的“减重”必修课
如果模型太大,浏览器会经历一个漫长的下载期,用户可没耐心等。我做过一个姿态估计模型,原始 FP32 权重有 38MB,压缩后落到 9MB,推理速度还更快了。怎么做到的?核心就两件事:int8 量化和权重裁剪。
ONNX Runtime Web 支持多种量化格式,我常用的做法是在 Python 端用onnxruntime.quantization做静态量化(static quantization),把权重从 FP32 变成 INT8。这里有个容易忽略的细节:静态量化需要一组校准数据,用来统计激活值的分布范围。校准数据集最好取自真实业务数据,不能随便拿随机数充数,否则量化后的精度损失会非常明显。
另一个技巧是权重裁剪。像 MobileNet 这类模型的最后几层全连接层其实占了很大体积,直接删除或者在导出前把全连接层替换成全局平均池化 + 更小的 FC,通常不会对精度造成太大影响,但体积能明显降下来。我做检测类模型时,还会顺手去掉 NMS 之后的冗余输出节点,让导出后的模型只保留关键输出,前端业务逻辑里再写 NMS。别嫌这些操作琐碎,在浏览器端,每省下 1MB 都是实打实的加载体验提升。
3. 前端工程架构:Worker 线程、张量生命周期与性能预算分配
3.1 千万别把推理跑在主线程上,Worker 是唯一解
看到这里你可能已经意识到,浏览器端“AI 推理”并不是一个单纯的计算问题,而是整个前端链路的工程问题。最重要的一条架构准则是:所有跟模型推理相关的脏活累活,全部放到 Web Worker 里。
我先说直观体验。最初我把推理直接塞在主线程里,本地开发时 PC 性能好,感觉还行;一旦放到中端 Android 手机上,页面直接卡成幻灯片,滑动列表都费劲。后来把所有推理逻辑迁移到 Worker,UI 线程压力才降下来。
但这里又有个容易踩的坑:Web Worker里默认没有DOM、没有window对象,并且普通 Worker 里没法直接使用OffscreenCanvas的某些方法。在处理摄像头帧数据时,你不能像主线程那样直接把video元素作为绘制源丢给drawImage。常见的做法是在主线程里用createImageBitmap把视频帧转成ImageBitmap,然后把它 transfer 给 Worker(transfer可以避免结构化克隆的额外开销,把原数据的所有权直接移交给 Worker)。在 Worker 侧再接收到ImageBitmap后,再绘制到OffscreenCanvas上做预处理。
这个步骤听起来绕,但它决定了你的摄像头端到端延迟到底是多少。每多一次数据克隆,画面延迟就会肉眼可见地增加。
另外,多线程还有一个容易忽略的收益:推理链路与渲染链路可以并行。主线程负责 UI 绘制和摄像头的帧采集,Worker 负责推理,两者不互相阻塞。但注意不要频繁地创建和销毁 Worker,开销很大。我用的是常驻 Worker 池的思路:启动时创建一个推理 Worker,整个应用生命周期内复用这一个实例。
3.2 张量生命周期管理:控制内存峰值的关键
浏览器端的另一个隐形杀手是内存峰值。一张 640x480 的 RGBA 图像大概就是 1.2MB,看着不多,但如果你在处理每一帧时不注意释放旧的纹理和张量,几秒钟就能堆到几十 MB。这在 PC 上可能没什么感觉,但在移动端 Safari 上,一旦内存超限,浏览器会直接强杀页面,连个错误提示都不给你。
我总结了一套适用于浏览器端推理的张量生命周期管理原则:能复用就不新建,能 transfer 就不拷贝,能释放就立刻释放。具体操作上,我会维护一个固定大小的张量缓冲池,比如一个用于输入图像的 Tensor 对象,每帧推理时直接往这个 Tensor 里填数据,推理完成后不等 GC 回收,而是主动dispose()掉中间结果。ONNX Runtime Web 提供了session.release()和tensor.dispose()这类接口,一定要记得调,别指望 JavaScript 的垃圾回收机制能及时处理 GPU 纹理——WebGL 纹理泄漏是出了名的难排查。
还有一个小细节:在 Worker 里处理完一帧之后,尽量把ImageBitmap和OffscreenCanvas产生的中间对象也显式释放掉,否则每帧累积的显存泄露,跑个十几分钟就会出现花屏或者设备上下文丢失(WebGL context lost)。这个问题我折腾了很久,最后发现罪魁祸首就是某一帧的纹理没有在finally块里释放干净。
3.3 性能预算分配:别把每帧都当成“必须实时”的任务
不是所有视觉任务都需要每帧推理。做摄像头实时检测时,我见过很多团队的方案是:每 30 秒一帧推理。这背后其实是性能预算分配的问题——你要想清楚你的任务应该跑在什么频率上。
推理频率应该由业务需求决定,而不是单帧耗时决定。做一个手势翻页功能,15 FPS 已经非常流畅;做边缘检测的辅助标注工具,3~5 FPS 就够用。不要为了“看起来很实时”去盲目追求 30 FPS,那会消耗巨大的 CPU/GPU 资源,导致设备过热、耗电增加,其他 UI 交互也会变卡。
我的做法是引入一个“节流调度器”:在 Worker 里维护一个令牌桶,每帧推理前检查当前时间距上一帧完成时间是否超过预设间隔,不够则跳过本帧。这样既保证了流畅度,又把推理负载控制在了可接受的范围内。你要是跟领导汇报,可以把这个叫“自适应帧率调度”,本质上就是给每帧的推理“发门票”,没有门票就排队等着。
4. 从理论到实践:一个真实的摄像头姿态检测案例
4.1 场景搭建与需求定义
这个案例说的是“用浏览器做手部关键点检测”。当时接到的业务需求是:用户在网页端做手部康复训练,需要实时看到自己的手部动作,同时系统要判断动作是否到位。这个场景的核心诉求是低成本、免安装、跨设备——用户打开浏览器就能用,这也是我选择端侧方案而不是服务端方案的关键原因。
在设计之初,我直接否定了服务端推理方案:视频帧传到服务器推理,延迟至少有 100ms 以上,而且每路视频都过公网,带宽和服务器成本都不可控。最终方案是:浏览器端推理 + 本地上传关键点结果到服务器做动作质量评估。这样传输链路的数据量能压缩很多——不再是一整串视频流,而是每帧 21 个手部关键点的坐标,一共几十个 float,毫无带宽压力。
4.2 模型选型与转换的过程记录
手部关键点检测我一开始想用 MediaPipe Hands,但 MediaPipe 走 WASM 或者 WebGL 集成比较麻烦,而且我只想要纯关键点坐标,不想引入它那一整套解决方案。最后选择了 PyTorch 训练的一个轻量级手部关键点模型,结构就是 MobileNetV2 骨干加轻量回归头,输入尺寸是 224x224。关键点回归本质上是个坐标预测问题,对全局语义信息要求没那么高,所以不需要特别大的骨干网络。
导出 ONNX 时,我固定了输入为[1, 3, 224, 224],batch 写死为 1,输出是[1, 21, 2]。21 指的是 21 个关键点,2 是归一化后的 x、y 坐标。注意,这里输出坐标是在[0, 1]范围,后续映射回 UI 时还需要注意原图和渲染画布之间的尺寸比例,这一步很多人会忽略,导致关键点位置漂移。
转换命令大致是:
torch.onnx.export( model, dummy_input, "hand_model.onnx", opset_version=12, input_names=["input"], output_names=["keypoints"], dynamic_axes=None, # 固定 shape,保证 WebGL 后端的兼容性 )无动态轴这一步很关键。如果你只导出时不设dynamic_axes,ORT Web 里基本不会出幺蛾子。转换完成后,我在 Python 里用onnxruntime做了一遍精度对比,确认输出和 PyTorch 原模型几乎一致,误差在 1e-3 量级,才放心进下一步。
4.3 前端推理链路的实现要点
前端代码的核心链路大概是酱紫的:摄像头采集(getUserMedia)→createImageBitmap转帧 → transfer 给 Worker → Worker 里绘制到OffscreenCanvas→ 转换为张量 → 推理 → 拿回关键点坐标 → postMessage 回主线程绘制。
这个顺序看着平平无奇,但每个环节都有细节。createImageBitmap的options参数里有个imageOrientation: "flipY",是专门为处理摄像头镜像准备的。用不用它取决于你的模型有没有做过水平翻转增强。如果你的训练数据没做过水平翻转,那摄像头预览进来的是镜像画面,模型输入也需要做同样的镜像处理,否则效果会崩。
在 Worker 里,我直接把ImageBitmapdrawImage到一个 224x224 的OffscreenCanvas上,一步完成了缩放和裁剪。注意如果你的源图像宽高比跟模型输入不一致,这里要决定是拉伸变形还是居中裁剪。我做的模型在训练时做了随机裁剪,所以这里选择居中裁剪,效果更自然。
// 在 Worker 内 const bitmap = event.data.bitmap; const inputCanvas = new OffscreenCanvas(224, 224); const ctx = inputCanvas.getContext('2d', { willReadFrequently: true }); // 居中裁剪逻辑:先算源图宽高比和目标宽高比的差异 const srcW = bitmap.width, srcH = bitmap.height; const aspect = srcW / srcH; let cropW = srcW, cropH = srcH; if (aspect > 1) { // 画面过宽,以高度为基准裁剪左右 cropW = srcH * (224 / 224); } else { // 画面过高,以宽度为基准裁剪上下 cropH = srcW * (224 / 224); } ctx.drawImage(bitmap, (srcW - cropW) / 2, (srcH - cropH) / 2, cropW, cropH, 0, 0, 224, 224);这里还有个优化点:OffscreenCanvas的 2D 上下文在读取像素时,如果设置了willReadFrequently: true,浏览器会切换到 CPU 后端来加速getImageData,但代价是 GPU 加速受限。如果你后续要传给 WebGL 后端推理,反而需要 GPU 路径,这俩之间是矛盾的。我实测下来,在 Worker 里画完图、转张量再传到主线程推理的方案,跟“直接在 Worker 里推理”相比差异不大,但“读取像素”这个环节用 CPU 后端反而快,所以这个标记值得开。
4.4 真实性能数据与调优前后对比
这里贴一组我在一款中端 Android 手机(骁龙 778G)上实测的数据,方便大家有直观感知:
| 阶段 | 初始方案耗时 | 优化后耗时 | 优化手段 |
|---|---|---|---|
| 摄像头帧采集(getUserMedia → ImageBitmap) | 8ms | 6ms | 用createImageBitmap替代drawImage+getImageData |
| 缩放与预处理(绘制到 224x224 画布) | 14ms | 9ms | 固定画布大小,避免每次动态调整尺寸导致上下文重建 |
| 推理(ORT Web,WebGL 后端) | 38ms | 24ms | 将模型改为 int8 量化,重启 Worker 并预热 |
| 后处理(关键点坐标回传与 UI 绘制) | 5ms | 3ms | 直接使用 transferable 对象传回,减少结构化克隆 |
优化后的完整端到端延迟在 42ms 左右,约 24 FPS,基本满足实时的业务需求。这里没有把 UI 绘制算进去,因为 UI 绘制跟推理是并行跑的,主线程不会因为画关键点而阻塞 Worker 的推理。
另一件让我印象很深的事是:模型的 int8 量化在这里产生了两个意想不到的好处——除了体积变小、速度变快,推理精度反而没有明显下降(关键点误差从原来的 3.2px 变成了 3.4px,几乎可以忽略)。原因是我用的校准集全部来自真实业务场景,覆盖了不同肤色、不同光照条件、不同手型。这说明量化不是“没有代价的免费午餐”,但也说明了校准数据选得好,代价真的可以很小。
5. 浏览器端推理的常见坑与排查经验
5.1 WebGL 上下文丢失(Context Lost)
这个坑是浏览器端做 GPU 推理最容易碰见的,而且一旦发生,整个页面基本就只剩一个黑色的画布。触发原因主要有两类:一类是显存泄漏累积到一定程度,浏览器主动“清理门户”;另一类是系统资源紧张(比如用户切了标签页、锁屏、或者打开了大量其他 GPU 任务)导致 GPU 上下文被回收。
我的排查思路很简单:监听webglcontextlost事件,一旦触发,立刻释放所有纹理和 Buffer,然后重建推理会话和渲染画布,并提示用户“图形加速已恢复”。不要试图在代码里硬扛,因为一旦 context lost 发生,之前绑定的所有 GPU 资源全都失效了。
还有个实践原则:任何 GPU 资源使用都要有对应的“释放路径”。写代码的时候,new 一个 Buffer 或者创建一张纹理时,脑子里就要想好:这个资源在哪一步会被释放?如果在每个分支都留了释放逻辑,context lost 发生的概率能低很多。
5.2 “运行速度慢”的元凶往往在算子层面
有时候你明明用了 WebGL 后端,却发现推理速度还不如 WASM CPU 快。这是很典型的问题——很多网上能下到的 ONNX 模型并非针对于 Web 端推理做过算子层面的优化。比如某些模型里的 Transpose 算子特别多,在 WebGL 上执行可能非常慢;又比如某些 Reshape 操作会产生大量中间张量,带来额外开销。
如果怀疑是算子问题,我建议先用 ONNX Runtime 官方提供的 profiling 工具跑一次、看下哪些算子耗时占比高。你也可以直接把模型跑在 WASM CPU 后端上做一个“基准对比”:如果 CPU 后端比 WebGL 还快,那基本可以确定算子层肯定有严重的“降级”情况。解决办法通常有两种:一是修改模型结构,减少 transpose、concat 这类算子;二是换用更适合 WebGL 执行的算子实现(比如把某些操作融合进卷积层)。这些操作需要一定的模型结构功底,但排查思路本身并不难。
5.3 移动端浏览器的“看不见”的性能瓶颈
同样的代码,在桌面 Chrome 上跑得飞起,拿到 iOS Safari 就卡到怀疑人生——这几乎是所有浏览器端推理项目的必经阶段。我总结了几条移动端专属的经验,你要是做移动端 Web AI,值得记一下:
Safari 的 WebGL 纹理尺寸限制比较严格,有些安卓上的 GPU 支持 8192 分辨率大纹理,但 iOS Safari 可能最多 4096。如果你把整帧图像直接作为纹理上传,很容易触碰上限。我的做法是尽量用模型输入尺寸的纹理上传,不要直接上传原图帧。
iOS Safari 的 GPU 是延迟渲染架构。这意味着 GPU 资源切换的代价更大,推理请求不要频繁提交,尽量批量。但更实际的做法是:移动端优先用 WASM CPU 推理 + SIMD 加速,实测在 iPhone 上有时比 WebGL 更稳更快。
灰度图和单通道数据在浏览器里效率很低,因为 canvas 和纹理基本都是 RGBA 四通道。如果你做的是灰度图像输入,很多转成三通道重复存储反而是个快速路子。比如把单通道的数据复制到 R、G、B 三个通道,虽然看起来浪费了两倍内存,但能避免奇怪的数据布局问题,实测 GPU 利用率反而更高。
5.4 跨浏览器兼容测试:不可能三角
最后必须承认一个事实:你没办法让所有浏览器都达到同样的性能。Chrome、Edge、Firefox、Safari 的 GPU 后端、WASM 支持、纹理格式支持都各有差异,而且差异还在不断演进。我现在维护的推理项目,浏览器兼容测试矩阵大概长这样:
| 浏览器 | WebGPU | WebGL | WASM SIMD | 主力推理后端 |
|---|---|---|---|---|
| Chrome 桌面 | 支持 | 支持 | 支持 | WebGPU |
| Edge 桌面 | 支持 | 支持 | 支持 | WebGPU |
| Firefox 桌面 | 部分支持 | 支持 | 支持 | WebGL |
| Safari 桌面 | 部分支持 | 支持 | 支持 | WebGL |
| Chrome Android | 支持 | 支持 | 部分支持(需开启 flag) | WebGL |
| Safari iOS | 部分支持 | 支持 | 支持 | WASM SIMD |
你可以看到,并没有一个“全浏览器唯一最优后端”的结论,可行的策略就是:写一个运行时自动选择后端的能力,启动时检测环境能力,选定一个最合适的后端跑。这个选型逻辑本身不需要很复杂,几十行代码就能写完,但它决定了你的用户究竟是“能跑但卡顿”还是“流畅到感受不到延迟”。
6. 端侧视觉 AI 在浏览器里的未来与我的工程判断
6.1 浏览器 AI 的“双生态”会长期共存
根据我自己的实践经历,未来一段时间的浏览器端视觉 AI 一定会是“双生态”共存的局面:一类是以 ONNX Runtime Web 为代表的“偏生产型”方案,强调跨框架、跨硬件、可部署;另一类是以 Hugging Face Transformers.js 为代表的“偏应用型”方案,强调低门槛、开箱即用。你不需要纠结用哪个“更好”,更关键的是面向你自己的场景选一个能长期维护的。如果是做产品原型,优先 Transformers.js 这种省心路线;如果是做正式项目,建议参考我上面的链路,自建一条可控的推理 pipeline。
6.2 WebGPU 成熟后的可能性与限制
WebGPU 确实带来了更底层的 GPU 控制能力,理论上推理效率会比 WebGL 高不少。但我的实测结论是:它在桌面 Chrome 上已经足够高效,在移动端则仍然取决于设备的驱动和浏览器实现,性能提升没有想象中那么大。另外,Mac 平台的 WebGPU 在某些版本中存在快速切换设备时的资源泄漏风险,这还需要时间沉淀。
所以我个人的工程判断是:把 WebGPU 当作未来 2 年的主要方向,但不要把你的系统架构绑死在某一种后端上。ORM 的后端抽象层做得不错,你的代码写好了,未来底层升级时几乎不用改上层业务逻辑,这才是浏览器端工程该有的节奏。
6.3 浏览器端视觉 AI 的“分内事”和“分外事”
做端侧视觉 AI 久了,你会发现一件很有意思的事:那些跑在浏览器里的视觉模型,真正考验工程师的往往不是模型本身,而是如何把模型“安排”进浏览器的计算、内存和 UI 调度体系里。模型是你“分内事”的起点,而 canvas、worker、纹理、内存这些则是“分外事”。把“分外事”做好了,模型再笨也能跑出不错的效果;把“分外事”做砸了,再先进的模型也会卡成幻灯片。
最后再分享一个小技巧,也是我最近才意识到的一个细节:在处理长时间运行的端侧 AI 任务时,要主动监控页面的长任务(long task)。浏览器一出现超过 100ms 的长任务,说明某个环节阻塞了主线程,要么是推理会话的创建、要么是某些框架层面的全局初始化、要么是内存 GC。用 Performance Observer 监测,能让你在用户感知到卡顿之前就发现问题。端侧视觉 AI 永远不是一次性打通就完事的,上线才是真正调试的开始——调试对象不只是模型,更是浏览器本身。这点你越早意识到,踩的坑就越少。