MiniMind 学习笔记 12:从 MiniMind-V 看懂 VLM,SigLIP、Projector 与图像 Token 注入

前面已经看过 MiniMind 的 tokenizer、dataset、训练循环、模型结构和推理生成。现在可以进入多模态版本:MiniMind-V。

VLM 看起来像是一个新模型,但 MiniMind-V 的核心思路非常直接:

LLM 原本只能接收文本 token embedding。
现在我们把图片也变成一串 embedding,
再把它们塞进 LLM 的输入序列里。

所以 MiniMind-V 不是重写一个大模型,而是在 MiniMind LLM 外面加了三块:

SigLIP processor:把图片整理成视觉模型输入。
SigLIP vision encoder:把图片编码成视觉 token。
MMVisionProjector:把视觉 token 映射到 MiniMind hidden space。

主线可以画成:

PIL.Image
-> SiglipImageProcessor
-> pixel_values
-> SiglipVisionModel
-> visual tokens
-> MMVisionProjector
-> 替换 <|image_pad|> 的 embedding
-> MiniMind LLM
-> 生成文本

1. 为什么 LLM 不能直接看图片

MiniMind LLM 的输入是 token id:

input_ids: [12, 35, 98, ...]

这些 token id 会先经过 embedding table:

token id -> continuous embedding

然后 Transformer 处理的是连续向量:

[seq_len, hidden_size]

图片本身不是 token id,也不能直接查 LLM 的 embedding table。因此 VLM 的关键问题变成:

如何把图片变成 LLM 能接收的 hidden states?

MiniMind-V 的答案是:

用 SigLIP 把图片变成视觉 token,
再用 Projector 把视觉 token 映射到 LLM hidden space。

2. SigLIP Processor:只做图片预处理

MiniMind-V 里加载 processor 的代码在 model/model_vlm.py

processor = SiglipImageProcessor.from_pretrained(model_path)

它的作用不是理解图片,也不是输出语义 embedding,而是把原始图片整理成视觉编码器能接收的 pixel_values

代码入口是:

@staticmethod
def image2tensor(image, processor):
    if image.mode in ['RGBA', 'LA']:
        image = image.convert('RGB')
    inputs = processor(images=image, return_tensors="pt")
    return inputs

典型流程是:

PIL.Image
-> RGB
-> resize
-> rescale
-> normalize
-> HWC 转 CHW
-> batch 化
-> pixel_values

对 MiniMind-V 当前使用的 siglip2-base-p32-256-ve 来说,输入目标是:

pixel_values: [1, 3, 256, 256]

也就是说,processor 做的是“图片标准化”,不是“图片理解”。

3. SigLIP VisionModel:把图片变成视觉 token

SiglipVisionModel 不在 MiniMind-V 仓库里实现,它来自 Hugging Face transformers

from transformers import SiglipImageProcessor, SiglipVisionModel

模型加载逻辑是:

model = SiglipVisionModel.from_pretrained(model_path)
for param in model.parameters():
    param.requires_grad = False
return model.eval(), processor

这里有两个关键信息:

1. SigLIP 是预训练视觉编码器。
2. MiniMind-V 默认冻结 SigLIP,只把它当特征提取器。

SigLIP vision model 的结构可以简化成一个 ViT:

pixel_values
-> Conv2d patch embedding
-> position embedding
-> Transformer encoder layers
-> post layernorm
-> last_hidden_state

MiniMind-V 使用的是 P32 视觉模型:

输入尺寸: 256 x 256
patch size: 32 x 32
patch 网格: 8 x 8
视觉 token 数: 64

因此输出大致是:

last_hidden_state: [batch, 64, 768]

这里的 64 就是后面 64 个 <|image_pad|> 的来源。

4. 视觉 token 长度一定固定吗

不一定。

MiniMind-V 当前是固定长度:

任意图片
-> processor
-> 256x256
-> SigLIP P32
-> 64 个视觉 token

固定长度的好处是:

实现简单;
batch shape 稳定;
显存容易估算;
和固定位置编码兼容。

但很多更强的 VLM 会支持动态分辨率、切图、padding、anyres 或 NaViT packing。这时视觉 token 长度可以变化:

224x224, patch=14 -> 16x16 = 256 tokens
336x336, patch=14 -> 24x24 = 576 tokens
448x448, patch=14 -> 32x32 = 1024 tokens

所以更准确的说法是:

VLM 都要把图片处理成视觉编码器可接受的张量,
但视觉 token 长度可以固定,也可以可变。

MiniMind-V 为了极简和低成本,选择了固定 64 个视觉 token。

5. Projector:从视觉空间到 LLM hidden space

