ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Multimodal Pathway:多模态融合通路设计与调优

Multimodal Pathway:多模态融合通路设计与调优

去年下半年我接手了一个内部系统的改造需求,要把图像、文本和一小段语音信号塞进同一个模型里做联合判断。数据量不小,标注质量也还行,单模态的指标都挺漂亮,可一旦拼到一起,整体效果反而比只用文本还差一截。排查了两周,最后发现问题不在数据,也不在模型大小,而在“通路”上——模态信息从各自的编码器出来之后,走哪条路、在哪一层汇合、汇合之后还剩多少信息,这条路径的设计直接决定了模型能不能真正“看懂”多模态输入。

这篇要聊的Multimodal Pathway,说的就是这条路径本身:从各模态编码、投影对齐、交互融合,到最终任务头的整条信息通道设计。它不是某个具体模型的名字,而是一类架构决策的统称。你在做图像问答、跨模态检索、视频内容理解、工业质检里的图文核对,甚至多传感器融合,都会撞上它。适合两类人看:一类是刚要把第二个模态接进现有模型的工程师,另一类是发现“融合了但没变好”的算法同学。下面我按自己踩坑的顺序,把设计思路、参数取舍、代码骨架和排查经验完整摊开讲。

1. 先把“通路”这两个字拆开看

1.1 一条通路要经过的四个关口

很多人一上来就纠结“用什么融合模块”,其实通路是分段的,每一段的失败模式完全不同,混在一起讨论永远吵不出结论。我习惯把它拆成四个关口,每个关口有自己的输入输出和验收标准。

第一个关口是模态内编码。图像走视觉编码器,文本走词嵌入加编码器,语音走声学前端。这一段的目标是把原始信号变成一串定长的向量序列。注意,这里的“定长”不是指数值定长,而是指 token 数量和维度要稳定,否则后面拼接时形状对不上。视觉侧通常是切 patch,224×224 的图按 14×14 切,得到 16×16=256 个 token;文本侧按分词后长度算,一般在几十到几百之间浮动。

第二个关口是投影对齐。不同编码器输出的维度往往不一样,视觉可能是 1024 维,文本可能是 768 维,投影层负责把它们映射到同一个宽度。这一步看着简单,其实是整个通路里最容易偷懒也最容易出问题的地方,后面单独展开。

第三个关口是交互融合。这是模态真正“见面”的地方。可以是简单拼接后一起过 Transformer,也可以是用交叉注意力让文本去查图像,还可以是到最后一层才做分数加权。位置选错,后面怎么调都补不回来。

第四个关口是任务头输出。分类头、生成头、检索用的对比头,都算。这一段的问题通常是“信息压缩过头”,尤其是做生成任务时,如果融合后的序列长度被压得太狠,解码端会明显感觉到“细节没了”。

把这四段分开看,你会发现很多“融合效果不好”的案例,其实是第一段就有问题——比如视觉 token 里混进了大量背景 patch,投影层再好也救不回来。

1.2 三种融合位置的取舍,别凭感觉选

融合位置的选择是通路设计里第一个大决策。我做过一个对比表,是我们内部实测之后整理的,参数量和显存是按单卡 80G、混合精度估算的:

融合方式交互发生位置显存增幅训练难度适合的场景
早期融合编码后立即拼接低,约 1.2 倍低模态对齐良好、序列都短
中期融合中间若干层交叉注意力高,约 2.5 到 3 倍中高需要细粒度对应,如图文问答
晚期融合各模态独立出分数再合并极低低模态独立性强、要求可解释
混合融合主路中期 + 旁路晚期中高高线上要降级兜底的生产系统

早期融合最省事,把视觉 token 和文本 token 拼成一条长序列直接喂给 Transformer。问题在于,图像 token 动辄两三百个,文本才几十个,拼完之后序列被视觉主导,模型容易“看着图忘了话”。我们最早那版就是早期融合,图文匹配任务还行,一到需要精确读文字的场合就崩,因为文本 token 在注意力里被稀释了。

中期融合是我现在的主力方案。核心是保留两条独立的主干,在中间某几层插入交叉注意力,让文本 token 作为 query 去查视觉 token 的 key/value。好处是文本序列长度不变,不会被稀释;代价是显存涨得厉害,交叉注意力的矩阵规模是文本长度乘以视觉长度,虽然比全序列自注意力省,但层数一多也顶不住。

晚期融合最稳,各模态各自算分再加权,线上降级特别方便——视觉服务挂了就只走文本。但它学不到细粒度对应关系,做“图里第三行文字写了什么”这种任务基本没戏。

