总结了NanoVLM(LLaVA格式)的背景、原理、核心代码、实现细节。最开始只是LLaVA思想的初步实现,即使用一个线性层(或MLP)将视觉特征转换为文本特征,或者说是将视觉特征和文本特征对齐,后边会慢慢加入LLaVA-1.5、LLaVA-NeXT、LLaVA-OneVision的思想。

LLaVA

LLaVA 最初的结构如下:

alt text

其中, Xv为输入图像,而Xq为输入文本指令。 Xv经过冻结的预训练视觉编码器(例如CLIP的视觉编码器)转换为视觉特征, 又进一步经过一个简单的线性层W转换为文本特征,便可和文本指令特征Hq一起送入LLM进行处理。

LLaVA的训练目标为LM:

$$ p(\mathbf{X}_{\mathrm{a}} \mid \mathbf{X}_{\mathrm{v}}, \mathbf{X}_{\mathrm{instruct}}) = \prod_{i=1}^{L} p_{\boldsymbol{\theta}}(x_i \mid \mathbf{X}_{\mathrm{v}}, \mathbf{X}_{\mathrm{instruct}}, \mathbf{X}_{\mathrm{a}, < i}) $$

即基于当前图像、历史指令、历史答复预测新一轮的答复。整个训练过程包含两步:

步骤1: 固定Vision Encoder和LLM,预训练Projection 以实现图像和文本模态对齐;

步骤2: 放开LLM,进行端到端的训练。

原始 LLaVA 1.0 的具体配置

组件配置参数量
Vision EncoderCLIP ViT-L/14@224²~300M
Projector单层 Linear (1024→4096)~4.2M
LLMVicuna-7B/13B7B/13B

Vision Encoder:使用 OpenAI 预训练的 CLIP ViT-L/14,输入分辨率为 224×224。patch_size=14,因此每张图产生 $(224/14)^2 = 256$ 个 patch token + 1 个 CLS token,每个 token 维度为 1024。视觉编码器在整个训练过程中完全冻结

Projector:一个单层线性变换 $\mathbf{W} \in \mathbb{R}^{1024 \times 4096}$,将 CLIP 输出的 1024 维视觉特征直接映射到 Vicuna 的 4096 维 embedding 空间。没有激活函数、没有隐藏层——纯粹的一个矩阵乘法 $\mathbf{H}_v = \mathbf{Z}_v \mathbf{W}$。这也是整个架构中参数量最小的组件(仅 ~4.2M)。

LLM:Vicuna,基于 LLaMA v1 进行指令微调的对话模型。embedding 维度为 4096(7B 和 13B 相同)。Stage 1 冻结,Stage 2 全量微调。

为什么原始 LLaVA 敢用单层线性? CLIP ViT 本身已经在大规模图文数据上通过对比学习进行过跨模态对齐——它的视觉特征和文本特征天然处于相近的语义空间。因此一个简单的线性映射就足以将视觉特征"适配"到 LLM 的 embedding 空间,不需要更复杂的非线性变换。

训练数据

阶段数据规模格式
Stage 1CC3M(Conceptual Captions)过滤595K 图文对单轮:随机指令 → 图片描述
Stage 2LLaVA-Instruct158K 指令多轮对话/问答/推理(基于 80K 张 COCO 图,GPT-4 生成)

两阶段训练

训练维度Stage 1(特征对齐)Stage 2(指令微调)
可训练模块仅 Linear ProjectorLinear Projector + 整个 LLM
冻结模块CLIP + VicunaCLIP
学习率2e-32e-5
Batch Size12832
Epochs13
硬件8×A100,~4小时8×A100,~10小时

与当前 NanoVLM 的详细对照

视觉编码器

维度原始 LLaVA 1.0NanoVLM(当前)
模型CLIP ViT-L/14SigLIP SO400M-patch14
输入分辨率224×224384×384
Patch Size1414
视觉 Token 数$256$(= $(224/14)^2$)$729$(= $(384/14)^2$)
特征维度10241152
CLS Token有(默认丢弃)无(SigLIP 视觉塔不含 CLS)
参数量~300M~400M
预训练数据400M 图文对(CLIP 对比学习)WEBLI-10B(SigLIP 对比学习)
训练中状态全程冻结全程冻结

从 CLIP@224² → SigLIP@384²,视觉 token 数从 256 增至 729(2.8×),分辨率从 50K 像素增至 147K 像素。更高的分辨率和更多的视觉 token 对 OCR、细粒度识别任务有直接收益——这也是 LLaVA 1.5(CLIP@336²,576 tokens)和 LLaVA-NeXT 1.6(SigLIP@384²,729 tokens)先后升级视觉编码器的核心动机。

跨模态投影器

维度原始 LLaVA 1.0NanoVLM(当前)
架构单层 Linear2 层 MLP + GELU
表达式$\mathbf{H}_v = \mathbf{Z}_v \mathbf{W}$$\mathbf{H}_v = \mathbf{W}_2 \cdot \text{GELU}(\mathbf{W}_1 \mathbf{Z}_v + \mathbf{b}_1) + \mathbf{b}_2$
维度变换$1024 \to 4096$$1152 \to 2048 \to 896$
激活函数GELU
参数量~4.2M~50M
权重初始化Xavier (gain=0.01),保证初始输出幅度可控

