
做多模态这几年我越来越觉得 CLIP 的双编码器结构是个绕不开的基准。图像一个塔、文本一个塔各自编码完了往同一个向量空间放一个余弦相似度就把图文关联的远近衡量得明明白白。前两篇把 CLIP 的背景和整体训练流程过了一遍这篇我按架构细节来写重点回答一个问题双编码器到底动了哪些设计才能把图文关联优化得这么干净。不管你是准备复现、微调还是想拿这套思想去做业务里的图文召回这篇应该能帮你省掉不少弯路。1. 双编码器的设计动机两个塔到底在优化什么1.1 图文关联的本质把是否相关变成距离远近图文关联这个任务说白了就是在像素空间和语言空间之间建立起对应关系。图像是一堆数值矩阵文本是一串离散符号两者本来不在一个坐标系里强行直接比较根本比不了。传统做法要么靠手工特征加排序要么训练一个融合模型让两种模态在内部做交互但都有各自的麻烦。CLIP 的双编码器换了个思路不去直接比较图像和文本而是分别把它们编码成向量然后让匹配的图文对在向量空间里离得近不匹配的拉远。模型学的其实不是一个分类器而是一个度量空间。用一个生活化的类比图像有自己的语言文本也有自己的语言双编码器就像两个翻译官各自把母语翻成同一门外语再用两段外语的意思是否接近来判断原来的图和文是否有关系。这个外语就是共享向量空间。这个设计最核心的价值在于它把关联这个模糊的概念变成了一个可以用点积直接计算的几何量。下游不管是检索、分类还是排序本质上都是在同一个空间里做距离运算。1.2 单流融合模型的代价交互深度换不来检索效率很多人问过一个问题为什么不把图像和文本塞进同一个模型让它们一层层充分交互这种单流思路在视觉问答、图文推理里非常常见效果也确实好因为模型每一层都能做深度的跨模态注意力。但单流的致命伤在于每次推理都必须把一对图文重新联合算一遍图片和文本都无法提前离线算好向量。一旦到了检索场景这个缺点就完全暴露了。假设图片库有一百万张线上来一个查询单流模型要把查询和一百万张图分别拼接后过一遍模型这个计算量在任何生产环境里都是不可接受的。双编码器恰恰用牺牲深层交互换来了表示和交互解耦图片可以提前批量编码成向量存进索引线上查询时只需要编码文本然后跟库里所有向量做一次点积复杂度从 O(M×N) 的联合推理降到了 O(MN) 的预处理加 O(查询数×库大小) 的向量运算。我把两种路线的差别整理了一下维度双编码器结构单流融合模型图文交互深度浅只在最终向量空间对齐深每层都有跨模态注意力表示复用性可离线预计算直接建索引每对图文必须重新联合计算检索场景适配极好适合大规模召回差适合小规模精排或重排训练数据要求弱监督图文对即可通常需要更细粒度的配对标注典型任务图文召回、零样本分类图文推理、视觉问答、精排所以你会发现业界做大规模跨模态系统几乎清一色选择双塔架构不是因为它比单流聪明而是因为它在大规模场景下更务实。这个取舍我认为是 CLIP 对多模态工程最大的贡献之一。1.3 对称结构的隐藏收益双塔不光是两个编码器关键在于两边的输出最终被投到同一个空间而且训练目标是对称的。后面讲损失函数的时候你会看到一个 batch 里的正样本就是对角线上的图文对图像塔和文本塔的地位完全对等没有谁支配谁。这种对称性带来一个容易被忽略的好处同一套向量天然支持双向检索。用图找文可以用文找图也可以不需要为每个方向单独训练模型。很多自己做业务的朋友一开始只做一个方向的匹配结果下游需求变了又要重新训CLIP 这种对称结构能帮你少踩这个坑。2. 两个编码器的内部细节从网络结构到最后一层投影2.1 图像塔ResNet 与 ViT 的改造点图像塔在原实现里有两个系列一个基于卷积的 ResNet 系列一个基于 Transformer 的 ViT 系列。如果只是照搬现成的图像分类模型效果并不理想关键原因是通用分类模型的输出特征是为了区分物体类别设计的而双塔需要的是适合跟文本对齐的特征两者侧重点不同。ResNet 版本里改动比较大的有两处。第一是对输入做了调整包括加大初始卷积的感受野让模型一开始就能看到更大范围的图像信息。第二是把最后一层常见的全局平均池化换成了注意力池化对空间位置上的特征做加权平均权重由一个单层的注意力机制学习出来。为什么这么改因为全局平均池化默认每个空间位置同等重要但一张图的主体往往只占画面的一部分注意力池化能让模型自己学该盯住哪里。很多做视觉特征提取的同学对这个改动感触很深直接拿来现成 ResNet 特征和文本做对齐效果总差一口气换成注意力池化后明显改善。ViT 版本的处理也不太一样。ViT 要把图像切成 patch 再送进 Transformerpatch 大小直接影响计算量和效果原实现里对多种 patch 尺寸做过实验最终常用 32×32 的 patch性价比最均衡。同时 ViT 的类别标记位置也被注意力池化替代因为图片级特征本来就该由内容决定而不是固定拿第一个 patch 的输出当全图表示。这个思路延续下来后来的很多多模态模型在图像塔上都保留了对齐友好型的池化设计。2.2 文本塔那些容易被低估的细节文本塔在结构上就是标准的 Transformer 编码器没有 decoder和机器翻译里的编码器几乎是同一套东西。但有几个细节非常容易被忽略。第一是长度限制。文本输入的最大长度是 76 个 token输入前要加起始标记输出时取末尾位置对应的向量作为整句表示加上起止标记后实际位置编码长度是 77。当时这个 76 的限制被不少人吐槽说太短但它有它的道理网络抓取的图文对数据里绝大多数描述就是一句话长文本在统计上占比极小。把长度限制在合理范围既能节省显存和算力又能避免模型被稀疏的长尾文本带偏。微调的时候要注意文本塔的位置编码在预训练时只见过 77 个位置硬把长度拉长会出现位置外推问题效果大概率往下掉。第二是分词方式。文本塔用的是字节级 BPE 分词词表大小约 4.9 万且默认不区分大小写。这跟很多 NLP 场景里用的分词器不完全一样所以拿现成语言模型来初始化文本塔并不直接可行。自己做微调时建议沿用原始分词器不要轻易换词表否则位置编码、词嵌入、图文对齐的分布全部要重新学。2.3 投影层、归一化与温度缩放最后一公里的对齐动作两个编码器的输出维度不一定相同图像侧可能是 2048 或 1024文本侧可能是 512 或 768。所以在两个塔的最后各接一个线性投影层把向量投影到一个共同的对比维度一般是几百维然后再做 L2 归一化让所有向量都落在单位球面上点积就等于余弦相似度。为什么归一化这么重要因为点积对向量长度是敏感的。如果不归一化模型完全可以走捷径靠把向量变长来拉高分数而不是真正学习语义对齐。归一化之后模型只能通过调整方向来优化相似度这会强迫它学到语义层面的对应关系。还有一个细节是温度缩放。对比计算时相似度矩阵要乘上一个可学习的温度系数等效的缩放系数初始值约 14对应温度约 0.07。温度的作用是控制相似度分布的峰度温度太高梯度对难分样本不敏感所有样本的梯度趋同温度太低梯度集中在少数的正样本上训练容易震荡。这个参数在原实现里以对数形式存储、随训练一起学习实现上经常被遗漏是很多人复现失败的高频原因。3. 对比学习双编码器的训练目标如何形成图文关联3.1 batch 内正负样本构造不需要额外标注训练时每次取一个 batch里面有 N 对图文。经过双塔编码和投影后会得到一个 N×N 的相似度矩阵。矩阵的对角线是 N 个正确配对的图文分数要被拉高其余 N²-N 个位置全是配错的组合分数要被压低。这个构造方式最大的好处是不需要额外标注负样本。传统检索模型要专门准备难负样本成本很高而对比学习直接用 batch 内其他样本当负样本网上抓来的弱监督图文对就能直接当训练数据。代价是如果 batch 里有大量非常相似甚至重复的样本负样本就不够负模型学起来会非常吃力。这也是为什么数据去重和清洗在 CLIP 训练里比模型结构还重要。3.2 对称 InfoNCE 损失公式和代码形态损失函数分成两个方向一个方向把图像当查询要求用图像从 batch 里找到自己的文本另一个方向把文本当查询要求用文本从 batch 里找到自己的图像。两个方向的交叉熵加起来取平均用伪代码写就是这样# image_features / text_features 已经过编码器和投影层且各自做了 L2 归一化 # temperature 是等效缩放系数即 exp(可学习的对数参数) # 计算 N x N 相似度矩阵 logits image_features text_features.T * temperature # logits[i][j] 表示第 i 张图和第 j 段文本的相似度 labels arange(N) # 图像方向每一行里对角线位置是正确的文本 loss_i cross_entropy(logits, labels) # 文本方向每一列里对角线位置是正确的图像 loss_t cross_entropy(logits.T, labels) loss (loss_i loss_t) / 2为什么要做成对称的因为如果只用以图找文一个方向图像塔和文本塔就处在不对称的位置上模型会偏向学习给图找句的映射给句找图这一侧的表现会明显变差。对称化之后两个塔在表示空间里才能真正等价。实际实现里还要注意数值稳定性softmax 的分子是指数运算数值范围容易爆常规做法是用 logsumexp 这类技巧来保障。3.3 大 batch 的作用以及跟显存怎么平衡对比学习有一个众所周知的特性负样本越多学到的东西越有区分度。原实现的 batch 规模达到几万对这种量级在工业级多模态训练里很常见。大 batch 的本质是给损失函数提供更准确的对比分布估计。batch 太小负样本太少模型很快就能把所有相似度压下去梯度里关于哪些样本难区分的信息几乎为零学出来的表示会非常平庸。但大 batch 的代价也很实在。首先是显存N×N 的相似度矩阵本身在 N32768 时就有约十亿个元素即使以半精度存放也要好几个 GB其次是优化器状态和大 batch 下的梯度稳定性。工程上的应对手段有梯度累积、缓存队列、混合精度、以及把 logits 的计算分块进行。要注意的是梯度累积并不完全等价于真正的全局大 batch因为在统计意义上累积过程会引入噪声差异比较稳妥的方案是先验证流程再逐步加大批大小。4. 训练与推理的工程实践把这些设计落进代码4.1 数据管线弱监督数据的清洗是成败关键原实现用的数据集是从网上抓取的约四亿对图文。数量听上去吓人但网上数据的噪声极大不洗根本没法用。普通团队不需要四亿对几百万对高质量数据配合充分训练同样能取得不错效果关键是清洗思路要到位。清洗大致分几步。第一步是过滤明显违规的内容包括色情、暴力等类型这既是为了合规也是防止模型学到奇怪关联。第二步是重复与近似去重大量重复样本会让模型过拟合而且 batch 内近似重复的样本会稀释负样本质量。第三步是图文相关性粗筛比如用 OCR 识别图片里的文字再跟文本描述做关键词匹配把相关性很低的样本丢掉。第四步是长尾过滤太短的文本、分辨率过低的图片、极端宽高比的图片都要控制比例。我的经验是宁可数量少一点也要保证每对图文的相关性过关。质量差的数据训出来的模型看起来 loss 能降但做图文检索时分数虚高、检索结果却不精准这种问题后期几乎没法靠调参补救只能回头洗数据。4.2 训练配置与超参数要点原论文的训练配置有几个特点我整理成一张表做复现时可以当参考基线配置项常见设置说明优化器Adambeta 取 0.9 / 0.98对比学习训练相对敏感偏大的 beta 有助于平稳权重衰减0.2 左右偏大的权重衰减能压制表征退化峰值学习率5e-4 量级过高容易震荡过低收敛太慢学习率调度余弦退火加 warmupwarmup 对训练前期稳定很重要批大小尽可能大直接决定负样本数量是最重要的超参数之一数据增强随机裁剪、颜色扰动增强图像塔的泛化能力注意别过度裁剪主体精度混合精度大模型大 batch 场景下基本是标配温度参数等效初始约 14可学习如果实现成固定值效果会明显打折权重衰减这个值在常规分类任务里其实偏大但在对比学习里反而有奇效。原因是对比学习容易陷入靠增大向量长度来刷分的陷阱虽然归一化已经堵住了这个口子但较大的权重衰减仍然能帮助模型学到更紧凑的表示。余弦退火则是让后期训练步长越来越小避免在损失面边缘反复震荡。4.3 推理与零样本分类离线向量化是关键训练好之后双塔结构在推理阶段的优势就完全体现出来了。图片库可以一次性批量编码成向量并建好向量索引线上查询时只需要把查询文本编码一次然后做向量检索。这个流程我相信很多人在实际项目里已经验证过了离线算力换线上时延非常划算。拿 CLIP 做零样本分类流程就是把类别标签构造成一张某某的照片这样的句子全部编码成文本向量再把待分类图片编码成图像向量算相似度取最大值。代码形态大致是这样# 假设已有训练好的双塔编码器 def zero_shot_classify(image, class_names): # 把类别名包装成自然语言描述 prompts [fa photo of a {name} for name in class_names] text_vecs encode_text(prompts) # 形状 [num_classes, dim] img_vec encode_image(image) # 形状 [dim] scores text_vecs img_vec # 余弦相似度已归一化 return class_names[scores.argmax()]这里面有个工程细节容易踩坑文本模板的影响非常大。a photo of a cat和cat得到的向量差别不小因为训练数据里的描述大多是完整短句。建议上线前多试几种模板比如a photo of a {name}in the wild这类风格化描述。另一个问题是如果类别之间的语义本身就很接近比如金毛和拉布拉多零样本效果会明显下降这个时候要么微调要么把类别描述写得更细化。5. 常见问题与排查经验5.1 表征退化所有输出变成同一个向量如果你发现损失怎么都降不下去而且任意图文向量的余弦相似度都很高这是典型的表征退化。排查时先打印一个 batch 的相似度矩阵重点看对角线和整行均值的差距。如果差距非常小说明正负样本没有被拉开。常见原因有三个训练数据太脏、温度初始化不对、学习率过高。按顺序调整先检查数据清洗再确认温度参数是否可学习且初始值在合理范围最后把学习率调低一个量级重试。5.2 图文分数整体偏高检索结果不准模型训完之后拿任何一对图文去算分都在 0.8 以上说明负样本区分度不够。这个问题的根源几乎都在数据侧比如 batch 内的相似样本太多或者去重阈值放得太松。解决方式是收紧去重必要时可以按照图文语义做粗聚类再抽样。这种情况重新训几十个 epoch 往往也没用问题不在训练时长在数据分布。5.3 显存不够大 batch 怎么凑梯度累积是最快的救急手段代码改动量小但它并不是真正的全局大 batch。想更接近理想效果可以维护一个全局的图文向量缓存队列每轮训练把当前 batch 的向量放进队列同时弹出等量的旧向量队列里的所有向量都可以参与负样本计算。队列方案的工程代码量多一些但对显存友好而且是很多复现项目的实测选择。另外 logits 矩阵可以分块算相似度不必一次性占满显存。5.4 正式上线之前先做一次坏例分析图文检索项目通常用双向检索的 recall 指标衡量效果但上线前至少要捞一次分数最高却配错的样本看看。这类坏例一般暴露两个问题一是文本跟图片场景强相关模型学到了场景偏差二是类别粒度太粗语义重叠导致区分困难。这两种问题靠调损失函数很难根除基本要回到数据层面去处理比如增加场景多样性、细化类别描述。5.5 预训练分布和业务数据不一致网上抓取的图文对和真实业务数据之间一定有分布差异。常见的做法是拿业务数据继续做双塔微调或者把业务数据和预训练数据按比例混合再训。需要注意不能只用业务数据从头训双编码器的大规模预训练知识会被快速覆盖出现灾难性遗忘。混入一部分原始数据或在有限步数内微调是比较可靠的做法。最后再分享一个个人体会。CLIP 这套双编码器结构最值得琢磨的地方不是两个塔这个形态本身而是它把表示学习和交互解耦开的设计哲学。我自己一开始做图文项目时总想着往模型里塞各种跨模态注意力结果训不动、显存爆、效果还不如老老实实跑双塔。后来把两个塔的底子打牢再在召回之后加一个轻量精排效果反而是最好的。如果你也是刚起步做图文关联建议先让双塔结构在你的数据上跑通、跑稳再考虑加花活。