1. “冻结”这个决定买到了什么,又欠下了什么
第一次把 BLIP-2 的配置表摊在面前,最刺眼的不是它用了多大的模型,而是那两个“冻结”字样——图像编码器冻住,语言模型也冻住,中间只留一个不到两亿参数的 Q-Former 在训练。做过端到端多模态预训练的人看到这个结构,第一反应通常是:这能行吗?我当时的怀疑更具体——视觉和语言的特征空间本来就不在同一个坐标系里,两端都不动,中间那个小模块凭什么把它们对齐?
后来我自己按这个思路跑了一轮图生文和检索任务,才明白这个设计的取舍有多清醒。BLIP-2 解决的不是“能不能做到最强”的问题,而是“在有限算力下,多模态预训练这件事还能不能做得起”的问题。它的核心用法很直接:拿一个已经训好的视觉骨干,拿一个已经训好的语言模型,然后用一个轻量的查询式 Transformer 在两者之间做翻译和压缩,把图像变成语言模型能读懂的“软提示”。适合读这篇的人大概是三类:想复现多模态预训练但显卡不够的工程师、想给自己的业务系统接入图文理解能力的应用开发者、以及想知道“冻结一部分参数”这个套路到底能走多远的研究型读者。
但“冻结”不是白捡的便宜。它意味着你放弃了端到端联合优化带来的那部分跨模态对齐收益,也意味着你的中间模块必须承担全部的桥接压力,稍有不慎就会出现信息压缩过度、梯度断流、query 退化这些毛病。下面我把这套结构里每个关键选择为什么这么定、实操中会在哪一步翻车,一条条拆开讲。
1.1 端到端多模态预训练那张看不见的账单
要理解 BLIP-2 为什么这么设计,得先把传统路线的成本摊开算。假设你想从零做一次图文对齐的联合预训练,视觉骨干十亿参数级,语言模型也是十亿参数级,加上中间的跨模态注意力层,整个模型规模很容易冲到几十亿。这时候的显存占用不只是参数本身,还有优化器状态、梯度、激活值。
按混合精度训练粗算,一个十亿参数的模型,参数占 2GB,梯度占 2GB,如果用的是 Adam 这类带一二阶动量的优化器,状态还要再占 8GB 左右(fp32 存储),光这三项就是 12GB。激活值取决于 batch size、序列长度和是否重计算,图文对里图像 patch 动辄 256 个 token,文本再算上几十个,激活很容易再吃掉十几 GB。也就是说,单卡想跑,batch size 会被压到个位数,训练稳定性直接崩。
更麻烦的是训练动力学。视觉骨干和语言模型的学习率需求完全不同,视觉侧通常需要更小的学习率避免破坏预训练表征,语言侧在适配新模态时又需要足够的更新幅度。联合训练时你很难给两部分配出各自最优的调度,常见结果是视觉特征被“拉坏”了,语言模型的通用能力也被拖下水,训完之后在下游任务上反而不如直接用原模型。
BLIP-2 的应对方式很干脆:这些账我不算了,两端都别动。冻结之后,图像编码器的输出可以离线预计算并缓存成特征文件,语言模型的前向也不需要保存梯度。训练时真正需要反向传播的只有 Q-Former 这一小块,显存占用一下子从“几十 GB 起”降到“单卡可承受”,batch size 能拉起来,训练稳定性也随之改善。
1.2 冻结带来的三种自由度,比省显存更有价值
省显存只是表层收益。我实际用下来,冻结策略真正值钱的地方在于它给了你三个可以独立调整的自由度,这在联合训练里是想都别想的。
第一个是模态扩展的自由度。图像编码器和语言模型解耦之后,你可以保持 Q-Former 不变,换一个更强的视觉骨干;也可以保持视觉侧不变,把语言模型从较小的版本换成更大的版本。这两件事在端到端训练里都意味着全量重训,而在 BLIP-2 的框架下,理论上只需要重跑第二阶段的对齐训练。我试过在同一个 Q-Former 权重基础上换语言模型,只重训第二阶段,图文描述任务的质量确实能跟着语言模型的能力往上走,代价是几天的训练而不是几个月。
第二个是数据配比的自由度。冻结的视觉编码器让你能提前把所有图像特征抽出来存成向量,训练时直接读特征而不是读原图。这一步的意义在于,图像解码和视觉前向通常占了整个数据管线的大头,去掉之后数据加载速度能翻好几倍,你能在同样时间里过更多轮数据,或者把 batch 内的负样本数量堆到很大——这对对比学习类损失特别关键,后面会细讲。
第三个是故障隔离的自由度。联合训练出问题时,你很难判断是视觉侧崩了、语言侧崩了还是对齐层崩了。冻结之后,变量少了一大半,Q-Former 训不动就是 Q-Former 的问题,排查路径清晰得多。这个好处在工程上被严重低估,我见过太多团队在联合训练里耗上几周只为定位一个梯度异常。
1.3 冻结的代价:那些被放弃的跨模态微调收益
说清楚好处,也得说清楚代价,否则容易误判适用边界。冻结视觉编码器意味着它的特征表达是固定的,如果你的下游任务和它预训练时的数据分布差异很大——比如医学影像、遥感图像、工业质检图——那么视觉侧提不出任务相关的细节,Q-Former 再强也只能在既有特征里挑挑拣拣,天花板是被锁死的。
我的经验判断标准是这样的:如果你的目标任务和通用自然图像足够接近,冻结完全够用,甚至因为避免了过拟合而表现更好;如果差异很大,那至少要把视觉编码器的最后几层解冻,或者干脆换个在该领域预训练过的骨干。语言模型侧同理,通用对话和指令跟随能力冻结的模型已经具备,但如果你的任务需要特定的输出格式或领域术语,第二阶段里给语言模型加一个低秩适配旁路,比全量微调划算得多。
还有一个隐性代价是训练目标的不一致。端到端训练时,视觉特征会主动朝着“对语言任务有用”的方向演化;冻结之后,视觉特征只对“通用视觉理解”负责,Q-Former 得去适应它,而不是反过来。这会让第一阶段的对齐训练收敛得更慢,也更依赖负样本的质量。这一点在后面的损失设计里会体现得很明显。
2. Q-Former:一个两亿参数不到的信息压缩器
Q-Former 这个名字容易被误解成“又一个 Transformer 编码器”,实际上它的职责更像是信息瓶颈。图像编码器吐出来的是几百个 patch 特征,每个都带着局部细节,直接塞给语言模型既太长又太碎;Q-Former 要做的是用固定数量的可学习查询向量,把这堆 patch 特征“问”出一份浓缩摘要。这个摘要的长度是固定的,与输入图像分辨率无关,这一点是整套设计能成立的关键。
2.1 32 个查询向量是怎么把 256 个 patch 压成一句话的
结构上,Q-Former 有两组子模块共享同一套自注意力层:一组是那 32 个可学习的查询向量(有的实现用 32 个,长度可调),另一组是文本 token 的嵌入。共享自注意力意味着查询向量之间可以互相通信,文本 token 之间也能互相通信,但两组之间在自注意力阶段是隔离的——这一点很重要,否则文本信息会提前泄漏进查询向量,对比学习就没意义了。
真正让查询向量“看到”图像的是交叉注意力层,注意力的 key 和 value 来自冻结的图像编码器输出,query 来自那 32 个可学习向量。每个查询向量相当于提出一个不同的问题:“这张图的主体在哪”“色调偏冷还是偏暖”“有没有文字”“物体之间的空间关系是什么”。32 个向量就是 32 个并行的问题,训完之后它们会自发分化出各自的关注点。
我在可视化查询向量的注意力图时发现一个有意思的现象:训练充分之后,大约有五六個查询向量的注意力会集中在图像的主体区域,另外一些会散在地面和背景上,还有几个几乎不怎么响应任何区域。后者不一定是坏事,它们可能承担了“全局语境”的角色,也可能是冗余——具体要看你的下游任务。如果你发现大量查询向量的注意力图几乎一模一样,那就是典型的 query 塌缩,后面会讲怎么处理。
这里补一个容易忽略的细节:交叉注意力的频率。有些实现是每个 Transformer block 都插一层交叉注意力,有些是隔层插。全都插的话参数和计算量上去了,但对齐效果更细;隔层插则更省。BLIP-2 的做法是每个 block 都带交叉注意力,因为 Q-Former 本身层数不多(一般 12 层左右),这个开销可以接受。如果你要把 Q-Former 压得更小,隔层插是个可行的折中,但第一阶段的收敛会更慢。
2.2 共享自注意力背后的三个训练模式开关
Q-Former 最巧妙的地方在于,它靠同一套参数、通过切换注意力掩码,同时支持三种训练任务。这个设计让第一阶段的训练效率提高了不少,也让参数量保持得极低。
对比学习模式下,文本侧用的是双向注意力(文本 token 之间互相可见),查询向量和文本之间完全隔离。这样查询向量提取的是纯视觉摘要,文本编码是纯文本表示,两边各自算完再做相似度比对,逻辑干净。
图文匹配模式下,允许查询向量和文本 token 之间互相注意。这时候查询向量里已经装进了视觉信息,文本 token 能读到这些信息,于是整个模块可以判断“这段文字和这张图配不配”。这是一个二分类任务,输出一个匹配分数。
生成模式下,文本侧切换成因果掩码,每个 token 只能看到自己前面的 token。查询向量作为前缀被拼在文本前面,语言建模的损失就算在文本部分。这时候 Q-Former 实际上在做一个小型的图生文模型,只不过它自己不懂词表,得靠一个线性层映射到词嵌入空间。
三种模式共用参数,靠掩码切换,这在实现上有个坑:掩码的构造必须精确,尤其是生成模式下的因果掩码要和前缀长度对齐,否则查询向量会被误当成可预测的目标。我第一次实现时就是前缀位置没屏蔽掉,损失一直降不下去,查了两天才发现是掩码偏了一位。
# Q-Former 单个 block 的示意结构(简化版) class QFormerBlock(nn.Module): def __init__(self, dim=768, heads=12): super().__init__() self.self_attn = nn.MultiheadAttention(dim, heads, batch_first=True) self.cross_attn = nn.MultiheadAttention(dim, heads, batch_first=True) self.ffn = nn.Sequential( nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim) ) self.norm1 = nn.LayerNorm(dim) self.norm2 = nn.LayerNorm(dim) self.norm3 = nn.LayerNorm(dim) def forward(self, queries, text_emb, image_feats, self_mask, cross_mask): # 查询向量与文本共享自注意力,靠 mask 控制可见性 x = torch.cat([queries, text_emb], dim=1) x = x + self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x), attn_mask=self_mask)[0] # 仅查询向量做交叉注意力,读取冻结的图像特征 q = x[:, :queries.size(1)] q = q + self.cross_attn(self.norm2(q), image_feats, image_feats, attn_mask=cross_mask)[0] x = torch.cat([q, x[:, queries.size(1):]], dim=1) x = x + self.ffn(self.norm3(x)) return x2.3 为什么查询向量必须和文本隔离,又必须在某一步合流
这个隔离与合流的设计是整个 BLIP-2 里我琢磨最久的一处。直觉上你会觉得,既然最终目标是图文对齐,为什么不一开始就让视觉和文本充分交互?
原因在对比学习的目标函数上。对比学习依赖的是“独立编码后算相似度”,如果查询向量在提取阶段就已经读到了配对的文本,那它提的就不是纯视觉表征,而是被文本污染过的混合表征。这时候正样本对的相似度会虚高,因为两边看的是同一份信息,模型学不到真正的跨模态对齐,泛化到检索任务上会明显掉点——检索时你只有图像没有文本,被污染的查询向量等于丢了半条命。
反过来,到了图文匹配和生成阶段,又必须让两边合流。因为这两个任务本质上是判断关系或者生成文本,需要跨模态的细粒度交互,隔离着做不了。所以 BLIP-2 的解法是靠掩码在同一个模块里切换这两种状态,参数共享保证了表征空间一致,掩码切换保证了每个任务的输入条件正确。
实操中有个细节值得注意:三种模式不是分三遍数据过,而是在同一个 batch 里混合采样。我的做法是每个 batch 里大约一半样本走对比学习路径,四分之一走匹配,四分之一走生成。这个配比不是死的,如果你的下游任务偏检索,对比学习的比例可以往上提;偏描述生成,生成路径的比例提高。比例失衡的典型症状是:对比损失降得很快但匹配分数不收敛,或者反过来。
3. 第一阶段:三条损失怎么分工,负样本怎么造
第一阶段的全部目标就一句话:让 32 个查询向量学会提取对语言任务有用的视觉摘要。三条损失分别是图文对比、图文匹配、基于图像的文本生成,它们从三个角度约束这件事,缺一条都会让表征偏科。
3.1 对比学习里的 batch 内负样本,是最容易被低估的变量
图文对比损失看起来最简单:一个 batch 里 N 对图文,算一个 N×N 的相似度矩阵,对角线是正样本,其余都是负样本,然后做双向的交叉熵。数学上没什么花头,但它对 batch size 的依赖程度远超其他两条损失,这是很多人复现时效果不佳的头号原因。
道理不复杂:batch 内负样本的数量等于 N-1,N 太小的时候,模型很容易靠一些浅层线索就把正负样本分开——比如图像的平均亮度、文本的长度分布。这些线索在训练集里碰巧和配对关系相关,模型学会了它们,看起来损失降得很好,实际学到的对齐是假的。等换到真实检索场景,这些虚假线索失效,召回率立刻打回原形。
我踩过这个坑。早期用单卡跑,batch size 只能开到 32,训练损失曲线漂亮得不像话,验证集上的图文检索召回率却始终在低位徘徊。后来把图像特征离线缓存,显存压力卸掉大半,batch size 提到 256,同样的训练轮数,检索召回率提升了一大截。这个提升不是模型变强了,是负样本变多了,模型被迫去学真正的语义对应。
| batch size | 训练损失表现 | 检索召回表现 | 主要问题 |
|---|---|---|---|
| 32 | 下降很快,曲线平滑 | 明显偏低 | 浅层线索即可区分正负样本 |
| 64 | 下降正常 | 中等 | 仍有部分捷径可走 |
| 256 | 下降稍慢但更稳 | 明显改善 | 显存与吞吐需优化 |
| 1024 以上 | 前若干轮几乎不动 | 上限更高 | 需要学习率预热与更长训练 |
除了数量,负样本的“难度”也影响很大。随机采到的负样本大多是易分样本,梯度贡献很小;真正推动模型进步的是那些难负样本——比如同一场景不同时刻的两张图配错文本、同一类物体的不同实例配错描述。BLIP-2 在这一阶段的做法是先用对比损失筛出相似度较高的负样本,再交给匹配任务去精判,这个思路在后面一节展开。
3.2 匹配任务和难负样本采样,是精度提升的主要来源
如果说对比学习负责“粗排”,匹配任务就负责“精排”。它把查询向量和文本 token 放进同一个注意力空间里做细粒度交互,输出一个二分类判断。这个任务的判别能力上限比对比学习高得多,因为它能看到词和图像区域的对应关系,而不只是一个全局向量相似度。
关键在于负样本怎么造。全随机采样的负样本对这个任务来说太简单了,模型几乎不用学就能判对,损失很快降到接近零,梯度消失,这个分支等于白搭。BLIP-2 用的是批内难负样本挖掘:在一个 batch 内,先用对比分支算出的相似度排序,为每张图挑出相似度最高但不是正样本的那段文本作为负例,反过来也为每段文本挑最相似的图。这样一来,模型每次面对的都是“差一点就对”的样本,必须真的理解语义才能分开。
实现上要注意两点。一是难负样本的选取要用不参与梯度回传的相似度来排序,否则排序结果会随着梯度更新抖动,训练不稳定。二是一个 batch 里如果存在重复或高度相似的样本,难负样本可能本身就是正例的近似,会造成标签噪声。我的处理方式是在数据加载阶段做一次近重复过滤,用图像哈希和文本编辑距离双重判重,虽然增加了预处理成本,但训练稳定性提升明显。
还有一个工程细节:匹配分支需要把查询向量和文本拼在一起过一遍 Q-Former,计算量比对比分支大不少。如果三条损失平均配比,匹配分支会成为吞吐瓶颈。我的做法是让匹配分支只处理 batch 里的一个子集,比如取相似度排序的中间段样本(太简单的没梯度,太难的可能是噪声),这样计算量降下来,效果基本不掉。
3.3 生成损失让查询向量学会“为描述而生”的表征
第三条损失是基于图像的文本生成。查询向量作为前缀,文本以因果方式生成,损失算在文本 token 上。这条损失的作用比较隐蔽——它不直接参与检索,但它逼着查询向量保留那些“能用来描述图像”的信息,比如物体属性、数量、空间关系、动作。
为什么这条损失重要?因为纯对比学习训练出来的表征,倾向于保留能区分不同图像的判别性信息,而丢掉那些“所有图都差不多”的细节。举例来说,两张都是“一只狗在草地上”的图,对比学习只关心怎么把它们和猫的图分开,“草地”“狗”这些共享概念的信息可能被压缩掉。但生成任务要求你把这些细节都写出来,于是查询向量不得不保留它们。这就让表征更完整,下游的描述生成任务表现更好。
实操中的坑在于生成分支的词表映射。Q-Former 本身不包含词表,它的输出要通过一个线性层映射到语言模型的词嵌入维度。这个映射层是随机初始化的,训练初期输出基本是噪声,如果生成损失的权重给得太大,会把前两个分支带偏。我的做法是前若干步只训对比和匹配,等表征初步成型后再把生成损失的权重从零逐步升上来,这个 warmup 过程大概占总训练步数的百分之十到十五。
# 第一阶段三条损失的调度示意 def stage1_loss(cmp_logits, itm_logits, itm_labels, lm_logits, lm_labels, step, warmup_steps): loss_itc = symmetric_contrastive(cmp_logits) loss_itm = F.cross_entropy(itm_logits, itm_labels) # 生成损失延迟介入并逐步加权 w_gen = min(1.0, step / max(1, warmup_steps)) loss_lm = F.cross_entropy( lm_logits.view(-1, lm_logits.size(-1)), lm_labels.view(-1), ignore_index=-100 ) return loss_itc + loss_itm + w_gen * loss_lm4. 第二阶段:把查询向量当成一段“图像软提示”喂给冻结语言模型
第一阶段结束后,查询向量已经能提取一份浓缩的视觉摘要了,但语言模型还不知道这份摘要在说什么。第二阶段要做的就是把这份摘要翻译成语言模型能理解的前缀,然后靠语言模型自己的生成能力完成任务。这一步的设计选择看起来很小,实际影响很大。
4.1 为什么用全连接投影,而不是再插一层交叉注意力
最直觉的做法是在语言模型的每一层里加交叉注意力,让文本 token 直接去“看”查询向量。这在结构上更强,表达力也更高,但代价是语言模型不再完全冻结——交叉注意力层的参数是新的,需要训练,而且插入之后前向路径变了,原本的推理优化全部作废。
BLIP-2 选了另一条路:用一个(或两层)全连接层,把 32 个查询向量直接投影成语言模型词嵌入空间里的 32 个向量,然后当作前缀拼在文本前面。这样语言模型收到的是“一段看起来像文本嵌入的向量序列”,它的所有层完全不用改,冻结得干干净净。
这个选择的理由我觉得有三层。第一是工程上的:语言模型一行代码不动,任何现成的推理框架都能用,部署成本几乎为零。第二是训练上的:全连接层参数量极小,不容易过拟合,也不需要给语言模型配学习率。第三是语义上的:软提示这种方式已经被证明能让冻结语言模型适配新任务,图像的摘要本质上和一段提示没有区别,只是它来自视觉而不是文本。
代价当然是表达力。全连接投影是逐位置的线性变换,没有跨位置交互,32 个查询向量之间的关系只能靠语言模型的自注意力去捕捉。实际效果上,这个损失可以接受——因为第一阶段已经把查询向量训得比较规整了,它们各自承载的信息相对独立,不需要太复杂的组合。
4.2 因果损失怎么绕开分词器,这一层的实现细节最多
第二阶段的损失是标准的自回归语言建模,但它有个特殊之处:输入是“图像前缀 + 文本”,损失只算在文本部分,前缀部分的标签全部置为忽略。这在实现上要对齐三个东西的长度:查询向量数、文本 token 数、标签张量长度。
最容易出错的地方是分词器的处理。文本部分前面通常要拼一些模板,比如“这张图显示了”之类的引导语,这些 token 参与输入但不应该参与损失计算,或者参与但不重要。我的做法是把模板部分和实际描述部分的标签分开处理,模板部分统一置 -100 忽略。另外要注意 padding 的处理,不同长度样本拼 batch 时,padding 位置的标签也必须忽略,否则模型会学着去预测 padding。
还有一个隐性问题:冻结语言模型的词嵌入和输出头通常是绑定的(weight tying),你把前缀投影到词嵌入空间之后,输出层算 logits 时也会用到同一套权重。这本身没问题,但要确保投影层的输出尺度和词嵌入的分布匹配,否则前缀会主导整个注意力分布。我的经验是给投影层加一个 LayerNorm 并初始化得保守一些,让前缀向量的范数大致落在词嵌入的范数范围内。
# 第二阶段:查询向量投影为软提示并拼接 class Stage2Wrapper(nn.Module): def __init__(self, qformer, llm, q_dim=768, llm_dim=4096, n_query=32): super().__init__() self.qformer = qformer # 第一阶段训练好的权重 self.llm = llm # 全程冻结 self.proj = nn.Sequential( nn.Linear(q_dim, llm_dim), nn.LayerNorm(llm_dim), # 稳定前缀范数 ) self.n_query = n_query def forward(self, image_feats, input_ids, attention_mask, labels): queries = self.qformer.extract_queries(image_feats) # [B, 32, q_dim] prefix = self.proj(queries) # [B, 32, llm_dim] text_emb = self.llm.get_input_embeddings()(input_ids) # [B, L, llm_dim] inputs = torch.cat([prefix, text_emb], dim=1) mask = torch.cat([ torch.ones(prefix.size()[:2], device=inputs.device, dtype=attention_mask.dtype), attention_mask, ], dim=1) # 前缀部分不计算损失 ignore = torch.full(labels.size()[:2], -100, device=labels.device, dtype=labels.dtype) full_labels = torch.cat([ignore, labels], dim=1) return self.llm(inputs_embeds=inputs, attention_mask=mask, labels=full_labels).loss4.3 冻结语言模型下,指令跟随能力从哪里来
有人会问:语言模型都冻住了,它怎么会知道要做“描述这张图”而不是“翻译这段话”?答案在前缀本身携带的信息里。那 32 个向量不只是图像内容,它们在第二阶段训练中还会吸收任务指令的语义——因为训练数据的文本部分通常以任务相关的引导语开头,模型在预测这些引导语时,前缀必须提供足够的方向信号。
这也解释了一个实践中的现象:第二阶段训练数据的文本模板设计,直接影响模型的指令跟随能力。如果你所有样本都用同一种模板,模型学到的就是“看到图像前缀就写描述”,遇到新指令会懵;如果训练时混入多种任务模板——描述、问答、分类、判断——模型就会学到根据模板切换行为。我在做自己的数据集时,故意混了四五种模板,效果比单一模板好很多,尤其是在开放式问答上。
另外,第二阶段可以选择性地解冻语言模型的一部分,比如只训前几层或者加低秩适配。我的建议是除非数据量足够大、任务足够特殊,否则别动。冻结带来的部署简洁性和训练稳定性,比那一点点效果提升值钱得多。
5. 跑通之后才会撞上的三个硬问题
第一阶段的损失能降、第二阶段的生成能出字,只说明流程通了,离“好用”还差一段。下面这三个问题,我是在把模型接到实际业务里之后才陆续撞上的,每一个都花了不短的时间定位。
5.1 查询向量塌缩:32 个问题变成了同一个问题
最典型的症状是:训练集损失正常下降,但把 32 个查询向量的注意力图可视化出来,发现它们几乎都盯着图像的同一块区域,相似度极高。这时候模型其实只用了一个查询向量的容量,剩下的都是冗余,相当于把一个 32 维的摘要压成了 1 维。
原因通常出在初始化和损失权重上。查询向量如果初始化得过于相似(比如全部用同一个分布但种子没散开),加上交叉注意力的 softmax 在初期很容易饱和到同一个 key 上,几个 batch 之后就锁死了。另一种情况是生成损失权重给得太大,模型为了降低生成损失,让所有查询向量都去关注最显著的物体,其它的信息被牺牲掉。
我的解决办法有三个,按优先级排:一是初始化时给每个查询向量加一点相互正交的扰动,简单做法是用不同的随机种子或者加一点噪声;二是训练初期给查询向量之间的相似度加一个惩罚项,逼它们分化,训到中后期再把这个惩罚撤掉;三是检查交叉注意力的温度,如果 softmax 太尖锐,早期就会锁死,适当提高温度能让注意力分布更分散。第一个方法最省事,我现在的默认做法就是它。
5.2 梯度断流与学习率错配,症状很像但根因完全不同
梯度断流的表现是第二阶段的损失降到一个平台就再也不动了,生成的文本要么极短要么复读。检查方法很直接:打印投影层参数的梯度范数,如果接近零,那就是断流了。常见原因有两个,一是第一阶段训完之后没把 Q-Former 设成可训练状态(有些实现默认 eval 模式),二是投影层的初始化太小导致梯度消失。
学习率错配的表现更隐蔽:损失在降,但降得断断续续,偶尔还会反弹。这通常是因为 Q-Former 和投影层用了同一个学习率。Q-Former 是从第一阶段继承来的,已经接近收敛,需要小学习率微调;投影层是全新的,需要较大的学习率快速进入合理区间。我一般给投影层配 Q-Former 五到十倍的学习率,并且给投影层单独设一个短预热。
还有一个容易被忽略的点:第二阶段如果只训投影层和 Q-Former,参数量很少,优化器的选择影响不小。带动量的优化器在小参数量、小 batch 的场景下反而不如简单的自适应方法稳,我试过几种组合,最后固定用自适应方法配较小的权重衰减,收敛过程最平顺。
| 症状 | 可能根因 | 快速验证方式 | 处理方式 |
|---|---|---|---|
| 第二阶段损失很快到平台 | Q-Former 处于 eval 模式 | 打印 Q-Former 参数 requires_grad | 显式设为可训练 |
| 损失降但生成复读 | 投影层初始化过小 | 查看投影输出向量范数 | 加 LayerNorm 并调大初始尺度 |
| 损失震荡反弹 | 两组参数共用一个学习率 | 分别打印两组梯度范数 | 分组设学习率并加预热 |
| 查询向量高度相似 | 初始化未分化或温度过低 | 算查询向量两两余弦相似度 | 初始化加扰动或加分化惩罚 |
5.3 batch size 与负样本数量的取舍,没有万能解
前面说过对比学习吃 batch size,但实际部署时 batch size 受限于显存和吞吐,不可能无限拉。我的经验是先算清楚显存预算:离线缓存图像特征之后,显存主要被 Q-Former 的激活和语言模型的前向占用。如果第二阶段和第一阶段分开训,第一阶段可以把 batch size 拉到很大,因为只有 Q-Former 在动。
另一个思路是用梯度累积模拟大 batch,但要注意对比学习的负样本是在 batch 内算的,梯度累积不会增加负样本数量,只会在多个小 batch 上分别算损失再平均,效果上并不等价于大 batch。真要增加负样本,得用跨 batch 的队列机制,把历史 batch 的特征存起来当额外负样本。这个做法有效,但队列里的特征是用旧参数算的,和当前参数不一致,会引入偏差,通常需要一个动量编码器来缓解,实现复杂度上去了。
我的取舍是:第一阶段的 batch size 优先保证,哪怕为此降低训练轮数;第二阶段 batch size 可以小一些,因为自回归损失对 batch 内负样本不敏感,更看重样本质量和多样性。
6. 下游怎么接:三种迁移路子的选择标准
模型训完之后,接下游任务有三种常见做法,各自的成本和适用场景差别很大,选错了会白白浪费算力。
6.1 零样本直接用,适合快速验证和冷启动
零样本的做法是把任务写成一句指令拼在图像前缀后面,直接让语言模型生成答案。比如“描述这张图”“图中有几个人”“这是什么颜色”。这套路子不需要任何标注数据,几分钟就能跑起来,适合在项目初期快速判断模型能力边界。
实测下来,零样本在描述生成和简单的属性问答上表现不错,在需要精确计数、空间推理、文字识别这几类任务上明显偏弱。我建议把它当作基线,先跑一遍看看哪些任务直接可用、哪些必须微调,再决定投入方向。另外提示词的写法对结果影响很大,同一张图用“描述这张图”和“详细描述图中的场景、物体和它们的关系”,输出的详细程度差别能有一倍以上。
6.2 少样本微调投影层,性价比最高的一档
如果零样本效果不够,最划算的做法是只微调第二阶段那个投影层,Q-Former 和语言模型都冻住。参数量只有几百万,几百到几千条标注数据就能看到明显提升,单卡几小时就能跑完一轮。
这个做法的原理是:Q-Former 提取的视觉摘要本身已经包含了丰富信息,缺的只是把它映射到具体任务输出格式的通道。投影层负责这个映射,微调它相当于教会模型“这个任务该怎么回答”,而不改变它对图像的理解方式。我做过对比,同样的数据量下,只调投影层和全量调 Q-Former 的效果差距不大,但训练成本差了一个数量级。
6.3 全量微调第二阶段,留给数据充足且任务特殊的场景
只有当你的任务和通用图文任务差异很大、且手上有上万条高质量标注时,才考虑把 Q-Former 一起放开训。比如需要输出结构化字段的表格识别、需要领域术语的工业质检描述、需要严格格式的医疗报告生成。
这时候要注意过拟合。Q-Former 参数量不大,很容易在几千条数据上就记住训练集。我的做法是冻结 Q-Former 的前若干层,只放开后面几层和投影层,同时用较小的学习率和早停。另外这个阶段最好保留一部分第一阶段的图文配对数据混着训,防止 Q-Former 的通用视觉摘要能力被特定任务带偏。
选哪条路子,我一般的判断顺序是:先零样本跑一遍看基线,如果差距在可接受范围内就加提示词工程;差得明显就用几百条数据试投影层微调;还不行再考虑放开 Q-Former。这个由轻到重的顺序能避免大量无效投入,我在几个不同类型的项目里都是这么走的,基本上到第二步就能覆盖大多数需求。