从单层 Linear → 2 层 MLP 是 LLaVA 1.5 最重要的改动。SimCLR/BYOL 等自监督学习工作表明,非线性投影头能学习更好的跨模态表示。原始 LLaVA 1.0 之所以敢用单层线性,是因为 CLIP ViT 本身已经在大规模图文数据上做过跨模态对齐——视觉特征和文本特征天然处于相近的语义空间。但随后的实验证明,2 层 MLP 在下游任务上一致性优于单层线性,因此 LLaVA 1.5 将其设为默认配置。NanoVLM 直接采用 2 层 MLP,跳过了单层 Linear 的阶段。

语言模型

维度原始 LLaVA 1.0NanoVLM(当前)
模型Vicuna-7B/13BQwen2-0.5B-Instruct
基座模型LLaMA v1Qwen2
Embedding 维度4096896
总参数量7B / 13B0.5B
Stage 1 状态冻结冻结
Stage 2 状态全量微调(7B 参数全部更新)LoRA 微调(仅 Attention 层低秩适配)

LLM 的选择反映了两种不同的设计哲学:原始 LLaVA 追求性能上限(7B-13B 大模型 + 全量微调,需要 8×A100),NanoVLM 追求资源效率(0.5B 小模型 + LoRA,单张 RTX 5060 Ti 即可)。Qwen2 系列是 LLaVA-OneVision 才开始采用的新一代基座,

训练策略

维度原始 LLaVA 1.0NanoVLM(当前)
Stage 1
可训练模块仅 Linear Projector(~4.2M)仅 Connector MLP(~50M)
学习率2e-32e-3
等效 Batch Size1288(per_device=1 × grad_accum=8)
Epochs11
Stage 2
可训练模块Projector + 全部 LLM(~7B)Connector + LoRA(~60M)
可训练参数占比~100% of LLM~1.2% of LLM(仅 Attention Q/K/V/O)
学习率2e-52e-4
等效 Batch Size3216(per_device=1 × grad_accum=16)
Epochs31
通用
优化器AdamAdamW
数值精度BF16 + TF32FP16(混合精度 + GradScaler)
梯度裁剪max_norm=1.0
LR 调度Cosine + 3% warmupCosine + 3% warmup
最大序列长度20482048
内存优化FSDP + Gradient CheckpointingGradient Accumulation + Gradient Checkpointing
训练硬件8×A100 (80GB)单张 RTX 5060 Ti (16GB)
Stage 1 耗时~4 小时
Stage 2 耗时~10 小时
总耗时~15 小时(8×A100)

训练策略上的关键差异在于 Stage 2:原始 LLaVA 的全量微调 7B 参数需要 8×A100 和 FSDP 分布式训练;NanoVLM 的 LoRA 微调仅训练 ~60M 参数,单张消费级 GPU 即可完成。Stage 1 的学习率两者一致(2e-3),因为投影器都是从零初始化,需要高学习率快速收敛;Stage 2 原始 LLaVA 用更低的 2e-5 保护预训练权重,而 NanoVLM 的 2e-4 高于它——这是因为 LoRA 本身不直接修改原始权重,对学习率的容忍度更高。

推理生成

