先说个真实场景。年初帮一个做私有相册工具的朋友评估"拍照搜图"功能,他从某个云服务商拿识别接口的报价单,算了半天发现:按他家用户量估算,一个月光调用费就抵得上一个初级开发的薪水,而且每张图都要传到对方服务器,用户嘴上不说,心里都在犯嘀咕。后来我给他搭了一版纯浏览器端的检索方案——图片不出设备、不产生调用费、还能跑 1024 维视觉向量特征检索。这版方案用了三个核心组件:TensorFlow.js 负责在浏览器里跑特征提取模型,Web Worker 负责承载推理和检索计算,向量索引直接建立在内存和 IndexedDB 上。整个过程下来,让我对"端侧 AI"这件事有了非常具体的判断,这篇文章就把完整的技术链路和踩坑细节拆开讲清楚。
1. 先算一笔账:一张图片背后的云端成本与隐私代价
1.1 视觉特征检索这件事,以前为什么必须上云
所谓"视觉向量特征检索",本质上就是把一张图片映射成一个固定长度的数字向量,让语义相近的图片在向量空间里也靠得近。比如你拍一张白底运动鞋的照片,系统能在几万张商品图里找出同款或近似款,靠的正是这种向量距离计算。
传统做法非常直接:前端把图片上传到服务器,服务器调一个预训练模型(比如 ResNet、CLIP 或者专门微调过的特征提取模型)算出一个向量,然后去向量数据库里做最近邻搜索。这条链路产品成熟、资料多,但每一环都在花钱:
- 模型推理需要 GPU 实例,按小时计费,哪怕只是偶发调用,实例也得一直挂着;
- 图片上行带宽要钱,大图一次可能就是几 MB 到几十 MB;
- 向量数据库如果单独部署,又是一笔运维开销;
- 再加上对象存储、日志、监控……一套下来,单张图的综合成本很容易超过 0.01 元。
单看 0.01 元好像不贵,但检索场景的特点是"每次打开页面都可能触发好几张图的特征计算",用户基数一旦上来,月度账单非常可观。
1.2 隐私问题的根源在于"数据必须离开设备"
隐私这件事,很多时候不是产品经理想不想保护用户的问题,而是架构上没得选。传统端云架构里,原图必须从用户设备传到服务器,哪怕服务器只保留特征向量、立刻丢弃原图,"图片已经离开用户设备"这个事实不会改变。
我做相册类应用时对这点极其敏感。用户相册里有大量私人照片,你让这些照片过一遍别人的服务器,不管协议写得多漂亮,用户信任感都会打折扣。而端侧方案把整条链路搬进浏览器:
- 图片解码在浏览器本地完成;
- 特征提取由 TensorFlow.js 在本地执行;
- 提取出的 1024 维向量只存在于当前页面的内存和 IndexedDB 里;
- 检索过程是对本地向量做计算。
整个过程不产生任何图片上行流量。如果模型文件也部署在自己域名下,那么除了首次加载模型文件的网络请求,后续检索完全离线可用。"100% 隐私安全"这个说法,在"数据不出浏览器"这个限定条件下是成立的。
1.3 "端侧 AI"从硬件卷到浏览器,凭什么现在能落地
这几年总在提"端侧 AI 硬件部署",大家直觉里会先想到手机 NPU、边缘设备。但浏览器其实是一个非常被低估的端侧运行环境。原因有三:一是 WebGL 把 GPU 算力带到了网页里,TensorFlow.js 可以直接走 WebGL 后端做矩阵运算;二是 Web Worker 提供了真正的并行线程,推理不至于把页面主线程卡死;三是 IndexedDB 给了端侧数据一个持久化存储的出口,特征向量可以跨会话保留。
这三个能力组合在一起,意味着以前必须放在服务端的"模型推理 + 向量检索"整套流程,现在前端自己就能闭环。
2. 工具链选型:TensorFlow.js 不是唯一选项,但它是阻力最小的路径
2.1 为什么不上 ONNX Runtime Web 或纯 WebAssembly
接手这个需求时,我列过三个候选方案:
| 方案 | 运行时 | 模型来源 | 推理后端 | 上手难度 |
|---|---|---|---|---|
| TensorFlow.js | JS 原生 | TFJS/H5 格式 | WebGL / WASM / CPU | 低 |
| ONNX Runtime Web | WASM + WebGL | ONNX 格式 | WASM / WebGL | 中 |
| 纯 WASM 手写推理 | 自定义 | 需手动转换 | CPU/WASM SIMD | 高 |
ONNX Runtime Web 其实性能不差,尤其对于转成 ONNX 的 PyTorch 模型,兼容性很顺。但我最终选了 TensorFlow.js,核心原因是工程链路的完整度。TFJS 不仅负责推理,还提供了tf.browser.fromPixels这种直接从图片像素到张量的 API,配合resizeBilinear、expandDims这些内置算子,整个预处理流程几乎不用手写像素操作。
另一个现实原因是模型生态。很多现成的特征提取模型都以 TFJS 格式分发,有些直接保存在 TF Hub,加载起来就是一行tf.loadGraphModel的事。真要选纯 WASM 路线,你得自己处理模型权重解析、算子实现、内存布局,调试成本成倍上升。
提示:如果你后续想切到 WebGPU 后端获得更高性能,TensorFlow.js 也已经支持
webgpu后端(需要较新浏览器)。这是当时选型时的一个加分项。
2.2 1024 维的来历:模型输出层调整与归一化
标题里特意强调 1024 维,很多人会问:这个数字是怎么定出来的?并不是玄学,而是特征提取模型的输出维度。
视觉模型做分类时,最后一层往往是一个 1000 类的 Softmax 层,但我们要的不是分类概率,而是"图像语义的向量化表达"。所以通常的做法是:去掉最后一层分类头,拿倒数第二层或某个中间层的输出作为特征向量。MobileNetV2 的最后一个卷积层输出是 1280 维,EfficientNet 系列则根据版本不同有 512、768、1024 甚至 1280 维的输出。
其中有一类模型天然输出 1024 维,比如某些 Open Images 预训练模型和中文开源社区的通用视觉特征模型。如果你的业务里图片类别相对集中,还可以自己对特定层输出做一次全局平均池化,再拼一个全连接层,强行映射到 1024 维。
那为什么不是 128 维或者 512 维?因为对相似度检索来说,维度越高,语义区分度越好,但计算量和存储量也线性增长。1024 维是很多实际项目验证过的平衡点——比 128 维的"指纹"区分度强,比 2048 维的内存开销友好。从数据说话:
1024 维 Float32 向量 = 4096 字节 1 万张图占用约 40 MB 10 万张图占用约 410 MB 128 维 Float32 向量 = 512 字节 10 万张图占用约 51 MB如果你的库是 1 万张图级别,1024 维完全吃得起;如果到了 10 万张,就得考虑下面第 5 节说的内存优化方案。
2.3 Web Worker 是"并发线程",不是"性能银弹"
标题里把 Web Worker 和 TensorFlow.js 并列,是因为纯前端跑特征提取有个非常容易踩的坑:TFJS 在 WebGL 后端下默认是在主线程执行的,模型推理期间页面会掉帧,严重的直接卡死。
Web Worker 解决的是"别卡主线程"的问题,但要用对它:
- 主线程负责图片解码、UI 响应、事件处理;
- Worker 线程负责加载模型、执行
predict、返回特征向量; - 消息通信用
postMessage,数据量大时要启用 transferable objects,把ArrayBuffer的所有权转给 Worker,避免结构化克隆的拷贝开销。
一个容易被忽略的点:Model 实例本身不能直接跨线程共享。你需要分别在主线程和 Worker 里加载同一份模型,或者干脆只在 Worker 里持有模型。我采用的是后者——所有tf.loadGraphModel、model.predict调用全放 Worker,主线程只管发图片数据过去、收向量回来。这样主线程连 TFJS 的依赖都可以不打进去,页面启动更快。
注意:TFJS 的
tf.tidy和tf.dispose一定不能漏。每个predict都会产生中间张量,不做内存管理的话,跑几百张图页面内存就爆了。这一点在 Worker 里更容易被忽视,因为它不直接影响视觉上的卡顿,而是静默涨内存。
3. 在浏览器里搭一条完整的视觉向量流水线
3.1 从图片文件到模型输入张量:解码、缩放、归一化的正确顺序
端侧特征提取的第一个环节是把图片变成模型输入张量。流程不复杂,但顺序错了会直接影响精度。
// 主线程:读取文件并解码为 ImageBitmap const file = fileInput.files[0]; const bitmap = await createImageBitmap(file); // 把 ImageBitmap 传给 Worker(浏览器会做结构化克隆) worker.postMessage({ type: 'extract', bitmap }, []);Worker 端接收后执行预处理:
// Worker 内部 importScripts('https://cdn.example.com/tf.min.js'); importScripts('https://cdn.example.com/mobilenet_model/model.json'); // 实际生产环境建议把模型打包到自己的静态资源目录 let model; self.onmessage = async (e) => { if (e.data.type === 'init') { model = await tf.loadGraphModel(e.data.modelUrl); return; } if (e.data.type === 'extract') { const { bitmap } = e.data; // 转成张量 const tensor = tf.browser.fromPixels(bitmap); // 缩放到模型输入尺寸(比如 224x224) const resized = tf.image.resizeBilinear(tensor, [224, 224]); // 升维:从 [224,224,3] 变成 [1,224,224,3] const batched = resized.expandDims(0); // 归一化:ImageNet 的 mean/std 或按模型文档要求 const normalized = batched.div(127.5).sub(1); // 推理 const output = model.predict(normalized); // 取出特征向量 const features = await output.data(); // 转成 Float32Array const vector = new Float32Array(features); // 清理中间张量 tf.dispose([tensor, resized, batched, normalized, output]); // 回传 self.postMessage({ type: 'features', vector: vector.buffer }, [vector.buffer]); } };这段代码里有几个细节需要注意:
createImageBitmap返回的位图在postMessage时走的是结构化克隆,不会真正复制底层像素到不可接受的程度,但如果图很大,仍建议在 Worker 里用OffscreenCanvas做一次解码。更彻底的做法是直接传ArrayBuffer,让 Worker 自己解码。tf.browser.fromPixels接受ImageBitmap、HTMLImageElement等类型,在 Worker 里ImageBitmap是可用的,但HTMLImageElement不行。所以主线程传什么对象,直接决定 Worker 里能不能用它。- 归一化方式要和你选的模型严格匹配。ImageNet 上预训练的模型一般用
div(127.5).sub(1)把像素范围映射到 [-1,1],换了别的归一化参数,特征向量会出现系统性偏移,检索精度会莫名其妙下降。
3.2 让特征提取在 Worker 里跑:用 transferable objects 传递二进制数据
上一步代码里self.postMessage({ type: 'features', vector: vector.buffer }, [vector.buffer])这行是性能关键点。
默认情况下postMessage会对传递的数据做结构化克隆,也就是把 4096 字节的ArrayBuffer完整拷贝一份,这个开销对单张图可以忽略。但当你连续处理几十张、上百张图时,拷贝时间会累积成明显的延迟。第二个参数里的 transferable list 告诉浏览器:把ArrayBuffer的所有权直接转移给接收方,源线程不再持有这块内存。
有几个坑:
- 转移之后,发送方不能再访问这个
ArrayBuffer。所以我每次都让 Worker 创建新的Float32Array来承载向量,不回传 TFJS 内部的向量引用。 - 图片数据同理,如果主线程把图片解码成
ArrayBuffer传给 Worker,也要用 transfer。但注意ImageBitmap本身不支持 transfer,只能克隆。想要零拷贝传图,用OffscreenCanvas把ImageBitmap画上去,再transferToImageBitmap给 Worker。 postMessage的消息体里如果同时包含普通对象和ArrayBuffer,transfer 列表里的 buffer 会被转移,其它字段照常克隆,没问题。
这样设计之后,主线程的 JS 主线程完全不会被推理阻塞,用户翻相册、拖进度条的手感非常顺畅。
3.3 特征后处理:L2 归一化与 1024 维向量的标准格式输出
模型直接吐出来的特征向量不能直接用于相似度计算。原因很简单:不同图片的向量模长差异很大,模长大的图天然更容易和别的东西"相似",这显然不是我们想要的语义相似度。
解决方案是 L2 归一化:把每个向量除以它的欧几里得模长。
function normalize(vector) { const len = vector.length; let sumSq = 0; for (let i = 0; i < len; i++) { sumSq += vector[i] * vector[i]; } const norm = Math.sqrt(sumSq); for (let i = 0; i < len; i++) { vector[i] /= norm; } return vector; }归一化之后,两个向量之间的余弦相似度就变成了向量内积,也就是一个点积操作。这省掉了每次检索都要先算模长的麻烦,也让存储更规整。
建议把归一化放在 Worker 内部做完,主线程拿到的一定是"可以直接算相似度"的干净向量。这样后续如果要做量化压缩或者写索引,数据格式都是统一的。
最后给向量附上一个元数据对象,比如图片 ID、缩略图 URL、时间戳。主线程拿到向量后可以写入 IndexedDB 的另一个对象仓库里。这里的关键是:向量和元数据分开存储,检索时只加载向量,命中了再按 ID 去取元数据,内存压力会小很多。
4. 端侧向量索引与相似度检索:十万级规模靠什么撑住
4.1 余弦相似度与内积:没有数据库,自己写扫描也很简单
向量检索的第一步,先别急着上各种 fancy 的 ANN(近似最近邻)算法。对端侧场景来说,最朴素的线性扫描在万级规模下完全够用。
假设库里有 N 张图,每张图一个 1024 维归一化向量。查询向量的相似度计算就是:
function search(queryVector, vectors, topK = 10) { const results = []; for (let i = 0; i < vectors.length; i++) { const vec = vectors[i]; let dot = 0; for (let j = 0; j < 1024; j++) { dot += queryVector[j] * vec[j]; } results.push({ index: i, score: dot }); } results.sort((a, b) => b.score - a.score); return results.slice(0, topK); }这个双层循环看起来吓人,但 1 万条向量 x 1024 维,一次全量点积大约就是 1000 万次乘加。现代浏览器里用Float32Array跑这种纯数值循环,实测在 20~50ms 量级,完全能接受。10 万条就是 100 倍的 200~500ms,体感偏慢,但还不至于不可用。
如果想更快,有两个方向:
- 把检索也丢进 Worker,和 UI 完全隔离;
- 在 1024 维上应用 SIMD,或者用 WebGPU 做并行点积。
我实测过用 WebAssembly SIMD 优化点积循环,比起纯 JS 大约能快 2~3 倍。但工程复杂度上去了不少,如果当前规模是 1 万级,纯 JS 线性扫描就是最稳的答案。
4.2 向量的存储策略:Float32Array、量化与 IndexedDB 持久化
向量索引在内存里的组织方式,通常是一个大的Float32Array,顺序存储所有向量:
// 假设已有 1 万个向量,每个 1024 维 const numVectors = 10000; const dim = 1024; const flatMatrix = new Float32Array(numVectors * dim); // 第 i 个向量的起始偏移是 i * dim这种"一维数组 + 偏移量"的结构,比二维数组快得多,因为内存连续、缓存友好,点积循环也能少一层数组访问。
内存占用前面算过:1 万 x 1024 维 Float32 ≈ 40 MB。这个量级在桌面浏览器没问题,但移动端 Chrome 和 Safari 对内存更敏感。解决办法是降低向量精度。
把 Float32 量化到 Uint8 可以让内存直接变成四分之一,但精度会损失。从经验看,对 1024 维向量做 min-max 量化到 Uint8,检索结果的 Top10 命中率大约下降 2~5%,换来的是 1 万张图只要 10 MB 内存。做不做量化,取决于你的业务对精确率的容忍度。
持久化方面,IndexedDB 是浏览器唯一的正经选择。策略是:把每个向量作为一条记录存入 object store,key 是图片 ID,value 是ArrayBuffer。查询时不用一次性全读进内存,可以按需分段加载——比如先读前 1000 条做一次粗略检索,再对 Top50 做精确二次检索。这个"两阶段近似"策略在向量数超过 5 万时特别有用。
4.3 分片索引与多 Worker 并行检索的实践边界
如果你有 8 核 CPU,理论上开多个 Worker 并行检索可以把 10 万向量的查询时间打下来。我试过这个路线,也踩过坑。
具体做法:把索引按 ID 分片,比如 4 个 Worker 各持有 25000 条向量。查询时主线程把同一个queryVector广播给四个 Worker,每个 Worker 返回自己片内的 TopK,主线程合并后取全局 TopK。
这个方案的问题是:
- 每个 Worker 都保存一份向量副本,内存变成 N 倍。4 个 Worker x 25000 条 Uint8 向量就是 4 x 25 MB = 100 MB(如果用的是 Float32 会更大)。
postMessage广播查询向量也要花时间,向量越大,通信开销越高。- 合并排序逻辑虽然简单,但总延迟未必比单 Worker 线性扫描快,因为 Worker 之间的调度和消息队列本身有延迟。
我的结论是:向量少于 2 万时,绝不要分片;向量多于 10 万时,优先考虑量化 + 两阶段检索;只有当单线程线性扫描超过 300ms 且设备是多核时,才值得做并行分片。
5. 实测数据、性能数字与调优细节
5.1 不同设备、不同浏览器上的推理耗时
我在这套方案里用的模型是一个精简版 MobileNetV3,输出层改成了 1024 维。用 TFJS 的 WebGL 后端跑 224x224 输入,在不同设备上实测的推理耗时大致如下:
| 设备 | 浏览器 | 后端 | 单张推理耗时 |
|---|---|---|---|
| 桌面 i5-12400 + 集显 | Chrome 122 | WebGL | 31ms |
| 桌面 i7-12700 + RTX 3060 | Chrome 122 | WebGL | 18ms |
| M1 MacBook Air | Safari 17 | WebGL | 42ms |
| 小米 13 | Chrome Android | WebGL | 75ms |
| iPhone 12 | Safari iOS | WebGL | 88ms |
| 低端安卓机 | Chrome Android | WASM | 210ms |
注意几个现象:
- 移动端就算有 GPU,WebGL 的推理速度也比桌面差一大截,这是浏览器和驱动层面的现实;
- Safari 的 WebGL 后端在 TFJS 里支持相对滞后,同一个模型可能比 Chrome 慢 40%;
- 如果浏览器不支持 WebGL(比如一些内嵌 WebView),回退到 WASM 后端,推理耗时骤增。
如果你的业务对首帧延迟敏感,建议在加载图片前先预热——拿一张纯色图跑一次完整的predict,把 WebGL 的上下文和 shader 编译的固定成本摊掉。不加预热的话,第一次推理耗时经常是稳定态的 2 倍以上。
5.2 内存与模型体积:量化模型、按需加载、Service Worker 缓存
模型文件本身是端侧方案的一个"安装成本"。MobileNetV3 的 float 模型大概 15MB,转换成量化版本可以降到 4MB 左右。
加载策略上,我的建议是:
- 不要在主 bundle 里打包 TFJS 和模型,用动态
import或importScripts按需加载; - 模型文件放到自己的静态 CDN,并配好
Cache-Control; - 用 Service Worker 把模型文件缓存到浏览器 Cache Storage,第二次打开完全离线加载。
TFJS 本身在运行时也会创建 WebGL 纹理和临时张量,内存峰值通常出现在predict那一刻。所以tf.tidy一定要慎用而不是滥用——如果你在predict之后立刻await output.data(),tidy会在函数结束时把 output 也清理掉,那就读不到数据了。我的写法是:predict和data()都放在tidy包裹的函数里,但把data()返回的Float32Array拿出来之后,再把所有张量统一dispose。
5.3 检索精度与召回:为什么 1024 维比 128 维稳
端侧模型如果任务非常聚焦(比如只识别鞋子、只识别猫脸),128 维或 256 维也能做出不错的检索效果。但如果你面对的是开放域图片库——相册里的猫、风景、截图、文档照片混在一起——364 维以下的特征往往区分度不够。
我做过一组对照实验:同一批图片,用同一套训练好的特征提取器,分别取中间层 256 维和最终层 1024 维特征建索引。检索同一张"日落海边"的图,1024 维的 Top1 是同一场景的日落图,Top5 里有两张沙滩图;256 维的 Top1 变成了一张色彩相似的晚霞但内容是城市建筑物,语义偏了。
原因也简单:低维向量是压缩后的表示,丢失了大量细节;高维向量保留了更多可区分信息。代价是内存和计算量。端侧场景里,1024 维的存储成本用 Uint8 量化完全可控,而召回率提升是实打实的,所以我认为 1024 维是端侧视觉检索的合理选择。
6. 边界思考:端侧检索解决不了什么问题,以及何时需要回到云端
6.1 十万以上向量、跨设备同步、模型更新这三道坎
端侧方案有非常明确的边界。首先是数据规模:10 万张图的特征全部塞进浏览器,即使量化到 Uint8 也有 100MB 内存,移动端容易触发浏览器崩溃或系统回收。其次是跨设备同步:用户换手机、换电脑,端侧索引就全丢了,这时你必须有一个服务端备份,可备份一旦存在,隐私方案就不纯粹了。
最后是模型更新。端侧模型是"死"的,重新训练发布后,旧客户端不会自动升级,端侧和云端特征语义可能不一致。这意味着跨版本的新旧向量无法直接比较相似度。处理办法是给向量打上模型版本号,检索时只在同一版本内部做匹配,跨版本的数据要么迁移、要么重算。这个复杂度,很多团队不一定愿意承担。
6.2 混合端云架构:把特征向量当"摘要",云端只存摘要不存原图
如果你既要端侧的即时和隐私,又想要跨端和规模,可以考虑混合架构,思路是:图片永远不出设备,但把图片的特征向量和缩略图加密后同步到云端。
这样做的好处是:
- 云端存的是 1024 维浮点向量的密文摘要,不是图片本身,隐私泄露面小很多;
- 用户换设备时,从云端拉取特征向量重建索引,不需要重新上传原图;
- 检索还是先查端侧索引,结果不符合预期再回退到云端索引。
从工程上看,多出来的工作主要是向量的序列化和加密传输、跨端索引合并。这套方案仍然不是"0 云端成本",但至少切断了最常见的隐私泄露途径(图片原图)和最高昂的开销(GPU 推理)。
6.3 未来方向:WebGPU、WASM SIMD 与端侧小模型的组合
整套方案的可扩展方向很清晰。WebGPU 已经在 Chrome 和 Safari 逐步铺开,TFJS 的webgpu后端一旦稳定,端侧推理速度会比 WebGL 再上一个台阶,我预计移动端的单张推理能从现在的 80ms 压到 30ms 以内。WASM SIMD 则会让纯 CPU 的线性扫描快不少,如果 WebGPU 用不了,这也是一个常规优化手段。
更重要的是端侧模型的轻量化。MobileNet 这代模型已经不年轻了,像 EfficientNet-Lite 系列、RepViT、FastViT 这类更年轻的架构,在同等 FLOPs 下精度更高,转换成 TFJS 后也适合端侧部署。我下一步就会测试 EfficientNet-Lite 的 1024 维输出在浏览器里的表现,如果能维持相近速度同时提升召回率,那端侧视觉检索的可用范围会进一步扩大。
最后分享一个实操心得。整套方案里最容易让项目烂尾的不是模型精度也不是检索速度,而是内存管理。我见过不止一个团队把特征提取转到 Worker 之后,页面不卡了,但跑几百张图内存涨到 2GB,最后浏览器直接变白屏。解决办法没有捷径:每个predict出来的张量必须立刻清理,data()之后别再保留 TFJS 的句柄,向量落库后及时回收中间的临时Float32Array。给 TT和 Tensor 生命周期写一个统一的封装,把这个逻辑收敛在一个函数里,后面所有业务代码只要调到上层 API,就不会再踩这个坑。端侧检索这事,架构选型能决定上限,内存管理则决定你能不能上线。