---
title: "LoRA 微调 VLM 2B～8B：参数、显存、踩坑记录"
description: "覆盖 Qwen2.5-VL / Qwen3-VL / InternVL 系列 2B-8B VLM 的 LoRA/QLoRA 微调最佳实践，含大模型参数策略、分辨率差异、多卡训练指南"
pubDate: 2026-06-25
tags: ["LoRA","VLM","微调","Qwen-VL","深度学习","QLoRA","FSDP","多卡训练"]
category: "AI"
lang: "zh"
math: false
---

> 覆盖 Qwen2.5-VL / Qwen3-VL / InternVL 2B～8B 模型，聚焦可直接使用的参数与代码。

---

## 一、VLM 和 LLM 微调的区别

LLM 是单一模型，所有参数属于同一个 Transformer，微调时整个模型共享一套学习率和策略。VLM 则由三个功能完全不同的模块拼接而成，每个模块对学习率、冻结策略、量化方式的要求各不相同：

```
图像 → [Vision Encoder (ViT)] → [Projector / Merger] → [LLM 主干] → 文本输出
        预训练视觉特征提取        跨模态对齐             语言生成
        通常冻结                小数据冻结/大数据训练     LoRA 微调
```

这带来几个 LLM 微调中不存在的问题：

**1. 模块间学习率必须分层**

LLM 微调只需设一个学习率。VLM 如果三个模块用同一个 LR，ViT 的预训练特征会被破坏（视觉能力退化），而 LLM 部分又学不够快。实践中 Vision LR 必须是 LLM LR 的 1/10 以内。

**2. 图像 token 占上下文但不算 loss**

LLM 的每个 input token 要么参与 loss 计算，要么是被 mask 的 prompt（至少语义上有意义）。VLM 中一张图片经 ViT 编码后产生数百到数千个视觉 token（Qwen2.5-VL 一张 1080p 图 ≈ 2584 token），这些 token 全部被 mask（label=-100），不参与 loss 但占满了 context window。一条 VLM 样本真正算 loss 的 token 可能只有 12%。

**3. 分辨率直接影响显存和训练速度**

LLM 的序列长度由文本决定，变化范围有限。VLM 的序列长度很大程度取决于图像分辨率 —— 同一张图片，不限制分辨率 vs 压缩到 512px，视觉 token 数可以差 10 倍以上，显存需求完全不同。

**4. 量化有额外限制**

LLM 做 QLoRA 时整个模型统一量化到 4bit。VLM 的 ViT 不能量化 —— 量化 vision tower 会导致训练崩溃。此外，训练 `embed_token` 时必须同步给 `lm_head` 挂 LoRA，因为两者权重共享，缺一侧会训练发散。

各模块的默认策略：

| 模块 | 典型组件 | 默认策略 | 学习率 |
|------|----------|---------|--------|
| **Vision Encoder（ViT）** | Qwen2.5-VL 675M ViT、InternViT-300M | 冻结 | — |
| **Projector / Merger** | MLP、Resampler | 训练（>5k 样本）/ 冻结（<5k 样本） | 1e-5 |
| **LLM 主干** | Qwen2、InternLM2 等 | LoRA all-linear | 1e-4 |

```python
from peft import LoraConfig, get_peft_model

def build_vlm_lora(model, r=16, alpha=32, dropout=0.05):
    skip_keywords = ("visual", "vision", "patch_embed", "img_projection")

    linear_names = set()
    for name, module in model.named_modules():
        if any(k in name for k in skip_keywords):
            continue
        if isinstance(module, torch.nn.Linear):
            linear_names.add(name.split(".")[-1])

    config = LoraConfig(
        r=r,
        lora_alpha=alpha,
        lora_dropout=dropout,
        target_modules=list(linear_names),
        bias="none",
        task_type="CAUSAL_LM",
    )
    return get_peft_model(model, config)
```

---

## 二、模型选型与参数规格

### 2.1 Qwen 系列