我们现在线上跑的是混合结构:主路用中期融合保证效果,旁路保留一个晚期融合的轻量分支,主路超时或异常时切旁路。这个设计不是一开始就想好的,是被线上事故逼出来的。

2. 核心设计里最容易被低估的三件事

2.1 投影层不是“降维那一下”,它决定信息损失上限

投影层最常见的写法就是一个线性层,把视觉特征从 1024 维映射到 4096 维。参数量算下来是 1024×4096+4096,大概 420 万,占整体模型的比例很小,所以很多人随手就定了。但我实测下来,这一层的设计对最终指标的影响远超它的参数量占比。

问题出在 token 数量上。视觉编码器输出 256 个 token,如果全部保留并投到 4096 维,光视觉部分就要占 256×4096 的激活空间,再乘以 batch 和层数,显存很快就见底。于是有两种常见处理:一种是直接线性投影全保留,另一种是先用查询式重采样把 256 个 token 压到 32 或 64 个,再投影。

我两种都试过,结论是看任务性质分。如果任务需要定位细节,比如“图中左上角的仪表读数是多少”,压缩 token 会直接丢掉空间分辨率,指标掉得很明显;如果任务是整体语义判断,比如“这张图是不是户外场景”,压缩到 64 个 token 几乎无损,还能省下一大截显存。

重采样器的写法不复杂,核心是一组可学习的查询向量去对视觉 token 做注意力:

import torch import torch.nn as nn class QueryResampler(nn.Module): """把变长的视觉 token 压缩成固定数量的语义 token""" def __init__(self, vis_dim, out_dim, num_query=64, num_heads=8, depth=2): super().__init__() self.query = nn.Parameter(torch.randn(1, num_query, out_dim) * 0.02) self.kv_proj = nn.Linear(vis_dim, out_dim) self.layers = nn.ModuleList([ nn.ModuleDict({ "q_norm": nn.LayerNorm(out_dim), "kv_norm": nn.LayerNorm(out_dim), "attn": nn.MultiheadAttention(out_dim, num_heads, batch_first=True), "ffn": nn.Sequential( nn.Linear(out_dim, out_dim * 4), nn.GELU(), nn.Linear(out_dim * 4, out_dim), ), "ffn_norm": nn.LayerNorm(out_dim), }) for _ in range(depth) ]) def forward(self, vis_tokens, vis_mask=None): # vis_tokens: [B, N_vis, vis_dim] kv = self.kv_proj(vis_tokens) # [B, N_vis, out_dim] q = self.query.expand(kv.size(0), -1, -1) # [B, num_query, out_dim] key_padding = None if vis_mask is None else ~vis_mask for layer in self.layers: q2 = layer["q_norm"](q) kv2 = layer["kv_norm"](kv) attn_out, _ = layer["attn"]( q2, kv2, kv2, key_padding_mask=key_padding, need_weights=False, ) q = q + attn_out q = q + layer["ffn"](layer["ffn_norm"](q)) return q # [B, num_query, out_dim]

这段代码有两个细节值得说。一是self.query的初始化标准差,我用 0.02,试过 0.5 那种大初始化,训练前期梯度爆炸过一次,loss 直接变 NaN。二是key_padding_mask的处理,视觉侧如果有 padding 的 token(比如不同分辨率图片拼成一个 batch),不做 mask 的话重采样会去关注无意义的填充位,训出来的 token 语义是脏的。这个坑我在第二批数据上才踩到,因为第一批图片全是统一尺寸,根本没有 padding。

2.2 路由与门控:让信息自己选择走哪条道

当模态数量超过两个,或者同一模态内部有不同来源(比如摄像头图像和雷达图像),简单拼接就不够用了。这时候可以做模态级路由:给每个 token 算一个权重,决定它更多地流向哪个专家分支。

思路参考混合专家的做法,但不要照搬到模态上。我最初的实现是给每个模态一个标量门控,结果训完之后门控全塌缩到某一个模态上,等于白做。后来改成 token 级路由才正常:

class ModalRouter(nn.Module): """token 级门控,为每个 token 分配专家权重""" def __init__(self, dim, num_experts=4, top_k=2, temperature=1.0): super().__init__() self.gate = nn.Linear(dim, num_experts, bias=False) self.num_experts = num_experts self.top_k = top_k self.temperature = temperature def forward(self, x, modality_ids): # x: [B, L, dim]; modality_ids: [B, L] 每个 token 属于哪个模态 logits = self.gate(x) / self.temperature # [B, L, E] # 重要:路由权重按模态做归一化,防止某个模态 token 多就占优 mask = torch.zeros_like(logits) for m in modality_ids.unique(): sel = (modality_ids == m) mask[sel] = 1.0 logits = logits.masked_fill(mask == 0, float("-inf")) weights = torch.softmax(logits, dim=-1) topv, topi = weights.topk(self.top_k, dim=-1) topv = topv / topv.sum(dim=-1, keepdim=True) return topv, topi

