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

其中, 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 Encoder | CLIP ViT-L/14@224² | ~300M |
| Projector | 单层 Linear (1024→4096) | ~4.2M |
| LLM | Vicuna-7B/13B | 7B/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 1 | CC3M(Conceptual Captions)过滤 | 595K 图文对 | 单轮:随机指令 → 图片描述 |
| Stage 2 | LLaVA-Instruct | 158K 指令 | 多轮对话/问答/推理(基于 80K 张 COCO 图,GPT-4 生成) |
两阶段训练:
| 训练维度 | Stage 1(特征对齐) | Stage 2(指令微调) |
|---|---|---|
| 可训练模块 | 仅 Linear Projector | Linear Projector + 整个 LLM |
| 冻结模块 | CLIP + Vicuna | CLIP |
| 学习率 | 2e-3 | 2e-5 |
| Batch Size | 128 | 32 |
| Epochs | 1 | 3 |
| 硬件 | 8×A100,~4小时 | 8×A100,~10小时 |
与当前 NanoVLM 的详细对照:
视觉编码器
| 维度 | 原始 LLaVA 1.0 | NanoVLM(当前) |
|---|---|---|
| 模型 | CLIP ViT-L/14 | SigLIP SO400M-patch14 |
| 输入分辨率 | 224×224 | 384×384 |
| Patch Size | 14 | 14 |
| 视觉 Token 数 | $256$(= $(224/14)^2$) | $729$(= $(384/14)^2$) |
| 特征维度 | 1024 | 1152 |
| 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.0 | NanoVLM(当前) |
|---|---|---|
| 架构 | 单层 Linear | 2 层 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.0 | NanoVLM(当前) |
|---|---|---|
| 模型 | Vicuna-7B/13B | Qwen2-0.5B-Instruct |
| 基座模型 | LLaMA v1 | Qwen2 |
| Embedding 维度 | 4096 | 896 |
| 总参数量 | 7B / 13B | 0.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.0 | NanoVLM(当前) |
|---|---|---|
| Stage 1 | ||
| 可训练模块 | 仅 Linear Projector(~4.2M) | 仅 Connector MLP(~50M) |
| 学习率 | 2e-3 | 2e-3 |
| 等效 Batch Size | 128 | 8(per_device=1 × grad_accum=8) |
| Epochs | 1 | 1 |
| Stage 2 | ||
| 可训练模块 | Projector + 全部 LLM(~7B) | Connector + LoRA(~60M) |
| 可训练参数占比 | ~100% of LLM | ~1.2% of LLM(仅 Attention Q/K/V/O) |
| 学习率 | 2e-5 | 2e-4 |
| 等效 Batch Size | 32 | 16(per_device=1 × grad_accum=16) |
| Epochs | 3 | 1 |
| 通用 | ||
| 优化器 | Adam | AdamW |
| 数值精度 | BF16 + TF32 | FP16(混合精度 + GradScaler) |
| 梯度裁剪 | — | max_norm=1.0 |
| LR 调度 | Cosine + 3% warmup | Cosine + 3% warmup |
| 最大序列长度 | 2048 | 2048 |
| 内存优化 | FSDP + Gradient Checkpointing | Gradient 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.0 | NanoVLM(当前) |
|---|---|---|
| Image Token 放置 | Prepend(视觉 token 拼接在文本序列前端) | Replace(<image> 占位符 → 原位替换为 729 个视觉 token) |
| 对话格式 | 模板字符串(如 Human: ...\nAssistant: ...) | Qwen2 ChatML(`< |
| 采样策略 | Greedy / Temperature | Temperature=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 参数,会带来两个问题:
- 显存爆炸:全量微调需要存储所有参数的梯度、优化器状态(AdamW 需要 momentum + variance 各一份),显存需求约为推理的 4 倍,远超消费级 GPU 的 24GB。
- 灾难性遗忘:全量更新 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_size 和 llm_hidden_size 是手工预设值,但模型初始化时会用真实加载到的维度覆盖,避免本地权重与配置不一致。
训练配置定义在 configs/training_config.py 中,两阶段超参差异明显:
| 超参 | Stage 1 | Stage 2 |
|---|---|---|
| learning_rate | 2e-3 | 2e-4 |
| gradient_accumulation_steps | 8 | 16 |
| 可训练模块 | 仅 Connector | Connector + LLM (LoRA) |
| 冻结模块 | Vision + LLM | Vision |
视觉编码器 (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 1:
self.model.eval()+ 所有参数requires_grad=False - Stage 2:
self.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 训练一致。
LLaVADataset 在 src/nanovlm/data/dataset.py 中实现了完整的 __getitem__:
- 加载图像 → PIL Image →
image_processor→pixel_values(3, 384, 384) - 解析对话 JSON →
conversation.tokenize_conversation()→input_ids,labels,attention_mask - 返回
{"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 1 | Stage 2 |
|---|---|---|
| 训练目标 | 模态对齐 | 指令跟随 |
| 可训练模块 | 仅 Connector (~50M) | Connector + LoRA (~60M) |
| 学习率 | 2e-3 | 2e-4 |
| 梯度累积 | 8 | 16 |
| 数据 | 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.bin 和 lora/,避免重复保存静态文件。
推理生成
推理生成器定义在 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:])
生成时的采样策略支持:
- Temperature:
logits = 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 1 | Please describe this image in detail. | a lunchbox with vegetables and fruits in it |
| Stage 2 | Please 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 1 | How many people are in this image? | a lunchbox with vegetables and fruits in it |
| Stage 2 | How 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 1 | What is the capital of France? | The capital of France is Paris. ✅ |
| Stage 2 | What 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 范式:
- SigLIP ViT (400M) 提取视觉特征 → MLP Connector (50M) 投影到 LLM 空间 → Qwen2-0.5B (500M) 自回归生成
- 两阶段训练:Stage 1 模态对齐(仅训 Connector)+ Stage 2 指令微调(Connector + LoRA)
- 关键实现细节:
prepare_inputs_embeds中的视觉/文本 token 替换、ChatML 对话模板、labels 掩码、梯度累积+混合精度训练
整个项目约 3000+ 行 Python 代码,结构清晰,可在单张 24GB 消费级 GPU 上完成完整的两阶段训练,适合作为学习 VLM 架构和训练流程的起点。