**Qwen2.5-VL**：全系列共用 675M ViT，patch size 14×14。

| 模型 | 总参数 | ViT | LLM | Merger 输出维度 |
|------|--------|-----|-----|---------------|
| Qwen2.5-VL-3B | ～3.0B | 675M | 2.3B Qwen2.5 | 2048 |
| Qwen2.5-VL-7B | ～7.0B | 675M | 6.3B Qwen2.5 | 3584 |

**Qwen3-VL**：改用分级 ViT（SigLIP2），patch size 16×16，token 数比 Qwen2.5-VL 少 21%。

| 模型 | 总参数 | ViT | LLM |
|------|--------|-----|-----|
| Qwen3-VL-2B | ～2.0B | SigLIP2-Large 300M | 1.7B Qwen3 |
| Qwen3-VL-4B | ～4.0B | SigLIP2-Large 300M | 3.7B Qwen3 |
| Qwen3-VL-8B | ～8.6B | SigLIP2-SO 400M | 8.2B Qwen3 |

### 2.2 InternVL 系列

统一 ViT-MLP-LLM 三段式架构，1B～14B 使用 InternViT-300M。

| 模型 | ViT | LLM | 总参数 |
|------|-----|-----|--------|
| InternVL2/2.5-2B | InternViT-300M | InternLM2-1.8B | ～2.2B |
| InternVL2.5-4B | InternViT-300M | Qwen2.5-3B | ～3.5B |
| InternVL2/2.5-8B | InternViT-300M | InternLM2.5-7B | ～8.0B |
| InternVL3-2B | InternViT-300M | Qwen2.5-1.5B | ～2.0B |
| InternVL3-8B | InternViT-300M | Qwen2.5-7B | ～8.0B |

### 2.3 LoRA 可训练参数量（all-linear，LLM 部分）

| 模型 | hidden_dim | layers | r=16 参数量 | r=64 参数量 |
|------|-----------|--------|-----------|-----------| 
| Qwen2.5-VL-3B | 2048 | 36 | ～16.5M | ～66M |
| Qwen2.5-VL-7B | 3584 | 28 | ～22.4M | ～90M |
| Qwen3-VL-4B | 2560 | 36 | ～20.6M | ～83M |
| Qwen3-VL-8B | 4096 | 32 | ～29.4M | ～118M |

---

## 三、LoRA 超参数

### 3.1 Rank 与 Alpha

| 参数 | 推荐值 | 说明 |
|------|--------|------|
| r | **16**（起点） | 简单分类用 r=8；结构化输出/多任务用 r=32～64 |
| alpha | r 或 2r | 高 rank（≥32）时 alpha/r=0.5 也常见 |
| scaling | alpha/r | 本质是权重更新的缩放系数，与 LR 联动 |

小数据场景的过拟合规律（实验数据）：

| 配置 | 总步数 | 最优步数 | 浪费比例 |
|------|--------|----------|----------|
| r=16, epochs=10 | 2200 | 1320（ep6） | 40% |
| r=64, epochs=10 | 4400 | 1320（ep3） | **70%** |
| r=16, epochs=10，数据清洗后 | 4400 | 2200（ep5） | 50% |
| r=64, 复杂任务 | 2800 | 930（ep5） | 67% |

r=64 过拟合比 r=16 严重得多。数据质量带来的收益远大于提高 rank：相同数据量清洗 5% 错误标签，任务准确率可提升 4～5 个百分点。

两条进阶选项：
- **rsLoRA**（`use_rslora=True`）：缩放改为 alpha/√r，rank ≥ 32 时建议开启，防止梯度不稳
- **DoRA**：权重分解为方向+幅度，通常有 1～2pp 稳定提升，训练慢约 20%

### 3.2 Target Modules

只挂 `q_proj, v_proj` 是 LoRA 原论文写法，不是最优。推荐挂所有 linear：

```python
target_modules = [
    "q_proj", "k_proj", "v_proj", "o_proj",  # Attention
    "gate_proj", "up_proj", "down_proj",      # FFN
]
# LLaMA-Factory 等价写法：lora_target: all
```

