MiniMind-V
MiniMind-V 是一个极简多模态视觉语言模型(VLM,Vision-Language Model)开源项目。核心目标:用最低成本实现多模态对话能力。最小版本参数量仅 GPT3 的约 1/2600,单张 NVIDIA RTX 3090 即可完成训练和推理。
什么是 VLM?
VLM(Vision-Language Model)让大语言模型同时理解图像和文本。传统 LLM 输入输出都是文本;VLM 在此基础上增加了视觉感知能力——给定一张图片,模型可以回答关于这张图片的问题,或者根据图片内容进行对话。
当前主流 VLM 架构分为两条路线:架构融合派(如 LLaVA,用 Projector 连接视觉编码器和 LLM)和语言模型多模态扩展派(如 GPT-4V,让 LLM 原生支持图像 token)。MiniMind-V 属于第一种,用极简的方式复现了这条路线的核心设计。
「用乐高拼出一架飞机,远比坐在头等舱里飞行更让人兴奋!」
MiniMind-V 的架构可以概括为三段式管线:
| 组件 | 模型 | 参数量 | 状态 |
|---|---|---|---|
| Vision Encoder | SiglipVisionModel (siglip2-base-p32-256-ve) | ~95M | 冻结,仅提取特征 |
| Projector | MLP(2 层 Linear + GELU + LayerNorm) | ~1M | 可训练 |
| LLM 主干 | MiniMind(8 层,hidden=768) | ~64M | 部分冻结(首尾层解冻) |
推理时整机参数量约 160M(dense)或 294M(MoE 版本)。训练时仅 Projector + LLM 首尾层参与梯度更新,实际可训练参数约 65M。
model/model_vlm.py 中的 MiniMindVLM 是整个项目的核心类,继承自 MiniMindForCausalLM:
class MiniMindVLM(MiniMindForCausalLM):
config_class = VLMConfig
def __init__(self, config: VLMConfig = None,
vision_model_path="./model/siglip2-base-p32-256-ve"):
self.config = config or VLMConfig()
super().__init__(self.config)
# 冻结的视觉编码器
self.vision_encoder, self.processor = \
self.__class__.get_vision_model(vision_model_path)
# 可训练的投影器
self.vision_proj = MMVisionProjector(
self.config.image_hidden_size, # SigLIP2 输出维度 768
self.config.hidden_size, # LLM 隐藏层维度 768
target_tokens=self.config.image_token_len # 64
)
关键设计:MMVisionProjector 是一个极简 MLP,将 SigLIP2 的视觉 token 映射为 LLM 可接受的表示。相比 QFormer 等重型投影器(来自 BLIP-2,需额外训练 Query 嵌入),MLP 投影器参数量仅约 1M,训练速度快。
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), # 768 → 768
nn.GELU(), # 非线性激活
nn.Linear(out_dim, out_dim), # 768 → 768
)
def forward(self, x):
return self.mlp(x)
如何把视觉特征嵌入到文本 token 序列中?MiniMind-V 使用 <|image_pad|> 作为图像占位符 token(token id = 12),在前向传播中通过 count_vision_proj 方法将视觉特征替换进去:
@torch.compiler.disable
def count_vision_proj(self, tokens, h,
vision_tensors=None, seqlen=512):
marker = self.config.image_ids[0] # token id = 12
vf = vision_tensors
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: # 找到连续的 image_pad
start = i
while i < len(seq) and seq[i] == marker:
i += 1
if k < vf.size(1):
# 用视觉 token 替换对应位置的 image_pad
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)
设计思路:遍历文本 token 序列,遇到连续的 <|image_pad|> 标记就用对应帧的视觉特征替换。替换后序列长度可能变化,截断到 seqlen。这种方法不需要复杂的 attention mask,实现简洁高效。
model/model_minimind.py 中的 LLM 主干完全复用 MiniMind 纯语言模型的结构:
- 8 层 Decoder Block(
MiniMindBlock),hidden_size = 768 - Attention:GQA(Grouped Query Attention),8 个 query heads,4 个 kv heads;每层有独立的
q_norm和k_norm(RMSNorm) - MLP:标准 SwiGLU 激活;如果
use_moe=True,则使用MOEFeedForward(4 个专家,top-1 路由) - 位置编码:YaRN(Yet another RoPE extensioN)支持长上下文扩展,max_position_embeddings = 32768
背景:GQA(Grouped Query Attention)
GQA 将 query 分成多个 heads,但只保留少量 key/value heads(这里是 4 个 kv heads 对比 8 个 query heads)。相比标准 Multi-Head Attention,GQA 在保持效果的同时大幅降低 kv cache 占用,对长序列生成尤为重要。Llama 2/3、Mistral 等主流模型均采用此设计。
class MiniMindBlock(nn.Module):
def __init__(self, layer_id: int, config: MiniMindConfig):
super().__init__()
self.self_attn = Attention(config)
self.input_layernorm = RMSNorm(config.hidden_size)
self.post_attention_layernorm = RMSNorm(config.hidden_size)
# Dense 或 MoE 二选一
self.mlp = (FeedForward(config) if not config.use_moe
else MOEFeedForward(config))
def forward(self, hidden_states, position_embeddings, ...):
residual = hidden_states
hidden_states, present = self.self_attn(
self.input_layernorm(hidden_states),
position_embeddings, ...)
hidden_states += residual # 残差连接
hidden_states = hidden_states + \
self.mlp(self.post_attention_layernorm(hidden_states))
return hidden_states, present
当 use_moe=True 时,每层的 MLP 被替换为 Mixture-of-Experts(混合专家)模块。总参数量增加到 ~200M,但每次推理仅激活 ~65M(top-1 路由)。
class MOEFeedForward(nn.Module):
def __init__(self, config: MiniMindConfig):
super().__init__()
self.gate = nn.Linear(
config.hidden_size, config.num_experts, bias=False)
self.experts = nn.ModuleList([
FeedForward(config)
for _ in range(config.num_experts) # 4 个专家
])
def forward(self, x):
x_flat = x.view(-1, hidden_dim)
scores = F.softmax(self.gate(x_flat), dim=-1)
topk_weight, topk_idx = torch.topk(
scores, k=1, dim=-1) # Top-1 路由
y = torch.zeros_like(x_flat)
for i, expert in enumerate(self.experts):
mask = (topk_idx == i)
if mask.any():
token_idx = mask.any(-1).nonzero().flatten()
weight = topk_weight[mask].view(-1, 1)
y.index_add_(0, token_idx,
(expert(x_flat[token_idx]) * weight))
return y.view(batch_size, seq_len, hidden_dim)
背景:MoE 辅助损失(Auxiliary Loss)
MoE 的核心问题是负载不均衡——如果所有 token 都被路由到同一个 expert,其他 expert 就浪费了。辅助损失通过惩罚 expert 被选中的不均匀程度来缓解这个问题。
# 辅助损失:鼓励负载均衡
load = F.one_hot(topk_idx, self.config.num_experts).float().mean(0)
self.aux_loss = (
load * scores.mean(0)
).sum() * self.config.num_experts * self.config.router_aux_loss_coef
SFT(Supervised Fine-Tuning)训练流程核心步骤如下:
背景:Pretrain 与 SFT 的区别
Pretrain 让 Projector 学习"图像→语言"的基础对应关系;SFT 则让模型学习"根据图像回答问题"的对话能力。MiniMind-V 的 SFT 数据(290 万条)已包含 Pretrain 子集,可直接跳过 Pretrain 阶段。
# 1. 初始化模型,加载 LLM 基座权重
model, tokenizer, preprocess = init_vlm_model(
vlm_config, from_weight=args.from_weight,
freeze_llm=args.freeze_llm
)
# 2. 冻结策略(默认 freeze_llm=1):
# - vision_proj:全部可训练
# - LLM 首尾层(layers.0 和 layers.7):解冻
# - LLM 中间层(layers.1-6):冻结
for name, param in model.model.named_parameters():
if 'layers.0.' in name or f'layers.{last_idx}.' in name:
param.requires_grad = True # 解冻首尾层
# 3. 数据:Parquet 格式,image_bytes + conversations
train_ds = VLMDataset(
args.data_path, tokenizer, preprocess, ...)
# 4. 训练:bf16 混合精度 + 梯度累积 + 断点续训
# 每 1000 step 原子性保存(.tmp + os.replace)
关键训练参数:batch_size=4,learning_rate=5e-6,max_seq_len=450,支持 torchrun --nproc_per_node N 多卡 DDP。
Parquet 格式数据集包含两列:conversations(JSON 字符串)和 image_bytes(图像原始字节列表)。
class VLMDataset(Dataset):
def __getitem__(self, index: int):
conversations = json.loads(
self.table['conversations'][index].as_py())
image_bytes = self.table['image_bytes'][index].as_py()
# 图像处理:Byte → PIL → SigLIP2 processor → tensor
image_inputs_list = [
MiniMindVLM.image2tensor(
Image.open(io.BytesIO(img)), self.preprocess
) for img in image_bytes
]
# 标签生成:仅 assistant 回复区间有 loss
labels = self.generate_labels(input_ids)
# <|im_start|>assistant\n ... <|im_end|>\n
return input_ids, labels, image_data
generate_labels 的巧妙设计:找到 <|im_start|>assistant\n 的位置,到 <|im_end|>\n 为止的 token 才计算 loss,system 和 user prompt 的 token 被置为 -100(忽略)。这样模型只学习"如何回答",不会试图记忆用户的提问。
背景:Parquet 数据格式
2025-12-27 起数据集统一为 Parquet 格式,替代之前的零散图像文件 + JSON 格式。Parquet 列式存储 + 压缩,图文一体化,体积更小,加载更快。290 万条图文对话数据经全局 dictionary encoding 去重后,体积只比 SFT 原版多 ~10%。
每次保存同时写入两个文件:
- 模型权重:
out/sft_vlm_768.pth(半精度,仅非 vision_encoder 参数) - 训练状态:
checkpoints/sft_vlm_768_resume.pth(model + optimizer + scaler + epoch + step + wandb_id)
续训时自动检测 GPU 数量变化,按比例换算 step 数。原子性保存:临时文件 + os.replace 防中断损坏。
使用 siglip2-base-p32-256-ve(P32 配置),输出 256×256 分辨率的 token,冻结不训练。SigLIP2 相比 CLIP 改进了训练目标(sigmoid loss 而非 InfoNCE),在图像描述任务上表现更好。
重写了 generate 方法以支持多图片输入和多回复采样:
def generate(self, *args, num_return_sequences=1, **kwargs):
if num_return_sequences > 1 and 'pixel_values' in kwargs:
pv = kwargs['pixel_values']
# 多图时 repeat pixel_values
kwargs['pixel_values'] = {
k: v.repeat(num_return_sequences, ...)
for k, v in pv.items()
}
return super().generate(*args, **kwargs)
MiniMind-V 与 LLaVA 的核心思路一致:冻结的视觉编码器 + 轻量投影器 + 语言模型主干。区别在于 MiniMind-V 将所有组件压缩到极致——65M 可训练参数 vs LLaVA 的 7B/13B,让个人开发者也能从零训练一个多模态模型。
MiniMind-V 的核心价值不在于刷 SOTA,而在于用最少的资源展示 VLM 的完整技术栈:
- 模型层面:SigLIP2 视觉编码 + MLP 投影 + MiniMind 语言主干,结构清晰
- 训练层面:DDP 多卡 + bf16 混合精度 + 断点续训 + 梯度裁剪,工程完整
- 成本控制:65M 可训练参数,3 块钱成本可验证,降低了多模态研究的准入门槛
对于想入门多模态大模型的同学,MiniMind-V 是一个极好的起点——它的代码量不大,但覆盖了从图像编码、特征投影、多模态融合到训练的完整闭环。