ESC
输入关键词搜索文章
目录

MiniMind-V

65M 参数 · 多模态视觉语言模型
源码解读 · 架构分析 · 工程实践
65M可训练参数
160M推理总参数
训练成本
2hSFT 训练
Chapter 0
项目定位与背景

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 项目作者
Chapter 1
核心架构总览

MiniMind-V 的架构可以概括为三段式管线:

数据流:图像 → SigLIP2 Vision Encoder(冻结)→ MLP Projector → 视觉 Token 序列 → 与文本 Token 拼接 → MiniMind LLM(主干语言模型)→ 预测下一个 Token
VLM 架构图
MiniMind-V Dense 架构:Vision Encoder + MLP Projector + MiniMind LLM
组件模型参数量状态
Vision EncoderSiglipVisionModel (siglip2-base-p32-256-ve)~95M冻结,仅提取特征
ProjectorMLP(2 层 Linear + GELU + LayerNorm)~1M可训练
LLM 主干MiniMind(8 层,hidden=768)~64M部分冻结(首尾层解冻)

推理时整机参数量约 160M(dense)或 294M(MoE 版本)。训练时仅 Projector + LLM 首尾层参与梯度更新,实际可训练参数约 65M。

Chapter 2
模型结构:MiniMindVLM

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)
Chapter 3
视觉 Token 融合:count_vision_proj

如何把视觉特征嵌入到文本 token 序列中?MiniMind-V 使用 <|image_pad|> 作为图像占位符 token(token id = 12),在前向传播中通过 count_vision_proj 方法将视觉特征替换进去:

VLM 输入格式示例
VLM 输入格式:64 个 <|image_pad|> 占位符 + 文本 prompt → 视觉特征替换后送入 LLM
@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,实现简洁高效。

Chapter 4
LLM 主干:MiniMindForCausalLM

model/model_minimind.py 中的 LLM 主干完全复用 MiniMind 纯语言模型的结构:

  • 8 层 Decoder BlockMiniMindBlock),hidden_size = 768
  • Attention:GQA(Grouped Query Attention),8 个 query heads,4 个 kv heads;每层有独立的 q_normk_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
Chapter 5
MoE 版本:MOEFeedForward

use_moe=True 时,每层的 MLP 被替换为 Mixture-of-Experts(混合专家)模块。总参数量增加到 ~200M,但每次推理仅激活 ~65M(top-1 路由)。

MoE 架构图
MiniMind-V MoE 架构:4 个 Expert + 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
Chapter 6
训练流程:SFT

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=4learning_rate=5e-6max_seq_len=450,支持 torchrun --nproc_per_node N 多卡 DDP。

Chapter 7
数据处理:VLMDataset

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%。

Chapter 8
工程亮点
断点续训机制

每次保存同时写入两个文件:

  • 模型权重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 Vision Encoder

使用 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)
对比
与 LLaVA 的架构对比
LLaVA 架构对比
LLaVA 架构:CLIP Vision Encoder + MLP Projection + LLM,MiniMind-V 采用类似的极简设计

MiniMind-V 与 LLaVA 的核心思路一致:冻结的视觉编码器 + 轻量投影器 + 语言模型主干。区别在于 MiniMind-V 将所有组件压缩到极致——65M 可训练参数 vs LLaVA 的 7B/13B,让个人开发者也能从零训练一个多模态模型。

总结

MiniMind-V 的核心价值不在于刷 SOTA,而在于用最少的资源展示 VLM 的完整技术栈:

  • 模型层面:SigLIP2 视觉编码 + MLP 投影 + MiniMind 语言主干,结构清晰
  • 训练层面:DDP 多卡 + bf16 混合精度 + 断点续训 + 梯度裁剪,工程完整
  • 成本控制:65M 可训练参数,3 块钱成本可验证,降低了多模态研究的准入门槛

对于想入门多模态大模型的同学,MiniMind-V 是一个极好的起点——它的代码量不大,但覆盖了从图像编码、特征投影、多模态融合到训练的完整闭环。