代价：可训练参数增加约 3.5 倍，显存 +15%。收益：下游指标通常提升 2～5pp。2B～4B 模型全挂 LoRA 压力不大，7B+ 建议开 QLoRA 配合。

### 3.3 Dropout 与正则

| 参数 | 推荐 | 说明 |
|------|------|------|
| lora_dropout | 0.05～0.1 | epochs≤2 用 0.05；epochs=5 用 0.1 |
| bias | "none" | 训练 bias 收益极小 |
| weight_decay | 0.01 | HF Trainer 默认值 |

Dropout 和训练长度必须配套。高 LR + 长训练必须用更强正则。

---

## 四、训练超参数

### 4.1 学习率

LoRA 只训练低秩增量矩阵（A·B），参数量 <1%，梯度不影响预训练权重。因此 LoRA LR 可以比全参微调高 10～100 倍。

| 微调方式 | 典型 LR |
|----------|---------|
| Full fine-tuning | 2e-6 |
| LoRA（通用） | **1e-4** |
| QLoRA | **2e-4** |
| VeOmni (ByteDance) | 3e-4（warmup=0.007） |

按模型规格细分：

| 模型规格 | LoRA LR | 全参 FT LR |
|---------|---------|-----------| 
| 2B-3B | 2e-4～5e-4 | 2e-5 |
| 4B | 1e-4～3e-4 | 1e-5～2e-5 |
| 7B-8B | 1e-4～2e-4 | 1e-5 |

更大的模型对 LR 更敏感，建议偏低选择。

### 4.2 Epochs 与 Warmup

- **Epochs**：通用场景 2～3 epoch 足够，小数据（<5000 样本）给 5 epoch + early_stop
- **Warmup**：LoRA 参数少，梯度方差天然小，warmup_ratio ≤ 0.03 足够，超过 0.05 是浪费

### 4.3 其他关键参数

| 参数 | 推荐值 | 说明 |
|------|--------|------|
| lr_scheduler | cosine | warmup 后余弦衰减 |
| per_device_batch_size | 1～4 | VLM 瓶颈在图像 token 数量 |
| gradient_accumulation_steps | eff_bs = 16～128 | 视任务复杂度 |
| optimizer | adamw_torch | AdamW 即可 |
| precision | bf16 | A100/H100/4090 均支持 |
| gradient_checkpointing | True | 显存减少 50%+，速度损失约 20% |
| max_grad_norm | 1.0 | 高 LR 下防梯度爆炸 |
| cutoff_len | 2048～8192 | 图像 token 已占数百个 |

### 4.4 只对 Response 计算 Loss

多轮对话中，user 部分不参与 loss 计算可提升约 1% 精度并减少一半无效计算：

```python
def mask_user_tokens(labels, input_ids, assistant_token_id):
    in_response = False
    for i, tid in enumerate(input_ids):
        if tid == assistant_token_id:
            in_response = True
        if not in_response:
            labels[i] = -100  # HF Trainer 忽略 -100
    return labels
```

---

## 五、显存规划

### 5.1 GPU 需求

| 模型规格 | LoRA BF16（单卡） | QLoRA 4bit（单卡） | 推荐 batch_size | 推荐 grad_accum |
|---------|----------------|------------------|---------------|----------------|
| 2B-3B | 1× 24GB | 1× 12GB | 2-4 | 4-8 |
| 4B | 1× 24GB | 1× 16GB | 1-2 | 8-16 |
| 7B-8B | 2× 24GB 或 1× 80GB | 1× 24GB | 1 | 8-16 |

含 1 张 1920×1080 图像（约 2000～2600 token）的估算：

| 模型 | LoRA BF16 训练显存 | QLoRA 4bit 训练显存 |
|------|-----------------|------------------|
| 2B-3B | ～10-14 GB | ～6-8 GB |
| 4B | ～16-20 GB | ～8-12 GB |
| 7B-8B | ～28-40 GB | ～10-16 GB |