即使用了 CLIP/SigLIP 这类做过图文对齐的视觉模型,VLM 仍然需要 projector。

原因是:

SigLIP 的视觉语义空间 != MiniMind 的 LLM hidden space

MiniMind-V 的 projector 很小:

class MMVisionProjector(nn.Module):
    def __init__(self, in_dim, out_dim, source_tokens=64, target_tokens=64):
        super().__init__()
        self.mlp = nn.Sequential(
            nn.LayerNorm(in_dim),
            nn.Linear(in_dim, out_dim),
            nn.GELU(),
            nn.Linear(out_dim, out_dim),
        )

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

形状上是:

SigLIP output: [batch, 64, 768]
Projector output: [batch, 64, hidden_size]

如果 MiniMind 的 hidden_size 也是 768,形状看起来没变,但向量空间已经被重新映射:

视觉 encoder space -> LLM hidden space

Projector 学到的不是“如何看图”,而是:

什么样的视觉向量放进 LLM 后,能让 LLM 生成正确文本。

6. <|image_pad|>:图片在文本序列里的占位符

MiniMind-V 的配置是:

class VLMConfig(MiniMindConfig):
    def __init__(self, image_special_token='<|image_pad|>', image_ids=[12], **kwargs):
        self.image_special_token = image_special_token
        self.image_ids = image_ids
        self.image_hidden_size = kwargs.get("image_hidden_size", 768)
        self.image_token_len = kwargs.get("image_token_len", 64)
        super().__init__(**kwargs)

几个关键字段:

