BLIP-2架构解析:冻结双端与Q-Former轻量桥接多模态
1. 为什么值得重读 BLIP-2:从"全量微调"到"只训一座桥"的思路转变
第一次把 BLIP-2 的推理脚本跑通,我盯着的不是它生成的 caption 有多准,而是显存占用曲线——图像那一侧几乎不动,语言那一侧也几乎不动,真正在训练的只有中间一个不到两亿参数的模块。那个模块叫 Q-Former,它就是整篇论文的核心:冻结图像编码器、冻结大型语言模型,只在两者之间训练一个轻量的查询式转换器,把视觉信息"翻译"成语言模型能直接吃进去的软提示。这套"两头冻、中间训"的范式,比它当年刷出的那些零样本指标更值得反复琢磨。
说白了,BLIP-2 解决的是一个很现实的问题:你想让语言模型"看见"图片,但你没有几千张 A100,也不想把 10B 级别的模型从头训一遍。传统做法是把视觉编码器和语言模型拼起来全量微调,参数量大、显存炸、小数据集上还容易把预训练学到的能力洗掉。BLIP-2 的答案是:别动那两个大块头,训一个足够聪明的转接头就行,两阶段预训练把这个转接头喂饱——第一阶段学"看得懂",第二阶段学"说得出"。
这篇内容适合三类人看:手里有几十张卡、想复现多模态预训练的研究型工程师;只想拿现成权重做图文检索、图像问答、内容打标的应用开发者;以及正在设计"大模型 + 某个新模态"架构、想借鉴这套冻结思路的人。我会把架构里每个设计选择的理由、显存怎么算、代码骨架长什么样、以及我踩过的那些坑,尽量写透。
2. 把标题拆开:三个关键词各自承担什么职责
2.1 冻结图像编码器到底省掉了什么
标题里的"冻结图像编码器",字面意思是视觉那一侧的参数不参与梯度更新。BLIP-2 用的是 ViT-g/14 这类大视觉主干,参数在十亿量级,输出的是每张图 257 个 patch token、每个 1408 维的特征序列。如果让它跟着训练,你要额外承担三份显存开销:参数本身的 fp32 主副本、AdamW 的一阶二阶动量、以及反向传播时必须保留的激活值。冻结之后,前两项直接归零,第三项也只剩下前向的中间结果——而且因为不更新,这些特征完全可以离线抽好存盘,训练时直接读。
这是我认为 BLIP-2 最实用的一点。我在一个图文检索项目里做过对比:同一批 200 万图文对,全量微调视觉主干时单卡 A100 80G 只能塞下 batch 32,冻结后把特征预抽成 fp16 存盘,训练阶段 batch 直接拉到 512,一个 epoch 从 14 小时压到 3 小时出头。速度差异不是靠优化技巧堆出来的,是"根本不需要算反向"这个结构性优势带来的。
当然代价也有。冻结意味着视觉特征的表达能力被锁死在预训练那一刻,如果你的下游领域跟它的预训练分布差得远——比如工业质检的显微图、医学影像、卫星遥感——你会明显感觉到天花板。这时候要么换一个在该领域预训练过的视觉主干,要么老老实实解冻最后几层做轻量适配。BLIP-2 的架构本身允许你换主干,只要你把输出的 patch 维度对上。
2.2 大型语言模型在这里只做"解码器"
第二个关键词是大型语言模型。BLIP-2 支持两类:decoder-only 的 OPT 系列和 encoder-decoder 的 FlanT5 系列。它们在架构里的角色很单纯——接收一段由视觉 token 和文本 token 拼成的序列,往下续写。整个语言模型在预训练过程中完全冻结,不做任何参数更新,也不做指令微调。
为什么敢冻?因为这两类模型本身已经具备很强的语言先验和一定的推理能力,缺的只是"视觉输入通道"。BLIP-2 要做的不是教它说话,而是给它开一扇窗。窗口开得对,模型自己会用已有的语言能力去描述画面、回答问题。论文里那个很直观的对比实验我一直记得:同一套视觉输入,接到不同规模的语言模型上,零样本 VQA 的准确率随语言模型变大而单调上升——说明视觉信息确实被"翻译"成了语言模型能理解的形式,而不是被硬塞进去一堆它读不懂的向量。
实际用的时候有个细节容易被忽略:decoder-only 和 encoder-decoder 两种接法,软提示的拼接位置不同。前者拼在输入序列最前面,后者拼在 encoder 的输入端。位置不一样,训练时的注意力掩码处理也不一样,迁移代码的时候这里最容易出错。
2.3 "引导"这个词藏在两阶段里
标题最后一个词是"引导",英文原文是 bootstrapping,我更愿意理解成"借力打力"。第一阶段借图像-文本对的监督信号,让 Q-Former 学会从图像里抽取和语言相关的信息;第二阶段借已经训好的 Q-Former,去引导冻结的语言模型产生正确的输出。两个阶段共用同一个 Q-Former,但目标完全不同——这是整篇论文最精巧的地方,也是复现时最容易做歪的地方。
我见过不少人直接跳到第二阶段训,结果模型能生成通顺的句子,但和图片内容对不上。原因很简单:第一阶段的三个损失(对比、生成、匹配)是在给 Q-Former 打"视觉理解"的地基,地基没有,第二阶段只是在教它怎么把噪声翻译成漂亮的句子。跳过第一阶段,等于让一个没看过图的人去给图配文。
注意:两阶段的检查点不能混用。第一阶段保存的是带 ITC/ITM 头的完整 Q-Former,第二阶段要的是去掉那两个头之后的主干。直接拿错检查点,训练损失会从正常的 5 左右跳到 20 以上,而且几乎不再下降。
3. 整体架构设计:Q-Former 是怎么把两座孤岛连起来的
3.1 三块积木与参数量分配
把整个模型拆开看,只有三块东西,各自职责清楚:
| 模块 | 典型规格 | 参数量级 | 训练状态 | 主要职责 |
|---|---|---|---|---|
| 图像编码器 | ViT-g/14,257×1408 输出 | 约 1.1B | 全程冻结 | 把像素变成 patch 特征序列 |
| Q-Former | 12 层,768 隐维,32 个 query | 约 188M | 全程训练 | 把 patch 特征压成固定长度的语义 token |
| 语言模型 | FlanT5-XL 3B / XXL 11B,或 OPT 2.7B/6.7B | 3B~11B | 全程冻结 | 接收软提示并生成文本 |
把这张表加一下就知道为什么这套设计香:以 ViT-g + FlanT5-XXL 为例,总参数约 12.1B,可训练的只有 188M,占比大约 1.55%。也就是说 98% 以上的参数被冻结,但你拿到的却是接近全量微调的效果。这个比例是我判断一个多模态方案是否"轻"的重要参照——超过 10% 的可训练参数,就意味着你在做真正的预训练而不是适配。
3.2 Q-Former 内部结构逐个说清
Q-Former 名字里带 Transformer,但它不是一个标准 Transformer。它的输入不是序列,而是 32 个随机初始化、可学习的 query 向量,每个 768 维。这 32 个向量在训练中逐渐学会"提问"——有的专门盯主体,有的盯背景,有的盯文字,有的盯动作。这一点跟 DETR 里的 object query 一脉相承,本质上是一种固定长度的信息瓶颈。
网络主体是 12 层,每一层由两个子层交替组成:一个是self-attention,让 32 个 query 之间互相交流,同时(在第一阶段)跟文本 token 交互;另一个是cross-attention,让 query 从图像 patch 特征里取信息,每层都会重新扫一遍全部 257 个 patch。整个结构用 BERT-base 初始化,只有 cross-attention 层是随机初始化的——这个细节很关键,它让 Q-Former 一开始就继承了不错的语言理解能力,收敛速度快很多。
真正让这套结构能同时干三件事的,是attention mask 的三种玩法:
- 单模态掩码:query 之间可以互相看,文本 token 之间可以互相看,但 query 和文本互相看不见。这是给图像-文本对比学习(ITC)用的,两边各算各的特征,最后在相似度矩阵上做 InfoNCE。
- 因果掩码:query 能看到文本,但只能看到当前位置之前的部分,文本 token 之间也是因果可见。这是给基于图像的文本生成(ITG)用的,等价于一个带视觉前缀的语言模型。
- 双向掩码:query 和文本完全互相可见。这是给图像-文本匹配(ITM)用的,输出一个二分类判断图文是否配对。
同一个 Q-Former、同一套权重,靠三个不同的 mask 跑出三种行为,训练成本几乎没增加。我第一次读懂这个设计的时候确实拍了下桌子——比训三个独立模块优雅太多了。
3.3 32 个 query 到底在学什么
这个问题我在调试时反复问过自己。可视化过 cross-attention 的权重分布之后,我的理解是:它不是在做 patch 的均匀压缩,而是在做任务驱动的信息筛选。32 个 query 面对 257 个 patch,注意力分布明显是不均匀的,有些 query 集中在少数几个 patch 上,有些铺得很开。
这也解释了为什么固定 32 个是够的。做图像描述,你不需要还原每一个像素,你需要的是"主体是谁、在干什么、在哪里、什么状态"这几类信息,32 个槽位足以覆盖。但如果你要做的是密集预测任务——比如 OCR 或者细粒度计数——32 个槽位就成了瓶颈,这时候要么加到 64、128,要么干脆绕开 Q-Former 直接用 patch 特征。
我试过把 query 数从 32 改到 64,在图文检索上 Recall@1 涨了不到 1 个点,但训练时间几乎翻倍(cross-attention 的计算量随 query 数线性增长)。性价比不高,最后还是回到 32。这个参数我建议非必要不动。
4. 两阶段预训练:先学"看得懂",再学"说得出"
4.1 第一阶段:三个目标为什么必须一起用
第一阶段的输入是图像-文本对,图像编码器冻结,只训 Q-Former。三个损失同时上,各自解决一个方向的问题:
ITC(图像-文本对比)负责全局对齐。把 Q-Former 输出的 32 个 token 和文本的 CLS token 各自过投影头,算相似度矩阵,对角线为正样本,其余为负样本。这一步让模型学会"这张图和这句话大体上是一回事"。但它的粒度很粗,只对齐到整段文本的语义层面,抓不住"红色的帽子在左边"这种细节。
ITG(基于图像的文本生成)负责细粒度。给图像,让 Q-Former 配合文本侧的因果掩码自回归地生成整句描述。这一步逼着 query 去捕捉可被语言描述的具体内容,是三个损失里最能提升描述质量的一个。
ITM(图像-文本匹配)负责判别。给定一对图文,输出是否匹配的二分类。它跟 ITC 的区别在于,ITC 是双塔式的独立编码后比对,ITM 是融合式的联合编码后判断,后者能看到更细的交互信息。
三者的关系不是简单叠加,而是互补:ITC 给了好的初始化表示,ITG 提供了细粒度的生成监督,ITM 补上了判别能力。我做过消融,去掉 ITG 之后零样本 captioning 的 CIDEr 掉得最狠,去掉 ITM 对 VQA 影响更明显,去掉 ITC 则整体收敛变慢很多。所以复现时这三个都不能省。
4.2 硬负样本挖掘:一个容易做错的细节
ITM 这个任务有个天然的坑:如果你从 batch 里随机抽一句不相干的文本当负样本,模型靠"领域匹配"就能分对——图片是自然风景,负样本是体育新闻,傻子都能看出来不配对。这种简单负样本训出来的 ITM 头,实际使用时判别能力很弱。
BLIP-2 的做法是用 ITC 算出的相似度矩阵,对每张图挑出 batch 内相似度最高的若干条不匹配文本作为硬负样本。这些文本和图片语义接近但不是原配,模型必须真正理解细节才能分开。
实现时有个必须注意的点:负样本挖掘那一步要切断梯度。我第一版没注意,直接用带梯度的相似度去做 top-k 选择,结果梯度沿着"选择路径"回传,把 ITC 的表示空间搞得很不稳定,损失震荡得厉害。正确做法是在 no_grad 上下文里算相似度、做选择,再拿选出来的索引去取文本特征做 ITM 的前向。
注意:硬负样本的 top-k 一般取 1 到 4 之间。k 太大容易选到语义确实相近的样本,反而变成噪声标签;k=0 就是纯随机负样本,判别能力上不去。我实测 k=2 比较稳。
4.3 第二阶段:软视觉提示与冻结语言模型的对接
第一阶段结束后,Q-Former 已经能输出 32 个富含视觉语义的 token 了。第二阶段要做的,是把这 32 个 token 变成语言模型能直接消费的输入。
具体做法是加一个线性投影层,把 768 维映射到语言模型的词嵌入维度,然后把这 32 个向量当作"软提示"拼在文本 token 嵌入的前面。以 FlanT5-XL 为例,它的隐维是 2048,所以投影层就是一个 768×2048 的矩阵,附带偏置,这个层是可训练的。拼好之后整段序列喂给冻结的语言模型,损失就是标准的自回归语言建模交叉熵。
这里有个设计上的取舍值得说:为什么用软提示而不是硬提示(也就是把视觉特征转成离散 token)?因为软提示不丢信息。离散化会引入量化误差,而且需要额外的码本训练。代价是可解释性差——你没法像读文字一样读那 32 个向量。论文里做过对比,软提示方案在同等参数量下效果明显更好。
第二阶段还有一个容易忽略的细节:Q-Former 里的 ITC 头和 ITM 头在这时候要摘掉。它们在第一阶段是有用的辅助任务,但第二阶段的目标是生成,留着它们只会分散容量。摘掉之后 Q-Former 的可训练参数会略少于 188M,实际训练时注意优化器参数组的构建别写错。
4.4 损失与超参的实操取值
论文给出的设置大致是:第一阶段用较大的 batch size(两千量级),Q-Former 学习率在 1e-4 量级,带 warmup 和 cosine 衰减;第二阶段学习率略低,且带一个最小学习率下限,避免后期完全停滞。这些数值我建议当作参考起点而不是金科玉律。
我自己复现时的调整:
- 数据量从千万级降到百万级时,学习率相应降到 5e-5,warmup 步数从几千降到 500 左右,否则前期会有一段明显的损失平台期。
- 第二阶段的学习率我通常设成第一阶段的 1/2 到 1/3,因为 Q-Former 已经有不错的初始化,学太快容易把第一阶段学到的东西冲掉。
- 梯度裁剪阈值设 1.0,多模态训练里梯度爆炸不算罕见,尤其是 ITG 的自回归损失,偶尔会出尖峰。
还有一点:两个阶段用的数据可以不同。第一阶段需要高质量的图文对来做细粒度监督,第二阶段更看重数据的规模和多样性。我一般第一阶段用清洗过的精标数据,第二阶段混入更多网络爬取的弱标注对。
5. 从零跑通一次:环境、显存与关键代码
5.1 数据准备与清洗的实际做法
数据这块我不想讲空话,直接说我用的流程。图像侧:分辨率低于 224 的直接丢弃,宽高比极端(超过 3:1)的做中心裁剪,用感知哈希做近重复去重(阈值我设的 8,能去掉大部分转载图)。文本侧:长度限制在 4 到 60 个词之间,去掉纯数字、纯符号、以及重复字符占比超过 40% 的样本。
还有一步是很多人会跳过的:图文相关性初筛。用现成的跨模态检索模型给每对图文打个分,把分数极低的样本剔掉。网络爬取的数据里噪声比例很高,"图是一只猫、文本讲的是股票"这种对子如果留着,会严重污染 ITC 的训练信号。这一步能砍掉 10%~20% 的数据,但对最终指标的影响是正向的。
数据格式我建议直接用 webdataset 的 tar 分片,配合流式加载。百万级的图文对如果用单张图片文件的形式存,训练时的 IO 会成为瓶颈,尤其是小文件多的场景。tar 分片顺序读,配足够的预取进程,吞吐能稳很多。
5.2 显存预算怎么算:一次完整的推演
这部分是我觉得最该写清楚的地方,因为大部分复现失败都死在 OOM 上。以第二阶段、FlanT5-XL(3B)+ ViT-g(1.1B)+ Q-Former(188M)为例,混合精度训练,逐项拆:
| 项目 | 计算方式 | 占用 |
|---|---|---|
| 图像编码器权重 | 1.1B × 2 字节(fp16) | 约 2.2 GB |
| 语言模型权重 | 3B × 2 字节 | 约 6.0 GB |
| Q-Former 权重(fp16 副本) | 188M × 2 字节 | 约 0.38 GB |
| Q-Former 主权重(fp32) | 188M × 4 字节 | 约 0.75 GB |
| AdamW 一阶 + 二阶动量 | 188M × 4 × 2 | 约 1.5 GB |
| 梯度(fp16) | 188M × 2 | 约 0.38 GB |
| 激活值(batch 32,序列约 64) | 主要是 Q-Former 12 层 | 约 2~4 GB |
| 图像特征缓存(batch 32 × 257 × 1408 fp16) | 32 × 257 × 1408 × 2 | 约 23 MB |
加起来大约 13~15 GB,一张 24G 卡就能跑起来,这在全量微调的方案里是不可想象的。如果换成 FlanT5-XXL,语言模型权重涨到 22 GB,就需要 40G 以上的卡了。
再进一步:因为图像编码器完全冻结,特征可以离线预抽。把所有训练图的 patch 特征存成 fp16 的 npy 或 h5 文件,训练时直接读特征,连那 2.2 GB 的编码器权重都不用加载。200 万张图、每张 257×1408 的 fp16 特征,磁盘占用大概 1.4 TB——这是这套方案唯一"贵"的地方,但换来的是训练速度 3 倍以上的提升,我认为非常值。
5.3 Q-Former 的关键代码骨架
下面这段是我自己实现时抽出来的核心部分,去掉了工程细节,保留结构。注意三种 mask 的构造方式不一样,这是整个模块的灵魂。
import torch import torch.nn as nn class QFormerBlock(nn.Module): def __init__(self, dim=768, heads=12, ffn_ratio=4): super().__init__() self.self_attn = nn.MultiheadAttention(dim, heads, batch_first=True) self.cross_attn = nn.MultiheadAttention(dim, heads, batch_first=True) self.norm1 = nn.LayerNorm(dim) self.norm2 = nn.LayerNorm(dim) self.norm3 = nn.LayerNorm(dim) self.ffn = nn.Sequential( nn.Linear(dim, dim * ffn_ratio), nn.GELU(), nn.Linear(dim * ffn_ratio, dim), ) def forward(self, q, text_emb, text_mask, img_feat, mode="itc"): # mode: "itc" 单模态 / "itg" 因果 / "itm" 双向 b, n_q, d = q.shape if mode == "itc": # query 与文本互相不可见,各自独立 q_cat = torch.cat([q, text_emb], dim=1) n_t = text_emb.size(1) mask = torch.ones(n_q + n_t, n_q + n_t, dtype=torch.bool, device=q.device) mask[:n_q, n_q:] = False mask[n_q:, :n_q] = False out, _ = self.self_attn(q_cat, q_cat, q_cat, attn_mask=mask) out = self.norm1(q_cat + out) else: # query 可看文本,文本之间按模式决定是否因果 if mode == "itg": causal = torch.triu( torch.ones(text_emb.size(1), text_emb.size(1), dtype=torch.bool, device=q.device), diagonal=1) else: causal = torch.zeros(text_emb.size(1), text_emb.size(1), dtype=torch.bool, device=q.device) q_cat = torch.cat([q, text_emb], dim=1) n_t = text_emb.size(1) mask = torch.zeros(n_q + n_t, n_q + n_t, dtype=torch.bool, device=q.device) mask[n_q:, n_q:] = causal out, _ = self.self_attn(q_cat, q_cat, q_cat, attn_mask=mask) out = self.norm1(q_cat + out) q_out = out[:, :n_q] # cross-attention:query 从图像 patch 取信息 q_out = self.norm2(q_out + self.cross_attn(q_out, img_feat, img_feat)[0]) q_out = self.norm3(q_out + self.ffn(q_out)) return q_out, out[:, n_q:]投影层的写法很简单,但维度一定要对上:
class VisualProjection(nn.Module): def __init__(self, q_dim=768, llm_dim=2048): super().__init__() self.proj = nn.Linear(q_dim, llm_dim) def forward(self, q_out): # (B, 32, 768) return self.proj(q_out) # (B, 32, llm_dim)第二阶段拼接软提示:
def forward_stage2(qformer, proj, img_feat, input_ids, labels, llm): q_out = qformer(img_feat) # (B, 32, 768) vis_emb = proj(q_out) # (B, 32, llm_dim) txt_emb = llm.get_input_embeddings()(input_ids) # (B, L, llm_dim) inputs = torch.cat([vis_emb, txt_emb], dim=1) # 视觉在前,文本在后 attn = torch.ones(inputs.shape[:2], device=inputs.device) # labels 前 32 位填 -100,不计算视觉 token 的损失 full_labels = torch.cat([ torch.full((labels.size(0), vis_emb.size(1)), -100, dtype=labels.dtype, device=labels.device), labels ], dim=1) return llm(inputs_embeds=inputs, attention_mask=attn, labels=full_labels).loss注意:
labels前面那 32 个位置必须填 -100,否则模型会去预测视觉 token 对应的"下一个词",这部分监督是错的,会让损失虚高并且学不到东西。我第一次跑第二阶段时忘了这一步,损失卡在 12 左右下不去,排查了大半天。
5.4 两阶段之间的检查点怎么衔接
第一阶段的输出是完整的 Q-Former,包含主干、ITC 投影头、ITM 分类头。保存时我建议分开存:主干一份,辅助头一份。第二阶段加载主干,辅助头丢掉。
衔接时有两个坑。一是位置编码和 query 初始化要保持一致,32 个 query 的顺序不能变,否则第一阶段学到的"第 N 个 query 负责某类信息"的对应关系会被打乱。二是优化器状态要重置,两个阶段的学习率和损失尺度都不一样,沿用第一阶段的动量会带来一段不必要的震荡。
我一般的做法是第二阶段重新建优化器,只对 Q-Former 主干和投影层设置参数组,前者学习率低一些,后者可以用高一些(投影层是随机初始化的,需要快速跟上)。
5.5 推理阶段:prompt 写法直接影响效果
BLIP-2 做零样本任务时,prompt 的形式对结果影响很大,这一点在实际使用中比论文里强调得更多。
图像描述任务,直接用"a photo of"作为前缀,让模型续写,效果比空 prompt 稳定。如果要做特定风格的描述,前缀改成"a detailed photo of"或者"the image shows",输出风格会明显变化。
视觉问答任务,格式固定成"Question: {问题} Answer:",这个模板很重要,换了格式准确率会掉。我试过直接拼问题不加模板,同一批测试样本的准确率掉了七八个点。
开放式生成,可以用"Question: {问题} Short answer:"来约束输出长度,避免模型长篇大论跑偏。
还有个小技巧:beam search 的 beam 数调小一点。零样本 captioning 时 beam=3 到 5 就够了,beam 太大会让输出变得保守和模板化,反而丢掉细节。VQA 任务我一般用贪心解码,速度最快且准确率几乎不损失。
6. 常见问题与排查技巧实录
6.1 训练不收敛或损失剧烈震荡
这是最高频的问题。我的排查顺序是这样的:
先看数据管道。把 batch 里的图文对可视化出来看一眼,十有八九能找到问题——图片全黑、文本是乱码、图文完全不对应。数据管道的 bug 比模型结构的 bug 常见得多,而且是最好查的。
再看损失尺度。ITC 是 InfoNCE,初始值在 log(batch_size) 附近,如果一开始就是几十,说明温度系数设错了或者相似度计算有 bug。ITG 的交叉熵初始值应该在 5 到 10 之间,如果直接冲到 30 以上,通常是 labels 的 padding 位置没设 -100。
最后看梯度。打开梯度范数监控,如果某个时刻突然飙到几十以上,通常是 ITG 的自回归部分遇到了长文本或者重复文本。梯度裁剪设 1.0 能压住大部分情况。
至于损失震荡,先确认学习率是不是太大了。多模态训练里 Q-Former 从 BERT 初始化,一开始对图像是一无所知的,前几百步损失下降慢是正常的,别急着调学习率。我一般会忍到 1000 步再判断。
6.2 生成重复、幻觉、答非所问
这三个症状的成因不完全一样,要分开处理。
重复(比如"一只猫在桌子上,一只猫在桌子上")通常是解码策略问题,加重复惩罚(repetition penalty 设 1.1~1.2)或者用 no-repeat n-gram 就能解决大半。
幻觉(描述里出现图里没有的东西)多半是训练数据噪声导致的。视觉信息没有被充分利用,模型就靠语言先验"编"。这时候可以在第二阶段增大视觉 token 的权重,或者干脆把第一阶段的数据清洗再严格一点。我在一个项目里把噪声数据比例从 20% 降到 5%,幻觉率明显下降。
答非所问通常出在 prompt 模板上。VQA 任务如果模型输出了一段描述而不是答案,说明它把任务理解成了 captioning。检查一下模板里的"Answer:"是不是漏了,以及训练时该任务的样本格式和推理时是否一致。
6.3 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 第二阶段损失卡在 10 以上不降 | 视觉 token 位置没设 -100 | 检查 labels 拼接逻辑 |
| 零样本检索 Recall@1 明显偏低 | ITC 温度系数过大或过小 | 扫一遍 0.05~0.1 区间 |
| ITM 准确率虚高但实际判别差 | 负样本太简单,没做硬挖掘 | 加 ITC 相似度 top-k 采样 |
| 训练前期 loss 平台期很长 | warmup 太长或学习率太低 | warmup 降到 500 步,lr × 2 |
| 生成的句子语法正确但内容空洞 | 第一阶段 ITG 权重太低 | 提高 ITG 损失系数并检查数据质量 |
| 显存占用远超预期 | 图像编码器没冻结或特征没缓存 | 检查 requires_grad 和离线特征流程 |
| 多卡训练速度不升反降 | 数据加载成为瓶颈 | 换 webdataset 流式加载,增大预取 |
| 换语言模型后效果骤降 | 投影层维度或拼接位置错误 | 核对词嵌入维度和拼接顺序 |
6.4 几个不太会写在文档里的经验
第一个:训练 Q-Former 时把图像特征缓存成 fp16,但 loss 计算用 fp32。混合精度下 ITC 的相似度矩阵在 fp16 里容易溢出或精度不够,尤其是 batch 大的时候。我现在都是在算相似度之前把特征 cast 回 fp32,代价很小,稳定性提升明显。
第二个:第二阶段训练数据的图文比例不要失衡。有些任务里 image caption 样本远多于 VQA 样本,训出来的模型会倾向于把所有输入都当成描述任务。我做过分层采样,让不同任务的比例维持在一个大致均衡的区间,效果比自然分布好。
第三个:别迷信大 batch。第一阶段 ITC 确实需要大 batch 提供足够的负样本,但如果你用的是梯度累积模拟的大 batch,负样本的质量会打折(因为跨累积步的样本不参与同一次对比)。真要用大 batch,优先考虑多卡并行而不是累积。
7. 能力边界与我实际项目里的改造点
7.1 这套架构做不好什么
用了几个项目之后,我对 BLIP-2 的能力边界算是比较清楚了。细粒度计数基本不行,问"图里有几个人"经常数错,尤其是超过五个的时候。密集文字识别很弱,图片里的招牌、标签、表格内容基本读不出来,因为 32 个 query 的信息带宽不够承载这么多细节。精确空间关系也一般,"左边的红色盒子旁边的蓝色杯子"这种多跳指代,成功率不高。长文本生成容易发散,描述超过三四句就开始编。
这些不是调参能解决的问题,是架构层面的信息瓶颈。32 个 query 就是 32 个槽,稠密信息塞不进去。真要做 OCR 类任务,老老实实接一个专门的文字检测识别模块,把结果作为文本输入喂给语言模型,比硬改 BLIP-2 靠谱得多。
7.2 我在实际项目里做过的三个改造
改造一:换视觉主干。在一个领域图像跟自然图像分布差异较大的项目里,我把 ViT-g 换成了在该领域数据上自监督预训练过的骨干。改动很小,只要保证输出的 patch 数量和维度对上,cross-attention 那边不用动。换完之后零样本检索的 Recall@10 涨了约 8 个点。代价是丢了跟原版权重的兼容性,得从头训第一阶段。
改造二:给语言模型挂轻量适配层。全冻语言模型在通用任务上没问题,但特定领域的表达风格(比如商品标题的写法)它学不来。我在冻结的 LLM 里插了秩很小的低秩适配矩阵,只训这部分加上 Q-Former。可训练参数从 188M 涨到 220M 左右,显存只多了 1G 出头,但生成风格的对齐度好很多。
改造三:把 Q-Former 输出同时接到检索头。原来的架构第二阶段只做生成,但我需要的是"既能生成又能检索"。做法是在第二阶段保留一个轻量的对比头,用 Q-Former 输出和 LLM 最后一层的池化表示做双塔对比。多加的参数量很小,两个任务互相还有正则效果。
7.3 给你的选型建议
如果你现在要做一个图文相关的应用,我的判断逻辑是这样的:数据量在百万级以下、算力有限、需要快速出效果,直接用现成的 BLIP-2 权重做零样本或者少样本微调,别自己从头训。数据量在千万级、有明确的下游任务、效果要求高,可以走"冻结双端 + 训中间"这条路,BLIP-2 的架构直接拿来当模板,但第一阶段一定要重训。如果你的任务对细节要求极高(OCR、密集检测、精细计数),别在这套架构上耗,它的信息瓶颈是设计决定的,绕不过去。
我在实际使用中最深的一个体会是:这套方法真正的价值不在模型本身,而在那个"只训 1.5% 参数"的思路。当你需要把一个新的模态接进语言模型时,问自己三个问题就够了——这个模态有没有现成的强编码器可以冻结?中间需要一个多大的信息瓶颈?两阶段训练该怎么拆?把这三个问题想清楚,架构基本就出来了。Q-Former 只是其中一个答案,不是唯一答案。