2B 模型单卡参考（RTX 4090 24GB）：

| 配置 | 显存 |
|------|------|
| batch=1, cutoff=1024, 单图 | ～14 GB |
| batch=2, grad_accum=8, cutoff=1024 | ～20 GB |
| 全分辨率（1920×1080），batch=1, cutoff=8192 | ～18 GB |

### 5.2 QLoRA 4bit

| 指标 | 全参微调 | LoRA BF16 | QLoRA 4bit |
|------|---------|---------|-----------| 
| 7B 训练显存 | ～100-120 GB | ～28 GB | **～8-10 GB** |
| 性能保留 | 100% | 90-95% | 80-90% |
| 适用 GPU | 8× H100 | 1× A100 80GB | **1× 4090 24GB** |

```python
from transformers import BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
)
model = AutoModelForCausalLM.from_pretrained(
    model_path, quantization_config=bnb_config, device_map="auto"
)
model = prepare_model_for_kbit_training(model)
```

### 5.3 Gradient Checkpointing

7B+ 模型几乎必需。7B BF16 + AdamW 显存分布：参数 14GB + 梯度 14GB + 优化器 56GB + 激活值 20～40GB，其中激活值可通过 gradient checkpointing 减少 50%+，训练速度损失约 20%。

```python
args = TrainingArguments(
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},  # 与 PEFT 兼容
)
```

---

## 六、图像分辨率与 VLM 特有问题

### 6.1 Qwen2.5-VL 动态分辨率机制

图像以原始宽高比处理，不强制裁剪：

1. 宽高取整为 **28 的整数倍**（14×14 patch 经 2×2 merge，每 token 覆盖 28×28 px）
2. `visual_tokens = (H/28) × (W/28)`

`preprocessor_config.json` 中默认 `max_pixels` 是架构上限（12,845,056），不是推荐训练值。1920×1080 不限制时产生 2584 个 token，4K 图像可达 10,000+，直接 OOM。

### 6.2 Qwen3-VL 的变化

patch size 从 14→16，取整倍数从 28→32，每 token 覆盖 1024px（vs 784px）：

| 系列 | 取整分辨率 | 1920×1080 视觉 Token |
|------|-----------|---------------------|
| Qwen2.5-VL | 1904×1064 | 68×38 = **2,584** |
| Qwen3-VL | 1920×1088 | 60×34 = **2,040** |

Qwen3-VL 相同图像少 21% token，显存和速度直接受益。

此外，Qwen3-VL 修复了 Qwen2.5-VL 的 MRoPE 频率不均衡问题（Interleaved MRoPE），长视频位置编码更稳定。

### 6.3 Image 参数对照

不能将 Qwen2.5-VL 的配置直接复制到 Qwen3-VL，patch size 28→32 会导致静默错误缩放：

| 参数 | Qwen2.5-VL | Qwen3-VL |
|------|-----------|----------|
| `image_min_pixels` | 256 × 28² = 200,704 | 256 × 32² = 262,144 |
| `image_max_pixels` | 1280 × 28² = 1,003,520 | 1280 × 32² = 1,310,720 |

```python
# Qwen2.5-VL
processor.image_processor.min_pixels = 256 * 28 * 28
processor.image_processor.max_pixels = 1280 * 28 * 28

# Qwen3-VL（必须用 32）
processor.image_processor.min_pixels = 256 * 32 * 32
processor.image_processor.max_pixels = 1280 * 32 * 32
```

### 6.4 常见图像陷阱

**截断图像**：训练报 `IOError: image file is truncated`。

```python
from PIL import ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True

# 或在数据集加载时预过滤
from PIL import Image

def filter_corrupt(data_list):
    valid = []
    for item in data_list:
        try:
            Image.open(item["image"]).verify()
            valid.append(item)
        except Exception:
            pass
    return valid
```

**全分辨率 vs 压缩**：需要坐标精度（GUI Agent、OCR 定位）必须保持全分辨率，不设 `image_max_pixels` 上限。纯分类任务可以压缩以节省显存。