维度原始 LLaVA 1.0NanoVLM(当前)
Image Token 放置Prepend(视觉 token 拼接在文本序列前端)Replace(<image> 占位符 → 原位替换为 729 个视觉 token)
对话格式模板字符串(如 Human: ...\nAssistant: ...Qwen2 ChatML(`<
采样策略Greedy / TemperatureTemperature=0.7, Top-P=0.9, Top-K=50, Repetition Penalty=1.1
KV Cache
单图问答延迟
最大生成长度512 tokens

Image Token 放置方式的差异影响了架构的灵活性:Prepend 方式简单但要求视觉 token 总是放在文本之前,无法支持多图交错、图文交织等复杂场景;Replace 方式通过 <image> 占位符支持任意位置的视觉 token。

原理

第一阶段:模态对齐

第一阶段要回答的核心问题是:如何让 LLM “看懂” 图像?

LLM 本质上是 text-only 的——它的输入是离散的 token id,经过 embedding 矩阵查表得到连续表示后进入 Transformer。而视觉编码器输出的是一组连续的 patch feature 向量,两者处于完全不同的表示空间。

用数学语言描述:SigLIP 视觉编码器的输出 $\mathbf{Z}_v \in \mathbb{R}^{N_v \times d_v}$(其中 $N_v=729$, $d_v=1152$)和 Qwen2 的 token embedding $\mathbf{E} \in \mathbb{R}^{|\mathcal{V}| \times d_t}$(其中 $d_t=896$)处于不同的向量空间。直接拼接是没有意义的——维度不同,语义也不对齐。

因此需要一个可学习的映射函数 $f_\phi: \mathbb{R}^{d_v} \rightarrow \mathbb{R}^{d_t}$ 将视觉特征投影到语言空间:

$$\mathbf{H}_v = f_\phi(\mathbf{Z}_v) \in \mathbb{R}^{N_v \times d_t}$$

在 NanoVLM 中,$f_\phi$ 是一个 2 层 MLP:

$$\mathbf{H}_v = \mathbf{W}_2 \cdot \text{GELU}(\mathbf{W}_1 \cdot \mathbf{Z}_v + \mathbf{b}_1) + \mathbf{b}_2$$

其中 $\mathbf{W}_1 \in \mathbb{R}^{1152 \times 2048}$、$\mathbf{W}_2 \in \mathbb{R}^{2048 \times 896}$。投影后的 $\mathbf{H}_v$ 在语义上相当于一批"视觉单词"的 embedding,可以和文本 token embedding 拼接后送入 LLM。

第一阶段训练的优化目标仍然是语言模型的 next-token prediction loss(交叉熵):

$$\mathcal{L}_{\mathrm{stage1}} = -\sum_{i} \log p(x_i^{\mathrm{answer}} \mid \mathbf{H}_v, \mathbf{X}_{\mathrm{instruct}}, \mathbf{X}_{\mathrm{answer}, < i})$$

但只更新 Connector 的参数 $\phi$,Vision Encoder 和 LLM 的参数都保持冻结。

为什么这样设计?

  • 冻结 Vision Encoder:SigLIP 已经在大规模图文数据上预训练,视觉特征提取能力足够好,不需要也不应该在早期阶段扰动。
  • 冻结 LLM:Qwen2-0.5B 的语言能力是"锚点"——第一阶段不是在教 LLM 新知识,而是在训练 Connector 把视觉信号"翻译"成 LLM 已经能理解的语言。如果同时放开 LLM,Connector 可能学到一个退化映射(例如把视觉特征映射到某个无意义的子空间,同时 LLM 被迫适配这个退化映射)。
  • 只训 Connector:Connector 是从零初始化的,它是唯一需要"学习翻译"的模块。

从信息论角度看,第一阶段本质上是在最大化 $\mathbf{H}_v$ 与文本 embedding 之间的互信息——让投影后的视觉特征与对应的文本描述在 LLM 的表示空间中尽可能接近。Connector 的所有 50M 参数都在学习这个对齐关系。

第二阶段:LoRA 指令微调

为什么不用全量微调?

第二阶段放开 LLM 进行端到端训练,但如果全量微调 Qwen2-0.5B 的全部 500M 参数,会带来两个问题:

  1. 显存爆炸:全量微调需要存储所有参数的梯度、优化器状态(AdamW 需要 momentum + variance 各一份),显存需求约为推理的 4 倍,远超消费级 GPU 的 24GB。
  2. 灾难性遗忘:全量更新 500M 参数可能破坏 Qwen2 在预训练阶段学到的通用语言能力。

LoRA 原理

LoRA (Low-Rank Adaptation) 基于一个关键假设:预训练权重在微调时的更新量 $\Delta W$ 具有低"内在秩"(intrinsic rank)。也就是说,模型适配新任务时不需要在高维参数空间中大范围移动,只需要在一个低维子空间内调整即可。

基于这个假设,LoRA 将 $\Delta W$ 分解为两个低秩矩阵的乘积:

$$\Delta W = BA, \quad B \in \mathbb{R}^{d \times r}, \; A \in \mathbb{R}^{r \times k}, \quad r \ll \min(d,k)$$

其中 $r$ 称为 LoRA 的秩(rank),通常取 8 或 16。

前向传播变为:

$$h = W_0 x + \Delta W x = W_0 x + \frac{\alpha}{r} \cdot BA x$$

其中:

  • $W_0$:原始预训练权重(冻结,不更新
  • $A$:低秩矩阵,用高斯分布初始化
  • $B$:低秩矩阵,用零初始化(保证训练开始时 $\Delta W = 0$,模型行为不变)
  • $\alpha$:缩放因子,控制 LoRA 更新的影响力(通常设 $\alpha = 2r$ 即 $\alpha/r = 2$)
  • $\frac{\alpha}{r}$:将 LoRA 输出的缩放与秩解耦,使得调整 $r$ 时不需要重新调学习率

从参数效率角度看,Qwen2-0.5B 中 attention 的 q_proj 矩阵形状为 $896 \times 896 = 802,816$ 个参数。使用 LoRA (r=8),可训练参数仅 $896 \times 8 + 8 \times 896 = 14,336$,参数减少 56 倍

NanoVLM 中的 LoRA 配置

NanoVLM 只在注意力层的四个投影矩阵上施加 LoRA:

lora_target_modules = ("q_proj", "k_proj", "v_proj", "o_proj")

原因:Attention 层是决定"模型关注什么"的核心——在图文任务中,模型需要学会关注图像相关 token 与问题相关 token 之间的交互。Transformer 的 FFN 层存储了大量通用知识,冻结它们有助于保留语言能力。实验表明,只对 attention 加 LoRA 即可在指令跟随能力与通用语言能力之间取得良好平衡。

第二阶段优化目标

与第一阶段相同,损失函数仍是 next-token prediction:

$$ \mathcal{L}_{\mathrm{stage2}} = -\sum_{i} \log p(x_i^{\mathrm{answer}} \mid \mathbf{H}_v, \mathbf{X}_{\mathrm{instruct}}, \mathbf{X}_{\mathrm{answer}, < i}) $$

但此时优化的参数包括 Connector 的 $\phi$ 和所有 LoRA 矩阵的 $\Delta W$(即 $A$ 和 $B$),总共约 60M 可训练参数。

和第一阶段的关键区别在于数据的性质:

  • Stage 1 数据是图文描述对(image-caption),学习的是"看到什么说什么",目标单一。
  • Stage 2 数据是多轮指令对话(image-instruction-response),学习的是"根据指令回答",涵盖描述、问答、推理等多种交互模式。

这意味着 Stage 2 不仅要让视觉 token 对齐到语言空间,还要让整个模型学会在视觉上下文中遵循指令——这正是 LoRA 微调要解决的核心问题。

核心代码

项目结构

NanoVLM 的项目结构如下:

NanoVLM/
├── configs/                     # 配置文件
   ├── model_config.py          # 模型架构配置(Vision/Language/Connector 参数)
   └── training_config.py       # 训练超参数配置(两阶段 lr/bs/epoch 等)
├── src/nanovlm/
   ├── model/                   # 模型定义
      ├── vision_encoder.py    # SigLIP 视觉编码器封装
      ├── language_model.py    # Qwen2-0.5B 语言模型封装
      ├── connector.py         # MLP 跨模态连接器
      └── nanovlm.py           # 完整 VLM 组装、forward、保存/加载
   ├── data/                    # 数据处理
      ├── conversation.py      # 对话模板(Qwen ChatML 格式)
      └── dataset.py           # LLaVA 格式数据集加载
   ├── training/                # 训练系统
      └── trainer.py           # 两阶段训练器(梯度累积/混合精度/断点续训)
   ├── inference/               # 推理
      └── generator.py         # 自回归生成器 & 交互式对话
   └── utils/                   # 工具函数
       └── utils.py             # 随机种子/参数量统计/设备管理
├── scripts/                     # 入口脚本
   ├── download_models.py       # 下载预训练权重
   ├── train.py                 # 训练入口
   └── inference.py             # 推理入口
├── models/                      # 预训练权重(需下载)
├── checkpoints/                 # 训练检查点
├── data/                        # 训练数据
└── requirements.txt

模型配置

三个核心配置类定义在 configs/model_config.py 中:

@dataclass
class VisionConfig:
    model_name_or_path: str = "./models/siglip-so400m-patch14-384"
    image_size: int = 384          # 输入分辨率
    freeze: bool = True            # 视觉编码器全程冻结

@dataclass
class LanguageConfig:
    model_name_or_path: str = "./models/Qwen2-0.5B-Instruct"
    hidden_size: int = 1024
    freeze_stage1: bool = True     # 第一阶段冻结
    use_lora_stage2: bool = True   # 第二阶段用 LoRA

@dataclass
class ConnectorConfig:
    connector_type: str = "mlp"
    vision_hidden_size: int = 1152  # SigLIP so400m 输出维度
    llm_hidden_size: int = 1024     # Qwen2-0.5B 输入维度(实际加载后覆盖为 896)
    mlp_hidden_size: int = 2048     # MLP 隐藏层维度
    mlp_depth: int = 2              # MLP 层数
    activation: str = "gelu"

关键设计点:vision_hidden_sizellm_hidden_size 是手工预设值,但模型初始化时会用真实加载到的维度覆盖,避免本地权重与配置不一致。

训练配置定义在 configs/training_config.py 中,两阶段超参差异明显:

超参Stage 1Stage 2
learning_rate2e-32e-4
gradient_accumulation_steps816
可训练模块仅 ConnectorConnector + LLM (LoRA)
冻结模块Vision + LLMVision

视觉编码器 (VisionEncoder)

VisionEncoder 封装了 SigLIP ViT,核心代码在 src/nanovlm/model/vision_encoder.py

class VisionEncoder(nn.Module):
    def __init__(self, config: VisionConfig):
        super().__init__()
        # 加载预训练 SigLIP 视觉模型
        self.model = AutoModel.from_pretrained(
            config.model_name_or_path,
            dtype=torch.float16,
            local_files_only=True
        )
        self.vision_model = getattr(self.model, "vision_model", self.model)

        # 判断是否有 CLS token(CLIP 系列有,SigLIP 视觉塔没有)
        model_type = getattr(self.vision_config, "model_type", "")
        self.has_cls_token = model_type.startswith("clip")

        # 冻结所有参数
        if config.freeze:
            self.model.eval()
            for param in self.model.parameters():
                param.requires_grad = False

    def forward(self, pixel_values):
        if self.config.freeze:
            with torch.no_grad():
                outputs = self.vision_model(pixel_values=pixel_values, return_dict=True)
        else:
            outputs = self.vision_model(pixel_values=pixel_values, return_dict=True)
        return outputs.last_hidden_state  # (B, num_patches+1, hidden_size)

输入为预处理后的图像 (B, 3, 384, 384),经过 ViT 的 patch embedding(patch_size=14),得到 (B, 729, 1152) 的特征序列。SigLIP 的视觉塔不输出 CLS token,因此 has_cls_token=False,直接使用全部 729 个 patch token。

get_num_patches() 方法通过 (image_size / patch_size)² 计算视觉 token 数:(384/14)² = 729

跨模态连接器 (Connector)

这是整个 VLM 最核心的组件,负责将视觉特征投影到语言模型的 embedding 空间。代码在 src/nanovlm/model/connector.py

class Connector(nn.Module):
    """
    输入:  (B, 729, 1152)   # SigLIP patch features
    输出:  (B, 729, 896)    # Qwen2 embedding 空间

    架构: Linear(1152 → 2048) → GELU → Linear(2048 → 896)
    """

    def __init__(self, config: ConnectorConfig):
        super().__init__()
        layers = []
        input_dim = config.vision_hidden_size  # 1152

        for i in range(config.mlp_depth):  # mlp_depth=2
            if i < config.mlp_depth - 1:
                # 隐藏层: 1152 → 2048
                layers.append(nn.Linear(input_dim, config.mlp_hidden_size))
                input_dim = config.mlp_hidden_size
            else:
                # 输出层: 2048 → 896
                layers.append(nn.Linear(input_dim, config.llm_hidden_size))

            if i < config.mlp_depth - 1:
                layers.append(nn.GELU())   # 隐藏层后加 GELU 激活

        self.mlp = nn.Sequential(*layers)
        self._init_weights()  # Xavier 初始化,gain=0.01

    def forward(self, vision_features):
        return self.mlp(vision_features)

几点设计细节:

  • 动态 MLP 深度:通过 mlp_depth 控制层数,当前为 2 层。若扩展为 3 层则变为 1152→2048→GELU→2048→GELU→896
  • Xavier 初始化 (gain=0.01):用小 gain 保证训练初期输出幅度较小,有利于与 LLM embedding 的数值范围对齐,避免初始 loss 爆炸。
  • 输出层无激活:最后一层不加激活函数,保留线性投影的完整表达能力。

为什么用 2 层 MLP 而不是单层线性? LLaVA 原版用单层线性,但实践表明 2 层 MLP 能更好地建模视觉→语言的复杂映射关系,且参数量仍然可控(约 50M)。

语言模型封装 (LanguageModelWrapper)

封装 Qwen2-0.5B-Instruct,代码在 src/nanovlm/model/language_model.py

class LanguageModelWrapper(nn.Module):
    def __init__(self, config: LanguageConfig):
        super().__init__()
        self.model = AutoModelForCausalLM.from_pretrained(
            config.model_name_or_path,
            dtype=torch.float16,
            trust_remote_code=True,
            local_files_only=True
        )
        self.tokenizer = AutoTokenizer.from_pretrained(
            config.model_name_or_path,
            use_fast=False,
            local_files_only=True
        )
        # 保证有 pad_token
        if self.tokenizer.pad_token is None:
            self.tokenizer.pad_token = self.tokenizer.eos_token

        # 启用 gradient checkpointing(用计算换显存)
        self.model.gradient_checkpointing_enable()

set_stage() 方法控制参数冻结策略:

  • Stage 1self.model.eval() + 所有参数 requires_grad=False
  • Stage 2self.model.train() + 所有参数 requires_grad=True(后续通过 PEFT 加 LoRA)

NanoVLM 主模型

NanoVLM 类在 src/nanovlm/model/nanovlm.py 中,组装三大模块并实现核心的前向传播逻辑。

初始化与 Image Token

class NanoVLM(nn.Module):
    def __init__(self, config: NanoVLMConfig):
        super().__init__()
        self.vision_encoder = VisionEncoder(config.vision)
        self.language_model = LanguageModelWrapper(config.language)

        # 用真实加载到的模型维度覆盖手工配置
        self.config.connector.vision_hidden_size = self.vision_encoder.hidden_size
        self.config.connector.llm_hidden_size = self.language_model.hidden_size
        self.connector = Connector(config.connector)

        self._setup_image_token()  # 注册 <image> 特殊 token

_setup_image_token()<image> 添加为 tokenizer 的特殊 token,并调用 resize_token_embeddings 扩展 LLM 的 embedding 矩阵。这样 <image> 在 tokenize 时会被编码为单个 token id,后续在 prepare_inputs_embeds 中被替换为视觉 embedding。

encode_images: 图像 → 视觉特征

def encode_images(self, images: torch.Tensor) -> torch.Tensor:
    # 1. Vision Encoder: (B,3,384,384) → (B, num_patches+1, 1152)
    vision_features = self.vision_encoder(images)

    # 2. 去掉 CLS token(如果有的话),只保留 patch tokens
    if self.vision_encoder.has_cls_token:
        patch_features = vision_features[:, 1:, :]
    else:
        patch_features = vision_features

    # 3. MLP 投影到 LLM 空间: (B, 729, 1152) → (B, 729, 896)
    visual_embeddings = self.connector(patch_features)
    return visual_embeddings

prepare_inputs_embeds: 视觉/文本融合(最关键的算法)

这是整个 VLM 前向传播中最核心的函数——如何将视觉 token 和文本 token 拼接成一条混合序列:

def prepare_inputs_embeds(self, input_ids, visual_embeddings, attention_mask=None):
    # 1. 获取文本 embedding: (B, L) → (B, L, 896)
    embed_tokens = self.language_model.get_input_embeddings()
    text_embeds = embed_tokens(input_ids)

    # 2. 找到所有 <image> token 的位置
    image_token_mask = (input_ids == self.image_token_id)  # (B, L)

    # 3. 逐样本替换 <image> 位置为视觉 embedding
    for b in range(batch_size):
        image_positions = image_token_mask[b].nonzero(as_tuple=True)[0]
        num_image_tokens = len(image_positions)
        num_vis_tokens = visual_embeddings.shape[1]  # 729

        # 处理数量不匹配的情况
        if num_image_tokens < num_vis_tokens:
            visual_embeds_used = visual_embeddings[b, :num_image_tokens, :]  # 截断
        elif num_image_tokens > num_vis_tokens:
            padding = torch.zeros(num_image_tokens - num_vis_tokens, hidden_size, ...)
            visual_embeds_used = torch.cat([visual_embeddings[b], padding], dim=0)
        else:
            visual_embeds_used = visual_embeddings[b]

        # 替换
        text_embeds[b, image_positions] = visual_embeds_used

    return text_embeds, attention_mask

这个函数的核心思想是:在 input_ids 序列中,找到 <image> 占位 token 的位置,用 MLP 投影后的视觉 embedding 原位置替换。替换后的序列就是一条视觉+文本交织的混合 embedding,可以直接送入 LLM 进行自回归生成。

实际流程:原始文本 "<image>\n请描述这张图片" → tokenize 后 <image> 变成 1 个 token id → 预处理阶段被复制为 729 个 <image> token → embedding 阶段这 729 个位置的文本 embedding 被视觉 embedding 替换。

forward: 完整前向传播

def forward(self, pixel_values=None, input_ids=None,
            attention_mask=None, labels=None, **kwargs):
    # 1. 图像 → 视觉 embedding
    if pixel_values is not None:
        visual_embeddings = self.encode_images(pixel_values)

    # 2. 构建混合 embedding 序列
    if visual_embeddings is not None and input_ids is not None:
        inputs_embeds, attention_mask = self.prepare_inputs_embeds(
            input_ids, visual_embeddings, attention_mask)

        # 3. 送入 LLM(直接传 embedding,不传 input_ids)
        outputs = self.language_model(
            input_ids=None,
            inputs_embeds=inputs_embeds,
            attention_mask=attention_mask,
            labels=labels,
        )
    else:
        # 纯文本模式
        outputs = self.language_model(
            input_ids=input_ids,
            attention_mask=attention_mask,
            labels=labels,
        )

    return {"loss": outputs.loss, "logits": outputs.logits}

对话模板与数据预处理

定义在 src/nanovlm/data/conversation.py,使用 Qwen2 的 ChatML 格式:

<|im_start|>system
You are a helpful vision-language assistant.<|im_end|>
<|im_start|>user
<image> × 729
请描述这张图片。<|im_end|>
<|im_start|>assistant
这是一张...的图片。<|im_end|>

tokenize_conversation() 的另一个关键任务是构建 labels

# assistant 回复之前的部分(system + user)的 labels 设为 -100
# 只有 assistant 回复部分的 labels 保留真实 token id
assistant_start_marker = "<|im_start|>assistant\n"
assistant_start_tokens = tokenizer.encode(assistant_start_marker)

# 在 input_ids 中搜索 assistant 起始位置
for i in range(len(input_ids) - len(assistant_start_tokens) + 1):
    if input_ids[i:i+len(assistant_start_tokens)] == assistant_start_tokens:
        ass_start_pos = i + len(assistant_start_tokens)
        break

# 将 assistant 之前的所有 token 的 label 设为 -100
for i in range(ass_start_pos):
    labels[i] = -100

这样 loss 只在 assistant 回复上计算,user 指令和 system prompt 不参与 loss 计算,与标准的 causal LM 训练一致。

LLaVADatasetsrc/nanovlm/data/dataset.py 中实现了完整的 __getitem__

  1. 加载图像 → PIL Image → image_processorpixel_values (3, 384, 384)
  2. 解析对话 JSON → conversation.tokenize_conversation()input_ids, labels, attention_mask
  3. 返回 {"pixel_values": ..., "input_ids": ..., "labels": ..., "attention_mask": ...}

一阶段训练:模态对齐

训练目标

第一阶段的核心目标是让 Connector 学会将视觉特征映射到 LLM 能理解的语言空间。此时:

  • 视觉编码器 (SigLIP):冻结。已经在大规模图文数据上预训练,具备良好的视觉语义提取能力。
  • 语言模型 (Qwen2-0.5B):冻结。LLM 的语言能力已经很强,无需在此阶段调整。
  • Connector (MLP):训练。这是唯一需要学习的模块,参数量约 50M。

冻结/解冻策略

# NanoVLM.set_stage("stage1") 触发:

# VisionEncoder: 冻结(构造时已设置 freeze=True)
# LanguageModel:
self.model.eval()
for param in self.model.parameters():
    param.requires_grad = False

# Connector: 保持可训练(默认 requires_grad=True)

此时可训练参数仅 ~50M,占总参数 ~1B 的 5%。

训练超参

@dataclass
class Stage1Config:
    num_epochs: int = 1
    per_device_batch_size: int = 1
    gradient_accumulation_steps: int = 8   # 等效 batch_size = 8
    learning_rate: float = 2e-3            # 高学习率,快速对齐
    warmup_ratio: float = 0.03             # 前 3% 步数预热
    weight_decay: float = 0.0              # 不对齐做正则化
    use_fp16: bool = True                  # 混合精度节省显存
    max_seq_length: int = 2048

Stage 1 用较高的学习率 2e-3,因为 Connector 是随机初始化的,需要快速收敛。不使用 weight decay,避免限制 MLP 的表达能力。

训练循环核心代码

NanoVLMTrainer.train_stage1() 的关键流程:

def train_stage1(self, stage1_config):
    # 1. 设置阶段
    self.model.set_stage("stage1")

    # 2. 创建 DataLoader, Optimizer (AdamW), Scheduler (warmup + cosine)
    optimizer, scheduler = self._get_optimizer_and_scheduler(stage1_config, total_steps)
    scaler = torch.amp.GradScaler('cuda', enabled=stage1_config.use_fp16)

    # 3. 训练循环
    for epoch in range(stage1_config.num_epochs):
        for step, batch in enumerate(train_loader):
            # 混合精度前向
            with torch.amp.autocast('cuda', enabled=stage1_config.use_fp16):
                outputs = self.model(pixel_values=pixel_values,
                                     input_ids=input_ids,
                                     attention_mask=attention_mask,
                                     labels=labels)
                loss = outputs["loss"] / gradient_accumulation_steps

            # 梯度累积
            scaler.scale(loss).backward()

            if (step + 1) % gradient_accumulation_steps == 0:
                scaler.unscale_(optimizer)
                grad_norm = clip_grad_norm_(model.parameters(), max_grad_norm=1.0)
                scaler.step(optimizer)
                scaler.update()
                optimizer.zero_grad()
                scheduler.step()   # warmup → cosine 衰减

几个技术要点:

  • 梯度累积per_device_batch_size=1 + gradient_accumulation_steps=8 等效 batch_size=8,在不增加显存的情况下模拟更大的 batch。
  • 混合精度 (AMP):前向用 float16 加速且省显存,反向用 GradScaler 防止小梯度下溢。
  • Warmup + Cosine 调度:前 3% 步数学习率从 0.1×lr 线性升至 lr,之后 cosine 衰减至接近 0。
  • 断点续训:每隔 save_steps 步保存 connector.bin + optimizer.pt + scheduler.pt + scaler.pt,可从中断处恢复。

显存分析

Stage 1 显存需求约 8GB (batch_size=1, fp16),推理如下:

  • SigLIP ViT (fp16):~800MB
  • Qwen2-0.5B (fp16):~1GB
  • Connector 梯度 + 优化器状态:~300MB
  • 中间激活 (2048 tokens):~2-3GB
  • 剩余开销:~2GB

二阶段训练:指令微调

训练目标

第二阶段的目标是让整个模型学会遵循指令。此时:

  • 视觉编码器:继续冻结。视觉特征提取能力保持不变。
  • Connector:继续训练。在第一阶段基础上进一步优化投影。
  • 语言模型:通过 LoRA 低秩适配器微调。让 LLM 学会在视觉上下文中更好地生成回答。

LoRA 配置

@dataclass
class Stage2Config:
    learning_rate: float = 2e-4              # 比 Stage1 低一个数量级
    gradient_accumulation_steps: int = 16    # 等效 batch_size = 16
    # LoRA 参数
    lora_r: int = 8                          # 低秩维度
    lora_alpha: int = 16                     # 缩放因子
    lora_dropout: float = 0.05
    lora_target_modules: tuple = ("q_proj", "v_proj", "k_proj", "o_proj")

_apply_lora() 方法通过 PEFT 库实现:

def _apply_lora(self, stage2_config):
    from peft import LoraConfig, get_peft_model, TaskType

    lora_config = LoraConfig(
        r=8,                    # 低秩矩阵的秩
        lora_alpha=16,          # LoRA 输出的缩放系数
        lora_dropout=0.05,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],  # 只对注意力投影加 LoRA
        task_type=TaskType.CAUSAL_LM,
    )
    self.model.language_model.model = get_peft_model(
        self.model.language_model.model, lora_config
    )

LoRA 的核心思想:对预训练权重矩阵 $W \in \mathbb{R}^{d \times k}$,不直接更新 $W$,而是学习一个低秩分解 $\Delta W = B A$,其中 $B \in \mathbb{R}^{d \times r}$,$A \in \mathbb{R}^{r \times k}$,且 $r \ll \min(d,k)$。最终输出为:

$$h = Wx + \frac{\alpha}{r} \cdot BAx$$

以 Qwen2-0.5B 的 attention 层为例:q_proj 形状为 (896, 896),LoRA 可训练参数仅 896×8 + 8×896 = 14,336,而全量微调需 802,816,参数减少约 56 倍。

Stage 2 训练策略变化

与 Stage 1 相比,Stage 2 有以下变化:

维度Stage 1Stage 2
训练目标模态对齐指令跟随
可训练模块仅 Connector (~50M)Connector + LoRA (~60M)
学习率2e-32e-4
梯度累积816
数据558K 图文对 (LLaVA-Pretrain)80K-150K 指令数据 (LLaVA-Instruct)
显存~8GB~12GB

学习率从 2e-3 降至 2e-4,因为此时 Connector 已有初步对齐能力,且 LLM 通过 LoRA 做小幅度适配,不宜用过大学习率。

模型保存

def save_pretrained(self, save_dir, save_config=True, save_tokenizer=True):
    # 保存 Connector 权重(训练的主要产出)
    torch.save(self.connector.state_dict(), "connector.bin")

    # 保存 LoRA 权重(Stage 2 独有)
    if isinstance(self.language_model.model, PeftModel):
        self.language_model.model.save_pretrained("lora/")

    # 保存 config.json + tokenizer

保存策略:config.json 和 tokenizer 在训练开始时保存一次(训练过程中不变),后续 checkpoint 只保存 connector.binlora/,避免重复保存静态文件。


推理生成

推理生成器定义在 src/nanovlm/inference/generator.py,核心流程:

def generate(self, image, question):
    # 1. 构建对话(system + user + generation_prompt)
    prompt = conversation.apply_chat_template(conversations, add_generation_prompt=True)
    # "<|im_start|>system\n...<|im_end|>\n<|im_start|>user\n<image>×729...<|im_end|>\n<|im_start|>assistant\n"

    # 2. 预处理图像: PIL → pixel_values (1, 3, 384, 384)

    # 3. Tokenize 文本

    # 4. encode_images → visual_embeddings (1, 729, 896)

    # 5. prepare_inputs_embeds → 混合 embedding 序列

    # 6. 自回归生成(KV Cache)
    for _ in range(max_new_tokens):
        if past_key_values is None:
            # 首步:传入完整 embedding 序列
            outputs = lm_model(inputs_embeds=inputs_embeds, use_cache=True)
        else:
            # 后续步骤:只传入最后 1 个 token 的 embedding + KV Cache
            current_embed = embed_tokens(generated[:, -1:])
            outputs = lm_model(inputs_embeds=current_embed,
                               past_key_values=past_key_values, use_cache=True)

        logits = outputs.logits[:, -1, :]
        next_token = sample(logits, temperature, top_k, top_p)
        generated = torch.cat([generated, next_token], dim=-1)

        if next_token == eos_token_id:
            break

    # 7. 解码输出
    response = tokenizer.decode(generated[0, input_len:])

生成时的采样策略支持:

  • Temperaturelogits = logits / temperature,调节输出的随机性
  • Top-K:只保留概率最高的 K 个 token
  • Top-P (Nucleus):保留累积概率 ≤ p 的最小 token 集合
  • Repetition Penalty:惩罚重复 token
  • KV Cache:后续步骤复用之前的 Key/Value 状态,避免重复计算

交互模式 chat() 支持持续加载图片和多轮对话。


效果验证

在 RTX 5060 Ti (16GB) 上对两阶段分别做了效果验证。Stage 1 使用 LLaVA-Pretrain 558K 训练 1 epoch,Stage 2 使用 LLaVA-Instruct 150K + LoRA (r=8) 训练 1 epoch。

测试图片 1:便当盒

便当盒

阶段输入输出
Stage 1Please describe this image in detail.a lunchbox with vegetables and fruits in it
Stage 2Please describe this image in detail.The image features a colorful lunchbox filled with a variety of foods, including a sandwich, a baked apple, and several broccoli. The sandwich has a large piece of meat and a side of cheese...
Stage 1How many people are in this image?a lunchbox with vegetables and fruits in it
Stage 2How many people are in this image?There are two people in this image, a child and a parent.

分析:Stage 1 无论问什么,都只输出固定简短 caption,完全忽略问题。Stage 2 学会了遵循指令——“describe in detail” 输出丰富细节,“How many people” 尝试回答人数(虽然图中实际没有人物,但至少体现了指令跟随能力的出现)。

测试图片 2:长颈鹿(中文)

长颈鹿

阶段输入输出
Stage 1图中有什么?请详细描述。two giraffes standing on a tree
Stage 2图中有什么?请详细描述。在图片中,有两只成年长颈鹿正在树下享用树上的食物。它们的鼻子伸进树洞,试图找到食物...

分析:Stage 2 中文输出流畅自然,细节丰富。Stage 1 同样只输出最简 caption。这说明 LoRA 微调不仅激活了指令跟随能力,也让 Qwen2 的中文能力在视觉上下文中被释放。

纯文本(无图)

阶段输入输出
Stage 1What is the capital of France?The capital of France is Paris.
Stage 2What is the capital of France?The capital of France is Paris.

分析:两阶段纯文本问答能力相同,说明 LoRA 微调没有损害 LLM 的通用语言能力。

验证结论

两阶段训练的效果差异和原理预测完全一致:

  • Stage 1(仅训 Connector):Connector 学会了把视觉特征"翻译"到 LLM 能理解的 embedding 空间,LLM 能"看到"图像内容。但 LLM 本身是冻结的,没有被教过"如何根据图像回答问题",所以只能输出固定的简短描述,完全忽略指令。

  • Stage 2(Connector + LoRA):LoRA 让 LLM 学会了在视觉上下文中遵循指令。“describe in detail” 会输出丰富细节,“图中有什么” 会用中文回答,“How many people” 会尝试计数。指令跟随能力从无到有的跃迁,是 Stage 2 最重要的质变。


总结

NanoVLM 是一个约 1B 参数的轻量级 VLM,核心架构遵循 LLaVA 范式:

  1. SigLIP ViT (400M) 提取视觉特征 → MLP Connector (50M) 投影到 LLM 空间 → Qwen2-0.5B (500M) 自回归生成
  2. 两阶段训练:Stage 1 模态对齐(仅训 Connector)+ Stage 2 指令微调(Connector + LoRA)
  3. 关键实现细节:prepare_inputs_embeds 中的视觉/文本 token 替换、ChatML 对话模板、labels 掩码、梯度累积+混合精度训练

整个项目约 3000+ 行 Python 代码,结构清晰,可在单张 24GB 消费级 GPU 上完成完整的两阶段训练,适合作为学习 VLM 架构和训练流程的起点。

参考资料

  • LLaVA — 多模态指令跟随
  • SigLIP — Sigmoid Loss for Language Image Pre-training
  • Qwen2 — Qwen Technical Report
  • LoRA — Low-Rank Adaptation of LLMs