多模态大语言模型(MLLMs)在近期经历了飞速发展,从单模态的文本处理跃迁至能够同时理解视觉与语言的复杂架构。这一演变的核心在于模态对齐(Modality Alignment)——将连续的视觉特征映射到预训练大语言模型(LLM)的离散语义空间中的机制。
端到端架构概览
一个典型的 MLLM(如 LLaVA 或 BLIP-2)通常由三个核心组件构成:视觉编码器(例如 ViT)、模态投影层(对齐模块)以及大语言模型。视觉信号被编码、投影到 LLM 的嵌入空间中,并作为视觉 Token 与文本 Token 一同拼接输入。
graph TD
A[图像输入] --> B[视觉编码器 ViT]
B -->|Patch Embeddings| C[模态投影层 / Q-Former]
C -->|视觉 Tokens| D[大语言模型]
E[文本输入] -->|文本 Tokens| D
D --> F[自回归文本输出]
classDef encoder fill:#f9f,stroke:#333,stroke-width:2px;
classDef projection fill:#bbf,stroke:#333,stroke-width:2px;
classDef llm fill:#bfb,stroke:#333,stroke-width:2px;
class B encoder;
class C projection;
class D llm;
对齐模块的数学原理:Q-Former 与交叉注意力
虽然较简单的模型使用直接的 MLP 投影,但诸如 BLIP-2 等高级架构采用了 Q-Former。Q-Former 利用交叉注意力机制(Cross-Attention)从视觉编码器变长的 Patch Embeddings 中提取固定长度的视觉 Token。
给定可学习的查询变量 $Z \in \mathbb{R}^{N \times D_q}$ 以及图像 Patch Embeddings $X \in \mathbb{R}^{M \times D_v}$,交叉注意力机制通过将它们投影为 Query、Key 和 Value 矩阵来运作:
$$ Q = Z W_Q, \quad K = X W_K, \quad V = X W_V $$
其中 $W_Q \in \mathbb{R}^{D_q \times d_k}$,$W_K \in \mathbb{R}^{D_v \times d_k}$,以及 $W_V \in \mathbb{R}^{D_v \times d_v}$ 为可学习的权重矩阵。注意力输出的计算公式为:
$$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V $$
这使得模型能够将 $M$ 个视觉 Patch 压缩为固定数量的 $N$ 个查询 Token,从而有效地总结与语言生成相关的视觉信息。
代码实现:PyTorch 中的 MLP 投影层
在诸如 LLaVA 等架构中,通常使用一种更为简单但高效的两层 MLP(通常带有 GELU 激活函数)将 ViT 的特征直接映射到 LLM 的维度 $D_{LLM}$。
import torch
import torch.nn as nn
class ModalityProjection(nn.Module):
def __init__(self, vit_dim=1024, llm_dim=4096):
super().__init__()
# 带有 GELU 激活的两层 MLP
self.proj = nn.Sequential(
nn.Linear(vit_dim, llm_dim),
nn.GELU(),
nn.Linear(llm_dim, llm_dim)
)
def forward(self, visual_features):
"""
参数:
visual_features: 形状为 (batch_size, num_patches, vit_dim) 的张量
返回:
visual_tokens: 形状为 (batch_size, num_patches, llm_dim) 的张量
"""
visual_tokens = self.proj(visual_features)
return visual_tokens
# 示例用法
vit_output = torch.randn(2, 256, 1024) # Batch 为 2,256 个 patches,1024 维度
projector = ModalityProjection(vit_dim=1024, llm_dim=4096)
llm_inputs = projector(vit_output) # 形状: (2, 256, 4096)
投影层真正决定的是 token 数量
上面比较了 MLP 和 Q-Former 的表达能力,但在部署时,两者更重要的区别是它们输出多少个 token——这个数字会一路决定上下文占用、首字延迟和显存。
MLP 投影是逐 patch 的:ViT 输出多少个 patch embedding,就有多少个视觉 token 进入 LLM。336×336 的输入配 14×14 的 patch,是 (336/14)² = 576 个 token。分辨率一提,这个数按面积增长。
Q-Former 是固定长度的:无论输入有多少个 patch,可学习查询变量有几个,输出就是几个 token。BLIP-2 用的是 32 个。
MLP 投影: 576 patches -> 576 tokens (随分辨率增长)
Q-Former: 576 patches -> 32 tokens (恒定)
差了 18 倍。这个差距在单张图上还不明显,一旦进入多轮对话或者多图输入就会主导一切——因为预填充阶段的注意力计算量正比于序列长度的平方,576 与 32 的平方差是 324 倍。
所以这两种设计的取舍不是”哪个更好”,而是在信息保留和序列长度之间选哪一端。MLP 保留了每个 patch 的空间信息,代价是长序列;Q-Former 把图像压成一个固定长度的摘要,细粒度的空间信息(比如”左下角那个小字写的是什么”)会在压缩中丢失,换来的是可控的成本。
判断依据很实际:如果任务需要读图中的文字、数细小的物体、定位精确位置,用 MLP 并接受长序列;如果任务是整体描述、分类、问答,Q-Former 的压缩通常不损失可用信息。先确定任务对空间细粒度的要求,再选投影层——反过来做会在部署时被 token 数量卡住,那时候换架构的代价已经很大了。
token 数量如何转化为实际显存和延迟,见部署多模态大模型时的 KV Cache 显存爆炸。
换更高分辨率的编码器时,位置编码要插值
知道 token 数由 patch 数决定之后,很自然会想到提高输入分辨率来获得更多细节。这一步有个必须处理的细节,漏掉不会报错,只会让效果莫名其妙地差。
ViT 的位置编码是为预训练时的网格尺寸学出来的。CLIP ViT-L 在 224×224 上预训练,patch 14×14,网格是 16×16 共 256 个位置。如果直接把输入改成 336×336,网格变成 24×24 共 576 个位置——多出来的 320 个位置没有对应的编码。
多数实现会在这里静默地做点什么:截断、补零、或者报一个形状不匹配。前两种最危险,因为模型照样能跑,只是图像下半部分的 patch 拿到的位置信息是错的或者空的。
正确做法是把原来的位置编码当成二维网格做双三次插值,重采样到新的网格尺寸:
import torch.nn.functional as F
def interpolate_pos_embed(pos_embed, old_grid, new_grid):
"""pos_embed: (1, old_grid*old_grid + 1, dim),第 0 个是 CLS"""
cls_tok, grid_tok = pos_embed[:, :1], pos_embed[:, 1:]
dim = grid_tok.shape[-1]
grid_tok = grid_tok.reshape(1, old_grid, old_grid, dim).permute(0, 3, 1, 2)
grid_tok = F.interpolate(grid_tok, size=(new_grid, new_grid),
mode='bicubic', align_corners=False)
grid_tok = grid_tok.permute(0, 2, 3, 1).reshape(1, new_grid * new_grid, dim)
return torch.cat([cls_tok, grid_tok], dim=1)
两个容易出错的地方:CLS token 的位置编码不能参与插值,它不属于空间网格,必须单独切出来再拼回去;插值要在二维网格形状上做,把一维序列直接线性插值会把每一行的末尾和下一行的开头混在一起。
插值之后模型仍然需要一段微调来适应新的分辨率——插值给出的是一个合理的初值,不是免费的能力提升。如果换了分辨率却完全不训练,效果通常比原分辨率还差。
视觉 token 借用的是一维位置编码
还有一个架构层面的固有限制,值得在选型阶段就知道。
视觉 token 被拼接进文本序列之后,用的是 LLM 自己的位置编码——而那套编码是一维的,为文本的线性顺序设计。图像本身是二维的,patch 之间的邻接关系有上下左右四个方向。
展平成序列之后,水平相邻的两个 patch 在序列里索引相差 1,而垂直相邻的两个 patch 索引相差一整行的宽度。24×24 的 patch 网格里,正上方的邻居在序列上隔了 24 个位置——和一个完全不相干的远处 patch 在位置编码看来差不多远。
patch 网格 展平后的序列索引
┌──┬──┬──┐
│ 0│ 1│ 2│ 水平邻居: |0-1| = 1
├──┼──┼──┤ 垂直邻居: |0-3| = 3 (24 列时是 24)
│ 3│ 4│ 5│
└──┴──┴──┘
模型不是无法学到二维结构——ViT 内部已经有自己的二维位置编码,patch embedding 本身携带了空间信息。但 LLM 这一侧只看到一串一维 token,它对”这两个 token 在图上是上下关系”这件事没有先验,只能从数据里学。这解释了为什么 MLLM 在需要精确空间推理的任务上(”A 在 B 的左边还是右边”)表现常常弱于它的整体描述能力。
缓解手段包括在展平时插入行分隔 token 让模型看到换行、或者使用二维旋转位置编码。选型时的实际含义是:如果任务重度依赖空间关系,不要指望换个更大的 LLM 就能解决,这是接口层的信息损失,不是容量问题。
生产环境避坑指南:线性层 vs 两层 MLP
在部署和训练 MLLM 时,投影架构的选择会显著影响模型的性能和训练稳定性。
- 线性投影(LLaVA-1.0): 使用单个
nn.Linear层。- 优点: 参数开销极小,训练速度快。
- 缺点: 表达能力不足。它仅仅执行僵硬的线性变换,往往难以跨越连续视觉空间和离散 LLM 语义空间之间复杂的语义鸿沟。这会导致初始训练 Loss 偏高,且在复杂的推理任务中 Zero-shot 准确率较低。
- 两层 MLP(LLaVA-1.5+): 使用带有非线性激活函数(例如 GELU)的多层感知机。
- 优点: 非线性特性提供了必要的网络容量,以便将复杂的视觉层次结构映射为语义概念。在生产环境中,两层 MLP 表现出明显更快的收敛速度、更低的最终交叉熵 Loss,并在描述细粒度图像细节时大幅降低了幻觉(Hallucination)率。
- 缺点: 参数量和计算量略有增加,但与庞大的 LLM 骨干网络相比微乎其微。
核心结论: 在生产环境中进行模态对齐时,永远优先默认选择两层 MLP。单层线性投影不足以捕获高保真指令微调所需的非线性语义关系。