---

## 七、基线配置

### 7.1 通用 VLM 微调（数据量 >3000，无坐标精度要求）

```yaml
# LLaMA-Factory 配置
model_name_or_path: Qwen/Qwen2-VL-2B-Instruct

finetuning_type: lora
lora_target: all
lora_rank: 16
lora_alpha: 32
lora_dropout: 0.05

freeze_vision_tower: true
freeze_merger: false

learning_rate: 1.0e-4
vision_lr: 2.0e-6
merger_lr: 1.0e-5
lr_scheduler_type: cosine
warmup_ratio: 0.03
weight_decay: 0.01
optim: adamw_torch
max_grad_norm: 1.0

per_device_train_batch_size: 2
gradient_accumulation_steps: 8
num_train_epochs: 3
cutoff_len: 2048

bf16: true
gradient_checkpointing: true

image_max_pixels: 1003520
image_min_pixels: 200704

val_size: 0.05
eval_strategy: steps
eval_steps: 200
save_steps: 200
load_best_model_at_end: true
metric_for_best_model: eval_loss
```

### 7.2 小数据 + 全分辨率（数据量 <5000，需要坐标精度）

```yaml
# 与通用配置的差异
lora_dropout: 0.1        # 训练更长，需要更强正则
freeze_merger: true       # 数据不足以安全训练 merger

num_train_epochs: 5
early_stopping: true
early_stopping_patience: 2

cutoff_len: 8192
# 不设 image_max_pixels  # 保持全分辨率

per_device_train_batch_size: 1
gradient_accumulation_steps: 8
```

### 7.3 两套配置对比

| 维度 | 通用 | 小数据全分辨率 |
|------|-----|-------------|
| 数据量 | >3000 | <5000 |
| 图像分辨率 | 压缩（max_pixels 限制） | 全分辨率 |
| Epochs | 3 | 5 + early_stop |
| Dropout | 0.05 | 0.1 |
| Merger | 训练 | 冻结 |
| 坐标精度 | 无要求 | 需要（<30px） |

---

## 八、过拟合：分析与防治

### 8.1 规律

小数据 VLM 微调的过拟合模式高度一致：

- 最优点集中在 epoch 3～6，之后 eval_loss 持续上升
- train_loss 后期降到极低（～0.001），eval_loss 反弹 30～50%
- r=64 过拟合比 r=16 严重，且数据越少越明显
- 最优 epoch 由数据量和任务复杂度决定，与超参关系不大

### 8.2 防过拟合策略组合

```python
from transformers import TrainingArguments, EarlyStoppingCallback

args = TrainingArguments(
    num_train_epochs=5,
    eval_strategy="steps",
    eval_steps=200,
    save_steps=200,
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    greater_is_better=False,
    save_total_limit=3,
)
early_stopping = EarlyStoppingCallback(early_stopping_patience=2)
```

### 8.3 数据质量是核心

- 500～5000 高质量样本通常已足够（2B～3B 模型）；超过 1w 收益边际递减
- 相同数据量，清洗 5% 的错误标签比调高 rank 的收益大
- Response 格式、长度、JSON 结构必须统一，模型无法同时学习多种输出风格

### 8.4 训练稳定性信号

| 信号 | 含义 | 处理 |
|------|------|------|
| train_loss < 0.1，eval_loss 反弹 | 过拟合 | 检查 early_stopping 是否生效 |
| loss 不降 | 数据或格式问题 | 检查 `<image>` token 位置和 chat template |
| 梯度爆炸 | LR 过高 | 设 max_grad_norm=1.0，降低 LR |
| 灾难性遗忘 | 垂域数据比例过高 | 混入 5～20% 通用 VQA 数据 |

---

## 九、多卡训练（FSDP）

### 9.1 策略选择

