前面已经看过 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=768 | SigLIP 输出维度。 |
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 的基本范式。