多模态Embedding实战:从微信场景到双塔模型训练与部署

发布时间:2026/9/5 21:12:24
多模态Embedding实战:从微信场景到双塔模型训练与部署 1. 从微信场景切入多模态 Embedding 到底在做什么先说个实际点的问题很多人把多模态 Embedding 想得太玄其实微信生态里到处都在跑这类模型。你搜一张表情包、发一段语音转文字、在小程序里检索商品图片背后都牵扯到把“不同类型的数据”塞进同一个向量空间这件事。拿微信场景举例用户的一条朋友圈可能同时包含文字描述、九张图片、地理位置、甚至一段视频传统做法是“文本走文本的检索、图片走图片的检索”两条线互不相通遇到“发一张猫的照片找对应朋友圈文案”这种需求基本就抓瞎了。多模态 Embedding 模型解决的核心问题就是让不同模态的数据在向量空间里可以直接比较相似度。用一个不严谨但好理解的类比以前的搜索引擎是“各科室分开挂号”文本去文本科、图片去图片科现在多模态 Embedding 是“全院会诊”把文字描述、图像内容、语音特征全部翻译成同一种“向量语言”拉到同一个坐标系里做距离计算。在微信这种量级的业务场景里多模态 Embedding 的典型应用有几个明显落点视频号/朋友圈的“以图搜视频”用户发一张截图后台用 Embedding 向量去匹配视频画面的关键帧微信搜一搜的“多模态召回”搜索词是文本候选内容包含公众号文章、视频号动态、小程序服务全部映射到统一向量空间后做混合召回表情包推荐和智能回复结合图片语义和对话上下文文本生成候选表情的排序特征这套方案的设计逻辑其实不只适用于微信。任何有“多类型内容语义匹配”需求的产品都可以套用同一套训练思路只是数据规模和工程容错要求不同。下面我围绕完整的训练链路把这件事拆开讲透。2. 模型选型与训练范式为什么不能直接拿现成模型用2.1 Embedding 模型选型的底层逻辑做多模态 Embedding第一步其实是模型结构选型这里面坑很多。我见过太多人上来就抱一个大参数量的多模态大模型试图直接推理比如拿 7B、13B 的 VLM 去抽特征结果线上延迟飙到几百毫秒召回率还没涨多少。微信这种场景更通用的做法是“双塔结构”也叫 two-tower。核心思路是文本塔和图像塔各自独立编码但在训练时拉近匹配样本的向量距离拉远不匹配样本的向量距离。这个结构的最大优势是部署时两边可以分开算、离线预计算一部分向量线上只做向量检索。具体到模型底座选择目前实践里比较稳的几类方案是CLIP 类模型底座OpenAI CLIP、开源社区的中文 CLIP如 CN-CLIP图文匹配能力成熟拿来初始化双塔模型的图像塔和文本塔非常合适微信场景的中文适配也更好。中文语义 Embedding 模型底座像智源的 BGE 系列、阿里的 GTE 系列文本侧语义理解很强适合作为文本塔的初始化权重。轻量级多模态融合模型如果既要统一向量空间、又要兼顾细粒度跨模态交互可以考虑 Qwen-VL 系列的轻量版本做蒸馏 Teacher但全程在线推理不现实通常是离线蒸馏后用 student 模型线上跑。以我的实测经验16G 显存级别的显卡比如 RTX 4080 / 4090 / A5000可以支撑 CLIP ViT-B/16 或 ViT-L/14 级别的双塔模型训练。如果数据量不大百万级以内ViT-B/16 性价比最高微调速度快显存占用大概 6-8G还能留出空间做梯度累积和更大的 batch。2.2 多模态融合算法的选择双塔模型结构简单但“塔之间不交互”也被人诟病——它学到的匹配分数终究是“各自编码后再比对”没有建模模态间的细粒度对齐。更进阶的做法是引入交叉注意力即让文本 token 和图像 patch token 在一个 Transformer 层里做注意力计算。但交叉注意力计算量大不适合做第一阶段的 Embedding更适合做精排模型。实际工程里最常见的折中方案是双塔做召回交叉注意力模型做精排。第一路向量召回解决“从千万级候选里捞出几百个”第二路精排解决“这几百个里谁最相关”。这是微信视频号、公众号搜索里非常典型的级联架构既不牺牲效果也能控制线上成本。2.3 微调还是从零训练关于“如何训练多模态 Embedding 模型”很多人搞不清楚该微调还是该从零预训练。我的建议非常明确除非你有数亿级以上的图文对数据否则绝不要从零预训练。从零训练一个多模态模型的数据成本、算力成本、调参成本都非常高而且很容易遇到不收敛的坑。更好的做法是拿开源 CLIP 模型做底座用自己的业务数据做领域微调。比如微信生态里有大量“中文网络用语表情包/小程序截图”的数据这些是公开 CLIP 模型没见过的微调空间很值得挖掘。3. 训练数据构造多模态对齐效果的关键命门3.1 数据来源与清洗多模态 Embedding 模型的对齐效果80% 取决于数据质量模型结构反而在其次。如果你只给模型扔几千条清理干净的数据效果大概率比扔十万条杂乱数据更好。我自己的处理流程分为四步原始数据抽取从业务日志里抽出图文共存的内容比如公众号文章的配图和段落文本、视频号视频的标题和封面帧粗清洗过滤广告、低俗内容、纯表情包无文本、纯文本无图像等无效样本细清洗做图文相关性打分。用一个小模型比如 BLIP-2 或现成 CLIP给每个图文对打分丢弃分数低于阈值的样本这个步骤能显著减少“图文不相关”的噪声去重用 MinHash 做文本去重用 PerceptualHash 做图像去重避免模型对高频样本过拟合这里有个值得注意的细节负样本的构造同样重要。训练双塔模型时不仅要有正样本匹配的图文对还要有负样本不匹配的图文对。负样本不能只从随机配对里抽样得加一些“难负样本”——比如文本是“狗在草地奔跑”随机负样本是“厨房里的桌子”难负样本是“狗在室内地毯上休息”后者图像和文本表面关联性更强模型需要学更细节的语义才能区分。3.2 多模态数据增强的实践经验文本侧增强相对有限核心是文本替换、同义词替换、随机 mask对应模型里做 dropout等方式。更有效的是图像侧增强包括随机裁剪、颜色抖动、模糊、翻转、旋转等。这能帮助模型学到更强的模态不变性。另外还可以做图文互换增强同一张图配多个不同角度的文本描述或者同一段文本配多张同主题不同风格的图相当于在数据层面做“多对多对齐”对增强模型鲁棒性非常有帮助。3.3 数据规模怎么定以微信这种规模的产品为例假设我想做“朋友圈多模态召回”图文对数据的数量级至少应该在千万级到亿级。如果只有几百万数据建议先不要追求模型容量而是把双塔模型的隐藏层控制在 512-768 维训练轮次控制在 5-10 个 epoch 内提前设置早停机制防止过拟合。我踩过的一个节奏问题是前期数据没清理干净时盲目堆数据量只会让模型学偏。我第一次做类似项目时用了两千万图文对结果模型上线后召回结果里出现大量低俗内容和无关广告翻查日志发现源头是数据清洗去重环节没做干净垃圾数据直接污染了 Embedding 空间。后来把数据规模砍到八百多万条清洗做得更认真效果反而明显提升。所以数据规模的第一优先级是“干净”其次才是“大”。4. 微信场景下的完整训练流程与核心代码实现4.1 环境准备与依赖安装开始训练前先把环境搭好。我推荐一套稳定的组合覆盖 PyTorch 2.x、CUDA、多模态模型加载与训练# 建议使用 Python 3.10 以上版本 conda create -n mm_embed python3.10 -y conda activate mm_embed # 安装 PyTorch以 CUDA 11.8 为例 pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118 # 安装多模态训练常用依赖 pip install transformers datasets accelerate sentencepiece pip install open_clip_torch pip install faiss-cpu # 训练阶段评估用 faiss-gpu 也可以 pip install tensorboard4.2 整体训练流程我在一次真实项目中用微信视频号的封面图和标题文本构造图文对做一次多模态 Embedding 微调大体流程分五步数据加载与预处理创建 Dataset为文本塔加载 tokenizer为图像塔加载图像预处理 pipeline构建双塔模型文本塔用 BERT/中文 RoBERTa 类底座图像塔用 ViT 类底座通过投影层把两个塔的输出映射到同一维度比如 768 维构造训练 batch一个 batch 内包含图文对同 batch 内其他图像作为负样本实现 InfoNCE 对比损失加入难负样本预先抽好难负样本与随机负样本混合送入训练评估与迭代使用图文检索 RecallK 指标评估定期保存 checkpoint4.3 双塔模型核心代码实战为了让你能更直接对照操作我放一个精简但可运行的双塔模型定义。这个结构非常适合做多模态 Embedding 微调实验import torch import torch.nn as nn import torch.nn.functional as F from transformers import AutoModel, AutoTokenizer, AutoImageProcessor import open_clip class TwoTowerModel(nn.Module): def __init__(self, text_model_name: str, vision_model_name: str, embed_dim: int 768): super().__init__() # 文本塔使用中文字符级或词级模型均可这里用 open_clip 的 RoBERTa-wwm 中文底座 self.text_encoder ( open_clip.create_model_and_transforms( hf-hub:laion/CLIP-ViT-B-32-roberta-base_laion2B-s12B-b32k )[0].text if False else AutoModel.from_pretrained(text_model_name) ) # 视觉塔使用 ViT 系列模型 self.vision_encoder AutoModel.from_pretrained(vision_model_name) # 投影层统一到同一向量维度 text_hidden_size self.text_encoder.config.hidden_size vision_hidden_size self.vision_encoder.config.hidden_size self.text_proj nn.Linear(text_hidden_size, embed_dim) self.vision_proj nn.Linear(vision_hidden_size, embed_dim) # L2 归一化层 self.logit_scale nn.Parameter(torch.ones([]) * 2.6592) def encode_text(self, input_ids, attention_mask): text_feats self.text_encoder(input_idsinput_ids, attention_maskattention_mask).last_hidden_state # 取 [CLS] token 表示 text_emb text_feats[:, 0, :] text_emb self.text_proj(text_emb) text_emb F.normalize(text_emb, dim-1) return text_emb def encode_image(self, pixel_values): vision_feats self.vision_encoder(pixel_values).last_hidden_state # 取 [CLS] token 表示 image_emb vision_feats[:, 0, :] image_emb self.vision_proj(image_emb) image_emb F.normalize(image_emb, dim-1) return image_emb def forward(self, input_ids, attention_mask, pixel_values): text_emb self.encode_text(input_ids, attention_mask) image_emb self.encode_image(pixel_values) return text_emb, image_emb4.4 对比损失函数与 Batch 内负样本训练双塔模型最常用的损失函数是 InfoNCE也叫 NT-Xent / Contrastive Loss。核心思想是对于一个 batch 里的每一对正样本文本 i图片 i把 batch 里其他所有图片当作负样本让正样本对的相似度得分尽量高负样本对的得分尽量低。def info_nce_loss(text_emb, image_emb, logit_scale, temperature0.07): # 计算相似度矩阵 [batch_size, batch_size] logits logit_scale * text_emb image_emb.T # 对角线是正样本 batch_size text_emb.shape[0] labels torch.arange(batch_size, devicetext_emb.device) loss F.cross_entropy(logits, labels) return loss上面的代码里有两个关键超参数值得展开说。一是logit_scale初始值2.6592对应的1/0.07是 CLIP 论文里的经验设置这个参数是可学习的训练中会自动调节匹配分数的尺度二是temperature温度越小对比损失对难负样本的惩罚越大但也越容易训练不稳定我建议初期固定在 0.07不要动它等模型稳定后再调。4.5 训练循环中的关键细节Loss 部分说完接下来是实际训练循环中非常容易被忽略但决定成败的细节。from torch.utils.data import DataLoader from torch.optim import AdamW from tqdm import tqdm # 假设 dataset 返回 dict: input_ids, attention_mask, pixel_values train_loader DataLoader(dataset, batch_size128, shuffleTrue, num_workers4) model TwoTowerModel( text_model_namehfl/chinese-roberta-wwm-ext, vision_model_nameopenai/clip-vit-base-patch32, embed_dim768 ) model model.cuda() optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) scaler torch.cuda.amp.GradScaler() # 混合精度训练 epochs 10 for epoch in range(epochs): model.train() total_loss 0.0 for step, batch in enumerate(tqdm(train_loader)): input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() pixel_values batch[pixel_values].cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): text_emb, image_emb model(input_ids, attention_mask, pixel_values) loss info_nce_loss(text_emb, image_emb, model.logit_scale) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() if step % 100 0: print(fEpoch {epoch}, Step {step}, Loss: {loss.item():.4f})这个训练过程里有两个细节直接决定效果第一个是学习率。双塔微调的学习率不能太大不然会把预训练模型的特征空间冲垮。文本塔和图像塔的学习率可以分开设置图像塔用 1e-5文本塔用 2e-5投影层用 5e-5这样更精细地控制“保留原特征”和“适配新任务”的平衡。上面代码里为了简化只设了一个学习率实际项目我强烈建议分组设置optimizer_grouped_parameters [ {params: model.text_encoder.parameters(), lr: 1e-5}, {params: model.vision_encoder.parameters(), lr: 2e-5}, {params: model.text_proj.parameters(), lr: 5e-5}, {params: model.vision_proj.parameters(), lr: 5e-5}, ] optimizer AdamW(optimizer_grouped_parameters, weight_decay0.01)第二个是梯度累积。如果你的显卡显存不大比如 16Gbatch size 可能只能设到 32 或 64这个规模对于对比学习来说偏小负样本太少会明显影响训练质量。解决办法是梯度累积用 4 个小 batch 累积梯度后再更新参数等效于一个 128/256 的大 batchaccumulation_steps 4 scaler.scale(loss).backward() if (step 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()4.6 模型检查器训练中可视化 Embedding 质量训练过程中我建议每隔几个 epoch 做一次小规模评估。除了常规的 RecallK 指标还有一个非常直观的做法——把训练集里随机抽出的 500 条文本和 500 张图片都编码成向量投影到二维空间里人工检查聚类效果。用到的工具就是模型检查器Model Inspector这类可视化工具。import numpy as np from sklearn.manifold import TSNE import matplotlib.pyplot as plt # 收集验证集的 text_emb 和 image_emb # 拼接所有向量 all_emb np.concatenate([text_emb_np, image_emb_np], axis0) # TSNE 降到 2 维 tsne TSNE(n_components2, perplexity30, random_state42) all_emb_2d tsne.fit_transform(all_emb) # 绘制图像文本用蓝色点图像用红色点观察对齐情况 plt.figure(figsize(10, 8)) plt.scatter(all_emb_2d[:len(text_emb_np), 0], all_emb_2d[:len(text_emb_np), 1], cblue, labeltext, s10, alpha0.6) plt.scatter(all_emb_2d[len(text_emb_np):, 0], all_emb_2d[len(text_emb_np):, 1], cred, labelimage, s10, alpha0.6) plt.legend() plt.savefig(fembedding_visualization_epoch_{epoch}.png)如果训练效果理想图中相同语义的文本点和图片点会聚成簇如果文本点和图片点各聚一团、互相分离说明两个塔还没对齐需要调整数据或模型结构。4.7 微信旧版客户端的兼容性排查在微信场景做多模态 Embedding有一个工程特殊性需要提醒部分老版本客户端的特征上报格式不一样。微信团队在灰度发布新模型时经常需要兼容旧版客户端传上来的特征。如果你做的是类似项目最好在后端接口设计时设置特征版本号字段兼容旧版和新型两套字段。我在实际项目中踩过这个坑新版模型上线后部分 Mac 旧版微信用户反馈搜索无结果排查后发现是旧客户端不传image_feature_v2字段导致后端拿到空向量直接掉出索引。5. 部署与上线从 PyTorch 模型到线上服务5.1 模型导出与量化训练好的模型不能直接以 PyTorch 格式上生产推理速度和显存占用都撑不住。常见做法是导出为 ONNX 格式再做 FP16 量化或 INT8 量化。对于双塔模型文本塔和图像塔可以分别导出import torch from model import TwoTowerModel model TwoTowerModel( text_model_namehfl/chinese-roberta-wwm-ext, vision_model_nameopenai/clip-vit-base-patch32, embed_dim768 ) checkpoint torch.load(best_model.pt, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) model.eval() # 分别导出文本塔和图像塔 dummy_input_ids torch.randint(0, 1000, (1, 64)) dummy_attention_mask torch.ones(1, 64, dtypetorch.long) dummy_pixel_values torch.randn(1, 3, 224, 224) torch.onnx.export( model.text_encoder, (dummy_input_ids, dummy_attention_mask), text_encoder.onnx, opset_version13 ) torch.onnx.export( model.vision_encoder, dummy_pixel_values, vision_encoder.onnx, opset_version13 )INT8 量化对显存和推理延迟的改善非常明显但会有少量精度损失。我的建议是先上 FP16压测后如果延迟还不达标再评估 INT8。微信视频号、搜一搜场景的检索服务有严格的延迟预算通常 P99 小于 50ms如果你真的要上 INT8需要在离线评测集上对比量化前后的 RecallK确保损失在可接受范围内比如不超过 2%。5.2 向量检索服务搭建与降级方案Embedding 模型上线之后你需要一个向量检索服务。开源的向量检索框架各有各的优势这里列一个对比表格方案适合规模延迟表现部署成本典型适用场景Faiss (CPU/GPU)千万级以内较好GPU 更佳低离线向量索引、小规模在线检索Milvus千万到亿级好中等大规模生产环境支持分布式Elasticsearch dense_vector百万到千万级中等中等已有 ES 技术栈、需要全文检索混合HNSW 自研亿级以上极好高大厂超高并发场景常配合分层检索微信这类超大规模场景一般会自研向量检索引擎但大多数项目直接用 Milvus 或者 Faiss 就够了。有一个建议向量检索服务一定要做降级方案比如索引挂了就退化到基于标题关键词的 BM25 文本检索避免整体服务不可用。这点在电商、社交等场景尤其重要用户遇到一次无结果可能就流失了。5.3 线上服务的 Embedding 缓存设计上线后你会发现一个高频问题相同或相似的文本比如热搜视频的标题会被重复调用模型编码浪费算力。我的做法是对 Encoder 做一层 LRU 缓存用文本或图片感知哈希做 key。在新版本模型训练完切换线上模型时最好灰度放量并同步清理缓存。有一次我没清缓存新模型上线后文本查询结果大量命中缓存导致 30% 流量还在用旧模型的结果问题排查了很久。这个教训让我现在把缓存版本号写进 key。6. 常见问题与优化技巧训练多模态 Embedding 的实战排查6.1 训练不收敛、Loss 震荡的排查思路这是最常遇到的问题。展开来说训练不收敛的根源大概率出在三方面数据问题正样本本身图文不匹配模型没法学习有效信号。这种问题要从数据清洗源头解决可以抽一批训练样本人工检查。学习率过大对比学习对学习率很敏感建议从 1e-5 起步用 cosine schedule 衰减到 1e-6。我曾经把学习率调到 5e-5训练到第二个 epoch 时 loss 从 3.2 暴涨到 15.8模型直接崩掉。Batch size 过小对比学习依赖大量负样本batch size 至少 128。显存不够就梯度累积千万别用 16 的 batch size 硬扛。6.2 图文召回效果差的排查方法假设训练正常Loss 也降下去了但 Recall10 上不去。我一般的排查顺序是单看文本检索文本用文本 Embedding 做纯文本语义检索如果文本侧自身的效果就差问题可能在文本 Encoder单看图像检索图像同理用图像 Embedding 做纯图像检索再做跨模态的图文互检如果前两步没问题但第三步差那大概率是数据里“图文对的对齐质量”不行另外一个非常重要的评估指标是模态对齐度Alignment。简单说就是计算正样本对在所有样本对中的平均距离再计算负样本对的平均距离两者差值越大对齐越好。我见过一种情况Loss 一直在降但正负样本的距离都在同步缩小说明模型只是把所有向量都压缩到一个小区域里根本没有学到区分性。这种情况通常要把 logit_scale 的初始化值调大或者切换成 hard negative mining 策略。6.3 16G 显存真的够用吗回到热搜词里的“16g显存多模态模型推荐”。以我的经验16G 显存做双塔模型微调完全够用甚至偏宽松。ViT-B/16 图像塔 RoBERTa-base 文本塔 batch size 64 FP16 混合精度占用大约 10-12G 显存。如果你想训更大的 ViT-L/14也勉强能跑但 batch size 可能要降到 16-32梯度累积步数需要相应增加。但如果你想微调一个 7B 的 VLM 来做交叉注意力精排16G 就不够看了。这种模型起码要 2 张 24G 以上的显卡或者用 DeepSpeed ZeRO-Offload 把优化器状态挪到 CPU。我的建议是召回阶段用双塔16G 够精排阶段用大模型直接上云 GPU 或内部 GPU 集群不要为这个问题浪费太多时间。6.4 数据隐私与合规问题微信场景的数据涉及用户隐私训练数据安全是红线。行业通用做法是数据进入训练管道前做匿名化和脱敏去掉能直接定位到具体用户身份的字段训练流程走内部私有化环境不允许数据出域模型上线前做内容安全审查尤其要防止模型生成或召回具有风险倾向的内容这块强调再多都不为过训练 Embedding 模型不是纯粹的技术活数据安全和合规意识必须从第一天就建立。7. 从“微信”到“你的场景”这套方案的迁移思路我在这类多模态 Embedding 项目上动手实践过一轮之后最大的体会是微信的技术方案虽然不对外公开细节但整个行业的多模态融合算法、Embedding 模型训练范式是趋同的并不会因为某家公司规模大就有本质不同。如果你想把这套方案迁移到自己的业务里我建议按照下面的路径逐步推进第一步明确场景和指标。你是要做搜索召回、推荐粗排还是做内容去重不同场景对指标的定义完全不一样。搜索场景看 RecallK推荐场景看 Recall 和多样性去重场景看 Precision。没有指标约束的训练就是耍流氓。第二步盘点数据和算力。你有多少图文对数据标注质量如何有没有 GPU 资源至少需要一张 16G 显存的卡才能比较舒服地训练双塔模型。如果连 16G 都没有那就只能使用在线 API 服务做推理逐步积累数据。第三步小规模实验跑通链路。用几千条干净数据先做成一个可以用的双塔模型哪怕效果一般先把数据加载、训练、评估、导出的链路跑通。这一步的价值是暴露工程问题而不是追求效果。第四步扩大数据规模和难负样本比例。数据量从几千扩到几十万、几百万难负样本比例逐步提高观察指标变化。这个阶段会反复进出“数据清洗、模型调参”的循环要有耐心。第五步稳定训练、完善监控。上线后关注 Embedding 距离分布、在线召回率、延迟等指标。你会发现数据分布随时间漂移Embedding 模型要定期更新和重训这块的 MLOps 流程可以另外单独写一篇。最后再分享一个实操心得不要迷信“多模态模型越大越好”。在大多数业务场景里一个微调过的 ViT-B/16 RoBERTa 双塔模型在垂直数据上表现往往超过一个没微调过的 7B 大模型做零样本特征抽取。少折腾模型规模多花时间在训练数据质量和难负样本构造上这几乎是我所有相关项目里投入产出比最高的优化方向。希望这篇内容能给你省点试错的成本。