| 场景 | 推荐策略 |
|------|---------|
| LoRA，单卡放得下 | DDP |
| LoRA，7B+ 单卡放不下 | **SHARD_GRAD_OP**（ZeRO-2） |
| 全参微调 | FULL_SHARD（ZeRO-3） |

LoRA 已大幅减少可训练参数，FULL_SHARD 的主要收益对 LoRA 场景不关键。VLM 的序列并行比参数分片更有价值。

| 策略 | 等价 ZeRO | 分片内容 |
|------|----------|---------|
| NO_SHARD | DDP | 无 |
| **SHARD_GRAD_OP** | ZeRO-2 | 梯度 + 优化器状态 |
| FULL_SHARD | ZeRO-3 | 参数 + 梯度 + 优化器 |
| HYBRID_SHARD | — | 多节点优化 |

### 9.2 LoRA Adapter 保存（已知 Bug）

FSDP 保存的 state_dict 中 LoRA key 不含 adapter name，PEFT 加载时期望 `lora_A.default.weight` 格式，导致 key 不匹配。

```python
from transformers import TrainerCallback
import os

class PeftSavingCallback(TrainerCallback):
    def on_save(self, args, state, control, **kwargs):
        ckpt = os.path.join(args.output_dir, f"checkpoint-{state.global_step}")
        kwargs["model"].save_pretrained(ckpt)
        return control
```

accelerate config 必须设置：

```yaml
fsdp_config:
  fsdp_use_orig_params: true       # 关键：支持 LoRA 冻结+可训练参数混合
  fsdp_sharding_strategy: SHARD_GRAD_OP
  fsdp_state_dict_type: FULL_STATE_DICT
```

### 9.3 NCCL 超时

FSDP 每步触发 all-gather + reduce-scatter，默认 30 分钟超时容易触发：

```bash
export NCCL_ASYNC_ERROR_HANDLING=1
export NCCL_TIMEOUT=1800
export TORCH_NCCL_HEARTBEAT_TIMEOUT_SEC=1800
export NCCL_P2P_LEVEL=NVL
```

```python
import torch.distributed as dist
from datetime import timedelta
dist.init_process_group(backend="nccl", timeout=timedelta(seconds=3600))
```

Docker 启动必须加 `--shm-size=10g --ulimit memlock=-1`，默认 64MB shared memory 不够。

---

## 十、训练监控

### 10.1 解析 trainer_state.json（不需要 WandB）

HF Trainer 每个 checkpoint 自动保存 `trainer_state.json`，包含完整训练日志：

```python
import json
from pathlib import Path

def show_progress(output_dir: str):
    checkpoints = sorted(
        Path(output_dir).glob("checkpoint-*"),
        key=lambda p: int(p.name.split("-")[1])
    )
    if not checkpoints:
        return

    state = json.load(open(checkpoints[-1] / "trainer_state.json"))
    logs = state["log_history"]
    trains = [l for l in logs if "loss" in l and "eval_loss" not in l]
    evals  = [l for l in logs if "eval_loss" in l]

    print(f"Progress : {state['global_step']} / {state['max_steps']}")
    print(f"Best ckpt: {state.get('best_model_checkpoint', 'N/A')}")
    print(f"Best val : {state.get('best_metric', 'N/A')}\n")

    print("Eval history:")
    for e in evals[-5:]:
        t = next((x["loss"] for x in reversed(trains) if x["step"] <= e["step"]), None)
        gap = e["eval_loss"] - t if t else float("nan")
        print(f"  step {e['step']:5d} | eval={e['eval_loss']:.4f} | gap={gap:+.4f}")

    print("Train (recent):")
    for t in trains[-3:]:
        print(f"  step {t['step']:5d} | loss={t['loss']:.4f} | lr={t.get('learning_rate', 0):.2e}")

show_progress("output/my_model")
```

后台训练时实时查看：

```bash
watch -n 60 python3 monitor.py output/my_model
```

### 10.2 TensorBoard（WandB 替代）

```python
args = TrainingArguments(
    report_to="tensorboard",
    logging_dir="runs/exp1",
    logging_steps=10,
)
```

