先说个结论:TensorFlow.js 不是什么玩具,它是正经能扛生产流量的深度学习运行时。我拿它做过的项目包括浏览器内的实时姿态识别、端侧商品抠图、还有给低配用户准备的侧端推荐模型,这几个场景都是真线上环境跑着的,不是 demo 那种刷新就丢的东西。这篇东西我不会从头教你什么是张量、什么是卷积,我假设你已经知道 TF.js 大概能干什么,你来读这篇文章是想要把它用得更深、更稳。
玩过一段时间的人应该都有感觉:文档和教程讲 API、讲模型转换讲得挺多,但真正决定线上能不能跑的,是它内部的架构怎么运转、算力怎么调度、以及在浏览器这个资源极其抠门的沙箱里怎么把每一个字节和每一毫秒都抠出来。这三点才是生产级和 demo 级的分水岭。
1. 架构内幕:TensorFlow.js 在浏览器里到底是怎么跑的
很多人以为 TensorFlow.js 就是把 Python 版 TensorFlow 编译成 JavaScript,这是个很常见的误解。真实情况是,TF.js 整个 runtime 是为浏览器环境从零写的,跟 Python 版只有“算子是同一套数学定义”这层关系。它的架构可以拆成三层看:上层是 ops 算子库和模型推理接口,中间是 executor 执行器负责把模型的计算图展开成可调度的算子序列,下层是 backend 后端,真正干活的 WebGL、WebGPU 或 WASM 就在这一层。
1.1 核心设计:延迟加载的 Kernel 注册表
TF.js 整套架构最核心的机制是Kernel Registry,也就是算子注册表。我去翻过它的源码,注册表里每一项把算子名、后端类型、输入输出的 Shape 推断器、还有实际的 kernel 实现绑在一起。为什么这样设计?因为一个算子在不同后端上的最佳实现路径完全不同——WebGL 上 matMul 要走纹理采样和向量化运算,WASM 上就得靠 SIMD 指令,CPU 后端则直接用一维遍历。注册表模式让同一套 ops API 可以在不同后端之间无缝切换,你不用改上层代码,只要在初始化时选 backend 就行。
实际用下来,这个设计的最大价值是按需加载。比如你只是跑个 MobileNet 做推理,它不会把整个 tfjs-core 里 500 多个算子全部加载进来,而是等模型计算图解析完,发现只用得到 conv2d、batchNorm、relu、pool 这几个算子,就只加载这几份 kernel 实现。我第一次用 Webpack 做 bundle 分析的时候,看到最终产物只有 200 多 KB(gzip 后),而完整版 tfjs-core 是 700 多 KB,这个差距省下来的就是用户首屏的加载时间。
然后是 executor 执行器。它做的事情跟一个编译器的中间代码优化器有点像:计算图进来之后,先做一次拓扑排序,标出哪些算子可以并行、哪些必须串行等待,然后把可以被融合的算子合并成一个执行单元。这里有个概念叫Op Fusion,比如 conv2d + biasAdd + relu 这种固定搭配,它会把这三个算子融合成一个底层 kernel 调用,避免中间张量的分配和拷贝。性能影响有多大?我实测过同样的 MobileNet,开启算子融合比逐算子执行快大约 20% 到 35%,在低端安卓机的 WebGL 后端上尤其明显。
1.2 后端矩阵:WebGL、WebGPU、WASM 和 CPU,它们各管哪一段
选后端是上线前必须做的一个决策,而且这个决策不是一劳永逸的。四个后端各有各的适用场景,我直接说结论:
WebGL 是市场占有率最大的后端。任何能开硬件加速的桌面浏览器、主流安卓浏览器、iOS Safari,基本都支持 WebGL 2.0。它的原理是把张量数据编码成纹理像素,通过片段着色器(Fragment Shader)里的并行计算来跑算子。这套机制的好处是 GPU 利用率高,跑 CNN 这类算子密集型的模型有真加速;坏处是显存有限制,而且纹理编码解码有额外开销——如果你喂进去的是 shape 不规整的输入,比如动态长度的文本序列,纹理到了边界还得填充,性能会掉一大截。
WebGPU 是新一代的图形计算 API,它的设计跟 WebGL 完全不同,更接近 Vulkan/Metal 这种原生 GPU API 的抽象层次。TF.js 里对应的浏览器后端支持 compute shader,能做通用的 GPU 并行计算,不像 WebGL 那样必须“曲线救国”地用纹理模拟显存。我在 2024 年之后用 Chrome 跑过几个纯 WebGPU 的推理任务,在同样一块显卡上,WebGPU 后端的矩阵乘、卷积这类算子整体要比 WebGL 快 10% 到 30%。但 WebGPU 的浏览器覆盖到现在也没有很全,Safari 直到比较晚的版本才跟上来,Firefox 最近也才默认开启。生产项目里我会把 WebGPU 当优先选项,但必须做降级回 WebGL 的方案,不然大批非 Chromium 内核的浏览器直接白屏。
WASM 后端是走 CPU 路线的。它可以把 C++ 写的 kernel 编译成 WebAssembly,配合 SIMD 指令集跑在 CPU 上。单看速度它肯定不如 GPU 后端,但它的优势是兼容性无敌,而且不占用显存。更关键的是,WASM 后端在模型比较小、算子比较稀疏的情况下,实际体验可能比 WebGL 更好。原因也不复杂:WebGL 后端启动要创建 GL 上下文、编译 shader,这些一次性的开销在几十毫秒甚至上百毫秒级别,而 WASM 后端没有这些负担。如果你的模型推理本身只有几毫秒,那初始化开销占比就会大得离谱。
CPU 后端,在 TF.js 里基本就是纯 JavaScript 实现,向量化靠的是 typed array 和手写循环。这个后端我建议只在后端开发、单测、还有 Node.js 端做推理脚本的时候用,浏览器生产环境基本没有理由选它——除非你做的是一个必须在无 GPU、无 WebGL、无 WASM 的极端环境里跑的降级方案,那总比报错强。
1.3 执行流与线程模型:为什么它是“异步单线程” 却还能跑模型
浏览器的主线程是单线程的,JavaScript 模型根本没有多线程并行这件事,但 TF.js 却敢在浏览器里跑深度学习模型,靠的是三层机制:
第一层是异步 API。TF.js 的所有 tensor 操作都是同步返回 Tensor 对象,但真正触发计算的 execute 操作走的是异步管线。write 到 backend 的数据、kernel 的执行、结果的读回,每一步都被封装成 Promise/Async 流程。所以你在业务代码里写await model.predict(x),它底层可能是这样一个流程:先把输入 tensor 上传到 GPU 显存,然后把计算图的多个 kernel 提交到 GL 命令队列,再等待 GPU 完成计算,最后把结果 tensor 下载回 CPU 内存并交给 JavaScript 回调。
第二层是 Web Worker。TF.js 官方支持把模型加载和推理放到 Worker 线程里执行。Worker 里跑的代码跟主线程几乎一样,只是不能碰 DOM。这样主线程可以继续处理点击、滚动、动画这些交互任务,模型推理在后台算,算完通过 postMessage 把结果传回来。这个做法的收益非常直接:移动端浏览器如果主线程被长任务占住,掉帧、卡顿、页面无响应警告都会冒出来,把推理挪到 Worker 里,主线程一秒都不卡。
第三层是 GPU 的并行本质。你看着 JavaScript 是单线程的,但一旦 tensor 数据被丢进纹理、kernel 指令提交给了 GPU,剩下的计算就交给 GPU 的几千个着色器单元并行执行了。JavaScript 线程只是“下单的人”,不是“干活的人”。所以理解 TF.js 的性能,不能只盯 JS 的执行时间,真正的大头往往在 GPU 计算和下一个同步点之间的等待时间。
这里有一个实战中非常容易踩的坑:不要在“显式读完结果”之后还保持之前的张量引用链不释放。由于 TF.js 是单线程模拟异步执行,如果你在 await 之前就丢掉了前面的中间张量引用,垃圾回收在这个框架里是不可依赖的——它有自己的内存管理机制,后面我单独用一节讲清楚。
2. 算力调度:浏览器怎么分配 GPU、CPU 和内存给深度学习
浏览器不是一个“只要 GPU 够强,模型就能跑得快”的简单环境。它的算力调度策略非常反直觉:GPU 上下文数量有限、显存有硬上限、内存分配被沙箱限制、还有后台标签页的节流机制在捣乱。你做生产级项目如果对这些不敏感,上线之后模型可能只在部分用户那里跑得正常,另一部分用户则会遇到卡死、白屏和 OOM。
2.1 算力获取:GPU 上下文从哪里来,又到哪里去
WebGL 后端的 GPU 上下文是浏览器进程向 GPU 进程申请来的。这里有两个核心限制:一是同一时间内一个页面能创建的 WebGL 上下文数量有限,Chrome 上一般是 16 个左右;二是整个浏览器所有标签页共享 GPU 进程和显存资源,你的页面只是其中一个租户。所以你做 TF.js 项目时,第一条纪律是:一个页面只创建一个 tf 实例和一个 GPU backend 上下文,不要反复 create 和 dispose。
实测过一个反面案例:有个项目在单页应用的路由切换时,每次进入识别页面都初始化一个新的 TF.js 实例,切几次之后直接在 iOS Safari 上黑屏。排查下来就是 WebGL 上下文泄漏,Safari 对上下文数量极其敏感,超过一定数量直接无法创建新上下文。修复方式就是全局单例管理 TF.js 实例,路由切换只做模型的热切换,不重建引擎。
内存这块要更仔细。WebGL 纹理占用的显存,在 TF.js 里是通过底层的DataStorage管理的。它有一个专门的MemoryManager,负责跟踪所有 tensor 的 GPU 端存储。当 tensor 不再被引用,或者你显式调用tensor.dispose(),它才会把纹理从显存里释放。这个机制的坑在于:JavaScript 的垃圾回收器并不知道 GPU 显存的压力。哪怕 JS 堆内存还很健康,显存可能已经爆了,此时 WebGL 会直接报上下文丢失错误,页面所有 GPU 计算全部失效。
2.2 内存调度的艺术:张量生命周期与显式 dispose
TF.js 有一个跟 Python 版 TensorFlow 完全不同的特点:你必须自己负责张量的内存释放。Python 里有引用计数和 GC 托底,你在函数里定义的中间张量基本不用管。但在浏览器里,一个张量背后是 GPU 上的一块纹理,这块纹理不随 JS 对象的垃圾回收而自动释放,必须调用dispose()。
我现在养成的代码习惯是这样的:
// 不推荐:中间张量大量堆积,GPU 显存容易爆 async function predictWithLeak(input) { const normalized = input.div(255.0); // 新张量,没引用 const resized = tf.image.resizeBilinear(normalized, [224, 224]); const batched = resized.expandDims(0); const result = model.predict(batched); return result; }这段代码跑不了多少次就会把纹理显存占满,因为 normalized、resized、batched 全部没有释放,而且 Predict 出来的结果如果后续还要处理,也有一份 GPU 拷贝没被回收。
// 推荐:用 tf.tidy 包裹,中间张量自动释放 async function predictClean(input) { return tf.tidy(() => { const normalized = input.div(255.0); const resized = tf.image.resizeBilinear(normalized, [224, 224]); const batched = resized.expandDims(0); const result = model.predict(batched); return result.clone(); // 关键:tidy 只会自动释放内部张量,返回值要 clone }); } // 外部拿到结果之后记得 dispose,或者再用一个 tidy 包住 const pred = await predictClean(inputTensor); const argMax = tf.argMax(pred, 1).dataSync(); pred.dispose();tf.tidy是这个框架里最重要的内存管理工具。它接受一个函数,函数内部创建的所有中间张量,在函数返回后统一释放。注意我写的return result.clone(),因为 tidy 的规则是:内部创建的张量都会释放,如果你直接返回result,它会被释放掉,外面就拿到一个已经失效的 Tensor 对象。clone 出来的是一个独立副本,需要你手动管理。
另外还有个工具叫tf.keep(),它可以把张量标记为“不受 tidy 管束”,适用于需要长生命周期缓存的情况。我的用法是缓存预处理后的常量输入,比如一个不变的背景帧特征向量。
还有一个隐蔽的坑:dataSync()会把 GPU 数据强制下载到 CPU,它是一个同步阻塞操作,而且会强制 GPU 管线 flush。我见过有人为了省事,拿到分类结果直接用 dataSync 读数组,这在移动端会卡掉 50 到 200 毫秒,而且主线程在此期间完全冻结。正确做法是优先用await tensor.data()拿异步结果,或者直接让模型输出留在 GPU 端做后续算子处理,最后一步才下载。
2.3 后台节流、掉帧与移动端的算力策略
浏览器对后台标签页是有算力惩戒的。页面不在前台时,requestAnimationFrame 会停止,定时器会被节流到每秒最多一次,WebGL 的渲染也可能被暂停。TF.js 在后台跑推理,看起来是“变慢了”,实际是浏览器把你的 GPU 命令排队全部挂起了。
生产项目里,我会在页面可见性变化时切换策略。visibilitychange事件触发进入后台时,直接把推理循环停掉,释放掉大 tensor,只保留模型本身;回到前台时重新初始化输入流。这不止是省电,更重要的是避免 GPU 上下文被浏览器在后台强制回收,后台时间过长会导致回来时上下文已经失效,模型怎么调都是空的。
移动端还有专门的策略——用小模型换帧率。如果你的功能是实时视频流处理,比如姿态识别、手势跟踪,模型的单帧推理时间必须小于帧间隔。我这里有一组实测数据可以给各位参考,在 iPhone 13 的 Safari 上,跑 COCO-SSD 的 MobileNet v1 单帧推理大约 35 到 50 毫秒,跑 PoseNet 的 MobileNet v1 大约 20 到 30 毫秒。结合摄像头采集本身的开销,只要推理超过 50 毫秒,30fps 就保不住。所以移动端实时场景,我会优先选 MobileNet 或 EfficientNet-Lite 这类可以在模型大小和精度之间明显倾斜的架构,而不是追求大模型的顶尖精度。
桌面端也不要太自信,GPU 型号杂、驱动差异大。我见过同一款 Chrome 版本,在 N 卡和 A 卡上跑同一个模型的性能差了一倍以上,最后发现是 N 卡驱动对 WebGL 的纹理格式支持更好。所以上线前的兼容性测试要在不同 GPU 品牌、不同操作系统上各跑一轮。
3. 生产级避坑实战:从我踩过的坑里挑最要命的几条
这一节都是真金白银的教训。TensorFlow.js 在 GitHub 上有一堆 issue,但你实际生产环境遇到的问题比 issue 还要魔幻。我按坑的类别分开讲,每条都给了诊断方法和修复方案。
3.1 模型加载与格式转换:Python 模型进不了浏览器?
TF.js 不能直接加载 Python 训练出来的 SavedModel 或 H5 文件,必须先转换成浏览器端格式(JSON + 权重 bin)。转换工具是官方提供的tfjs-converter,最常用的是 pip 安装的tensorflowjs包:
pip install tensorflowjs # 把 SavedModel 转成 TF.js 格式 tensorflowjs_converter \ --input_format=tf_saved_model \ --output_format=tfjs_graph_model \ --output_node_names='final_dense/Softmax' \ /path/to/saved_model \ /path/to/tfjs_model如果是 Keras H5 模型,用--input_format=keras就行。这里最容易出问题的是--output_node_names参数:你必须知道模型的输出节点名叫什么。搞错一个字母,转换会成功,但推理时拿不到正确输出。我的经验是先用 Python 把模型 load 起来,用model.outputs打印每个输出的 name,再把这个 name 填进去。
转完之后,浏览器端加载有两种格式:tfjs_graph_model和tfjs_layers_model。顺序模型(Sequential/Functional)用 layers_model,动态图或包含控制流的用 graph_model。graph_model 执行效率略高,但体积稍大;layers_model 调试友好,可以逐层检查。生产上我的偏好是 graph_model,因为线上不需要调试,性能优先。
还有一类坑是模型里包含了 TF.js 不支持的算子。转出来的文件能加载,但推理到中间直接报错。处理方法各不一样:有的算子可以替换成等效组合,比如某些自定义激活函数可以用tf.tensor手动实现;有的则要回到 Python 里改模型结构,换用标准算子重训。这很常见,建议模型选型阶段就避开太花哨的自定义层。我在 Keras 里见过有人喜欢自己写一个 attention 层、一个 rotate 层,到转 TF.js 的时候全傻眼。
3.2 精度问题:浏览器端推理结果跟 Python 对不上
最常见的问题是H5/SavedModel 里的浮点精度与 TF.js 执行时的精度不一致。Python 端如果你用的默认 float32,TF.js WebGL 后端跑的时候,理论上也是 float32。但 WebGL 纹理的内部存储对 float32 的支持并不统一——很多移动端 GPU 只支持半精度浮点纹理,超出精度范围的数值会直接截断或丢精度,结果就是预测分数跟 Python 算的差了几个百分点。
解决办法有两个方向:一是初始化后端时设置WEBGL_CPU_FORWARD或者显式启用precision: 'highp',但这在低端 GPU 上不一定有效;二是把模型的输入输出数值范围压缩到半精度友好的区间,比如输入归一化到 [0, 1] 而不是 [0, 255]。这在 MobileNet 上效果很明显。
再一个是 batch 维度的坑。TF.js 的 predict 只接受 4D 或 3D 的张量输入(视模型而定),不少人把单张图的 shape 写成 [224, 224, 3] 直接传进去,报错。需要先expandDims(0)变成 [1, 224, 224, 3]。
还有归一化参数错位。Python 训练时用的 mean/std 是训练集的统计量,那套预处理参数如果没同步到前端,模型精度会直接垮掉。我在一个图像分类项目里排查了半天,最后发现前端忘了做通道维度重排:Python 端用的是 RGB,前端 canvas 给到的是 BGR,颜色通道全反了。这种问题模型不会报错,但推理结果完全不对,你们检查的时候一定要把预处理步骤逐行对一遍。
3.3 性能瓶颈定位:用 tf.profile 找出真正的慢算子
生产项目上线前,我会对每个模型跑一遍tf.profile,把每个算子的耗时单独拎出来看:
const profileResult = await tf.profile(() => { return model.predict(tf.zeros([1, 224, 224, 3])); }); console.log('total kernel time:', profileResult.totalKernelTimeMs); console.log('all kernels:', profileResult.kernels); profileResult.kernels .sort((a, b) => b.kernelTimeMs - a.kernelTimeMs) .slice(0, 10) .forEach(k => { console.log(k.name, k.kernelTimeMs); });这个 profile 输出能告诉你瓶颈到底在卷积、在池化、还是在最后的 Dense 层。如果发现瓶颈集中在某一个算子,你还能尝试用算子替换策略优化。我做过一个例子:一个分割模型里用了大量resizeBilinear上采样,每个占 30 多毫秒,总共 5 个占掉 150 毫秒,直接把模型换成用转置卷积上采样的变体,总推理时间降了一半。
profile 的另一个用途是定位显存压力大的算子。有些算子临时分配的 GPU 纹理特别大,比如转置卷积、全局池化,它们频繁调用会让显存波动剧烈。工厂我见过最多的是批处理维度设置过大,单帧视频输入叠了个 8 的 batch,显存直接爆掉。实时应用里宁可单帧跑 8 次,也不要一次跑 batch=8,后者单次峰值暴增,但总耗时未必更小。
3.4 浏览器兼容矩阵与降级:你的 20% 用户可能跑不了 WebGPU
兼容性不是简单的“Chrome 支持、Safari 不支持”能够概括的。真实情况是:同一款浏览器,不同版本、不同操作系统、不同 GPU 驱动,行为都可能不一样。我把兼容策略分层来做:
第一层是能力检测。用tf.getBackend()确认实际拿到的是哪个后端,再根据后端类型切换功能和提示文案。
await tf.ready(); const backend = tf.getBackend(); // 'webgl' | 'webgpu' | 'wasm' | 'cpu' if (backend === 'cpu') { // 降级到轻量模型,或者提示用户开启硬件加速 }第二层是不把 WebGPU 当唯一依赖。我的初始化逻辑是优先尝试 WebGPU,失败就打 WebGL,再失败打 WASM,最后是 CPU。每一层切换都要有响应的 UI 反馈,不能让用户看到白屏或者无限 loading。
第三层是模型分级。根据当前端到端推理耗时,决定加载大模型还是小模型。这个判断可以在用户首次进入时跑一个 profile 基准测试,然后用 localStorage 记住结果。我目前的做法是:推理耗时小于 50ms 加载完整模型,50 到 100ms 加载中档模型,大于 100ms 直接切轻量模型。
还有 Safari 特有的问题:它对 WebGL 的纹理格式、frameBuffer 操作的支持比 Chrome 略保守,个别算子可能直接黑屏。我的做法是在 Safari 上强制启用 WASM 后端跑推理,虽然慢一点,但稳定压倒一切。你们可以在我这个基础上做一些 A/B 测试,看你的特定模型在 Safari 上 WebGL 后端到底行不行,如果实测可行也可以用 WebGL。
4. 常见问题与排查技巧实录
这一节我给一个实战速查表,都是我在生产环境里遇到并解决过的问题。有些问题可能在一开始让你怀疑人生,但找到根因之后会发现,大部分都是资源管理和初始化顺序的问题。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 推理结果全 0 | 模型未 warmup,或输入 shape 错误 | 打印输入 tensor 的形状与数值范围 | 先用 tf.zeros 做一次 predict,确认输出非零 |
| 首次推理极其慢 | 后端初始化、shader 编译、模型权重上传 | 用 Performance 面板记录首次推理时间 | 静态资源预加载,或用 requestIdleCallback 提前初始化 |
| 浏览器提示页面无响应 | dataSync 强制阻塞主线程 | 检查代码中的同步操作 | 改用 await tensor.data(),推理放 Worker |
| 模型加载报 404 / 已加载但 predict 失败 | 权重的相对路径错误,或 JSON 里的 weightsManifest 没配对 | 检查 JSON 文件内容,确认 weights 路径 | 用绝对路径,或用 CDN 前缀拼接权重地址 |
| iOS Safari 白屏 | WebGL 上下文泄漏或驱动 bug | 手动触发多次页面切换观察是否必现 | 全局单例管理 TF.js 实例,并监听 webglcontextlost 事件 |
| WebGPU 后端报错无法使用 | 浏览器版本不足或 GPU 黑名单 | 查看 navigator.gpu 是否存在 | 降级到 WebGL / WASM |
| 显存持续增长直到卡死 | 中间张量未释放 | 每 100 帧打印 tf.memory() 的 numBytesInGPU | 用 tf.tidy 包裹推理过程,监控 numTensors 变化 |
| 精度比 Python 低 5% 以上 | WebGL highp 精度不足 | 同一输入分别用 CPU/WebGL 后端跑,对比输出 | 归一化输入,或换 WASM 后端,或用 quantized 模型 |
| 模型体积太大 | 未量化/剪枝 | 观察加载+初始化耗时 | 用 tfjs-converter 的 quantization 参数转一个 16bit 版本 |
| 页面切到后台再回来,模型失效 | GPU 上下文被浏览器回收 | 监听webglcontextlost和webglcontextrestored | 事件回调里销毁旧实例、重新初始化 |
这里挑几个展开说一下排查细节。
显存泄漏怎么快速定位?在推理循环里每 50 帧打一次tf.memory()的numBytesInGPU和numTensors。如果 numTensors 单调上涨,基本就是又没有 dispose。定位到具体位置的办法是给 tensor 命名,或者在关键代码段前后各打一次tf.memory().numTensors,差值就是这段泄露的张量数。
webglcontextlost 怎么处理?这个事件必须亲自处理,因为发生之后所有 WebGL 状态已经丢失,TF.js 内部缓存的 shader 和纹理全部无效。监听事件后要销毁当前的 tf 实例,重新初始化后端,再重新加载模型。你可以在 event 对象上调用preventDefault()让浏览器允许上下文恢复,但 TF.js 的缓存状态还是得重来。
const canvas = document.getElementById('glCanvas'); canvas.addEventListener('webglcontextlost', async (e) => { e.preventDefault(); await tf.disposeVariables(); model.dispose(); // 重新初始化整个推理链路 await initTfBackend(); model = await loadModel(); });为什么 WASM 后端也有内存问题?WASM 的内存是独立于 JS 堆的 ArrayBuffer,由 emscripten 管理。TF.js 调用 WASM kernel 的时候,tensor 数据要拷贝到 WASM 内存,推理完再拷回来。如果这个拷贝操作频繁,WASM 内存会不断增长,同样需要 dispose 机制回收。而且 WASM 内存不能像 JS 堆那样自动扩展下降,它申请的内存只会涨不会自动缩,长期跑需要定期tf.engine().customOperations清理。
5. 生产级项目的架构设计:不只是调 API,更是搭基建
最后一部分讲真正把 TF.js 嵌进生产项目的整体架构设计。你如果只是在一个页面上调用模型做一次分类,那前面几节的内容已经够了。但如果你的产品是一个长期运行、会被大量用户使用的 Web AI 应用,你需要把下面这些东西都设计进去。
5.1 推理服务是“无状态函数”,不是“全局对象”
很多人在代码里把 TF.js 模型挂成全局单例,页面一直不卸载。这个做法在短期 demo 没问题,但在长期运行的应用里,我必须劝你们谨慎。一个 page session 内,自动释放是好事,但如果在 SPA 里频繁切页,模型实例不释放就意味着 GPU 显存一直被占着。而且模型文件残留在缓存里,浏览器内存被大块占用,会出现用户开着你的页面几天不关,整个系统越来越卡的情况。
我的做法是把推理封装成一个独立的模块,提供init()、predict()、dispose()三个生命周期方法。路由切换的时候,只有用到推理的页面才会调用 init,离开时调用 dispose,用onBeforeUnload兜底清理。
5.2 推理请求队列与并发控制
当你同时做多个推理任务的时候,比如实时摄像头识别 + 用户手动拍照识别 + 上传图片识别,请求不能无脑并发。GPU 计算通道是共享的,并发过多会导致每个任务都变慢,还可能触发上下文崩溃。
我的方案是维护一个推理任务队列,同一时间只执行一个推理任务,其他任务排队。实时流式任务优先级最高,用户交互任务次之,后台批量任务优先级最低。这个调度逻辑不复杂,但能让整体延迟更稳定。
class InferenceQueue { constructor() { this.queue = []; this.running = false; } enqueue(task, priority = 0) { this.queue.push({ task, priority }); this.queue.sort((a, b) => b.priority - a.priority); this.runNext(); } async runNext() { if (this.running || this.queue.length === 0) return; this.running = true; const { task } = this.queue.shift(); try { await task(); } finally { this.running = false; this.runNext(); } } }5.3 模型热更新与版本管理
模型文件也要当版本化资源管理。模型名字带上版本号:posenet_v1.2.0.json、posenet_v1.3.0_quant16.json,和服务端 API 的版本策略保持一致。发布新模型的时候,老模型文件留一个缓存期,用户切到新版本前还能用旧的兜底。
热更新机制我采用的策略是:后端下发activeModelVersion,前端初始化时请求这个字段,决定加载哪个模型文件。如果你发现某个模型效果不好想紧急回滚,不需要动前端代码,改一下服务端的版本字段就能让所有客户端切换。这个思路在半年多的运营周期里帮我做了 5 次模型迭代,没有一次发版事故。
5.4 观测数据与质量监控
最后一件生产项目必须做的事是观测。你要知道线上真实用户的推理耗时、显存占用、成功率、空请求率,以及出现异常的浏览器分布。我的埋点方案是:每次推理完成后上报modelName、modelVersion、backend、inferenceTimeMs、gpuMemoryUsedBytes、success、errorMessage。这些数据服务端汇总后,用百分位数(P50、P95、P99)来监测性能变化,一旦 P95 推理耗时比上周涨了 20%,就要看是不是新模型引入的回归。
精度监控也不能少。在线上的数据里定期抽样一部分,跑一次模型推理,然后把结果跟人工标注或者旧版本模型的输出做比对。如果某个类别的新模型准确率掉了 3 个百分点以上,我就能很快定位是数据问题、模型问题还是预处理流程的问题。
这些基建不一定第一天就全部上线,但你们做生产级项目,心里要有这张图。TensorFlow.js 只是整个系统里的一个小角色,它负责把模型在浏览器里跑起来,但让它跑得长久、跑得稳定的,是这些看不见的架构设计。
我自己的体会是——如果你只把 TF.js 当又一个 npm 包去用,它的上限就是你 demo 的上限。真正吃到浏览器端深度学习红利的人,都是把架构内幕、算力调度和这些零碎的坑都摸了遍,才敢在线上放开手脚。希望这篇细节够多、够脏的文章,能让你们少走几周弯路。