File size: 8,443 Bytes
c6b1b88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2460459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b1b88
 
 
 
 
 
 
 
2460459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b1b88
2460459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6b1b88
2460459
c6b1b88
 
 
 
 
 
2460459
c6b1b88
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# 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` 控制多模态信息「插入」主干的深度,是平衡早/晚融合的超参。