```bash
# 启动
tensorboard --logdir runs/ --port 6006
# 远程服务器
ssh -L 6006:localhost:6006 user@server
```

### 10.3 WandB：自定义 train/eval gap

```python
import wandb
from transformers import TrainerCallback

class GapMonitorCallback(TrainerCallback):
    def on_log(self, args, state, control, logs=None, **kwargs):
        if not (logs and state.is_world_process_zero):
            return

        metrics = {k: v for k, v in logs.items()
                   if k in ("loss", "eval_loss", "learning_rate", "grad_norm")}

        trains = [l for l in state.log_history if "loss" in l and "eval_loss" not in l]
        evals  = [l for l in state.log_history if "eval_loss" in l]
        if trains and evals:
            metrics["train_eval_gap"] = evals[-1]["eval_loss"] - trains[-1]["loss"]

        if metrics:
            wandb.log(metrics, step=state.global_step)
```

---

## 十一、调参顺序与速查

### 11.1 实验顺序（节省 GPU 时间）

每次只动一组变量：

1. **跑通基线**：确认 loss 正常下降
2. **数据质量 A/B**：换不同数据子集，通常这步收益最大
3. **Rank 扫描**：r ∈ {8, 16, 32, 64}
4. **LR 扫描**：lr ∈ {5e-5, 1e-4, 2e-4, 3e-4}
5. **Target modules**：Q/V only vs all-linear
6. **Merger / ViT 解冻**：图像分布与自然图像差异较大时再试
7. **rsLoRA / DoRA**：基线稳定后的进阶选项

### 11.2 综合参数速查

| 参数 | 2B-3B | 4B | 7B-8B |
|------|------|----|-------|
| LoRA rank | 16 | 16-32 | 16-32 |
| LoRA alpha | 16-32 | 32 | 32-64 |
| LoRA target | all-linear | all-linear | all-linear |
| LR | 2e-4～5e-4 | 1e-4～3e-4 | 1e-4～2e-4 |
| Warmup ratio | 0.03 | 0.03 | 0.03 |
| Batch size | 2-4 | 1-2 | 1 |
| Grad accum | 4-8 | 8-16 | 8-16 |
| Epochs | 3-5 + early_stop | 1-3 | 1-3 |
| Gradient checkpointing | 可选 | 推荐 | **必需** |
| 分布式策略 | DDP | DDP / SHARD_GRAD_OP | SHARD_GRAD_OP |
| QLoRA（单卡） | 12GB | 16-24GB | 24GB |
| Flash Attention 2 | 推荐 | 推荐 | **必需** |

### 11.3 经验法则

1. **LR**：LoRA 用 1e-4，QLoRA 用 2e-4，Full FT 用 1e-5～1e-6
2. **数据 > 参数**：清洗 5% 错误标签 > 任何超参调整
3. **Rank**：小数据 r=16，大数据/多任务再考虑 r=64
4. **Warmup**：≤ 0.03
5. **Dropout**：epochs≤2 用 0.05，epochs=5 用 0.1
6. **冻结 ViT**：分布贴近自然图像时冻结，省 40% 显存
7. **全分辨率**：坐标精度任务必须保持，纯分类可压缩
8. **Merger**：数据量 >5000 训练，<5000 冻结
9. **Early stopping**：必须开，patience=2 对小数据集足够

### 11.4 Qwen2.5-VL → Qwen3-VL 迁移检查清单

1. 所有 `28*28` 改为 `32*32`（image_min/max_pixels）
2. 确认 `mrope_interleaved: true`
3. Qwen3-VL ViT 更小（300M vs 675M），可考虑解冻 ViT
4. 相同图像 token 数少 21%，可适当增大 batch_size
5. LLaMA-Factory 已支持 Qwen3-VL，检查最新版本配置格式

---

## References