modality_ids这个参数看着多余,其实是我踩坑之后加的。第一版没有它,路由权重的归一化是在整个 batch 的所有 token 上做的,结果视觉 token 数量是文本的五六倍,softmax 之后视觉专家被分到的权重天然就高,路由形同虚设。加上模态掩码之后,每个模态内部独立竞争,负载才均衡。

另外必须配一个负载均衡损失,否则专家利用率会严重倾斜。我用的形式是各专家被选中概率的平方和乘以专家数:

$$ L_{bal} = E \cdot \sum_{i=1}^{E} f_i^2 $$

其中f_i是第 i 个专家在当前 batch 里被选中的频率。这个损失权重我设在 0.01 量级,太大了会影响主任务收敛,太小了没效果。调这个系数的那几天,日志里专家命中率的曲线一会儿接近均匀一会儿全打到一个专家上,来回试了七八个值才稳定下来。

2.3 训练阶段怎么切分,决定通路能不能学起来

通路结构搭好之后,训练策略比结构本身更影响结果。我的做法是分三段,每段的冻结范围不同:

阶段编码器投影层融合层任务头典型步数
对齐预热冻结训练冻结训练2k 到 5k
通路放开冻结训练训练训练10k 到 20k
联合微调部分解冻训练训练训练5k 到 10k

第一阶段只训投影层和任务头,目的是让两个模态的表示先落到一个大致兼容的空间里。这一步学习率可以给大一点,我用 1e-3,因为投影层是随机初始化的,太小的学习率根本挪不动。如果跳过这一步直接联合训练,我遇到过投影层梯度被编码器传来的大梯度冲垮的情况,loss 前两千步剧烈震荡。

第二阶段放开融合层,学习率降到 1e-4,编码器继续冻结。这一步是真正学“跨模态对应关系”的阶段,耗时最长。有个细节,融合层里的交叉注意力,key/value 侧的权重可以给更小的学习率,因为它承接的是已经训好的视觉表示,动太快会破坏原有语义。

第三阶段才解冻编码器的高层,学习率压到 1e-5 到 2e-5。这一步收益不大但风险不小,我的经验是只在数据量足够(十万级以上)的时候做,小数据集上解冻编码器几乎必然过拟合,验证集指标第二 epoch 就开始往下走。

3. 一条完整通路的具体实现与参数核算

3.1 先把张量形状和序列布局约定死

写代码之前,我强制自己做了一件事:把所有张量的形状、含义、mask 约定写在一张表里,贴在旁边。这个习惯是被一次 debug 折磨出来的——某次训练一切正常,但推理时结果全乱,查了一晚上发现是训练时视觉 token 在前文本在后,推理脚本里顺序反了,因为模型没报错,形状完全兼容。

我们的约定是这样的:

张量形状含义
vis_tokens[B, Nv, Dv]视觉编码器输出,Nv 一般 256 或 576
vis_mask[B, Nv]True 表示有效 token
txt_ids[B, Lt]文本 token id,Lt 为 padding 后长度
txt_tokens[B, Lt, Dt]文本编码器输出
modality_ids[B, Nv+Lt]拼接后每个位置属于哪个模态,0 视觉 1 文本
fused[B, Nv+Lt, D]融合后序列,D 为统一宽度

序列拼接顺序我固定成“视觉在前、文本在后”,并且在融合层之后加一个可学习的位置偏置,让模型知道两段的分界在哪。这个偏置听起来可有可无,但实测去掉之后,做需要精确定位的任务会掉一到两个点,因为纯位置编码区分不出“这里是模态边界”这件事。

3.2 融合块的代码骨架

融合块我写成了一个独立模块,方便按需插入到主干的不同层。核心是双向的交叉注意力加上残差:

