一、多模态技术概论
1.1 什么是多模态
模态(Modality):信息的载体形式。文本、图像、音频、视频、点云、表格……
多模态(Multimodal):模型能同时理解和处理多种模态,并建立它们之间的关联。
text
单模态:文本 → 文本(GPT) 图像 → 标签(ResNet) 多模态:图像 + 文本 → 文本(GPT-4V) 文本 → 图像(Stable Diffusion) 图像 + 文本 → 图像(风格迁移) 视频 + 音频 + 文本 → 描述(视频理解)
1.2 为什么需要多模态
| 场景 | 单模态的局限 | 多模态的价值 |
|---|---|---|
| 医疗 | 只看病历,看不到影像 | 影像 + 病历联合诊断 |
| 电商 | 只看标题,抓不到视觉卖点 | 图 + 文 + 评论联合推荐 |
| 自动驾驶 | 只靠雷达 | 摄像头 + 雷达 + 地图融合 |
| 教育 | 只有题目文本 | 题目 + 手写步骤 + 语音讲解 |
| 搜索 | 只能文字搜 | 以图搜图、以图搜文 |
1.3 多模态的核心挑战
挑战 1:表示鸿沟(Representation Gap)
图像是像素矩阵(连续、空间结构),文本是离散 token。怎么把它们放到同一个语义空间?
解法:对比学习(Contrastive Learning)
text
图像 → 图像编码器 → 向量 v_img 文本 → 文本编码器 → 向量 v_txt ↓ 拉近匹配的 (图,文),推远不匹配的 ↓ 同一个语义空间
代表:CLIP(OpenAI,2021)—— 用 4 亿图文对训练,让"猫的图片"和"cat 的文字"向量接近。
挑战 2:对齐(Alignment)
图像的"红色圆形"和文本的"red circle"怎么对应到同一概念?
粗粒度对齐:整图 ↔ 整句(CLIP)
细粒度对齐:区域 ↔ 词(GLIP、Grounding DINO)
挑战 3:融合(Fusion)
拿到图像特征和文本特征后,怎么合并?
| 融合方式 | 说明 | 代表 |
|---|---|---|
| 早期融合 | 原始输入层就拼 | 早期多模态模型 |
| 中期融合 | 中间层交叉注意力 | ViLBERT、LXMERT |
| 晚期融合 | 各自出结果再合并 | 简单分类 |
| 统一融合 | 全塞进一个 Transformer | GPT-4V、LLaVA |
挑战 4:模态缺失
现实中经常缺模态(只有图没文,只有文没图)。要能鲁棒处理。
1.4 多模态技术演进
text
2015-2018 早期融合(VQA、图像描述) ↓ 2019-2020 双塔 + 交叉注意力(ViLBERT、UNITER) ↓ 2021 CLIP 用对比学习统一表示 ↓ 2022 Flamingo 把视觉接进 LLM ↓ 2023 LLaVA / MiniGPT-4 开源 VLM 爆发 ↓ 2024 GPT-4o / Gemini 原生多模态 ↓ 2025 Agent + 多模态 + 具身智能
1.5 多模态的主要任务
| 任务 | 输入 | 输出 |
|---|---|---|
| 图像分类 | 图 | 类别 |
| 图像描述 | 图 | 文本 |
| VQA | 图 + 问 | 答 |
| 图文检索 | 图或文 | 匹配项 |
| OCR | 图 | 文字 |
| 图像生成 | 文 | 图 |
| 视频理解 | 视频 | 描述/问答 |
| 视觉定位 | 图 + 文本 | 框 |
二、Vision Transformer(ViT)原理与从零实现
2.1 为什么需要 ViT
CNN 的局限:
局部感受野,需堆很多层才能看全局
卷积核固定,缺少动态性
长距离依赖建模弱
Transformer 的优势:
自注意力天然全局
可扩展性强(堆数据、堆参数就涨点)
统一架构(和 NLP 一致)
2020 年 Google 的论文:《An Image is Worth 16x16 Words》
核心思想:把图像切成 patch,当成"单词"喂给 Transformer。
2.2 ViT 的核心原理
Step 1:图像切 patch
text
输入图像:224 × 224 × 3 切 patch:16 × 16 patch 数量:(224/16) × (224/16) = 14 × 14 = 196 个 每个 patch:16 × 16 × 3 = 768 维
Step 2:Patch Embedding
每个 patch 展平成 768 维向量,通过线性层映射到模型维度 D(如 768)。
python
patch (16×16×3=768) → Linear(768, D) → token (D维)
等价于:用 stride=16、kernel=16 的卷积。
Step 3:加 [CLS] token 和位置编码
text
[CLS] + 196 个 patch token = 197 个 token ↓ + 位置编码(可学习) ↓ 输入 Transformer
[CLS]:类似 BERT,用它的输出做分类
位置编码:因为自注意力无序,要显式告诉模型"谁在谁旁边"
Step 4:Transformer Encoder
标准 Transformer Encoder 堆 L 层,每层:
text
x = x + MultiHeadSelfAttention(LayerNorm(x)) x = x + MLP(LayerNorm(x))
Step 5:分类头
取[CLS]token 的输出,接一个 MLP:
text
[CLS] output → LayerNorm → Linear(D, num_classes) → softmax
2.3 从零实现 ViT
下面给一个完整可跑的 ViT,用 CIFAR-10 做图像分类。
python
# -*- coding: utf-8 -*- """ 从零实现 Vision Transformer 依赖:pip install torch torchvision """ import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader from torchvision import datasets, transforms import math # ============================================================ # 1. Patch Embedding:把图像切成 patch 并映射到 D 维 # ============================================================ class PatchEmbedding(nn.Module): def __init__(self, img_size=32, patch_size=4, in_channels=3, embed_dim=192): """ CIFAR-10 是 32x32,patch_size=4 → 8x8=64 个 patch """ super().__init__() self.img_size = img_size self.patch_size = patch_size self.num_patches = (img_size // patch_size) ** 2 # 用卷积实现 patch 切分 + 线性映射 self.proj = nn.Conv2d( in_channels, embed_dim, kernel_size=patch_size, stride=patch_size ) def forward(self, x): # x: [B, 3, 32, 32] x = self.proj(x) # [B, D, 8, 8] x = x.flatten(2) # [B, D, 64] x = x.transpose(1, 2) # [B, 64, D] return x # ============================================================ # 2. 多头自注意力 # ============================================================ class MultiHeadAttention(nn.Module): def __init__(self, embed_dim, num_heads, dropout=0.0): super().__init__() assert embed_dim % num_heads == 0 self.embed_dim = embed_dim self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.scale = self.head_dim ** -0.5 # Q, K, V 投影 self.qkv = nn.Linear(embed_dim, embed_dim * 3) self.proj = nn.Linear(embed_dim, embed_dim) self.dropout = nn.Dropout(dropout) def forward(self, x): B, N, D = x.shape # 计算 QKV: [B, N, 3D] → 拆成 3 个 [B, N, D] qkv = self.qkv(x).chunk(3, dim=-1) # 每个 reshape 成 [B, num_heads, N, head_dim] q, k, v = [ t.reshape(B, N, self.num_heads, self.head_dim).transpose(1, 2) for t in qkv ] # 注意力分数: [B, H, N, N] attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) attn = self.dropout(attn) # 加权求和: [B, H, N, head_dim] out = attn @ v # 拼回: [B, N, D] out = out.transpose(1, 2).reshape(B, N, D) out = self.proj(out) return out # ============================================================ # 3. MLP(FFN) # ============================================================ class MLP(nn.Module): def __init__(self, embed_dim, mlp_ratio=4.0, dropout=0.0): super().__init__() hidden = int(embed_dim * mlp_ratio) self.fc1 = nn.Linear(embed_dim, hidden) self.fc2 = nn.Linear(hidden, embed_dim) self.dropout = nn.Dropout(dropout) self.act = nn.GELU() def forward(self, x): x = self.fc1(x) x = self.act(x) x = self.dropout(x) x = self.fc2(x) x = self.dropout(x) return x # ============================================================ # 4. Transformer Encoder Block # ============================================================ class TransformerBlock(nn.Module): def __init__(self, embed_dim, num_heads, mlp_ratio=4.0, dropout=0.0): super().__init__() self.norm1 = nn.LayerNorm(embed_dim) self.attn = MultiHeadAttention(embed_dim, num_heads, dropout) self.norm2 = nn.LayerNorm(embed_dim) self.mlp = MLP(embed_dim, mlp_ratio, dropout) def forward(self, x): # Pre-Norm 结构(更稳定) x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x # ============================================================ # 5. ViT 主模型 # ============================================================ class ViT(nn.Module): def __init__( self, img_size=32, patch_size=4, in_channels=3, num_classes=10, embed_dim=192, depth=6, num_heads=6, mlp_ratio=4.0, dropout=0.1, ): super().__init__() self.patch_embed = PatchEmbedding(img_size, patch_size, in_channels, embed_dim) num_patches = self.patch_embed.num_patches # [CLS] token(可学习) self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) # 位置编码(可学习),长度 = 1 + num_patches self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim)) self.pos_drop = nn.Dropout(dropout) # Transformer Encoder 堆叠 self.blocks = nn.ModuleList([ TransformerBlock(embed_dim, num_heads, mlp_ratio, dropout) for _ in range(depth) ]) self.norm = nn.LayerNorm(embed_dim) self.head = nn.Linear(embed_dim, num_classes) self._init_weights() def _init_weights(self): nn.init.trunc_normal_(self.pos_embed, std=0.02) nn.init.trunc_normal_(self.cls_token, std=0.02) for m in self.modules(): if isinstance(m, nn.Linear): nn.init.trunc_normal_(m.weight, std=0.02) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.LayerNorm): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) def forward(self, x): B = x.shape[0] # 1. Patch embedding: [B, 3, 32, 32] → [B, N, D] x = self.patch_embed(x) # 2. 加 [CLS] cls = self.cls_token.expand(B, -1, -1) # [B, 1, D] x = torch.cat([cls, x], dim=1) # [B, N+1, D] # 3. 加位置编码 x = x + self.pos_embed x = self.pos_drop(x) # 4. 过 Transformer for block in self.blocks: x = block(x) # 5. 取 [CLS] 输出分类 x = self.norm(x) cls_out = x[:, 0] return self.head(cls_out) # ============================================================ # 6. 训练脚本 # ============================================================ def train(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[INFO] 使用设备: {device}") # 数据增强 transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) train_set = datasets.CIFAR10("./data", train=True, download=True, transform=transform_train) test_set = datasets.CIFAR10("./data", train=False, download=True, transform=transform_test) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=0) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=0) # 模型 model = ViT( img_size=32, patch_size=4, num_classes=10, embed_dim=192, depth=6, num_heads=6, mlp_ratio=4.0, dropout=0.1, ).to(device) n_params = sum(p.numel() for p in model.parameters()) print(f"[INFO] 参数量: {n_params/1e6:.2f}M") # 优化器 + 调度 optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.05) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # 训练 epochs = 30 for epoch in range(epochs): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) scheduler.step() train_acc = correct / total avg_loss = total_loss / total # 测试 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in test_loader: imgs, labels = imgs.to(device), labels.to(device) logits = model(imgs) correct += (logits.argmax(1) == labels).sum().item() total += imgs.size(0) test_acc = correct / total print(f"Epoch {epoch+1:2d}/{epochs} | " f"Loss {avg_loss:.4f} | " f"Train {train_acc:.4f} | Test {test_acc:.4f}") torch.save(model.state_dict(), "vit_cifar10.pth") print("[INFO] 模型已保存 vit_cifar10.pth") if __name__ == "__main__": train()跑起来的结果(参考):
30 epoch 后 CIFAR-10 测试准确率 ~80%
参数量约 2.8M
单卡 3090 约 10 分钟
2.4 ViT 的关键设计点
为什么用 [CLS] token
类似 BERT,让一个专门的 token 汇聚全局信息
也可以用全局平均池化(GAP)替代,效果差不多
位置编码为什么可学习
原论文对比过:可学习 vs 正弦,效果差不多
但可学习的在小数据集上略好
大分辨率时需要插值
Pre-Norm vs Post-Norm
Post-Norm(原 Transformer):
x = Norm(x + Sublayer(x))Pre-Norm(ViT 用):
x = x + Sublayer(Norm(x))Pre-Norm训练更稳定,适合深层网络
ViT 为什么需要大数据
没有 CNN 的归纳偏置(局部性、平移不变性)
小数据上不如 ResNet
JFT-300M 上训练后超越 CNN
用DeiT的蒸馏方法可以在小数据上训好
2.5 ViT 的变体
| 模型 | 改进 |
|---|---|
| DeiT | 知识蒸馏,小数据也能训 |
| Swin | 层级结构 + 滑动窗口,效率高 |
| BEiT | 掩码图像建模,自监督 |
| MAE | 掩码自编码器,重建像素 |
| DINOv2 | 自监督,特征质量极高 |
| EVA | 从 CLIP 蒸馏,强大 |
三、VLM:视觉大语言模型
3.1 什么是 VLM
VLM(Vision-Language Model):能同时理解图像和文本,并生成文本的大模型。
代表:
闭源:GPT-4V、Claude 3.5 Sonnet、Gemini 1.5
开源:LLaVA、Qwen-VL、InternVL、CogVLM、MiniCPM-V
3.2 VLM 的核心架构
通用范式:视觉编码器 + 投影层 + LLM
text
图像 → [Vision Encoder] → 视觉特征 ↓ [Projector] ↓ 文本 → [Tokenizer] → token ──→ 拼接 → [LLM] → 输出文本
三个核心组件
1. Vision Encoder(视觉编码器)
通常用CLIP ViT或SigLIP,冻结或微调。
python
# 输入: [B, 3, 336, 336] # 输出: [B, N, D_v] N=576, D_v=1024 (CLIP-L/14)
2. Projector(投影层)
把视觉特征映射到 LLM 的词嵌入空间。
常见方案:
| 方案 | 说明 | 代表 |
|---|---|---|
| 线性层 | 一个 Linear | LLaVA-1.0 |
| MLP | 2 层 MLP | LLaVA-1.5 |
| Q-Former | 用可学习 query 压缩 | BLIP-2 |
| Perceiver Resampler | 类似 Q-Former | Flamingo |
| C-Abstractor | 卷积 + 池化 | Honeybee |
python
# LLaVA 的 projector class Projector(nn.Module): def __init__(self, vision_dim=1024, llm_dim=4096): super().__init__() self.mlp = nn.Sequential( nn.Linear(vision_dim, llm_dim), nn.GELU(), nn.Linear(llm_dim, llm_dim), ) def forward(self, x): return self.mlp(x)
3. LLM(语言模型)
接收"视觉 token + 文本 token"的拼接序列,输出文本。
3.3 VLM 的训练流程(以 LLaVA 为例)
Stage 1:预训练对齐(Feature Alignment)
冻结:Vision Encoder + LLM
训练:只训 Projector
数据:图文对(CC3M、LAION),任务=图像描述
目的:让视觉特征对齐到 LLM 词空间
成本:便宜(只训一个 MLP)
Stage 2:指令微调(Instruction Tuning)
冻结:Vision Encoder
训练:Projector + LLM(或 LoRA)
数据:多模态指令数据(LLaVA-Instruct-150K)
目的:让模型能对话、问答、推理
成本:贵(要训 LLM)
3.4 从零实现一个 Mini VLM
下面用预训练的 CLIP + 一个简单 LLM 的思路演示。为了能跑,我用一个小模型(GPT-2)代替。
python
# -*- coding: utf-8 -*- """ Mini VLM: CLIP Vision Encoder + Projector + GPT-2 依赖:pip install torch transformers pillow """ import torch import torch.nn as nn from transformers import CLIPVisionModel, GPT2LMHeadModel, CLIPProcessor from PIL import Image # ============================================================ # 1. Projector # ============================================================ class Projector(nn.Module): def __init__(self, vision_dim=768, llm_dim=768): super().__init__() self.mlp = nn.Sequential( nn.Linear(vision_dim, llm_dim), nn.GELU(), nn.Linear(llm_dim, llm_dim), ) def forward(self, x): return self.mlp(x) # ============================================================ # 2. Mini VLM # ============================================================ class MiniVLM(nn.Module): def __init__(self, vision_model_name="openai/clip-vit-base-patch32", llm_name="gpt2"): super().__init__() # 视觉编码器 self.vision_encoder = CLIPVisionModel.from_pretrained(vision_model_name) vision_dim = self.vision_encoder.config.hidden_size # 768 # 语言模型 self.llm = GPT2LMHeadModel.from_pretrained(llm_name) llm_dim = self.llm.config.n_embd # 768 # 投影层 self.projector = Projector(vision_dim, llm_dim) # 冻结视觉编码器 for p in self.vision_encoder.parameters(): p.requires_grad = False def forward(self, pixel_values, input_ids, attention_mask=None, labels=None): B = pixel_values.shape[0] # 1. 视觉编码 with torch.no_grad(): vision_out = self.vision_encoder(pixel_values=pixel_values) vision_features = vision_out.last_hidden_state # [B, N, D_v] # 2. 投影到 LLM 空间 vision_embeds = self.projector(vision_features) # [B, N, D_llm] # 3. 文本 embedding text_embeds = self.llm.transformer.wte(input_ids) # [B, L, D_llm] # 4. 拼接 [视觉 token][文本 token] inputs_embeds = torch.cat([vision_embeds, text_embeds], dim=1) # 5. attention mask 补齐 if attention_mask is not None: vision_mask = torch.ones(B, vision_embeds.size(1), device=attention_mask.device) attention_mask = torch.cat([vision_mask, attention_mask], dim=1) # 6. labels 补齐(视觉部分不参与 loss) if labels is not None: vision_labels = torch.full( (B, vision_embeds.size(1)), -100, dtype=labels.dtype, device=labels.device ) labels = torch.cat([vision_labels, labels], dim=1) # 7. 过 LLM outputs = self.llm( inputs_embeds=inputs_embeds, attention_mask=attention_mask, labels=labels, ) return outputs # ============================================================ # 3. 推理示例 # ============================================================ def inference_demo(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = MiniVLM().to(device) model.eval() processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32") # 造一张假图 img = Image.new("RGB", (224, 224), color="red") pixel_values = processor(images=img, return_tensors="pt")["pixel_values"] pixel_values = pixel_values.to(device) # 输入文本 prompt = "This image shows a" from transformers import GPT2Tokenizer tokenizer = GPT2Tokenizer.from_pretrained("gpt2") tokenizer.pad_token = tokenizer.eos_token inputs = tokenizer(prompt, return_tensors="pt").to(device) with torch.no_grad(): outputs = model( pixel_values=pixel_values, input_ids=inputs["input_ids"], attention_mask=inputs["attention_mask"], ) # 简单取 logits(未训练,仅演示结构) print("[INFO] 模型结构 OK") print(f"[INFO] logits shape: {outputs.logits.shape}") if __name__ == "__main__": inference_demo()这只是一个结构演示,真实训练需要:
大规模图文对
分阶段训练(对齐 → 指令微调)
多卡分布式
3.5 VLM 的关键技术点
1. 视觉 token 数量问题
CLIP ViT-L/14 在 336×336 分辨率下产生576 个 token。如果图像再大,token 更多,吃掉 LLM 上下文。
解法:
池化:直接平均池化
Q-Former:用固定数量 query 压缩(BLIP-2 用 32 个)
Perceiver Resampler:类似
Token Merging:合并相似 token
2. 高分辨率处理
方案 A:直接放大图像 → token 爆炸
方案 B:切图(LLaVA-1.6 / Qwen-VL)
整图缩到固定大小
切成多个子图分别编码
拼一起
方案 C:动态分辨率(Qwen2-VL)
按原图比例切 patch
3. 多图 / 视频
多图:每张图编码后拼接
视频:抽帧 + 时序建模
4. 训练数据
| 数据类型 | 用途 | 规模 |
|---|---|---|
| 图文对(CC3M、LAION) | 对齐预训练 | 百万-十亿 |
| VQA(VQAv2、OKVQA) | 问答 | 十万 |
| 指令数据(LLaVA-Instruct) | 对话 | 15 万 |
| OCR 数据 | 文字识别 | 百万 |
| Grounding 数据 | 定位 | 十万 |
5. 训练技巧
LoRA / QLoRA:省显存
梯度检查点:省显存
混合精度:bf16
DeepSpeed ZeRO:多卡
3.6 VLM 评估
| Benchmark | 测什么 |
|---|---|
| MMBench | 综合能力 |
| MMMU | 大学水平多学科 |
| MathVista | 数学推理 |
| MME | 感知 + 认知 |
| POPE | 幻觉 |
| TextVQA | 文字识别 |
| SEED-Bench | 综合 |
3.7 VLM 的挑战
幻觉:描述图中不存在的东西
细粒度理解:小物体、密集文字
空间推理:左/右/上/下
长视频:时序理解
效率:视觉 token 太多
安全:越狱、有害内容
四、ViT 与 VLM 的关系
text
ViT(视觉编码器) ↓ CLIP(图文对齐,ViT + 文本编码器) ↓ VLM(CLIP ViT + Projector + LLM) ↓ GPT-4V / LLaVA / Qwen-VL
一句话:ViT 是 VLM 的"眼睛",LLM 是"大脑",Projector 是"神经连接"。