卷3 · 多模态理解 VLM
3.2 LLaVA 范式精读与复现:两阶段训练全流程
展开本章目录与同卷章节
1. 架构:简单到令人不安2. 输入怎么拼:inputs_embeds 拼接(复现的核心技巧)3. 两阶段训练(必考,也是你复现的目录)阶段一:特征对齐(pretrain projector)阶段二:视觉指令微调(SFT)之后的演化(知道即可)4. 迷你复现路线(学生算力版,大三下的第一个大项目)5. 失败模式速查(复现时的排障表)6. 自测题7. 动手作业(大三下里程碑第一段)图解与案例 · 两阶段管线与”一条样本的一生”8. 延伸资源附录 · 完整可运行脚本:llava_mini.py(教学骨架)同卷章节3.1 多模态总览:把视觉接进语言模型的三条路线3.2 LLaVA 范式精读与复现:两阶段训练全流程3.3 Connector 设计空间:Flamingo、BLIP-2 与开源双雄的答案3.4 视觉 Tokenizer:VQ-VAE 与 VQGAN——离散化是生成路线的地基3.5 开源双雄精读:Qwen-VL 与 InternVL 系列技术报告3.6 文档理解与 OCR-free:让 VLM 读懂 PDF3.7 多模态评测体系与陷阱3.8 数据为中心的 VLM:具体案例研读本章目标:吃透 LLaVA——结构最简、血统最正、最适合复现的 VLM 骨架。几乎所有开源 VLM 都是它的变体。学完标准:能讲清两阶段各自冻什么训什么、为什么这样设计,并能亲手跑一次迷你版两阶段训练。
1. 架构:简单到令人不安
LLaVA-1.5 的全部组件:
- 视觉塔:CLIP ViT-L/14 @336px → 576 个视觉特征(336/14=24,24×24=576,取 patch token,CLS 弃用);
- Connector:一个两层 MLP(
Linear(1024→d) → GELU → Linear(d→d))——把 CLIP 特征投影到 LLM 词嵌入空间; - LLM:Vicuna/Qwen(任意 decoder-only)。
设计哲学:把复杂度从架构转移到数据和训练。后来者不断证明”简单 connector + 好数据 > 复杂 connector”(3.3 的 Q-Former 之死),这是本领域最重要的经验教训之一。
2. 输入怎么拼:inputs_embeds 拼接(复现的核心技巧)
占位符方案:文本里写 <image> 占位 token(占 576 个位置),过 embedding 层后,把这几个位置的嵌入向量替换成 projector 输出的视觉特征,再喂给 LLM:
embeds = llm.get_input_embeddings()(input_ids) # (B, L, d) 文本嵌入
img_feats = proj(vision_tower(pixel_values)) # (B, 576, d) 视觉特征
embeds[img_mask] = img_feats.flatten(0, 1) # 占位位置替换
out = llm(inputs_embeds=embeds, attention_mask=mask) # 一套注意力统一处理
之后的一切(causal mask、next-token loss、SFT 的 loss mask)与卷2 完全相同。没有新机制——这就是 LLaVA 的力量所在。
3. 两阶段训练(必考,也是你复现的目录)
阶段一:特征对齐(pretrain projector)
- 冻结:视觉塔 ❄️ + LLM ❄️;训练:仅 projector(约几十 M 参数);
- 数据:约 558K 图文 caption(原版用 LCS558K,你复现用小规模 caption 集);
- 为什么这样设计:CLIP 特征是”检索友好”的,但 LLM 词嵌入空间不认识它。此阶段的唯一任务是让 projector 学会翻译——把视觉特征平移到 LLM 嵌入空间中”有意义的位置”。LLM 冻结保证了翻译目标稳定(空间不动,只学映射)。
阶段二:视觉指令微调(SFT)
- 冻结:视觉塔 ❄️(原版);训练:projector + LLM 全开;
- 数据:665K 指令数据(多轮对话、VQA、详细描述、推理题,用 GPT-4 从 caption/框标注合成的”学术任务数据”——又是数据为中心的胜利);
- 为什么 LLM 要解冻:模型要学会使用视觉信息回答问题(视觉条件下的语言能力),这是行为层的学习,需要整个 LLM 参与。
之后的演化(知道即可)
LLaVA-NeXT(AnyRes):高分辨率图切成网格块分别过 ViT + 一张缩略图——动态分辨率的标准解法,Qwen-VL 的动态 patch 同属此思路的家族;LLaVA-OneVision:图文视频统一训练。你复现的迷你版做到阶段二即可称为完整 LLaVA。
4. 迷你复现路线(学生算力版,大三下的第一个大项目)
配置:视觉塔用 open_clip 的 ViT-B/16(冻结);LLM 用 Qwen2.5-0.5B-Instruct(0 元档友好);projector 两层 MLP。
阶段一(免费卡可跑):
- 数据:COCO caption 子集 5–10 万条(HF
HuggingFaceM4/COCO或本地裁剪); - 训练:仅 projector,bs 尽量大、epoch 1、lr 1e-3(新模块学习率可以大);
- 验收标准:让模型对没见过的图做 caption,输出应”像话”——如果胡言乱语,对齐没成。
阶段二(租 4090 数小时):
- 数据:LLaVA-Instruct 的一个子集(1–3 万条)或自己蒸馏的领域指令数据(接你导师方向);
- 训练:projector + LLM(LoRA 或全参,0.5B 全参单卡毫无压力),lr 2e-5(LoRA 则 1e-4),epoch 1–2;
- 验收标准:VQA 问答合理、能指图说话(grounding 描述)、无严重幻觉。
数据格式注意:<image>\n{question} 里占位符 expansion 由框架处理——用 LLaMA-Factory 时它的 llava template 已封装(visual_inputs 传图);用 transformers 的 AutoProcessor 系(Qwen2-VL 类)则走 apply_chat_template + image_token。第一次跑通建议直接用 LLaMA-Factory 的多模态配置,第二次再用裸 transformers 自己拼 inputs_embeds——前者学工程,后者学原理,两个都要做。
5. 失败模式速查(复现时的排障表)
| 症状 | 嫌疑人 |
|---|---|
| 阶段一后 caption 全是重复短语 | projector 学习率过大 / 数据太少 / LLM 没冻结住(意外被更新) |
| 阶段二后”失明”(回答与图无关) | 视觉特征没替换进正确位置(占位符 mask bug)/ projector 输出 dtype 错 |
| 图像越精细回答越差 | 分辨率处理不一致(训练 336 推理 224 之类)——预处理必须逐位一致 |
| loss 正常但回答是英文模板腔 | 指令数据语言分布 / LLM 底座太小 |
| 显存爆 | 视觉 token 数 × 序列长——max_seq_len 和图片分辨率先降一档 |
6. 自测题
- 阶段一为什么冻结 LLM?阶段二为什么解冻?(对齐目标稳定 / 学习视觉条件下的行为)
- 576 个视觉 token 从哪来?(336px ÷ 14px patch → 24×24)
- 为什么 CLIP 特征不能直接当词嵌入用?(检索空间 ≠ 词嵌入空间,尺度与语义结构不匹配——projector 是翻译官)
- AnyRes 解决什么?(高分辨率:切块保留细节 + 缩略图保全局)
- LLaVA 的经验教训对科研选择有什么启示?(数据与训练质量 > 架构炫技——“简单基线调强”是顶会认可的路径)
7. 动手作业(大三下里程碑第一段)
- 完成迷你版两阶段训练,repo 里放:训练前(阶段0:未对齐)→ 阶段一 → 阶段二 的同一批图片的回答对比图——这个对比是你理解”对齐”的最好证据,也是面试杀手锏;
- 消融一个变量(选一):阶段一数据量减到 1 万看对齐质量退化;或把 MLP projector 换成单层 Linear 对比;或解冻视觉塔观察是否过拟合;
- 用你的模型跑 8.1 作业里挑的名画——写下它”看画”时的具体错误,这是卷8.5(VLM 看画)的第一手素材;
- 训练日志 + 显存曲线进 README(用 0.4 的四件套)。
图解与案例 · 两阶段管线与”一条样本的一生”
flowchart LR
subgraph S1["阶段一:对齐(只训 projector)"]
I["图像"] --> V["视觉塔 ❄️"] --> P["projector 🔥"] --> M1["caption loss"]
M2["55.8万 caption"] --> M1
end
P --> OUT1["学到了:翻译能力"]
subgraph S2["阶段二:视觉指令微调(projector + LLM 解冻)"]
OUT1 --> D2["指令数据"] --> T2["projector 🔥 + LLM 🔥<br>视觉塔仍 ❄️"]
end
T2 --> OUT2["学到了:看图说话的行为"]案例:一条训练样本的一生
输入:一张 336×336 油画 + 问句”这幅画的流派是什么?”
- 图像过冻结 CLIP → 576 个特征 → projector → 576 个”外语 token”;
- 问句 tokenizer 后约 12 个文本 token;序列中
<image>占位符所在的 576 个位置被视觉嵌入顶替;- 最终序列:
[视觉×576][问句×12][回答×8],整条过一次 causal attention——视觉 token 对 LLM 就是不认识但可以学的外来词;- loss mask:问句部分标签 = −100(不学提问),只有回答的约 8 个 token 计入 loss;
- 阶段一时 LLM 冻结 → 8 个 token 的梯度只流向 projector(学翻译);阶段二解冻 → 梯度涌向整个 LLM(学行为)。
一段话总结:VLM 训练没有新机制,只是”把一批特殊 token 塞进序列 + 规定哪些位置算 loss”。
8. 延伸资源
- LLaVA 原论文 + LLaVA-1.5 论文——连读,看”简单架构如何一步步调强”
- llava 官方 repo(
LLaVA-VL)——llava/train/train.py对照本章逐行读 - LLaMA-Factory 多模态文档——工程捷径
附录 · 完整可运行脚本:llava_mini.py(教学骨架)
依赖:
torch>=2.2 transformers>=4.44 open_clip_torch pillow tqdm。数据:jsonl,每行{"image": "path.jpg", "question": "...", "answer": "..."}。这是教学骨架(跳过 chat template,模板问题见 2.5)——先跑通,再换成 LLaMA-Factory 正式管线。
# llava_mini.py —— 迷你 LLaVA:视觉塔❄️ + projector + Qwen2.5-0.5B
import argparse, json, math, torch, torch.nn as nn
from PIL import Image
from torch.utils.data import Dataset, DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer
import open_clip
class MiniLLaVA(nn.Module):
def __init__(self, device="cuda"):
super().__init__()
self.clip, _, _ = open_clip.create_model_and_transforms("ViT-B-16", pretrained="openai")
self.clip = self.clip.to(device).eval()
for p in self.clip.parameters(): p.requires_grad = False # 视觉塔永久冻结
d_llm = AutoModelForCausalLM.from_config(
__import__("transformers").AutoConfig.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")).config.hidden_size
self.proj = nn.Sequential(nn.Linear(512, d_llm), nn.GELU(), nn.Linear(d_llm, d_llm))
self.llm = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-0.5B-Instruct", torch_dtype=torch.bfloat16).to(device)
self.tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
self.device = device
def vision_tokens(self, pixel): # (B,3,224,224) → 49 个 token
f = self.clip.encode_image(pixel, normalize=False) # (B,512,7,7) 前置投影后
return f.flatten(2).transpose(1, 2) # (B,49,512)
def forward(self, pixel, q_ids, a_ids):
vis = self.proj(self.vision_tokens(pixel).to(torch.bfloat16)) # (B,49,d)
qe = self.llm.get_input_embeddings()(q_ids) # (B,Lq,d)
ae = self.llm.get_input_embeddings()(a_ids) # (B,La,d)
inputs = torch.cat([vis, qe, ae], dim=1)
Lq, La = q_ids.size(1), a_ids.size(1)
labels = torch.cat([torch.full((inputs.size(0), 49 + Lq), -100,
device=inputs.device, dtype=torch.long), a_ids], dim=1)
return self.llm(inputs_embeds=inputs, labels=labels) # HF 内部算 shift loss
class DS(Dataset):
def __init__(self, path, clip_pre, tok, max_q=24, max_a=48):
self.rows = [json.loads(l) for l in open(path, encoding="utf-8")]
self.pre, self.tok, self.mq, self.ma = clip_pre, tok, max_q, max_a
def __len__(self): return len(self.rows)
def __getitem__(self, i):
r = self.rows[i]
img = self.pre(Image.open(r["image"]).convert("RGB"))
q = self.tok(r["question"], add_special_tokens=False).ids[:self.mq]
a = self.tok(r["answer"] + self.tok.eos_token, add_special_tokens=False).ids[:self.ma]
return img, torch.tensor(q), torch.tensor(a)
def collate(batch, pad):
pixel = torch.stack([b[0] for b in batch])
def padto(seqs):
m = max(len(s) for s in seqs)
return torch.stack([torch.cat([s, torch.full((m-len(s),), pad)]) for s in seqs])
return pixel, padto([b[1] for b in batch]), padto([b[2] for b in batch])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--stage", type=int, required=True, choices=[1, 2])
ap.add_argument("--data", required=True); ap.add_argument("--resume")
ap.add_argument("--epochs", type=int, default=1); ap.add_argument("--bs", type=int, default=8)
args = ap.parse_args()
dev = "cuda" if torch.cuda.is_available() else "cpu"
model = MiniLLaVA(dev)
if args.resume: model.proj.load_state_dict(torch.load(args.resume, map_location=dev))
if args.stage == 1: # 对齐:LLM 冻结,只训 projector
for p in model.llm.parameters(): p.requires_grad = False
lr = 1e-3
else: # 行为:projector+LLM 解冻
lr = 2e-5
params = [p for p in model.parameters() if p.requires_grad]
print(f"可训练参数:{sum(p.numel() for p in params)/1e6:.2f}M")
opt = torch.optim.AdamW(params, lr=lr, weight_decay=0.01)
ds = DS(args.data, None, model.tok)
dl = DataLoader(ds, batch_size=args.bs, shuffle=True,
collate_fn=lambda b: collate(b, model.tok.pad_token_id))
model.train()
for ep in range(args.epochs):
for i, (pixel, q, a) in enumerate(dl):
pixel, q, a = pixel.to(dev), q.to(dev), a.to(dev)
out = model(pixel, q, a)
opt.zero_grad(set_to_none=True)
out.loss.backward()
torch.nn.utils.clip_grad_norm_(params, 1.0)
opt.step()
if i % 20 == 0: print(f"ep{ep} step{i} loss={out.loss.item():.3f}")
tag = "stage1.pt" if args.stage == 1 else "final.pt"
torch.save(model.proj.state_dict(), tag)
print("saved →", tag, "(验收:3.2 §5 的三段对比图,训练前后各截 10 例)")
if __name__ == "__main__":
main()