1. 为什么要在浏览器里跑神经网络
第一次听到“把神经网络塞进浏览器标签页”这个说法,很多人的第一反应是:这不是自找麻烦吗?服务器上挂一块推理卡,前端发个请求拿结果,多省事。我一开始也是这么想的,直到我真正接手了一个需要在弱网环境、甚至离线状态下做实时视觉识别的项目,才彻底改变了看法。
端侧视觉 AI 的核心诉求其实就三个字:低延迟。你把一帧摄像头画面传到服务器,服务器推理完再把结果传回来,哪怕网络再好,一个来回也得好几十毫秒,遇到网络抖动直接飙到几百毫秒。而如果模型就跑在浏览器本地,从拿到画面到出结果,中间没有任何网络环节,延迟可以压到十几毫秒甚至更低。这个差距在实时交互场景里是致命的——比如手势控制、AR 试戴、实时滤镜,用户能明显感觉到“跟手”还是“卡顿”。
第二个诉求是隐私。摄像头画面是最敏感的数据之一,很多用户和合规场景根本不允许把原始视频流上传到服务器。模型在本地跑,画面从头到尾没离开过设备,这个问题就天然解决了。第三个诉求是成本。推理服务器是要花钱的,并发一上来,GPU 账单能吓死人。把计算下放到用户设备上,服务器只负责发模型文件,边际成本几乎为零。
但“塞进浏览器”这件事,工程上的坑远比想象中多。浏览器不是为高性能计算设计的,它有一套自己的运行模型:主线程负责渲染和交互,一旦被长任务阻塞,页面就卡死;JavaScript 是单线程的(虽然有 Worker);GPU 能力要通过 WebGL 这类图形接口间接调用。所以真正的问题不是“能不能跑”,而是“怎么跑得不卡、不崩、不烧电池”。
这篇文章我想把端侧视觉 AI 在浏览器里落地的完整工程链路拆开讲清楚:从模型怎么选、怎么转换,到 Web Worker 怎么分工,再到 WebGL 怎么加速,最后聊聊实测中那些文档里不会写的坑。适合已经有一定前端基础、想往 AI 工程方向走的同学,也适合做端侧推理、边缘计算的同行参考。
2. 模型选型:不是越小越好,而是越“合适”越好
2.1 浏览器能承受的模型体量到底有多大
先给一个直观的参考。一个普通的移动端网页,用户能忍受的首屏加载时间大概是 3 秒以内。模型文件是要下载的,假设用户网速是 10Mbps(约 1.25MB/s),3 秒能下载大约 3.75MB。也就是说,模型文件最好控制在 2MB 到 5MB 之间,超过这个量级,加载体验就会明显变差。
但模型大小和精度是直接矛盾的。一个标准的 ResNet-50 大概 100MB,直接排除。MobileNetV2 大概 14MB,还是偏大。真正适合浏览器的是经过量化压缩的轻量模型,比如 MobileNetV3-Small 量化到 INT8 之后大概 2.5MB,EfficientNet-Lite 系列也有类似量级的版本。如果是做人脸检测这种特定任务,Ultra-Light-Fast-Generic-Face-Detector 这种专门为端侧设计的模型,量化后甚至能压到 1MB 以内。
这里有个经验:不要一上来就追求 SOTA 模型。浏览器端侧推理,精度差几个百分点用户根本感知不到,但模型大 5MB 用户可能直接关页面走人。我一般会先拿一个轻量模型跑通全链路,确认延迟和体验达标之后,再考虑要不要换更大的模型。
2.2 量化:把 FP32 压成 INT8 到底损失了什么
量化是端侧部署绕不开的一步。简单说,就是把模型权重和激活值从 32 位浮点数(FP32)转换成 8 位整数(INT8)。这样做的好处很直接:模型体积缩小到原来的四分之一,推理速度提升 2 到 4 倍,内存占用也大幅下降。
但量化不是无损的。它本质上是在做一个映射:把原来连续的浮点值域,离散地映射到 256 个整数格子上。这个过程中必然有精度损失。损失多少取决于模型本身对数值精度的敏感程度。卷积层通常比较鲁棒,量化后精度掉得不多;但一些涉及 softmax、layer norm 的操作,量化后可能偏差比较大。
实际操作中,我推荐用训练后量化(Post-Training Quantization),也就是拿一批校准数据跑一遍模型,统计每层激活值的分布,据此确定量化参数。TensorFlow Lite 和 ONNX Runtime 都提供了这套工具。如果精度掉得太多,再考虑量化感知训练(QAT),但那需要重新训练,成本高很多。
提示:量化校准数据的分布要尽量贴近真实推理场景。如果你用室内照片校准,结果拿去跑室外场景,精度可能会崩。我踩过这个坑,校准集和测试集分布不一致,量化后 mAP 掉了将近 10 个点。
2.3 从训练框架到浏览器:模型格式转换的完整链路
模型训练一般用 PyTorch 或 TensorFlow,但浏览器不认识这些格式。中间需要经过几次转换。常见的链路有两条:
第一条是PyTorch → ONNX → TensorFlow Lite → TFLite Web。ONNX 是通用的模型交换格式,PyTorch 导出 ONNX 很成熟。然后 ONNX 转 TFLite 可以用 onnx-tf 这个工具。最后 TFLite 模型通过 TensorFlow.js 的 tflite 后端加载。
第二条是PyTorch → ONNX → ONNX Runtime Web。ONNX Runtime 官方提供了 Web 版本,可以直接在浏览器里加载 ONNX 模型,支持 WebGL 和 WebAssembly 两种后端。这条路我个人更推荐,因为转换环节少,出错概率低。
转换过程中最容易出问题的是算子兼容性。不是所有 PyTorch 算子都有对应的 ONNX 实现,也不是所有 ONNX 算子都被 Web 推理引擎支持。比如一些自定义的激活函数、特殊的池化方式,转换时可能直接报错。我的做法是:训练时尽量用标准算子,如果非要用自定义算子,提前查一下目标推理引擎的支持列表。
3. Web Worker:让推理不阻塞主线程的正确姿势
3.1 主线程为什么扛不住推理任务
浏览器的主线程是个“大忙人”,它要处理 DOM 操作、样式计算、布局、绘制、事件响应,还要跑 JavaScript。你如果在主线程里跑一个 50ms 的推理任务,这 50ms 内页面完全没法响应用户操作,滚动会卡、点击没反应。如果推理是持续进行的(比如每帧都跑),那页面基本就废了。
Web Worker 的价值就在这里。它相当于在浏览器里开了一个后台线程,可以独立跑 JavaScript,不干扰主线程的渲染和交互。把模型推理整个搬到 Worker 里,主线程只负责把图像数据传过去、把结果拿回来渲染,两边互不阻塞。
但 Worker 也不是银弹。它和主线程之间不能直接共享内存(除非用 SharedArrayBuffer,但那个有跨域隔离限制),数据传递要靠 postMessage,而 postMessage 默认是结构化克隆,也就是把数据复制一份传过去。一张 640x480 的 RGBA 图像,数据量是 640×480×4 = 1.2MB,每帧复制一次,这个开销相当可观。
3.2 图像数据怎么高效传给 Worker
解决数据传输开销有几个思路。第一个是用 ImageBitmap 代替 ImageData。ImageBitmap 是一种可以直接被 GPU 使用的图像格式,传递时不需要复制像素数据,只是转移一个引用。从 video 元素或 canvas 创建 ImageBitmap 很快,传给 Worker 也几乎零开销。
第二个是用 Transferable Objects。postMessage 的第二个参数可以指定一个数组,里面列出的对象会被“转移”而不是“复制”。转移之后,原线程就访问不到了,所有权完全交给接收方。ArrayBuffer 就是典型的可转移对象。如果你必须传原始像素数据,用转移的方式可以避免复制。
第三个是SharedArrayBuffer。它允许主线程和 Worker 共享同一块内存,双方都能读写,完全不需要传递。但它要求页面开启跨域隔离(Cross-Origin Isolation),需要服务器设置 COOP 和 COEP 响应头。这个限制在生产环境里有时候不太好满足,所以我的建议是优先用 ImageBitmap,实在不行再考虑 SharedArrayBuffer。
3.3 Worker 内部的任务调度与背压控制
Worker 里跑推理,还有一个容易被忽略的问题:任务堆积。摄像头每秒产生 30 帧,如果推理一帧要 50ms,那 Worker 根本处理不过来,任务队列会越来越长,延迟越积越大,最后结果完全对不上当前画面。
解决办法是丢帧。不要每一帧都推理,而是维护一个“最新帧”变量,Worker 处理完当前帧之后,直接拿最新的那一帧继续处理,中间过期的帧全部丢掉。这样虽然推理帧率可能只有 15fps,但结果永远是对应当前时刻的,用户体验反而更好。
具体实现上,主线程用一个变量存最新帧,Worker 处理完后发消息说“我空了”,主线程再把最新帧发过去。或者更简单粗暴:主线程定时(比如每 66ms)检查 Worker 是否空闲,空闲就发一帧。关键是不要让任务队列无限增长。
注意:Worker 里不要用 setInterval 做定时推理,因为 setInterval 的回调可能重叠执行。用 setTimeout 递归调用,或者用 requestAnimationFrame 的 Worker 版本(如果环境支持),能保证上一次处理完再开始下一次。
4. WebGL 加速:把卷积计算交给 GPU
4.1 为什么 CPU 跑卷积这么慢
卷积是视觉神经网络里最耗时的操作。一个标准的 3x3 卷积,对每个输出像素要做 9 次乘加运算,再乘以输入通道数和输出通道数。假设输入是 112x112x32,输出是 112x112x64,那总的乘加次数是 112×112×64×32×9 ≈ 2.3 亿次。CPU 就算每秒能跑 10 亿次浮点运算,这一层也要 0.23 秒。而一个 MobileNet 有几十层,CPU 根本扛不住。
GPU 的优势在于大规模并行。它有几千个流处理器,可以同时处理成千上万个像素。同样是上面那个卷积,GPU 可能只需要几毫秒。浏览器里访问 GPU 的接口就是 WebGL(以及更新的 WebGPU,但兼容性还不够好)。
4.2 用纹理和着色器实现卷积的思路
WebGL 的编程模型和普通计算不太一样。它本质上是图形渲染管线:你提供顶点和纹理,它输出像素。要拿它做卷积计算,核心思路是把特征图当成纹理,把卷积核当成着色器里的常量,渲染一遍就得到输出特征图。
具体来说,输入特征图是一个 H×W×C 的三维张量。WebGL 纹理是二维的,所以要把通道维度铺开。常见做法是把 C 个通道排成一张大图,比如 C=32 就排成 8×4 的网格,每个格子放一个 H×W 的通道。这样一张纹理就能装下整个特征图。
着色器里,每个片元(像素)负责计算一个输出值。它需要读取输入纹理上对应位置的邻域像素,乘以卷积核权重,累加得到结果。权重通过 uniform 变量传进去,或者预先烘焙到另一张纹理里。
这里有个性能关键点:纹理采样的次数。一个 3x3 卷积,每个输出像素要采样 9 次。如果通道数多,采样次数还要乘以通道数。WebGL 的纹理采样是有开销的,采样太频繁会成为瓶颈。优化方法是把多个通道打包到一张纹理的 RGBA 四个分量里,一次采样就能拿到 4 个通道的值,采样次数直接除以 4。
4.3 实测中的性能瓶颈与优化手段
我在实际项目里用 WebGL 跑 MobileNetV2,在桌面 Chrome 上单帧推理能到 8ms 左右,在手机上大概 25ms。这个性能已经能满足实时需求了。但过程中遇到几个瓶颈:
第一个是纹理上传开销。每帧图像要从 CPU 传到 GPU,这个传输本身要时间。如果图像是来自 video 元素,可以用 texImage2D 直接上传,浏览器内部会做优化。如果是从 canvas 拿的 ImageData,就要走一遍 CPU 到 GPU 的拷贝,比较慢。
第二个是着色器编译时间。WebGL 着色器是运行时编译的,第一次加载模型时,几十个着色器编译下来可能要几百毫秒。这个时间用户能明显感觉到卡顿。解决办法是预编译,在 Worker 初始化阶段就把所有着色器编译好,不要等到推理时才编译。
第三个是精度问题。WebGL 默认的纹理格式是 8 位无符号整数,做卷积累加的时候很容易溢出。要用浮点纹理(OES_texture_float 扩展),但浮点纹理的渲染在某些设备上不支持。折中方案是用半精度浮点(OES_texture_half_float),精度够用,兼容性也好一些。
5. 那些文档里不会写的坑
5.1 内存泄漏:模型加载一次就够了,但很多人忘了释放
浏览器里加载模型,本质上是分配了一大块内存存权重。如果你在单页应用里反复加载模型(比如路由切换时重新初始化),旧模型的内存不会自动释放,很快就会把标签页撑爆。我见过一个项目,用户切换几次页面之后,标签页内存占用飙到 2GB,直接崩溃。
正确的做法是:模型只加载一次,全局复用。如果确实需要释放,要手动调用推理引擎提供的 dispose 方法,把张量、纹理、缓冲区都清理掉。WebGL 的纹理和缓冲区尤其要注意,它们不受 JavaScript 垃圾回收管理,必须显式 deleteTexture、deleteBuffer。
5.2 不同浏览器的 WebGL 实现差异
WebGL 虽然是个标准,但各家浏览器的实现差异不小。Chrome 的 WebGL 后端是 ANGLE,在 Windows 上会转成 DirectX;Firefox 和 Safari 各有各的实现。同样的着色器代码,在 Chrome 上跑得好好的,到 Safari 上可能就编译失败。
常见的差异点包括:浮点纹理的支持情况、纹理尺寸上限、着色器里的循环次数限制、精度限定符的处理。我的经验是:尽量用最保守的写法。能用 mediump 就不用 highp,循环次数控制在编译期常量,避免动态索引。上线前一定要在目标浏览器矩阵里都测一遍。
5.3 移动端的发热与降频问题
移动端跑 WebGL 推理,最大的敌人不是性能不够,而是发热降频。手机 SoC 的散热能力有限,GPU 满载跑几分钟,温度上来之后就会降频,推理速度可能直接腰斩。用户会感觉“刚开始挺流畅,用一会儿就卡了”。
缓解办法有几个:一是控制推理频率,不要每帧都跑,隔帧跑或者按需跑;二是降低分辨率,输入图像从 640 降到 320,计算量直接少四分之三;三是混合精度,不是所有层都需要高精度,关键层用浮点,其他层用整数。实测下来,隔帧推理加上 320 分辨率输入,手机能连续跑十几分钟不明显降频。
6. 一套可复现的最小实现骨架
6.1 目录结构与依赖选择
我一般会这样组织代码:
project/ index.html main.js // 主线程:摄像头采集、UI 渲染 worker.js // Worker:模型加载、推理调度 model/ model.onnx // 转换好的模型文件 lib/ ort.min.js // ONNX Runtime Web依赖就一个 ONNX Runtime Web,通过 CDN 引入或者本地打包都行。不引入 TensorFlow.js 是因为它体积太大,而且 ONNX Runtime 的 WebGL 后端性能更好。
6.2 主线程与 Worker 的通信协议
主线程和 Worker 之间约定好消息格式:
// 主线程发给 Worker { type: 'inference', bitmap: ImageBitmap, // 转移所有权 timestamp: 1234567890 } // Worker 返回给主线程 { type: 'result', detections: [{x, y, w, h, score, class}], timestamp: 1234567890, inferenceTime: 12.5 }用 timestamp 做对齐,主线程拿到结果后可以判断这个结果对应的是哪一帧,避免画面和结果错位。
6.3 关键代码片段与注释
Worker 里的推理循环大概长这样:
let session = null; let latestBitmap = null; let isProcessing = false; async function init() { // 加载模型,只执行一次 session = await ort.InferenceSession.create('./model/model.onnx', { executionProviders: ['webgl'], graphOptimizationLevel: 'all' }); self.postMessage({ type: 'ready' }); } async function processFrame() { if (!latestBitmap || isProcessing) return; isProcessing = true; const bitmap = latestBitmap; latestBitmap = null; // 预处理:resize、归一化、转张量 const tensor = preprocess(bitmap); const start = performance.now(); const results = await session.run({ input: tensor }); const inferenceTime = performance.now() - start; const detections = postprocess(results); self.postMessage({ type: 'result', detections, inferenceTime }); bitmap.close(); // 释放 ImageBitmap isProcessing = false; // 如果还有新帧,继续处理 if (latestBitmap) processFrame(); } self.onmessage = (e) => { if (e.data.type === 'inference') { latestBitmap = e.data.bitmap; processFrame(); } }; init();主线程这边,用 requestAnimationFrame 驱动采集:
const video = document.getElementById('camera'); const worker = new Worker('./worker.js'); worker.onmessage = (e) => { if (e.data.type === 'result') { drawDetections(e.data.detections); } }; async function captureLoop() { if (video.readyState === video.HAVE_ENOUGH_DATA) { const bitmap = await createImageBitmap(video); worker.postMessage({ type: 'inference', bitmap }, [bitmap]); } requestAnimationFrame(captureLoop); } captureLoop();注意postMessage的第二个参数[bitmap],这表示把 bitmap 的所有权转移给 Worker,主线程不再持有,避免了复制开销。
6.4 从跑通到可用的最后一步
代码跑通只是第一步。真正上线前还要做几件事:加错误处理,模型加载失败要有降级方案;加性能监控,记录每帧推理耗时,超过阈值就自动降分辨率;加资源释放,页面隐藏时暂停推理,释放 GPU 资源。这些细节决定了产品是“能用”还是“好用”。
我在实际项目里最大的体会是:端侧视觉 AI 在浏览器里落地,技术选型只占三成,剩下七成都是工程细节的打磨。模型转换、线程调度、内存管理、兼容性处理,每一项都有坑,但每一项也都有成熟的解法。把这条链路走通一次之后,后面再做类似的项目就会快很多。