# models/vam · 全模态 (VAM) `VAM`(原 MiniMindOmni)在 `VLM` 基础上再接入**语音**:既能理解音频/图像输入,也能**生成语音**(Talker 模块)。同样继承 `LMForCausalLM`,复用 `LM` 主干。 ## 结构 ``` VAM(LMForCausalLM) ├─ model: LM # 复用主干 ├─ vision_encoder + vision_proj # 同 VLM(图像理解) ├─ audio_encoder (SenseVoice) + audio_proj # 语音理解 └─ talker: TalkerModule # 语音生成(文本→音频 code) ``` ## 双输出(理解 + 生成语音) `VAM.forward` 同时产出: - `logits`:文本 token 分布(与 LM 一致); - `audio_logits`:语音 code 分布(8 层 Mimi 音频 code,维度 `audio_vocab_size=2112`)。 即模型**并行预测文本和语音**,训练时两份交叉熵分别监督。 ## 语音(Talker)模块详解 ### 音频表示:Mimi Codec 语音不直接用波形,而是用 **Mimi 声码器** 把音频编码为 8 层离散 code: ``` 音频波形 └─ Mimi Encoder → 8×T 离散 token(每层 codebook 大小 2048 + 64 special tokens = 2112) └─ VAM talker 在每一层并行预测 ``` - `audio_vocab_size=2112`:2048 个 Mimi code + 64 个特殊 token(含 `<|audio_pad|>`、`<|audio_stop|>`、`<|audio_spk|>`) - `audio_pad_token=2049` / `audio_stop_token=2050` / `audio_spk_token=2051` - talker 输出 8 个 `(B, T, 2112)` 的 logits 张量,每层独立计算 CE 损失 - `stop_mask` 对 `<|audio_stop|>` token 做 10× 加权,鼓励模型及时停止 ### TalkerModule 在 LLM 某层(`bridge_layer`)之后接一个小型 Transformer 解码器: ``` LM 主干输出 h (B, T, hidden) └─ TalkerModule: └─ 第 1 层 cross-attn(以 h 为 query,h 为 key/value)→ 预测第 1 层 code └─ 第 2 层 cross-attn → 预测第 2 层 code └─ ...(共 num_talker_hidden_layers 层) ``` - 每层使用独立参数,**并行**预测(非自回归,类似 SoundStorm) - 训练时 8 层 CE 损失取均值 - Talker 初始化策略:从 `thinker` 的后几层复制权重 ### Thinker / Talker 分工 `VAM` 的 LM 主干有 `num_hidden_layers` 层(默认 8),分为: | 角色 | 层范围 | 功能 | | --- | --- | --- | | **Thinker** | `layers[0:bridge_layer]` | 文本理解与推理 | | **Bridge** | `bridge_layer`(默认 3) | 多模态特征注入点 | | **Talker** | `layers[bridge_layer+1:]` | 语音生成解码 | ```python VAM.forward(hidden_states): # Thinker 层 for layer in self.thinker.layers: h = layer(h) # 注入多模态特征(vision_proj / audio_proj) h = inject_multimodal(h) # Talker 层 for layer in self.talker.layers: h = layer(h) # 文本 head + 语音 head logits = self.lm_head(h) audio_logits = self.talker.decode(h) ``` ## 关键字段(`VAMConfig`) | 字段 | 含义 | | --- | --- | | `num_talker_hidden_layers` / `talker_hidden_size` | Talker 子网络规模 | | `audio_ids` / `audio_special_token` | 音频占位符(如 `<|audio_pad|>`) | | `audio_hidden_size` / `audio_vocab_size` | 音频特征维 / 音频词表(2048 code + 64 special) | | `audio_pad/stop/spk_token` | 音频特殊 token id | | `spk_emb_size` | 说话人 embedding 维(默认 192) | | `bridge_layer` | 多模态特征注入主干的层(通常 `num_layers // 2 - 1`) | | `image_token_len` | 单张图像占用的 token 数 | | `use_moe` | 是否在主干 FFN 使用 MoE | ## 3 阶段 SFT 训练流程 参考 MiniMind-O 的设计,VAM 的 SFT 分为 3 个阶段逐步激活各能力: ### Stage 1:T2A(文本→音频对齐) ``` 配置: mode=all, batch_size=4, lr=5e-4, max_samples=2000 数据: sft_t2a_mini.parquet(515k 条,无音频输入,无 spk_emb) ``` - 从预训练 checkpoint(`omni.pth` 或 `omni-v.pth`)初始化 - `mode=all`:所有参数参与训练(113M trainable) - 数据只有文本(用户问题 + 文本答案 + 音频标签),`question_audios` 列为空 - 目标:让模型学会**生成声学 token**,文本+音频双损失下降 - 损失变化举例:12.79 → 9.80(500 steps) ### Stage 2:A2A audio_proj(音频特征对齐) ``` 配置: mode=audio_proj, batch_size=8, lr=5e-4, max_samples=2000 数据: sft_a2a_mini.parquet(77k 条,含 question_audios + spk_emb) ``` - 从 Stage 1 输出初始化 - `mode=audio_proj`:**冻结除 `audio_proj` 外的所有参数**(仅训练 1.0M / 113M 参数) - 数据包含真实音频输入,需要 `librosa` / `torchaudio` 重采样到 16kHz - 目标:训练 `audio_proj` 将 SenseVoice 的音频特征映射到 LLM 隐藏空间 - 无视觉数据时 `vision_proj` 梯度为 0,不受影响 ### Stage 3:A2A mode=all(全参数微调) ``` 配置: mode=all, batch_size=4, lr=2e-5, max_samples=2000 数据: sft_a2a_mini.parquet ``` - 从 Stage 2 输出初始化 - 恢复全参数训练(113M trainable),但**学习率降低至 2e-5**(Stage 1 的 1/25) - 目标:在已对齐的音频特征基础上,精细调优全部参数 - 损失变化举例:9.72 → 9.27(500 steps) ### 为什么分 3 阶段? | 阶段 | 解决的问题 | 训练参数 | LR | | --- | --- | --- | --- | | 1: T2A mode=all | 冷启动:从头学音频 code 生成 | 全部 | 5e-4 | | 2: A2A audio_proj | 对齐:让音频特征进入 LLM 空间 | 仅 proj (1%) | 5e-4 | | 3: A2A mode=all | 精调:全参数协同优化 | 全部 | 2e-5 | - 若跳过 Stage 1 直接 A2A,模型未见过音频 code 分布,生成质量差 - 若跳过 Stage 2 直接全参数,`audio_proj` 远未收敛,梯度方向主次不分 - Stage 2 用高 LR 只训 proj,是**多模态对齐的标准做法** ## 训练要点 ### 损失函数 训练总损失 = 文本 CE + 音频 CE + aux_loss(仅 MoE 时非 0): ```python # 文本损失(与 LM 一致) text_loss = CE(logits, labels, ignore_index=-100) # 音频损失(每层独立 CE,对 stop token 加权) audio_loss = 0 for i, al in enumerate(audio_logits): # 8 层 layer_loss = CE(al.view(-1, 2112), targets[:, i, :].reshape(-1)) stop_mask = (targets == audio_stop_token).float() # 2050 weighted = layer_loss * valid_mask * (1 + stop_mask * 9) audio_loss += weighted.sum() / valid_mask.sum() audio_loss = audio_loss / 8 # 8 层均值 ``` ### 优化器与梯度 ```python optimizer = AdamW( filter(lambda p: p.requires_grad, model.parameters()), # 只训 trainable lr=learning_rate ) ``` - `mode=audio_proj` 时,`model.audio_proj` 约 1.0M 参数,其余 112M 冻结 - `filter(requires_grad)` 避免 optimizer 持有冻结参数的动量(省显存) ### 数据集 | 数据 | 格式 | 行数 | 大小 | 特点 | | --- | --- | --- | --- | --- | | T2A | parquet | 515k | 1.5 GB | 无 question_audios,无 spk_emb | | A2A | parquet | 77k | 841 MB | 含 question_audios + spk_emb + ref_audios | - 使用 `pyarrow.parquet.iter_batches(batch_size=4096)` 流式读取 - `max_samples` 控制加载行数,适合快速验证 - 音频列(`question_audios`)为二进制 bytes,在 `__getitem__` 时解码 ### 说话人嵌入(spk_emb) - A2A 数据包含预计算的 `spk_emb` 维(192 维,campplus 模型提取) - T2A 数据无此列 → 回退到 `torch.zeros(192)` - `spk_emb` 在 forward 中与 hidden states 拼接,协助模型区分说话人 ## 检查点管理 训练过程保存两种检查点: ``` save_dir/sft_omni_768.pth # 推理权重(仅 LLM 部分,fp16) ../checkpoints/sft_omni_768.pth # 完整检查点(含 optimizer state 用于续训) ../checkpoints/sft_omni_768_resume.pth # 带 optimizer 的续训文件 ``` 推理权重过滤掉 `audio_encoder.` 和 `vision_encoder.` 前缀(编码器需单独加载)。 ## 要点(面试) - **统一主干 + 多 head**:理解用共享 `LM` 主干;感知侧挂 encoder+projector,生成侧挂 Talker。新增模态 = 新增 encoder/projector,不改主干。 - **音频 code 多层级**:语音用 8 层离散 code 表示,模型并行预测每一层,贴近 SoundStorm/Mimi 思路。 - **占位 + 投影**范式与 VLM 一致,保证跨模态位置对齐。 - **3 阶段 SFT**:T2A→audio_proj→full,逐步激活文本生成、音频理解、协同优化能力。 - `bridge_layer` 控制多模态信息「插入」主干的深度,是平衡早/晚融合的超参。