class FusionBlock(nn.Module): def __init__(self, dim, num_heads=16, dropout=0.1, use_gate=True): super().__init__() self.txt2vis = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True) self.vis2txt = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True) self.norm1 = nn.LayerNorm(dim) self.norm2 = nn.LayerNorm(dim) self.ffn = nn.Sequential( nn.Linear(dim, dim * 4), nn.GELU(), nn.Dropout(dropout), nn.Linear(dim * 4, dim), ) self.norm3 = nn.LayerNorm(dim) self.use_gate = use_gate if use_gate: # 门控初值偏小,让训练初期以恒等映射为主,避免破坏预训练表示 self.gate = nn.Parameter(torch.full((dim,), -2.0)) def forward(self, vis, txt, vis_mask=None, txt_mask=None): kv_pad = None if vis_mask is None else ~vis_mask q_pad = None if txt_mask is None else ~txt_mask t2v, _ = self.txt2vis(self.norm1(txt), vis, vis, key_padding_mask=kv_pad, need_weights=False) v2t, _ = self.vis2txt(self.norm2(vis), txt, txt, key_padding_mask=q_pad, need_weights=False) if self.use_gate: g = torch.sigmoid(self.gate) txt = txt + g * t2v vis = vis + g * v2t else: txt = txt + t2v vis = vis + v2t txt = txt + self.ffn(self.norm3(txt)) return vis, txt

门控的初值我设成 -2.0,sigmoid 之后大约 0.12,意思是训练刚开始时融合分支只贡献一成多的信息,主体还是各模态自己的表示。这个技巧是从残差连接的思想里变通过来的:融合层是随机初始化的,如果一开始就全量注入,会把编码器辛苦学到的表示冲散。实测带门控的版本,第一阶段 loss 下降明显更平滑。

3.3 显存和吞吐的核算,别等 OOM 才想

参数量的账好算,激活的账不好算,但必须算。以 batch=8、视觉 token 256、文本 token 512、融合宽度 4096、主干 32 层为例,我按混合精度粗算一遍:

单样本单层的 token 激活大约是 768×4096×2 字节,约 6.3MB。32 层乘 8 个样本,光这一项就是 1.6GB 左右,还不算 FFN 里的 4 倍扩展,实际要乘 4 到 5,就是 6 到 8GB。

真正吃显存的是注意力矩阵。768 个 token 的自注意力,矩阵规模 768×768,16 个头,混合精度下是 768×768×16×2 字节,约 18.9MB 每样本每层。乘 32 层乘 8 个样本,差不多 4.8GB,如果头数翻倍或者序列变长,这块会迅速失控。

我实际跑下来,单卡 80G 上这个配置能跑 batch 6 左右。想上到 batch 16 就得开梯度检查点,代价是训练速度慢四成左右。这个取舍我的做法是:预训练阶段不开检查点,用大 batch 保证梯度稳定;微调阶段开检查点,用更大 batch 换更少的步数,因为微调阶段本来就步数少。

还有个容易被忽略的点,交叉注意力的显存要看两边长度的乘积。文本 512、视觉 256,交叉注意力的矩阵是 256×512,比 768×768 的自注意力小三倍多,所以中期融合的显存开销其实主要来自主干自注意力,不是融合本身。这个认知帮我在优化时找对了方向——一开始我以为是融合层太重,去砍融合层数,结果指标掉了显存没省多少。

3.4 训练日志里必须盯的四个指标

跑起来之后,光看总 loss 是不够的。我固定盯四个数:

第一个是各模态 token 的梯度范数。如果文本侧梯度范数是视觉侧的十分之一,说明通路严重偏向视觉,后面调多久都没用,得回去查投影层的初始化尺度。

第二个是专家或门控的命中分布。上面提过,塌缩是最常见的问题,我一开始每 200 步打一次直方图,现在设成自动告警,某个专家占比超过 60% 就打印警告。

第三个是验证集上单模态和融合的差值。理想情况融合明显更好;如果融合反而不如最好的单模态,说明通路上有信息被破坏了,通常是融合层过早注入或者门控没起作用。

第四个是投影层输出的数值范围。这一项很少有人看,但我吃过亏:某次投影层输出均值偏移越来越大,训到一半激活值到了几千的量级,后面接的 LayerNorm 直接被撑爆。加上输出端的归一化和权重衰减之后稳住了。

4. 实际跑下来遇到的坑和排查方法

4.1 故障速查表

这些问题我基本都亲自遇到过,按症状整理出来,方便对照:

症状最可能的原因排查动作处理方式
融合后指标不如单模态融合层注入过早,破坏预训练表示看融合层门控值分布门控初值调小,或延后融合层插入位置
loss 前期剧烈震荡投影层随机初始化 + 学习率过大打印投影层梯度范数先冻结主干单训投影层,学习率 1e-3 起
训练正常推理全乱序列拼接顺序或 mask 约定不一致对齐训练与推理的张量布局把约定写成文档并加断言检查
门控全塌缩到一个分支缺少负载均衡,或归一化范围错误打印路由权重直方图加模态掩码 + 均衡损失,权重 0.01
生成任务细节丢失视觉 token 压缩过度对比压缩前后定位类指标提高查询数量,或关键任务不压缩
显存 OOM 但参数量不大注意力矩阵随序列平方增长按公式手算矩阵显存开梯度检查点,或降 token 数
验证指标第二 epoch 就下降解冻范围过大,数据量不够看训练/验证 loss 分叉点回退到只训融合层

