
浏览器端跑 AIWebGPU 加速 MobileNet 特征提取实战——1024 维向量实现以图搜图前两天一个做电商图片库管理系统的朋友问我能不能在浏览器里直接对本地图片做相似度搜索不传服务器、不装 Python、不碰向量数据库。我第一反应是“有点折腾”但细想之后发现2025 年这个需求其实已经具备了完整的技术闭环WebGPU 已经把 GPU 算力搬进了浏览器MobileNet 这种轻量 CNN 跑一次前向推理只需要几十毫秒而 1024 维特征向量的余弦相似度检索在前端更是小菜一碟。这篇文章我就把整个项目的完整思路、模型转换、前端推理、相似度检索实现和性能调优过程全部分享出来手把手告诉你我是怎么用纯浏览器端代码实现了一套可用的“以图搜图”功能。不管你是前端工程师想跨界 AI还是算法工程师想找个轻量落地场景这篇内容都能给你一份可以直接抄作业的参考。先说清楚这套方案能解决什么问题。传统的以图搜图要么依赖后端 TensorFlow/PyTorch 服务要么调用付费 API图片数据来回传输不仅延迟高还有隐私风险。而把模型推到浏览器端之后图片不出本地就能完成特征提取和相似度匹配既没有服务器成本也没有带宽消耗还天然解决了隐私问题。这个项目的核心链路就三步WebGPU 加载 MobileNet 模型提取 1024 维特征向量将向量归一化后写入本地索引最后通过余弦相似度对用户上传的目标图和库里的图做排序。看起来简单但每一步都有不少细节坑接下来我会逐个拆开讲。1. 项目整体设计与思路拆解1.1 为什么偏要跑到浏览器端“折腾”我在最初规划这个项目时的第一个念头其实是“何必呢”。以图搜图这种需求社区现成方案一抓一大把Python 端装个timm库两行代码把 MobileNet 的倒数第二层抽出来再用faiss建索引几万张图也只是一眨眼的事。但问题在于我这个项目面对的真实使用场景是图片数据在本地电脑上用户明确不想上传到任何服务器使用者是一个非技术岗的运营人员不会装 Python 环境后端资源有限不想为这个功能单独开一台带 GPU 的服务需要即时反馈拖一张图进来两三秒内要出相似结果。这种情况下把算力搬到浏览器反而是性价比最高的路径。现代浏览器已经不再只是一个“能跑 JavaScript 的文档阅览器”WebGPU 把 GPU 的通用计算能力直接暴露给了前端再加上 WebAssembly 可以把 C 的推理引擎编译到浏览器里运行前端早就具备跑深度学习模型的能力了。区别只是以前我用 TensorFlow.js 跑 MobileNet 都嫌慢现在 WebGPU 加持下MobileNet 这类轻量模型的单次推理延迟已经被压缩到了几十毫秒级别完全达到交互可用的标准。我采用的架构非常朴素页面加载时用 ONNX Runtime Web 初始化 WebGPU 执行后端同时拉取一个转好的 MobileNet ONNX 模型用户传入图片后先在 canvas 上完成 resize、归一化等预处理再送入推理会话拿到一个 1024 维向量最后拿这个向量和内存中缓存的索引做暴力线性检索。没有复杂的微服务没有消息队列没有 Redis全部逻辑都在一个静态站点里完成。1.2 模型选型MobileNet V3、ONNX 与 WebGPU 的组合逻辑模型选型这件事上我几乎没有犹豫就锁定了 MobileNet 系列。原因非常直接浏览器端跑模型第一约束是体积和内存不是精度。MobileNet V3 Small 的 ONNX 模型量化后大概 5-8MB原始 FP32 也只要 10MB 左右相比之下 ResNet50 动辄 90MB 的模型体积在网页加载场景下是很难接受的。MobileNet 的核心设计是深度可分离卷积Depthwise Separable Convolution它把标准卷积拆成了“逐通道卷积 逐点卷积”两步参数量和计算量都大幅下降这就注定了它是浏览器端特征提取的性价比之王。虽然它在 ImageNet 上的分类精度不如 ResNet 系列但当仅把第二个全连接层之前的特征图当作向量使用而不是把它当分类器用时MobileNet 的语义特征表达能力完全够用。框架方面我没有用 TensorFlow.js而是选择了 ONNX Runtime Webonnxruntime-web。工具链上 PyTorch 的torch.onnx.export一条命令就能把模型导出为 ONNX再丢给 ORT 的 WebGPU EPExecution Provider直接执行中间几乎没有额外转换成本。而 TensorFlow.js 模型要么从 TensorFlow SavedModel 转要么直接加载 tfhub 的模型链路没那么顺。更关键的一点是ONNX Runtime Web 对 WebGPU 的支持已经在 1.17 版本之后趋于稳定FP16 计算、动态 shape 输入都支持得比较好实践体验很顺。执行后端的优先级我是按照“WebGPU WebAssembly SIMD 多线程 WebAssembly 单线程 CPU fallback”的顺序做的。也就是能用 GPU 就用 GPUGPU 不可用时降级到多线程 WASM以保证在老旧设备上功能仍可使用。WebGPU 带来的性能提升非常可观这个数字我后面会给出实测对比。1.3 1024 维向量从哪来这个项目的标题里有个关键词是“1024 维向量”这个 1024 不是随便拍脑袋定的。MobileNet V3 Small 最后一个卷积层输出的特征图通道数是 1024V3 Large 是 1280也就是说经过全局平均池化后一张图会被压缩成一个 1024 维的向量这个向量就是图片的“指纹”或者说语义特征。我在模型导出时把 MobileNet 的分类头整个砍掉只保留到features末尾的全局平均池化层这样模型输出就直接是 1024 维的浮点向量了。为什么不用 MobileNet V3 Large我也试过V3 Large 输出的 1280 维特征在检索精度上确实比 Small 略好一点毕竟模型的“见识”更多但代价是模型体积翻倍约 22MB单次推理耗时也涨了差不多一倍。我的项目目标不是做一个刷榜级的搜索系统而是要在浏览器里跑得动、跑得快所以在实测了两版模型的检索效果后最终选择了 V3 Small 作为主力同时保留了 V3 Large 的配置开关如果用户机器性能好、图片库不大切换过去也能用。这里再补充一个关键理解特征提取模型输出的向量本身并不能直接用来做相似度比较而是要经过 L2 归一化把向量长度缩放为 1。这样两张图的相似度就可以直接用向量的内积余弦相似度来度量值越接近 1 代表越相似。这个细节虽然简单但很容易被刚接触向量检索的人忽略实际效果会差很多。2. 环境准备、模型转换与前端工程骨架2.1 你需要准备的前置工具清单开始动手之前先检查一下手头的环境。软件栈很简单但版本匹配容易踩坑我直接给出一份我本地验证过的版本组合组件版本建议说明Python3.10主要用于模型转换和导出PyTorch2.1.0加载预训练 MobileNetonnx1.15.0检查/修复导出模型onnxruntime1.17.0本地验证 ONNX 输出Node.js18前端构建与开发服务器Vite5.x前端构建工具开发体验好onnxruntime-web1.19.0浏览器端推理Chrome / Edge113必须启用 WebGPU 的浏览器版本浏览器端的硬性门槛是 Chrome 113 或 Edge 113 以上版本Firefox 的 WebGPU 支持还在实验阶段Safari 的情况也不太乐观。如果目标用户用的是老浏览器WebGPU 分支无法启用系统会自动降级到 WASM 后端功能依然可用只是速度会慢一些。这一点在项目设计阶段就要想清楚否则开发到一半发现兼容性问题会很被动。2.2 模型转换砍掉分类头保留特征向量模型转换是整个项目里最核心的一步直接决定最终特征向量的质量。我用 PyTorch 自带的torchvision加载 MobileNet V3 Small 的 ImageNet 预训练权重然后把分类头去掉重新定义模型结构。PyTorch 里torchvision.models.mobilenet_v3_small的结构由features和classifier两部分组成。features负责卷积特征提取输出一个[1, 1024, 7, 7]的特征图classifier则是一层全局平均池化后的线性分类层。我们要做的是跳过classifier直接取features的输出再叠一个同步的全局平均池化。import torch import torchvision.models as models import torch.nn as nn # 加载预训练模型 model models.mobilenet_v3_small(weightsmodels.MobileNet_V3_Small_Weights.IMAGENET1K_V1) # 保留 features 之前的结构替换为特征提取头部 class FeatureExtractor(nn.Module): def __init__(self, backbone): super().__init__() self.features backbone.features self.pool nn.AdaptiveAvgPool2d((1, 1)) def forward(self, x): x self.features(x) x self.pool(x) return x.view(x.size(0), -1) extractor FeatureExtractor(model) extractor.eval() # 转 ONNX dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( extractor, dummy_input, mobilenet_v3_small_feat_1024.onnx, input_names[input], output_names[embedding], dynamic_axes{input: {0: batch}, embedding: {0: batch}}, opset_version17, )导出过程中有几个容易出问题的点。第一AdaptiveAvgPool2d((1, 1))之后一定要view或flatten否则输出是[1, 1024, 1, 1]的四维张量后续在前端代码里还要做一次冗余处理。第二dynamic_axes建议加上虽然本次项目固定输入 batch1但动态 shape 在 WebGPU EP 上的兼容性更好一些 Operator 对静态 shape 反而有额外限制。第三opset 版本建议设 17ORT WebGPU EP 对 opset 17 的算子覆盖已经非常全面太低的版本可能触发不必要的算子兼容问题。导出后用onnxruntime在本地做一次推理和 PyTorch 的输出对比最大误差不能超过 1e-4 级别这样才能保证浏览器端的向量和本地模型完全一致。2.3 前端工程骨架与 WebGPU 初始化流程前端工程我用了 Vite 原生 TypeScript没有引入 React 或 Vue因为这个项目需要的界面非常有限一个图片拖放区、一个图库展示区、一个相似度结果列表。不需要复杂的状态管理原生 DOM 操作就够用了还能减少依赖体积。工程结构如下project/ ├── index.html ├── src/ │ ├── main.ts # 入口逻辑 │ ├── inference.ts # 推理会话管理 │ ├── preprocessing.ts # 图像预处理 │ ├── vectorIndex.ts # 特征向量索引与检索 │ └── ui.ts # 界面交互 ├── public/ │ └── models/ │ └── mobilenet_v3_small_feat_1024.onnx └── package.jsonWebGPU 初始化是整个流程的地基。ONNX Runtime Web 的执行后端初始化是异步的需要等待 GPU 设备就绪、计算管线编译完成后才能开始推理。一个常见的坑是在 WebGPU 尚未初始化完成时就开始加载模型会抛出 “No available execution providers” 之类的错误。所以初始化流程里我用了一个Promise做门闩所有推理请求必须先等待初始化完成。import * as ort from onnxruntime-web; let session: ort.InferenceSession | null null; let initPromise: Promisevoid | null null; const eps [webgpu, wasm]; export function initInference(onnxUrl: string): Promisevoid { if (!initPromise) { initPromise (async () { ort.env.wasm.numThreads navigator.hardwareConcurrency || 4; ort.env.wasm.simd true; try { session await ort.InferenceSession.create(onnxUrl, { executionProviders: eps, graphOptimizationLevel: all, }); } catch (e) { // WebGPU 不可用时 ORT 会自动尝试 wasm这层 catch 只处理更严重的错误 console.error(Init inference session failed:, e); throw e; } })(); } return initPromise; }executionProviders数组传多个候选值时ORT 会按顺序选择可用的 EP。WebGPU 下模型的算子会通过 WGSL shader 编译到 GPU 上执行首次加载时有一个编译阶段耗时会比后续推理高不少这在用户感知上相当于一次“冷启动”。实测下来WebGPU 冷启动可能需要 1-2 秒后续单张图推理在 20 毫秒左右这个差距需要在 UI 上做编程式提示不然很容易被误判为页面卡死。3. 核心功能实现与关键代码3.1 图像预处理从像素到张量MobileNet 的输入约定是 224x224 的 RGB 图片像素值需要归一化到特定的均值和方差范围。这里必须和训练阶段的预处理完全一致否则特征向量的质量会受到显著影响。torchvision 的 MobileNet 预训练权重在训练时用的是 ImageNet 标准化每个通道先除以 255 变成[0, 1]再按mean [0.485, 0.456, 0.406]、std [0.229, 0.224, 0.225]做归一化。所以在浏览器端我不能简单地只除以 255必须完整复现这套数学变换。实际操作时我会先把图片画到一张 224x224 的离屏 canvas 上拿到ImageData后遍历像素数组做通道分离和归一化最后填进一个Float32Array。这个过程看似简单但有一个非常容易忽略的性能问题如果直接在 JavaScript 主线程里逐像素遍历一个大图片会造成明显的卡顿尤其图库里的图片动辄 3000x4000 像素时预处理耗时可能比推理本身还长。我的处理方式是用离屏 canvas 先做一次快速 resize 到 224x224相当于把大规模像素操作交给了浏览器底层然后再遍历这 5 万个像素成本就低到几乎可以忽略了。export function preprocessImage(imageSource: CanvasImageSource): Float32Array { const canvas document.createElement(canvas); const size 224; canvas.width size; canvas.height size; const ctx canvas.getContext(2d, { willReadFrequently: true })!; ctx.drawImage(imageSource, 0, 0, size, size); const imageData ctx.getImageData(0, 0, size, size); const target new Float32Array(3 * size * size); const mean [0.485, 0.456, 0.406]; const std [0.229, 0.224, 0.225]; for (let i 0; i size * size; i) { const offset i * 4; const r imageData.data[offset] / 255; const g imageData.data[offset 1] / 255; const b imageData.data[offset 2] / 255; target[i] (r - mean[0]) / std[0]; target[size * size i] (g - mean[1]) / std[1]; target[2 * size * size i] (b - mean[2]) / std[2]; } return target; }这段代码里ctx.getContext(2d, { willReadFrequently: true })这个参数值得展开说一下。Canvas 默认的 2D context 会尽量把绘制操作缓存在 GPU 上但如果频繁调用getImageData把像素读回 CPU浏览器会频繁中断 GPU 同步性能损耗很大。设置willReadFrequently: true后浏览器会优先使用 CPU 加速的 canvas 实现对“画图 读像素”这种模式友好得多。这个小参数不关注文档的话几乎不可能知道但对预处理性能的影响非常明显。3.2 推理执行把图片换成向量模型加载完成、预处理得到Float32Array之后推理这一步反而是整个流程中最简单的部分。把张量包装成 ORT 的Tensorsession.run一下拿到输出再归一化就得到一个可用的特征向量。export async function extractEmbedding( inputTensor: Float32Array, width: number, height: number ): PromiseFloat32Array { if (!session) throw new Error(Inference session not initialized); const feeds { input: new ort.Tensor(float32, inputTensor, [1, 3, height, width]), }; const results await session.run(feeds); const output results[embedding].data as Float32Array; // L2 归一化 let norm 0; for (let i 0; i output.length; i) norm output[i] * output[i]; norm Math.sqrt(norm); for (let i 0; i output.length; i) output[i] / norm; return new Float32Array(output); }拿到向量之后要立刻做归一化这是非常重要的一步。我之前第一次实现时没注意直接拿原始向量去算余弦相似度结果出来的排序结果错得离谱。原因在于 MobileNet 抽取的特征图数值范围很大不同图片的向量范数可能差好几个量级不做 L2 归一化时范数大的图片天然占优势相似度排序完全失真。因此“提取向量 归一化”必须作为原子操作处理归一化之后的向量才能进入索引库。3.3 特征向量索引与相似度检索上面提到文中的 1024 维向量归一化之后长度为 1两个向量的余弦相似度就等于它们的点积。对前端场景来说这个数学性质简化了大量实现。我设计了一个简单的VectorIndex类内部用一个大Float32Array存储建库时的所有向量用普通数组存储每张图的元信息缩略图 URL、文件名、描述等检索时就逐个计算点积找到 Top-K 之后返回对应图片信息。export interface IndexItem { id: number; name: string; thumbnailUrl: string; vector: Float32Array; } export class VectorIndex { items: IndexItem[] []; add(name: string, thumbnailUrl: string, vector: Float32Array): number { const id this.items.length; this.items.push({ id, name, thumbnailUrl, vector }); return id; } search(target: Float32Array, topK 10): IndexItem[] { const scored: { item: IndexItem; score: number }[] []; for (const item of this.items) { let dot 0; const v item.vector; for (let i 0; i v.length; i) { dot v[i] * target[i]; } scored.push({ item, score: dot }); } scored.sort((a, b) b.score - a.score); return scored.slice(0, topK).map((s) s.item); } get size(): number { return this.items.length; } }这里必须坦率地讲暴力线性检索的时间复杂度是 O(n)n 是图库数量在向量维度固定在 1024 时这个 O(n) 的常数非常大。实测在我的 M1 MacBook Pro 上3000 张图的全表扫描耗时约 25ms5000 张图约 40ms还能忍但如果图库到了 10 万张级别这个方案就会明显卡顿。中大型图库需要引入倒排索引、HNSW 或 PQ 一类的近似最近邻算法但那是另一个项目的规模了浏览器端能做 5000 张以内的高效搜索已经很有实用价值。如果你的图库更大可以退一步把建库和检索放到 Web Worker 里做甚至用 IndexedDB 缓存向量数据避免每次刷新页面都要重新提取所有图片特征。这里还想分享一个实用优化因为我存储的是归一化向量点积结果的范围是 [-1, 1]这个范围其实就是相似度得分。当得分大于 0.85 时基本可以认为两张图在视觉上高度相似0.7-0.85 属于内容相近但细节有差异低于 0.5 基本是不相关了。把这个分数直接显示在 UI 上用户就能直观理解搜索结果的置信度。3.4 建库流程批量提取特征的性能优化实现以图搜图时单张图片的检索只是最后一步真正麻烦的是“建库”——需要提前对图库里所有图片逐个提取特征向量。这个流程里有非常多的坑最典型的就是如果用户一次性拖入 100 张图片直接 for 循环调用extractEmbedding会在瞬间发起 100 个推理请求浏览器直接卡死。我的做法是把建库任务串行化同时用requestAnimationFrame控制节奏每处理完一张图就给界面一个更新机会让用户看到进度条在走。代码上通过一个异步队列来处理async function buildIndex(images: ImageBitmap[], names: string[]) { for (let i 0; i images.length; i) { const input preprocessImage(images[i]); const vector await extractEmbedding(input, 224, 224); const url createThumbnailUrl(images[i]); index.add(names[i], url, vector); progress ((i 1) / images.length) * 100; updateProgressUI(progress); // 每次循环让出主线程保证 UI 可响应 await new Promise((r) setTimeout(r, 0)); } }这里的await new Promise(r setTimeout(r, 0))是处理长任务的经典技巧。它能让浏览器在两次推理之间处理点击、渲染、滚动等 UI 事件避免“页面未响应”的弹窗。实测 100 张图建库WebGPU 模式下大约需要 3 秒左右WASM 模式下大约是 8-10 秒整体体验在可接受范围内。4. 性能实测与踩坑记录4.1 WebGPU vs WASM 实测数据对比做技术选型时口头说“WebGPU 快”是没有说服力的我把同一台电脑、同一个 ONNX 模型、同一张测试图在不同执行后端下的推理耗时做了对比。以下数据基于 M1 MacBook Pro8 核 CPU16GB 内存模型为 MobileNet V3 Small 特征提取版输入 224x224单张图片推理预热 10 次后取平均执行后端单张推理耗时100 张图建库耗时浏览器版本WebGPU (Chrome 131)18-25ms约 3sChrome 131WASM SIMD 多线程 (4线程)90-120ms约 12sChrome 131WASM 单线程180-220ms约 25sChrome 131WebGPU 相对多线程 WASM 的加速比大约是 4-6 倍这个数据和我预期基本一致。GPU 上的卷积计算与矩阵乘法天生高效MobileNet 这种轻量模型在 GPU 上的并行效率尤其高。有一点值得注意WebGPU 的前几次推理耗时波动较大能达到 30-50ms这是管线编译和显存分配的预热成本等跑起来十几张图之后才会趋于稳定。所以做性能测试时一定要预热不要在冷启动阶段就下结论。设备差异方面我也用几台不同机器做过测试。集显Intel Iris Xe的跑分大约是 M1 的 1.5 倍耗时高性能独显RTX 3060 Laptop可以压到 10ms 左右。这台项目对低端集显的容忍度比我预想的好因为 MobileNet 本身计算量不大瓶颈更多在预处理和内存拷贝上。4.2 预处理细节MobileNet 的“输入约定”前面提过MobileNet 的输入归一化必须严格符合预训练权重的约定。但这里我想专门多讲一层torchvision 的 MobileNet 实现里torchvision.transforms的标准化用的是mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]但如果你直接去看原版 Google 的 MobileNet 论文或者 Keras 的 MobileNet 实现它们的输入像素范围是[-1, 1]也就是只做image/255 * 2 - 1不做均值和方差标准化。这两个方案不能混用。我在项目里遇到过一个真实事故最开始我拿 torchvision 权重导出模型但前端预处理按 Keras 习惯写了[-1, 1]归一化检索结果质量一塌糊涂几乎随机。排查半天找到根因是预处理和模型训练约定不匹配。所以在模型转换阶段必须清晰记录模型的来源和预处理参数并在前端代码里用常量定义好不允许在运行时猜测。下面的常量是所有图像处理函数共享的唯一事实来源export const IMAGE_MEAN: [number, number, number] [0.485, 0.456, 0.406]; export const IMAGE_STD: [number, number, number] [0.229, 0.224, 0.225];4.3 兼容性检查与降级策略WebGPU 目前还不是所有浏览器都原生支持所以项目里必须做能力检测并给出合理的降级路径。检测代码很简单一行navigator.gpu是否为空即可。但真正重要的是降级策略的完整性。我在initInference里把executionProviders设置为了[webgpu, wasm]。这里有个隐形的行为逻辑当 WebGPU 创建会话失败时ORT 会自动尝试下一个 EP不需要手动写if/else。但要注意这个自动降级只对“创建会话”阶段有效如果在 WebGPU 模式下推理中途报错比如设备丢失或者 shader 编译问题不会自动切到 WASM。所以生产环境里最好做一次冒烟测试初始化完成后先跑一张 1x1 的测试图片如果失败就强制重建 WASM 会话。还有一个小坑HTTP 服务必须走 HTTPS 或者 localhost否则 WebGPU 接口不可用。WebGPU 对安全上下文要求严格生产环境如果部署在 HTTP 上GPU 能力会直接被禁用用户会默默落到 WASM 模式。我在本地开发时用 Vite 的--host访问局域网 IP就遇到过这个情况排查出来的那一刻很想拍自己脑袋。5. 常见问题与排查技巧实录5.1 问题速查表我把项目开发过程中遇到的高频问题整理成了一个表格方便直接对照排查问题现象根本原因解决方法浏览器虽然支持 WebGPU但始终跑在 WASM 模式页面没有在 HTTPS/localhost 环境下部署到 HTTPS 或本机 localhost 开发模型加载报 “Error: cannot load file from url”ONNX 文件路径不正确或没放在 public 目录下确认浏览器控制台网络请求中文件返回 200推理结果全为 NaN输入张量数据可能包含Infinity通常是预处理归一化公式错误打印输入 Tensor 的min/max确认像素范围约为[-2.5, 2.5]特征向量长度不是 1024模型导出时未正确移除分类头或全局池化未生效用onnxruntime本地检查输出 shapeWebGPU 初始化极慢超过 3 秒首次 shader 编译耗时正常现象在 UI 上显示“GPU 加速初始化中”提示图库建库时页面卡死批量推理未让出主线程用异步队列串行任务setTimeout(0)让出主线程相似度搜索结果明显不合理向量未做 L2 归一化紧接推理后统一归一化低端集显上首帧推理耗时超过 100ms集显 WebGPU 驱动效率较低正常现象可考虑把executionProviders改成[wasm]优先每一个问题都是我实际遇到过的其中“推理结果全为 NaN”和“相似度排序失真”这两个坑最隐蔽因为它们编译期不报错、运行期也不崩溃但结果完全不可用。排查这类问题最好的方法就是分步验证先用 Python 跑一遍同一张图的 ONNX 输出和 PyTorch 输出对比再在前端打印输出 Tensor 的数值对照差异出现在哪一层。5.2 一些个人心得和工程化建议项目做到后面我发现真正的复杂度不在 AI也不在浏览器而在“工程化落地”的边界处理。有几个建议想给后来人。模型体积的优化值得花时间。MobileNet V3 Small 的 FP32 ONNX 大约是 10MB但用 onnxruntime 的quantize_model_dynamic转成 INT8 动态量化后体积能压到 5MB 左右WebGPU 推理速度还会进一步提升。代价是特征向量精度会发生微小偏移和全精度模型对比相似度排序在 10 万张图级别会差 2-3 个位次但在 5000 张图级别几乎没有感知差异。如果对精度有强迫症可以导出 FP16 版本大约 5MBWebGPU 原生支持 FP16 计算精度损失更小这是我目前最推荐的部署形态。IndexedDB 缓存策略非常重要。浏览器端的图库索引建好后如果用户刷新页面就全部丢失那每次打开都要重新提取几百张图的特征体验太糟糕。我在项目里把向量和图片元信息序列化后存进 IndexedDB刷新后直接读取缓存恢复索引。这本质上就是一个最简单的前端向量数据库虽然缺少复杂的索引结构但对 5000 张以内的图库足够实用。缓存命中后建库时间从几秒降到了几十毫秒用户几乎无感。最后想说的是多模态、大模型带来的浏览器端能力进化比大多数人想象的快得多。WebGPU 不仅能让 MobileNet 跑得飞快也能让 Stable Diffusion、LLM 这类重型模型在本地部分运行。但无论模型多强落地时最关键的仍然是头尾两件事输入侧的特征对齐输出侧的工程封装。这两件事想清楚剩下的其实都是体力活。这次实战项目踩了不少坑也积累了不少可以直接复用的代码片段。如果你也在做车牌识别、图片去重、商品检索、相册分类之类的项目完全可以直接把这篇的模型转换和前端推理代码拿过去改改用。浏览器端跑 AI 不再是玩具而是一条走通了的路。