字段含义
image_special_token文本里的图像占位符。
image_ids=[12]`<
image_hidden_size=768SigLIP 输出维度。
image_token_len=64一张图占 64 个视觉 token。

数据里通常写:

<image>
请描述这张图片

进入模型前会替换成:

<|image_pad|><|image_pad|>...<|image_pad|>  # 64 个
请描述这张图片

注意:<|image_pad|> 自身并不携带图片信息,它只是占位。真正的图片信息在后面会替换到这些位置的 embedding 上。

7. 核心函数:count_vision_proj

MiniMind-V 最关键的代码是 count_vision_proj

@torch.compiler.disable
def count_vision_proj(self, tokens, h, vision_tensors=None, seqlen=512):
    if vision_tensors is None or not self.config.image_ids:
        return h
    marker, vf = self.config.image_ids[0], vision_tensors
    if vf.dim() == 3:
        vf = vf.unsqueeze(1)
    out = []
    for b in range(h.size(0)):
        hb, seq, k, i = h[b], tokens[b].tolist(), 0, 0
        while i < len(seq):
            if seq[i] == marker:
                start = i
                while i < len(seq) and seq[i] == marker:
                    i += 1
                if k < vf.size(1):
                    hb = torch.cat((hb[:start], vf[b][k][:i - start], hb[i:]), dim=0)[:seqlen]
                    k += 1
            else:
                i += 1
        out.append(hb)
    return torch.stack(out)

它做的事情是:

找到 input_ids 里连续的 <|image_pad|>
把这段 token 原本的 embedding 替换成视觉 embedding

其中最关键的一行是:

hb = torch.cat((hb[:start], vf[b][k][:i - start], hb[i:]), dim=0)[:seqlen]

假设序列是:

[文本A, <|image_pad|> x 64, 文本B]

原始 embedding 是:

[文本A embedding, image_pad embedding x64, 文本B embedding]

替换后变成:

[文本A embedding, vision embedding x64, 文本B embedding]

所以这里不是修改 token id,而是修改 token id 查表之后得到的 embedding。

这一点非常关键:

input_ids 里仍然是 <|image_pad|> 的 token id;
但进入 Transformer 的向量已经换成了图片视觉向量。

8. forward:图像和文本在哪里合流

MiniMindVLM.forward 可以分成几段。

第一步,文本 token 先正常 embedding:

hidden_states = self.model.dropout(self.model.embed_tokens(input_ids))

到这里为止,它还是普通 LLM。

第二步,如果有图片,并且当前是 prompt 阶段,就注入视觉信息:

if pixel_values is not None and start_pos == 0:
    ...

start_pos == 0 很重要。它表示当前正在处理完整 prompt。后续生成 token 时,视觉上下文已经在 KV cache 里,不需要每一步都重新跑 SigLIP。

单图常见路径是:

vision_tensors = self.vision_proj(
    MiniMindVLM.get_image_embeddings(pixel_values, self.vision_encoder)
)
hidden_states = self.count_vision_proj(
    tokens=input_ids,
    h=hidden_states,
    vision_tensors=vision_tensors,
    seqlen=input_ids.shape[1]
)

到这里,图像和文本完成合流。

后面就回到普通 MiniMind LLM:

hidden_states
-> MiniMindBlock x N
-> final norm
-> lm_head
-> logits

训练时,如果传入 labels,就计算 next-token loss:

loss = F.cross_entropy(
    shift_logits.view(-1, shift_logits.size(-1)),
    shift_labels.view(-1),
    ignore_index=-100
)

ignore_index=-100 表示只训练 assistant 回复部分,用户 prompt、padding、image pad 位置不参与 loss。

9. Projector 是如何训练的

Projector 训练数据仍然是图文对话样本,不是特殊格式。

MiniMind-V 的 parquet 样本主要包含:

conversations: json string
image_bytes: binary 或 list[binary]

Pretrain 阶段常见样本是 caption:

[
  {
    "role": "user",
    "content": "<image>\n请提供对图片的详细文字描述。"
  },
  {
    "role": "assistant",
    "content": "这张图片展示的是一个..."
  }
]

进入模型前会变成两路:

文本侧:
<image> -> 64 个 <|image_pad|> -> tokenizer -> input_ids / labels

图像侧:
image_bytes -> PIL.Image -> SigLIP processor -> pixel_values

训练目标仍然是 assistant 回复:

给定图片和用户问题,预测 assistant 的下一个文本 token。

如果 freeze_llm=2,也就是只训练 projector:

冻结: SigLIP vision encoder
冻结: MiniMind LLM
训练: MMVisionProjector

梯度路径是:

assistant token loss
-> frozen LLM 反向传播但不更新参数
-> 视觉 embedding 注入的位置
-> projector
-> 更新 projector 参数

这就是为什么即使 SigLIP 已经做过图文对齐,projector 仍然需要训练。SigLIP 让视觉特征有语义,projector 让这些语义变成 LLM 能用来生成文本的 hidden states。

10. Pretrain 与 SFT 的分工

MiniMind-V 的训练通常分两种阶段。

Pretrain 阶段:

数据:图片 caption
任务:看图描述
目标:让 projector 完成基础视觉语言对齐
默认策略:freeze_llm=2,只训练 projector

SFT 阶段:

数据:图像问答、描述、推理、纯文本混合
任务:按用户指令回答
目标:让模型学会利用图片完成对话
默认策略:freeze_llm=1,训练 projector + LLM 首尾层

为什么 SFT 不直接全参训练?

因为 MiniMind-V 的 LLM 很小,全参训练容易破坏原语言能力。保守冻结可以让中间层保留语言知识,只让首层适配视觉输入、末层适配回答风格。

可以这样理解:

SigLIP 负责看图;
Projector 负责翻译视觉特征;
LLM 首层负责融合视觉 token;
LLM 中间层保留语言能力;
LLM 末层负责生成回答。

11. VLM 训练时最容易踩的坑

第一,<|image_pad|> 数量必须和视觉 token 数一致。

MiniMind-V 是:

64 个 <|image_pad|> <-> 64 个 SigLIP patch token

如果更换视觉 encoder、输入分辨率或 patch size,必须同步修改 image_token_len 和数据处理逻辑。

第二,labels 只能覆盖 assistant 回复。

用户问题、system prompt、image pad、padding 都应该是:

-100

否则模型会被训练去复读 prompt 或预测占位符。

第三,processor 要和视觉 encoder 匹配。

不要随便手写 resize 和 normalize。应该使用:

SiglipImageProcessor.from_pretrained(model_path)

第四,loss 下降不等于视觉能力好。

需要固定一组 eval 图片和问题,观察:

能否描述主体;
颜色、数量、位置是否可靠;
是否幻觉;
是否能按问题回答;
纯文本能力是否下降。

第五,视觉 token 会占用 LLM 上下文长度。

MiniMind-V 当前每张图只占 64 个 token,成本很低。如果改成更高分辨率,视觉 token 数可能涨到几百甚至上千,attention 成本会明显增加。

12. 一句话总结 MiniMind-V

MiniMind-V 的核心不是神秘的“多模态魔法”,而是一个很清晰的工程桥接:

把图片切成视觉 token,
把视觉 token 投影到 LLM hidden space,
再把它们替换进文本序列里的 <|image_pad|> 位置,
最后让原来的 MiniMind LLM 按 next-token prediction 生成回答。

如果只抓一条代码主线,就是:

image2tensor
-> get_image_embeddings
-> vision_proj
-> count_vision_proj
-> MiniMind layers
-> lm_head

把这条链路读通,就能理解大多数 LLaVA-style VLM 的基本范式。

发表评论