LLaVA 深度剖析:从视觉指令微调到开源多模态助手的完整技术栈
1. LLaVA 的核心洞察
1.1 为什么需要视觉指令微调?
在 LLaVA 出现之前,VLM(视觉语言模型)的训练目标主要是图文匹配(如 CLIP)和图像描述(如 BLIP-2)。这些模型能回答”这张图里有什么”,但无法回答更复杂的问题:
CLIP 能做的: ✓ "这张图是猫还是狗?" (二选一分类) ✓ "给这张图一个描述" (简短描述)
CLIP 不能做的: ✗ "图中这只猫在做什么?" ✗ "根据图表趋势,预测下个季度的增长" ✗ "这张 X 光片有什么异常?" ✗ "逐步解释这道数学题的解法"根本原因:CLIP/BLIP-2 的训练目标是”描述图像”,而不是”根据图像回答问题”。
LLaVA 的核心洞察:把 GPT-4 在语言指令微调上的成功经验,迁移到视觉领域——构建”视觉指令微调”数据集,让 VLM 学会像人类助手一样,根据图像内容回答各种类型的开放性问题。
1.2 GPT-4V 的启发
2023 年 3 月,OpenAI 发布 GPT-4V,展示了多模态模型的强大能力:
GPT-4V 能做的: ✓ 视觉问答("图里有什么?") ✓ 文档理解(OCR + 理解) ✓ 图表分析(解释图表含义) ✓ 视觉推理(逐步分析图像) ✓ 多模态对话(图像 + 文本 → 多轮对话)
开源模型的差距: - 没有开源模型能接近 GPT-4V 的能力 - BLIP-2 等模型只能做简单的图像描述 - 缺乏高质量的视觉指令数据LLaVA 的目标是用开源数据和模型,复现 GPT-4V 的核心能力。
1.3 LLaVA 的三个核心贡献
贡献 1: 视觉指令微调范式 把语言指令微调的思路迁移到视觉领域 训练目标: 根据图像内容回答问题
贡献 2: LLaVA-Instruct 数据集构建方法 用 GPT-4 自动生成高质量的视觉指令数据 不需要昂贵的人工标注
贡献 3: 简洁有效的架构设计 CLIP 视觉编码器 + MLP 投影 + Vicuna LLM 参数量小,数据效率高,效果好1.4 论文信息
LLaVA (2023): 论文: "Visual Instruction Tuning" 作者: Haotian Liu, Chunyuan Li, Qingyang Wu, Yong Jae Lee 单位: UW-Madison, MBZUAI 发表于: NeurIPS 2023 (Spotlight)
LLaVA-1.5 (2023): 论文: "Improved Baselines with Visual Instruction Tuning" 作者: Haotian Liu, Chunyuan Li, Yuheng Li, Yong Jae Lee 发表于: NeurIPS 2023 Workshop
LLaVA-1.6 (2024): 改进: 高分辨率、AnyRes、Llava-RLHF1.5 一句话概括 LLaVA
LLaVA 的核心是”视觉指令微调”——用 GPT-4 生成高质量的图像-指令-回答三元组数据,然后用这些数据微调一个 CLIP + LLM 的简单架构,使模型学会根据图像内容回答各种开放性视觉问题。
2. 架构设计:简单但有效
2.1 整体架构
┌─────────────────────────────────────────────────────────────┐│ ││ [图像] → CLIP ViT-L/14 → [IMG patch tokens] ││ (冻结 ❄️) ↓ ││ MLP 投影器 ││ (训练 🔥) ││ ↓ ││ [文本] → Vicuna-7B LLM → [文本 tokens] ││ (训练 🔥) ↓ ││ 自回归生成 ││ ↓ ││ [回答文本] ││ │└─────────────────────────────────────────────────────────────┘
LLaVA 架构的核心简洁性: 视觉编码器: CLIP ViT-L/14(冻结,用预训练权重) 模态对齐: 线性投影 → MLP(两层,带 GELU) LLM 骨干: Vicuna-7B(指令微调后的 Llama-2)2.2 LLaVA vs BLIP-2 的架构对比
"""LLaVA 和 BLIP-2 的核心区别。
BLIP-2: 复杂的多阶段桥接器 CLIP ViT → Q-Former (12 层 attention, 32 queries) → LLM 问题: Q-Former 训练困难,压缩率固定
LLaVA: 简洁的两层 MLP CLIP ViT → MLP (线性→GELU→线性) → LLM 优点: 参数量小,训练稳定,效果好"""| 维度 | BLIP-2 | LLaVA |
|---|---|---|
| 模态桥接器 | Q-Former (12 层, 768M) | MLP (2 层, ~20M) |
| 训练难度 | 高(Q-Former 训练不稳定) | 低(简单的两层 MLP) |
| 参数量 | ~3B (Q-Former + LLM) | ~7B (Vicuna-7B) |
| 数据效率 | 中等 | 高(LLaVA-1.5 用 600K 数据超越) |
| 推理速度 | 慢(Q-Former 额外计算) | 快(无额外开销) |
| 开源程度 | 部分开源 | 完全开源 |
2.3 特殊 Token 设计
LLaVA 在 Vicuna 的 tokenizer 中添加了两个特殊 token 来标记图像位置:
输入格式:[SYSTEM] You are a helpful assistant.[USER] [IMG][IMG][IMG]...[IMG] Describe this image. [ASSISTANT]
其中 [IMG] token 位置会被替换为视觉 token 序列。
注意: LLaVA 原始版本使用 4 个 [IMG] token(对应 4 个 grid patches)LLaVA-1.5 改为 [IMG]...[IMG] 范围标记,由投影器输出长度决定class LLaVATokenizer: """ LLaVA 的 tokenizer 配置。 """ def __init__(self, base_tokenizer="lmsys/vicuna-7b-v1.5"): from transformers import AutoTokenizer self.tokenizer = AutoTokenizer.from_pretrained(base_tokenizer)
# 添加图像标记 token special_tokens = ["[ IMG ]", "[ / IMG ]"] num_new = self.tokenizer.add_tokens(special_tokens, special_tokens=True) self.img_start_id = self.tokenizer("[ IMG ]", add_special_tokens=False).input_ids[0] self.img_end_id = self.tokenizer("[ / IMG ]", add_special_tokens=False).input_ids[0]
# Vicuna 的系统提示 self.system_prompt = ( "A chat between a curious human and an artificial intelligence assistant. " "The assistant gives helpful, detailed, and polite answers to the user's questions." )
def build_input(self, instruction, image_count=1): """构建多模态输入。""" img_tokens = " [ IMG ] " + " [ IMG ] " * (image_count - 1) + " [ / IMG ] " text = f"{self.system_prompt} [ USER ] {img_tokens} {instruction} [ ASSISTANT ]" return text3. 数据集构建:LLaVA-Instruct 的核心创新
3.1 为什么需要自动生成数据?
LLaVA 最大的创新不是架构,而是数据集构建方法:
传统方法(人工标注): - 人工标注图像描述: $0.05-0.50/张 - 人工标注 VQA: $0.10-1.00/题 - 人工标注复杂推理: $1.00-5.00/题 - 成本极高,规模受限
LLaVA 方法(GPT-4 自动生成): - GPT-4 是强大的语言模型 - 可以生成高质量的文本描述 - 可以生成合理的问答对 - 成本低,可扩展到百万级3.2 GPT-4V 的不可用性困境
2023 年 3 月 GPT-4V 发布时,OpenAI 尚未开放 GPT-4V 的 API,也没有开源的多模态 GPT-4 模型。
LLaVA 的解决方案:用纯文本的 GPT-4 来生成数据!
核心思路: - GPT-4 (纯文本) 无法直接"看图" - 但 GPT-4 可以理解图像的文本描述 - 如果先让 GPT-4 描述一张图,再让它根据描述生成问答 - 那么生成的问题就是"基于图像描述的"
这个方法巧妙地绕过了 GPT-4V 不可用的问题。3.3 数据生成流程(Stage-by-Step)
LLaVA-Instruct-150K 数据集的生成分为三个阶段:
┌─────────────────────────────────────────────────────────────┐│ Stage 1: 图像描述 (Image Captioning) ││ 输入: 图像 ││ 方法: BLIP-2 生成初始描述 + GPT-4 改进 ││ 输出: 详细、准确的图像描述 │├─────────────────────────────────────────────────────────────┤│ Stage 2: 指令问题生成 (Instruction Generation) ││ 输入: 图像描述 ││ 方法: GPT-4 根据描述生成多样化的指令问题 ││ 输出: 各种类型的问题(描述/推理/计算/复杂) │├─────────────────────────────────────────────────────────────┤│ Stage 3: 回答生成 (Response Generation) ││ 输入: 图像描述 + 指令问题 ││ 方法: GPT-4 根据描述和问题的组合生成回答 ││ 输出: 对应每个问题的详细回答 │└─────────────────────────────────────────────────────────────┘3.4 Stage 1:图像描述
def generate_image_caption(image, blip_model, gpt4_api): """ Stage 1: 生成详细的图像描述。
步骤: 1. 用 BLIP-2 生成初始 caption 2. 用 GPT-4 改进 caption """ # 1) BLIP-2 生成初始描述 caption_raw = blip_model.generate_caption(image)
# 2) GPT-4 改进描述 prompt = f"""Based on the following image description, generate a more detailed and accurate description:
Raw caption: {caption_raw}
Please provide a more detailed description that includes:- Main objects and their locations- Actions and interactions- Background context- Style and mood (if applicable)- Any text or labels visible in the image
Output only the improved description:"""
improved_caption = gpt4_api.complete(prompt) return improved_caption
def caption_quality_filter(captions, min_length=50, max_repeat=0.3): """ 过滤低质量描述。 """ filtered = [] for cap in captions: # 长度检查 if len(cap.split()) < min_length: continue
# 重复检查(避免 GPT-4 的循环输出) words = cap.lower().split() if len(words) > 0: unique_ratio = len(set(words)) / len(words) if unique_ratio < (1 - max_repeat): continue
filtered.append(cap)
return filtered3.5 Stage 2:指令问题生成
GPT-4 根据图像描述,生成四种类型的指令问题:
def generate_instructions(caption, gpt4_api): """ Stage 2: 根据描述生成多样化的指令问题。 GPT-4 根据描述,生成四种不同类型的问题。 """ prompt = f"""You are an AI assistant. Generate diverse question-answer pairs based on the following image description.
Image description: {caption}
Generate questions from FOUR different categories:
1. CONVERSATIONAL (简单对话型): - 自然、口语化的问题 - 例: "这张图片里有什么?" "这是什么地方?"
2. DETAILED DESCRIPTION (详细描述型): - 需要详细描述的问题 - 例: "请详细描述图中的人物穿着" "描述这个场景的氛围"
3. COMPLEX REASONING (复杂推理型): - 需要多步推理的问题 - 例: "图中这个人在做什么,可能会发生什么?"
4. SPATIAL RELATIONSHIPS (空间关系型): - 关于物体位置和关系的问题 - 例: "红球和蓝球的位置关系是什么?"
For EACH category, generate 1-2 questions.
Output format:CONVERSATIONAL:Q: ...DETAIL:Q: ...REASONING:Q: ...SPATIAL:Q: ..."""
response = gpt4_api.complete(prompt) questions = parse_questions(response) return questions
def parse_questions(gpt4_output): """ 解析 GPT-4 的输出,提取问题列表。 """ questions = [] current_category = None
for line in gpt4_output.split("\n"): line = line.strip() if not line: continue
if line.startswith("CONVERSATIONAL"): current_category = "conversational" elif line.startswith("DETAIL"): current_category = "detail" elif line.startswith("REASONING"): current_category = "reasoning" elif line.startswith("SPATIAL"): current_category = "spatial" elif line.startswith("Q:"): question = line[2:].strip() if current_category: questions.append({"type": current_category, "question": question})
return questions3.6 Stage 3:回答生成
def generate_response(caption, question, gpt4_api): """ Stage 3: 根据描述和问题,生成详细回答。 """ prompt = f"""You are a helpful AI assistant. Answer the following question based on the image description.
Image description: {caption}
Question: {question}
Please provide a detailed and accurate answer based on the description above.If the description doesn't contain enough information to answer, say so honestly.Be specific and informative."""
response = gpt4_api.complete(prompt) return response
def build_instruction_dataset(images, blip_model, gpt4_api): """ 完整的数据集构建流程。 """ dataset = []
for image in tqdm(images): # Stage 1: 图像描述 caption = generate_image_caption(image, blip_model, gpt4_api) if not caption: continue
# Stage 2: 生成问题 questions = generate_instructions(caption, gpt4_api)
# Stage 3: 生成回答 for q_data in questions: question = q_data["question"] answer = generate_response(caption, question, gpt4_api)
dataset.append({ "image": image, "caption": caption, "instruction": question, "response": answer, "type": q_data["type"], })
return dataset3.7 LLaVA-Instruct-150K 的数据构成
def dataset_statistics(): """ LLaVA-Instruct-150K 数据集统计。 """ stats = { "conversational": { "count": 30_000, "examples": "简单问答,如'图里有什么'", "来源": "GPT-4 自动生成", }, "detailed_description": { "count": 30_000, "examples": "详细描述,如'请描述图中的人物'", "来源": "GPT-4 自动生成", }, "complex_reasoning": { "count": 30_000, "examples": "推理问题,如'图中的因果关系'", "来源": "GPT-4 自动生成", }, "spatial_relationships": { "count": 30_000, "examples": "空间问题,如'物体相对位置'", "来源": "GPT-4 自动生成", }, "llava_lcs_558k": { "count": 558_000, "examples": "图像描述对(CC3M 筛选)", "来源": "BLIP-2 caption + GPT-4 改进", }, } return stats3.8 数据质量控制
def quality_control(dataset): """ LLaVA 的数据质量控制策略。 """ strategies = { "长度过滤": { "caption_min_len": 10, # 描述至少 10 词 "response_min_len": 10, # 回答至少 10 词 "instruction_max_len": 512, # 指令最多 512 词 }, "重复检测": { "ngram_repeat_threshold": 0.3, # 唯一词比例 > 70% "copy_from_caption_ratio": 0.8, # 回答不能 80% 直接复制 caption }, "一致性验证": { "use_gpt4_validator": True, # 用 GPT-4 检查一致性 "consistency_threshold": 0.7, # 一致性得分 > 0.7 }, "GPT-4 改进": { "improve_captions": True, # 改进 BLIP-2 的 caption "gpt4_temperature": 0.0, # 生成用 temperature=0 "gpt4_top_p": 1.0, }, } return strategies4. 两阶段训练:模态对齐 + 指令微调
4.1 训练流程概览
┌──────────────────────────────────────────────────────────────┐│ 阶段 1: 模态预训练 (Modality Alignment) ││ ││ 数据: LLaVA-LCS-558K (558K 图像-描述对) ││ 目标: 让 MLP 学会把 CLIP 视觉特征映射到 LLM 空间 ││ 方法: 语言建模损失(给定图像,预测描述) ││ ││ 视觉编码器: 冻结 ❄️ ││ LLM: 冻结 ❄️ ││ MLP: 训练 🔥 ← 唯一要训练的部分! ││ ││ 训练: ~1 epoch, batch_size=128, lr=1e-3 │├──────────────────────────────────────────────────────────────┤│ 阶段 2: 视觉指令微调 (Visual Instruction Tuning) ││ ││ 数据: LLaVA-Instruct-150K (150K 指令问答对) ││ 目标: 让模型学会根据图像内容回答各种问题 ││ 方法: 语言建模损失(给定图像+问题,预测回答) ││ ││ 视觉编码器: 冻结 ❄️ ││ LLM: 解冻 🔥 (Vicuna-7B 全量或 LoRA) ││ MLP: 训练 🔥 ││ ││ 训练: ~3 epochs, batch_size=32, lr=2e-5 │└──────────────────────────────────────────────────────────────┘4.2 阶段 1:模态预训练
def pretrain_stage1(projector, vision_encoder, llm, dataset): """ 阶段 1: 模态预训练。
目标: 训练投影器,让视觉特征和文本特征对齐。 数据: LLaVA-LCS-558K (CC3M 筛选后的图文对) """ # 冻结 LLM 和视觉编码器 vision_encoder.requires_grad_(False) for param in llm.parameters(): param.requires_grad = False
# 只训练投影器 optimizer = torch.optim.AdamW(projector.parameters(), lr=1e-3, weight_decay=0.05)
dataloader = DataLoader(dataset, batch_size=128, shuffle=True)
for epoch in range(1): for batch in tqdm(dataloader): images, captions = batch["image"], batch["caption"]
# 1) 视觉编码 (冻结) with torch.no_grad(): vision_output = vision_encoder(images) vision_embeds = vision_output.last_hidden_state[:, 1:, :] # 去掉 [CLS]
# 2) 投影 visual_tokens = projector(vision_embeds) # (B, N_v, D_l)
# 3) 构建输入 input_texts = [f"[ IMG ] [ / IMG ] {cap}" for cap in captions] input_ids = tokenizer(input_texts, return_tensors="pt", padding=True) input_ids = {k: v.to(device) for k, v in input_ids.items()}
# 4) 在 [IMG] 位置插入视觉 token inputs_embeds = llm.get_input_embeddings()(input_ids["input_ids"])
for b in range(inputs_embeds.shape[0]): img_pos = (input_ids["input_ids"][b] == img_start_id).nonzero(as_tuple=True)[0] end_pos = (input_ids["input_ids"][b] == img_end_id).nonzero(as_tuple=True)[0] if len(img_pos) == 0: continue
sp = img_pos[0].item() ep = end_pos[0].item() num_v = ep - sp - 1
before = inputs_embeds[b, :sp, :] after = inputs_embeds[b, ep + 1:, :] v_tok = visual_tokens[b, :num_v, :]
inputs_embeds[b] = torch.cat([before, v_tok, after], dim=0)
# 5) LLM 前向 outputs = llm(inputs_embeds=inputs_embeds, attention_mask=input_ids["attention_mask"], labels=input_ids["input_ids"]) loss = outputs.loss
# 6) 反向传播 optimizer.zero_grad() loss.backward() optimizer.step()
return projector4.3 阶段 2:视觉指令微调
def finetune_stage2(projector, vision_encoder, llm, dataset): """ 阶段 2: 视觉指令微调。
目标: 微调 LLM + 投影器,让模型学会回答视觉问题。 数据: LLaVA-Instruct-150K (150K 指令问答对) 方法: 全量微调 Vicuna-7B 或 LoRA 微调 """ # 解冻 LLM(使用 LoRA 节省显存) from peft import LoraConfig, get_peft_model
lora_config = LoraConfig( r=64, lora_alpha=16, target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) llm = get_peft_model(llm, lora_config)
# 视觉编码器仍然冻结 vision_encoder.requires_grad_(False)
optimizer = torch.optim.AdamW( list(projector.parameters()) + list(llm.parameters()), lr=2e-5, weight_decay=0.0, )
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)
for epoch in range(3): for batch in tqdm(dataloader): images, instructions, responses = ( batch["image"], batch["instruction"], batch["response"] )
# 构建对话格式 # [SYSTEM] ... [USER] [IMG] ... [IMG] {instruction} [ASSISTANT] {response} prompts = [ f"{SYSTEM_PROMPT} [ USER ] [ IMG ] [ / IMG ] {inst} [ ASSISTANT ]" for inst in instructions ]
# Tokenize input_enc = tokenizer(prompts, return_tensors="pt", padding=True) label_enc = tokenizer(responses, return_tensors="pt", padding=True, truncation=True, max_length=512)
# 视觉编码 + 投影 vision_output = vision_encoder(images) vision_embeds = vision_output.last_hidden_state[:, 1:, :] visual_tokens = projector(vision_embeds)
# 嵌入 inputs_embeds = llm.get_input_embeddings()(input_enc["input_ids"])
# 插入视觉 token for b in range(inputs_embeds.shape[0]): sp_pos = (input_enc["input_ids"][b] == img_start_id).nonzero(as_tuple=True)[0] ep_pos = (input_enc["input_ids"][b] == img_end_id).nonzero(as_tuple=True)[0]
if len(sp_pos) == 0: continue
sp = sp_pos[0].item() ep = ep_pos[0].item() nv = ep - sp - 1
before = inputs_embeds[b, :sp, :] after = inputs_embeds[b, ep + 1:, :] v_tok = visual_tokens[b, :nv, :]
inputs_embeds[b] = torch.cat([before, v_tok, after], dim=0)
# LLM 前向 outputs = llm( inputs_embeds=inputs_embeds, attention_mask=input_enc["attention_mask"], labels=label_enc["input_ids"], ) loss = outputs.loss
optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(llm.parameters(), max_norm=1.0) optimizer.step()
return projector, llm4.4 训练超参数对比
def training_hyperparams(): """ LLaVA 两个训练阶段的超参数对比。 """ return { "Stage 1 - 模态预训练": { "batch_size": 128, "learning_rate": 1e-3, "weight_decay": 0.05, "epochs": 1, "LR_schedule": "constant", "视觉编码器": "冻结", "LLM": "冻结", "投影器": "训练", "数据量": "558K (LLaVA-LCS-558K)", }, "Stage 2 - 指令微调": { "batch_size": 32, "learning_rate": 2e-5, "weight_decay": 0.0, "epochs": 3, "LR_schedule": "cosine", "视觉编码器": "冻结", "LLM": "解冻 (LoRA r=64)", "投影器": "训练", "数据量": "150K (LLaVA-Instruct-150K)", }, }5. LLaVA-1.5:关键改进
5.1 LLaVA-1.5 的三大改进
LLaVA-1.5 在 LLaVA 基础上做了三个关键改进:
改进 1: MLP 替换线性投影 - LLaVA: 单层线性投影 W ∈ ℝ^{D_l×D_v} - LLaVA-1.5: 两层 MLP (Linear → GELU → Linear) - 效果: +3.5% VQAv2 提升
改进 2: 336px 高分辨率 - LLaVA: 224×224, 196 个 patches - LLaVA-1.5: 336×336, 576 个 patches (2.25 倍像素) - 效果: 保留更多细节,尤其是文档和图表
改进 3: 大规模高质量训练数据 - LLaVA-1.5: 665K 视觉指令数据 + 3M 语言数据 - 包括: OCR 数据、图表数据、GQA、VQAv2 等 - 效果: 在多个 benchmark 上达到 SOTA5.2 MLP 投影的消融实验
def ablation_mlp_projector(): """ LLaVA-1.5 论文中的投影器消融实验。 在 ScienceQA 数据集上的准确率。 """ results = { "线性投影 (LLaVA)": 66.7, "MLP 投影 (LLaVA-1.5)": 70.4, # +3.7% "MLP + LayerNorm": 71.2, "MLP + GELU": 70.4, "MLP + SiLU": 70.3, } return results # 结论: MLP 投影比线性投影高 3.7%,LayerNorm 额外 +0.8%5.3 分辨率的消融实验
def ablation_resolution(): """ 分辨率对性能的影响。 """ results = { "ViT-L/14 @ 224px (LLaVA)": 66.7, "ViT-L/14 @ 336px (LLaVA-1.5)": 70.4, # +3.7% "ViT-L/14 @ 448px (更大分辨率)": 72.1, # +5.4% "EVA-CLIP ViT-L/14 @ 336px": 73.8, # +7.1% } return results # 结论: 分辨率越高越好,但计算量也增加5.4 训练数据混合
def llava15_data_mixture(): """ LLaVA-1.5 的训练数据混合。 总计约 6M 样本。 """ mixture = { "视觉语言数据 (总计 ~1.3M)": { "LLaVA-LCS-558K": { "数量": 558_000, "类型": "图像描述对 (预训练)", "来源": "CC3M 筛选 + GPT-4 改进", }, "LLaVA-Instruct-665K": { "数量": 665_000, "类型": "视觉指令问答 (微调)", "来源": "GPT-4 生成", }, }, "纯语言指令数据 (总计 ~4.6M)": { "LLaVA-Instruct-150K": { "数量": 150_000, "类型": "GPT-4 生成的视觉问答", }, "ShareGPT": { "数量": 900_000, "类型": "人类对话数据 (Vicuna 来源)", }, "Alpaca": { "数量": 3_000_000, "类型": "语言指令数据 (GPT-4 生成)", }, }, "特定能力数据 (总计 ~330K)": { "OCR/文档": {"数量": 200_000, "来源": "OCR 数据集"}, "图表": {"数量": 100_000, "来源": "ChartQA, DVQA"}, "GQA": {"数量": 80_000, "来源": "VQA 推理"}, "DocVQA": {"数量": 50_000, "来源": "文档问答"}, }, } return mixture6. LLaVA 版本演进
6.1 版本对比总览
| 版本 | 发布时间 | 视觉编码器 | LLM | 投影器 | 分辨率 | 训练数据 | 特点 |
|---|---|---|---|---|---|---|---|
| LLaVA | 2023.04 | CLIP ViT-L/14 | Vicuna-7B | 线性 | 224px | 558K+150K | 开山之作 |
| LLaVA-1.0 | 2023.07 | CLIP ViT-L/14 | Vicuna-13B | 线性 | 224px | 558K+150K | 更大 LLM |
| LLaVA-1.5 | 2023.09 | CLIP ViT-L/14-336 | Vicuna-7B/13B | MLP | 336px | 1.3M+3M | 三大改进 |
| LLaVA-1.6 | 2024.02 | CLIP ViT-L/14-336 | Vicuna-7B/13B | MLP | 336px/672px | 1.3M+3M | AnyRes |
| LLaVA-NeXT | 2024.05 | SIGLIP-So400m | Llama-3 8B | MLP | 动态 | 2M | 新骨干 |
6.2 AnyRes:LLaVA-1.6 的高分辨率策略
LLaVA-1.6 引入了 AnyRes 策略来处理高分辨率图像:
传统方法 (直接放大): 224×224 → 448×448 (4倍像素) 问题: 视觉 token 数量爆炸 (196 → 784)
AnyRes 策略: 原始图像: 任意分辨率 下采样到低分辨率: (H', W') → 编码得到全局特征 切分成子图: (h, w) → 分别编码得到局部特征 全局 + 局部特征拼接 → 丰富的高分辨率表示
示例 (672px, 4×4 grid): 原始: 672×672 下采样到: 336×336 → 全局特征 (576 tokens) 切分成 4×4 = 16 个子图 → 每个 168×168 → 各 144 tokens 总 tokens: 576 + 16×144 = 2880 (可接受)class AnyResStrategy: """ AnyRes 高分辨率策略。 """ def __init__(self, base_resolution=336, grid_size=2): self.base_resolution = base_resolution self.grid_size = grid_size
def split_image(self, image): """ 把高分辨率图像切分成子图。 """ W, H = image.size patch_size = self.base_resolution // self.grid_size # 168
patches = [] for y in range(0, H, patch_size): for x in range(0, W, patch_size): patch = image.crop((x, y, x + patch_size, y + patch_size)) patches.append(patch)
# 全局缩略图 global_img = image.resize((self.base_resolution, self.base_resolution))
return global_img, patches
def encode_with_anyres(self, image, vision_encoder, projector): """ AnyRes 编码。 """ global_img, patches = self.split_image(image)
# 编码全局 global_feat = vision_encoder(global_img) # (1, N+1, D) global_feat = projector(global_feat[:, 1:, :]) # (1, N, D_l)
# 编码每个 patch patch_feats = [] for patch in patches: feat = vision_encoder(patch) feat = projector(feat[:, 1:, :]) patch_feats.append(feat)
# 拼接全局 + 局部 all_feats = torch.cat([global_feat] + patch_feats, dim=1) # (1, N*(grid+1), D_l) return all_feats6.3 LLaVA-NeXT(后续版本)
def llava_next_features(): """ LLaVA-NeXT 的关键特性。 """ features = { "视觉编码器升级": "SIGLIP-So400m (比 CLIP 更强)", "LLM 升级": "Llama-3 8B (比 Vicuna 更强)", "分辨率": "动态分辨率,支持任意宽高比", "训练数据": "~2M 高质量样本", "关键创新": "Mistral-7B 对比、动态数据过滤", "性能提升": { "MMMU": "LLaVA-1.5 7B: 36.7% → LLaVA-NeXT: 42.1%", "VQAv2": "LLaVA-1.5 7B: 83.1% → LLaVA-NeXT: 85.4%", }, } return features7. 与 GPT-4V 的对比
7.1 能力对比
def gpt4v_comparison(): """ LLaVA-1.5 vs GPT-4V(2023) 能力对比。 数据来源: 2023-2024 公开评测。 """ comparison = { "通用视觉问答": { "GPT-4V": "★★★★★ (接近人类水平)", "LLaVA-1.5": "★★★★ (较好,但有差距)", "差距原因": "GPT-4V 有更大规模的预训练", }, "文档/OCR 理解": { "GPT-4V": "★★★★★ (极强)", "LLaVA-1.5": "★★★ (需额外数据)", "差距原因": "LLaVA-1.5 加入 OCR 数据后缩小差距", }, "图表分析": { "GPT-4V": "★★★★★ (多步推理强)", "LLaVA-1.5": "★★★ (基础图表可以)", "差距原因": "复杂推理能力差距", }, "空间推理": { "GPT-4V": "★★★ (一般)", "LLaVA-1.5": "★★ (较弱)", "差距原因": "两者在空间关系上都有局限", }, "开放世界泛化": { "GPT-4V": "★★★★★ (极强)", "LLaVA-1.5": "★★★★ (较好)", "差距原因": "GPT-4V 数据规模和多样性更大", }, "代码生成": { "GPT-4V": "★★★★★ (能生成 matplotlib 等)", "LLaVA-1.5": "★★ (几乎没有)", "差距原因": "LLaVA-1.5 没有相关训练数据", }, } return comparison7.2 为什么开源模型与 GPT-4V 有差距?
def gap_analysis(): """ 开源 VLM 与 GPT-4V 差距的分析。 """ gaps = { "预训练数据": { "GPT-4V": "数万亿图文对 + 内部数据", "LLaVA-1.5": "CC3M/LAION ~600K 对外公开", "影响": "数据量差 100-1000 倍", }, "模型规模": { "GPT-4V": "GPT-4 (估计 1.8T 参数)", "LLaVA-1.5": "Vicuna-7B + CLIP ViT-L = ~8B", "影响": "模型规模差 200 倍", }, "训练计算": { "GPT-4V": "数百万 GPU 小时", "LLaVA-1.5": "数十 GPU 小时 (开源可复现)", "影响": "训练成本不可比", }, "对齐数据": { "GPT-4V": "大量人类标注的偏好数据", "LLaVA-1.5": "GPT-4 生成数据", "影响": "GPT-4V 的回答质量更高", }, } return gaps8. 完整实现:LLaVA-1.5 架构
import torchimport torch.nn as nnimport torch.nn.functional as Ffrom dataclasses import dataclass
@dataclassclass LLaVA15Config: """LLaVA-1.5 配置。""" vision_model: str = "openai/clip-vit-large-patch14-336" llm_model: str = "lmsys/vicuna-7b-v1.5" vision_hidden_size: int = 1024 # CLIP ViT-L: 1024 llm_hidden_size: int = 4096 # Vicuna-7B: 4096 image_size: int = 336 patch_size: int = 14
class LLaVA15MLPProjector(nn.Module): """ LLaVA-1.5 的 MLP 投影器。 比 LLaVA 的单层线性投影效果更好。 """ def __init__(self, vision_dim, llm_dim): super().__init__() self.proj = nn.Sequential( nn.Linear(vision_dim, llm_dim), nn.GELU(), nn.Linear(llm_dim, llm_dim), nn.LayerNorm(llm_dim), )
def forward(self, x): return self.proj(x)
class LLaVA15(nn.Module): """ LLaVA-1.5 完整架构。 论文: "Improved Baselines with Visual Instruction Tuning" """
def __init__(self, config: LLaVA15Config): super().__init__() self.config = config
# 1) 视觉编码器 (冻结) from transformers import AutoModel, AutoImageProcessor self.vision_encoder = AutoModel.from_pretrained(config.vision_model) self.image_processor = AutoImageProcessor.from_pretrained(config.vision_model) self.vision_encoder.requires_grad_(False)
# 2) MLP 投影器 (训练) self.mlp_projector = LLaVA15MLPProjector( config.vision_hidden_size, config.llm_hidden_size, )
# 3) LLM (训练) from transformers import AutoModelForCausalLM, AutoTokenizer self.llm = AutoModelForCausalLM.from_pretrained( config.llm_model, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True, )
self.tokenizer = AutoTokenizer.from_pretrained( config.llm_model, trust_remote_code=True, )
# 4) 特殊 token special_tokens = ["[ IMG ]", "[ / IMG ]"] self.tokenizer.add_tokens(special_tokens, special_tokens=True) self.img_start_id = self.tokenizer("[ IMG ]", add_special_tokens=False).input_ids[0] self.img_end_id = self.tokenizer("[ / IMG ]", add_special_tokens=False).input_ids[0] self.llm.resize_token_embeddings(len(self.tokenizer))
# 5) 系统提示 self.system_prompt = ( "A chat between a curious human and an artificial intelligence assistant. " "The assistant gives helpful, detailed, and polite answers to the user's questions." )
def encode_images(self, images): """ 编码图像,返回投影后的视觉 tokens。 """ if not isinstance(images, torch.Tensor): inputs = self.image_processor(images, return_tensors="pt") else: inputs = {"pixel_values": images}
inputs = {k: v.to(self.llm.device) for k, v in inputs.items()}
with torch.no_grad(): outputs = self.vision_encoder(**inputs) image_embeds = outputs.last_hidden_state # (B, N+1, D_v)
# 去掉 [CLS],投影到 LLM 空间 visual_tokens = image_embeds[:, 1:, :] # (B, N, D_v) visual_tokens = self.mlp_projector(visual_tokens) # (B, N, D_l)
return visual_tokens
def build_inputs(self, images, instruction, response=None): """ 构建多模态输入。 """ if isinstance(instruction, str): instruction = [instruction] if not isinstance(images, list): images = [images] * len(instruction)
# 构建 prompt prompts = [] for instr in instruction: if response is not None: prompt = ( f"{self.system_prompt} [ USER ] [ IMG ] [ / IMG ] " f"{instr} [ ASSISTANT ] {response}" ) else: prompt = ( f"{self.system_prompt} [ USER ] [ IMG ] [ / IMG ] " f"{instr} [ ASSISTANT ]" ) prompts.append(prompt)
return images, prompts
def forward(self, images, instruction, response=None, labels=None): """ 完整前向传播。 """ # 编码图像 visual_tokens = self.encode_images(images) # (B, N, D_l)
# 构建输入 images, prompts = self.build_inputs(images, instruction, response)
# Tokenize input_enc = self.tokenizer( prompts, return_tensors="pt", padding=True, truncation=True, max_length=2048, ).to(self.llm.device)
# 文本 embedding inputs_embeds = self.llm.get_input_embeddings()(input_enc["input_ids"])
# 插入视觉 token B = inputs_embeds.shape[0] device = inputs_embeds.device
for b in range(B): sp_pos = (input_enc["input_ids"][b] == self.img_start_id).nonzero(as_tuple=True)[0] ep_pos = (input_enc["input_ids"][b] == self.img_end_id).nonzero(as_tuple=True)[0]
if len(sp_pos) == 0: continue
sp = sp_pos[0].item() ep = ep_pos[0].item() num_v = ep - sp - 1
before = inputs_embeds[b, :sp, :] after = inputs_embeds[b, ep + 1:, :] v_tok = visual_tokens[b, :num_v, :]
inputs_embeds[b] = torch.cat([before, v_tok, after], dim=0)
# 处理 attention mask 和 labels 的长度变化 T = inputs_embeds.shape[1] new_mask = torch.ones(B, T, device=device) new_mask[:, :input_enc["attention_mask"].shape[1]] = input_enc["attention_mask"]
if response is not None and labels is None: # 训练模式:构建 labels new_labels = input_enc["input_ids"].clone() # [USER] 之后的内容是回答,需要计算 loss # 简化:所有非 [IMG] 的 token 都计算 loss new_labels[new_labels == self.img_start_id] = -100 new_labels[new_labels == self.img_end_id] = -100 else: new_labels = labels
# LLM 前向 outputs = self.llm( inputs_embeds=inputs_embeds, attention_mask=new_mask, labels=new_labels, )
return outputs
@torch.no_grad() def generate(self, images, instruction, max_new_tokens=512, temperature=0.7): """ 生成回答。 """ self.eval()
if isinstance(instruction, str): instruction = [instruction] if not isinstance(images, list): images = [images] * len(instruction)
B = len(instruction)
# 编码图像 visual_tokens = self.encode_images(images)
# 构建输入(不带回答) images_list, prompts = self.build_inputs(images, instruction, response=None)
# Tokenize input_enc = self.tokenizer( prompts, return_tensors="pt", padding=True, truncation=True, max_length=2048, ).to(self.llm.device)
# 嵌入 + 插入视觉 token inputs_embeds = self.llm.get_input_embeddings()(input_enc["input_ids"])
for b in range(B): sp_pos = (input_enc["input_ids"][b] == self.img_start_id).nonzero(as_tuple=True)[0] ep_pos = (input_enc["input_ids"][b] == self.img_end_id).nonzero(as_tuple=True)[0]
if len(sp_pos) == 0: continue
sp = sp_pos[0].item() ep = ep_pos[0].item() num_v = ep - sp - 1
before = inputs_embeds[b, :sp, :] after = inputs_embeds[b, ep + 1:, :] v_tok = visual_tokens[b, :num_v, :]
inputs_embeds[b] = torch.cat([before, v_tok, after], dim=0)
# 生成 outputs = self.llm.generate( inputs_embeds=inputs_embeds, max_new_tokens=max_new_tokens, temperature=temperature, do_sample=temperature > 0, pad_token_id=self.tokenizer.pad_token_id, )
responses = self.tokenizer.batch_decode(outputs, skip_special_tokens=True) return responses9. 微调实践:LoRA 高效训练
def finetune_llava_lora(llava_model, dataset_path, output_dir): """ 用 LoRA 高效微调 LLaVA-1.5。 """ from peft import LoraConfig, get_peft_model
# 只微调 LLM 部分(投影器全量训练) llava_model.llm = get_peft_model( llava_model.llm, LoraConfig( r=64, lora_alpha=16, target_modules=["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) )
# 投影器全量训练 optimizer = torch.optim.AdamW( list(llava_model.mlp_projector.parameters()) + list(llava_model.llm.parameters()), lr=2e-5, weight_decay=0.0, )
# 数据 dataset = LLaVADataset(dataset_path) dataloader = DataLoader(dataset, batch_size=8, shuffle=True, num_workers=4)
# 训练 for epoch in range(3): for batch in tqdm(dataloader): outputs = llava_model( images=batch["image"].to(device), instruction=batch["instruction"], response=batch["response"], )
loss = outputs.loss loss.backward()
torch.nn.utils.clip_grad_norm_(llava_model.parameters(), max_norm=1.0) optimizer.step() optimizer.zero_grad()
# 保存 llava_model.save_pretrained(f"{output_dir}/epoch_{epoch}")10. LLaVA 的影响与遗产
10.1 LLaVA 对开源多模态生态的贡献
def llava_legacy(): """ LLaVA 对开源社区的影响。 """ impact = { "视觉指令微调范式": { "影响": "开创了用 GPT-4 生成多模态指令数据的范式", "后续": "MiniGPT-4, InstructBLIP, Qwen-VL 等纷纷采用", }, "简洁架构设计": { "影响": "证明了简单 MLP + 指令微调可以匹敌复杂 Q-Former", "后续": "OpenVLA, LLaVA-NeXT 等继续使用 MLP 架构", }, "开源数据构建方法": { "影响": "GPT-4 生成数据的pipeline被广泛复制", "后续": "LLaVA-RLHF, LLaVA-Med 等基于相同方法", }, "社区复现": { "影响": "推动了 open-source VLM 的快速发展", "后续": "8B 模型可以在单卡运行,降低了多模态 AI 的门槛", }, } return impact10.2 LLaVA 衍生的重要工作
直接衍生: - MiniGPT-4 (2023): 用更少的参数实现类似 LLaVA 的效果 - InstructBLIP (2023): BLIP-2 + 指令微调 - Qwen-VL (2023): 阿里开源的中文多模态模型 - CogVLM (2024): 清华的深度视觉语言融合
数据方法衍生: - LLaVA-RLHF (2024): 用 RLHF 进一步对齐 - LLaVA-Med (2024): 医疗领域的多模态模型 - LLaVA-Hailuo (2024): 视频理解版本
架构创新: - VideoLLaVA (2024): 加入视频理解 - LLaVA-1.6 (2024): AnyRes 高分辨率 - LLaVA-NeXT (2024): 更大规模训练11. 总结
11.1 核心要点
| 维度 | 关键要点 |
|---|---|
| 核心创新 | 视觉指令微调 + GPT-4 自动生成训练数据 |
| 架构设计 | CLIP ViT-L/14 (冻结) + MLP 投影 + Vicuna-7B |
| 训练范式 | 阶段 1(冻 LLM 训投影器)+ 阶段 2(解冻 LLM 指令微调) |
| 数据构建 | Stage 1: BLIP-2 caption → GPT-4 改进 → Stage 2: GPT-4 生成问答 |
| LLaVA-1.5 改进 | MLP 投影 + 336px 分辨率 + 大规模高质量数据 |
| 开源意义 | 证明了简洁架构 + 高质量数据可以接近 GPT-4V |
| 生态影响 | 开创了开源 VLM 的视觉指令微调范式 |
11.2 LLaVA vs 其他 VLM
LLaVA BLIP-2 Qwen-VL架构 MLP(简单) Q-Former(复杂) 线性+注意力训练数据 GPT-4生成 人工标注 混合开源 完全 部分 完全中文支持 弱 弱 强分辨率 336px 224px 动态
LLaVA 的优势: 简单、高效、开源、可复现LLaVA 的劣势: 中文弱、GPT-4数据有上限11.3 一句话总结
LLaVA 的核心贡献是”视觉指令微调”范式——用 GPT-4 自动生成高质量的图像-指令-回答三元组数据,然后微调一个 CLIP + MLP + Vicuna 的简洁架构,使开源模型也能回答”这张图里有什么""这张图表说明了什么”这类开放性视觉问题。LLaVA-1.5 通过 MLP 投影、336px 分辨率和大规模数据混合,将开源多模态模型的性能提升到接近 GPT-4V 的水平,成为开源 VLM 领域的里程碑。
11.4 推荐资源
论文: - LLaVA (Liu et al., 2023): "Visual Instruction Tuning" - LLaVA-1.5 (Liu et al., 2023): "Improved Baselines with Visual Instruction Tuning" - Vicuna (Chiang et al., 2023): "Vicuna: Open-Source Chatbot Impressing GPT-4"
代码: - haotian-liu/LLaVA (官方实现) - haotian-liu/LLaVA-Instruct-150K (数据集) - lmsys/vicuna-7b-delta (LLM 权重)
相关工具: - BLIP-2 (Salesforce): 用于生成初始 caption - GPT-4 API: 用于生成指令数据 - peft (HuggingFace): 用于 LoRA 微调文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!