- [Unsloth · LoRA Hyperparameters Guide](https://docs.unsloth.ai/)
- [Sebastian Raschka · Practical Tips for Finetuning LLMs Using LoRA](https://magazine.sebastianraschka.com/)
- [InternVL · LoRA Finetune 文档](https://internvl.readthedocs.io/)
- [Qwen-VL-Series-Finetune](https://github.com/2U1/Qwen-VL-Series-Finetune)
- [Dettmers et al. · QLoRA](https://arxiv.org/abs/2305.14314)
- [LIMA · Less Is More for Alignment](https://arxiv.org/abs/2305.11206)
- [VeOmni · ByteDance Training Framework](https://github.com/volcengine/veomni)
- [Qwen3-VL Technical Report](https://arxiv.org/abs/2511.21631)
- [HuggingFace PEFT · FSDP](https://huggingface.co/docs/peft/en/accelerate/fsdp)
- [Learning Rate Matters for LoRA](https://arxiv.org/html/2602.04998v1)

---

## 补充：多阶段 LoRA 流水线中的 Rank 选择（2026-07 消融实验）

> 本节基于 Qwen3-VL-8B 三阶段 LoRA 串联训练的消融实验，补充上文 LoRA rank 取 4~32 就够结论的适用边界。

### 背景

上文建议 8B 模型用 r=4~32，这个建议适用于单阶段任务微调。但在多阶段串联流水线中，首阶段的 rank 选择需要额外考虑。

我们的三阶段流水线：

```
Qwen3-VL-8B-Instruct
  -> S1 LoRA (r=64, 170M params) + merge   # 游戏 UI 理解预训练
  -> S2P1 LoRA (r=8, 21M params) + merge   # 动作类型学习
  -> S2P2 LoRA (r=8, 21M params) + merge   # 完整 Agent 能力
  = v5.1 (50px=81.2%)
```

### 消融实验结果

| 版本 | S1 | S2P1 | S2P2 | 50px% | vs v5.1 |
|------|-----|------|------|-------|---------|
| v5.1 (baseline) | r=64 | r=8 | r=8 | 81.2 | - |
| v5.1d (只 S2P2) | 无 | 无 | r=8 | 76.2 | -5.0pp |
| v5.1e (全 r=8) | r=8 | r=8 | r=8 | 75.7 | -5.5pp |
| v5.1c (跳 S1) | 无 | r=8 | r=8 | 75.1 | -6.1pp |

### 核心发现：S1 是领域适配预训练

S1 的表面任务是 13 类 screen_type 分类（3512 条数据），但它的真正效果是领域适配：用 170M 参数把 LLM 从通用模型改造成理解游戏 UI 的专用模型。这和 S2P1/S2P2 学习具体输出格式的任务微调本质不同。

S1 r=8 甚至不如完全跳过 S1（v5.1e 75.7% < v5.1d 76.2%），说明 r=8 的参数量不足以完成领域适配，反而引入有害的不完整知识。

### 串联 merge 的生存性问题

多阶段 LoRA 的独特约束：每个阶段 merge 后，后续阶段的 LoRA 会覆盖前一阶段的修改。S1 r=64 修改了 64 个独立的权重方向，后续 S2P1(r=8) 最多只能覆盖其中 8 个，剩余 56 个方向的领域知识保留下来。如果 S1 也用 r=8，那 8 个方向会被 S2P1 完全覆盖，领域知识全部丢失。

### Rank 选择建议（修订）

| 场景 | 推荐 rank | 原因 |
|------|---------|------|
| 单阶段任务微调 | r=4~32 | 本文上半部分的结论，依然成立 |
| 多阶段流水线 - 任务微调阶段 | r=4~32 | 学习具体的输出格式 |
| 多阶段流水线 - 领域适配阶段 | r=64+ | 需要大参数量做 domain shift，且修改要能在后续 merge 中存活 |

总结：r=4~32 的建议适用于任务微调。当首阶段承担领域适配角色时，需要 r=64+ 以确保领域知识在串联 merge 后存活。判断依据是该阶段的目的：学新格式（低 rank）还是改造模型的领域理解（高 rank）。