表格里第一行那个问题最隐蔽。我们有一版结构,融合层从第一层就开始插入,结果模型在图文匹配上还行,一到需要语言理解的子任务就崩。后来把融合层挪到主干的后三分之一,同样的数据同样的超参,语言类子任务直接涨了四个点。原因不难理解:浅层的表示还很“局部”,视觉 token 和文本 token 在这个阶段语义空间差得远,强行交互等于让两个说不同语言的人在还没学会共同词汇时就对话,只会互相干扰。

4.2 几条我认为最值钱的避坑经验

第一条,永远先跑一个“只拼不融”的基线。就是把两个模态的表示直接拼接或者相加,不做任何交叉注意力,跑一遍看指标。这个基线看起来简陋,但它告诉你数据本身能撑到什么程度。我后来所有的融合改进都是跟这个基线比,而不是跟单模态比。跟单模态比容易得出“融合有效”的错觉,其实可能只是多参数带来的收益。

第二条,mask 一定要在最早的地方处理。我见过太多实现是在融合之后才处理 padding,那时候无效 token 已经参与了注意力计算,污染已经发生了。正确做法是编码器输出后立刻 mask,重采样和交叉注意力全程带着 mask。这个改动没有任何性能代价,但对小 batch、变长输入的场景影响巨大,我们的短文本样本指标因此涨了将近三个点。

第三条,别迷信“层数越多融合越充分”。我试过在 24 层主干里插 12 个融合块,结果训练时间翻倍,指标反而比插 4 个差。原因是每次融合都会引入一次信息重排,插得太密,各模态自己的表示还没来得及在层内充分演化就被反复混合,最终学到的是一种“糊在一起”的表示,区分度下降。我现在的经验值是融合块占主干层数的 15% 到 25%,插在中后段。

第四条,模态不平衡要在损失层面处理,不要在数据层面硬凑。视觉 token 远多于文本 token 是常态,有人靠复制文本 token 来平衡,这是错的,复制出来的 token 梯度会叠加,等于给文本加了额外权重。正确做法是在注意力 mask 和路由归一化里按模态处理,让每个模态内部独立竞争,这样数量差异不影响权重分配。

第五条,推理链路上留一条旁路。这个不是算法问题,是工程问题。多模态通路意味着多个服务串联,任何一个环节超时都会拖垮整体。我们现在的主路走中期融合,旁路保留一个只用文本的轻量分支,主路响应超过阈值就切旁路,虽然精度降一些,但可用性保住了。这个设计上线后,P99 延迟从原来的接近两秒降到了六百毫秒以内。

第六条,把投影层的输出范围当成一个监控指标。前面提过,这个数不看会出事。我的做法是在训练脚本里每 500 步统计一次投影层输出的均值和 99 分位数,均值偏移超过初始值的三倍就告警。这个小监控帮我在一次结构调整中提前发现了初始化尺度的问题,省了至少两天的排查时间。

5. 关于通路设计的一点个人体会

做这类结构改造做久了,我越来越觉得“多模态通路”这个说法其实挺准确的——它强调的是路径,不是模块。很多人(包括最早的我)习惯去找一个“更好的融合模块”,仿佛换个注意力变体就能解决问题。但实际调试下来,绝大部分收益来自路径上的位置选择和训练阶段的切分,模块本身用的是最标准的交叉注意力,没什么花哨的。

还有一个感受是,多模态的调试比单模态难,难在问题不报错。形状对得上、loss 在下降、指标不算难看,但就是没达到预期,你得靠一堆中间统计去定位到底是哪一段通路在漏信息。我现在养成了一个习惯,每加一个新模态或者改一次通路结构,先把四个关口的中间输出各存一份小样本,人眼看一遍。视觉 token 里是不是全是背景、投影后有没有数值异常、融合后的表示和融合前差多少,这些看一眼就有感觉,比盲调超参高效得多。

最后分享一个我最近在试的扩展方向:把通路的“宽度”也做成可学习的。现在的做法是每个模态固定走一条路径,我在想能不能让模型自己决定某个样本要不要走完整的融合链路,简单的样本走浅通路,复杂的走深通路。初步试验里,用路由权重做样本难度估计,把三成左右的简单样本分流到浅通路,整体吞吐提升了四成,精度只掉了零点几。这条线还在跑,等数据稳定了再细聊。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进