File size: 15,112 Bytes
f664f3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
# 面试:多模态深度

> 本仓库 `src/models/vlm/` 和 `src/models/vam/` 的多模态实现,覆盖视觉语言模型和全模态模型

## 0. 多模态数据流

```
原始输入(图/音)
   └─ encoder      → 模态特征
        └─ projector → 投影到 hidden_size
             └─ 替换 input_ids 中的占位 token 位置的 embedding
                  └─ 进入共享 LM 主干
```

---

## Q1. 图像/音频怎么进 LLM?

### VLM 注入流程(`src/models/vlm/model.py:56-77`)

```python
def count_vision_proj(self, input_ids, vision_proj_output):
    # 1. 找到图像占位 token 位置
    image_ids = self.config.image_ids  # [12]
    image_positions = (input_ids == image_ids[0]).nonzero()
    
    # 2. 将视觉特征注入 hidden_states 对应位置
    hidden_states = self.model.embed_tokens(input_ids)
    for i, pos in enumerate(image_positions):
        hidden_states[pos] = vision_proj_output[i]
    
    return hidden_states
```

### 注入范式

```
文本侧:特殊占位 token(<|vision_start|>` / ` Müslü`)预留位置
视觉侧:encoder → projector → 替换占位处 embedding
音频侧:encoder → projector → 替换占位处 embedding
```

> 面试点:为什么用占位 token 而不是拼接?→ 占位 token 可以精确定位注入位置,拼接会打乱文本顺序

---

## Q2. 为什么常冻结 encoder 只训 projector?

### 原因

1. **避免灾难性遗忘**:预训练主干参数量大,微调容易遗忘
2. **轻量对齐**:投影层参数少,足以完成模态对齐
3. **计算效率**:冻结 encoder 可以省显存和计算

### 本仓库实现(`src/utils/multimodal.py:29-53`)

```python
def init_vlm_model(model, config):
    # 1. 先冻结全部参数
    for param in model.parameters():
        param.requires_grad = False
    
    # 2. 只开 vision_proj
    for param in model.vision_proj.parameters():
        param.requires_grad = True
    
    # 3. 按需解冻 LLM
    if config.unfreeze_llm:
        for param in model.model.parameters():
            param.requires_grad = True
```

> 面试点:如果全量微调会怎样?→ 显存不足,且容易过拟合,投影层已经足够完成模态对齐

---

## Q3. VAM 的语音生成怎么做?

### Thinker + Talker 双塔结构

```
输入文本


┌─────────────┐
│   Thinker   │  (共享 LM 主干)
└──────┬──────┘

       ├──────────────────────┐
       │                      │
       ▼                      ▼
┌─────────────┐        ┌─────────────┐
│  Text Head  │        │ TalkerModule │
└──────┬──────┘        └──────┬──────┘
       │                      │
       ▼                      ▼
    文本输出              8 层音频 code
```

### TalkerModule(`src/models/vam/model.py:52-79`)

```python
class TalkerModule(nn.Module):
    def __init__(self, config):
        # 独立的 Transformer 层
        self.layers = nn.ModuleList([
            Block(config) for _ in range(config.num_talker_hidden_layers)
        ])
        
        # bridge 机制:将 thinker 的 hidden_states 投影到 talker 维度
        self.embed_proj = nn.Linear(config.hidden_size, config.talker_hidden_size)
        
        # 双尺度融合(可学习参数)
        self.text_scale = nn.Parameter(torch.ones(1) * 3.0)
        self.audio_scale = nn.Parameter(torch.ones(1) * 1.0)
    
    def forward(self, thinker_hidden, audio_embeddings):
        # 1. 投影 thinker hidden
        text_features = self.embed_proj(thinker_hidden)
        
        # 2. 双尺度融合
        fused = self.text_scale * text_features + self.audio_scale * audio_embeddings
        
        # 3. 通过独立 Transformer 层
        for layer in self.layers:
            fused = layer(fused)
        
        return fused
```

### TalkerHead(`src/models/vam/model.py:22-34`)

```python
class TalkerHead(nn.Module):
    def __init__(self, config):
        self.base = nn.Linear(config.talker_hidden_size, config.audio_vocab_size)
        # 8 个 adapter,每个 adapter: Linear -> GELU -> Linear
        self.adapters = nn.ModuleList([
            nn.Sequential(
                nn.Linear(config.talker_hidden_size, config.talker_hidden_size),
                nn.GELU(),
                nn.Linear(config.talker_hidden_size, config.audio_vocab_size)
            ) for _ in range(8)
        ])
    
    def forward(self, x):
        # 8 个并行输出头
        return [self.base(x) + adapter(x) for adapter in self.adapters]
```

> 面试点:为什么用 8 个 adapter?→ 音频 code 有 8 层,每层独立预测,8 个 adapter 并行处理

---

## Q4. VAM 的流式生成(`src/models/vam/model.py:309-388`)

### 核心思想

文本先完成,然后 8 个音频 code 交错流式输出。

### 实现细节

```python
def stream_generate(self, input_ids, ...):
    # 1. 先生成文本
    text_tokens = self.thinker.generate(input_ids, ...)
    
    # 2. 找到 think 结束标记
    think_end_ids = [26, 234, 234]  # 思考结束标记
    
    # 3. 8 个音频 code 交错生成
    audio_codes = [[] for _ in range(8)]
    for step in range(max_audio_length):
        # 每个 step 生成 8 个 code
        for i in range(8):
            logits = self.talker_head(talker_hidden)
            code = sample(logits[i])
            
            # 音频 code 范围: 0-2047 为正常 code,>=2048 为 stop token
            if code >= 2048:
                break
            
            audio_codes[i].append(code)
        
        # 更新 talker hidden
        audio_embeddings = self.audio_embedding(torch.tensor(audio_codes))
        talker_hidden = self.talker(text_hidden, audio_embeddings)
    
    return text_tokens, audio_codes
```

> 面试点:为什么文本和音频分开生成?→ 文本需要自回归生成,音频 code 可以并行预测,分开生成更高效

---

## Q5. SigLIP 视觉编码器

### 本仓库使用(`src/encoders/vision/siglip.py`)

- **输入**:图像 (batch, 3, 224, 224)
- **输出**:patch 特征 (batch, num_patches, hidden_size)

### 特点

1. **冻结参数**:预训练权重不参与训练
2. **Patch 特征**:将图像分割为 patch,每个 patch 作为一个 token
3. **输出维度**:通过 projector 投影到 LLM 的 hidden_size

> 面试点:SigLIP 和 CLIP 的区别?→ SigLIP 使用 sigmoid loss 替代 softmax loss,训练更稳定

---

## Q6. SenseVoice 音频编码器

### 本仓库使用(`src/encoders/audio/sensevoice.py`)

- **输入**:音频 (batch, audio_length)
- **输出**:语义/声学特征 (batch, seq_len, hidden_size)

### 特点

1. **双特征输出**:语义特征(理解内容)+ 声学特征(理解语气)
2. **冻结参数**:预训练权重不参与训练
3. **输出维度**:通过 projector 投影到 LLM 的 hidden_size

> 面试点:为什么需要双特征?→ 语义特征用于理解内容,声学特征用于理解语气和情感,两者结合才能完整理解语音

---

## Q7. VLM 的多图处理(`src/models/vlm/model.py:79-157`)

### 支持的输入格式

```python
# pixel_values 可以是:
1. tensor (batch, channels, height, width)  # 单图
2. dict {'pixel_values': tensor, 'image_ids': list}  # 多图
3. 4D/5D/6D 形状  # 不同分辨率
```

### 实现细节

```python
def forward(self, input_ids, pixel_values=None, ...):
    # 1. 文本 embedding
    hidden_states = self.model.embed_tokens(input_ids)
    
    # 2. 处理视觉输入
    if pixel_values is not None:
        if isinstance(pixel_values, dict):
            # 多图处理
            vision_features = self.vision_encoder(pixel_values['pixel_values'])
            vision_features = self.vision_proj(vision_features)
            
            # 注入到对应位置
            for i, pos in enumerate(pixel_values['image_ids']):
                hidden_states[:, pos:pos+self.config.image_token_len] = vision_features[i]
        else:
            # 单图处理
            vision_features = self.vision_encoder(pixel_values)
            vision_features = self.vision_proj(vision_features)
            hidden_states = self.count_vision_proj(input_ids, vision_features)
    
    # 3. 通过 LM 主干
    outputs = self.model(hidden_states, ...)
    return outputs
```

> 面试点:多图处理时如何区分不同图像?→ 通过 image_ids 标记每张图像的位置,分别注入对应的视觉特征

---

## Q8. aux_loss 中的技巧(`src/models/vlm/model.py:145`)

### 问题

在分布式训练中,如果 vision_proj 的参数没有出现在 loss 计算图中,DDP 梯度同步会出错。

### 解决方案

```python
def forward(self, input_ids, pixel_values=None, ...):
    # ... 前向传播 ...
    
    # 确保 vision_proj 参数出现在计算图中
    aux_loss = aux_loss + sum(p.sum() for p in self.vision_proj.parameters()) * 0
    
    return logits, aux_loss
```

### 为什么用 `* 0`?

- 保持计算图连通,让 DDP 可以同步梯度
- 实际值为 0,不影响 loss

> 面试点:为什么不用 `.requires_grad = True`?→ 那只是让参数可训练,但不会出现在计算图中,`* 0` 才能保证计算图连通

---

## Q9. object.__setattr__ 技巧(`src/models/vam/model.py:88-99`)

### 问题

VAM 模型需要将 thinker 的某些属性直接赋值给 VAM,但 `nn.Module.__setattr__` 会自动注册为子模块。

### 解决方案

```python
class VAM(PreTrainedModel):
    def __init__(self, config):
        super().__init__(config)
        
        # 绕过 nn.Module.__setattr__ 直接设置属性
        object.__setattr__(self, 'thinker', self.model)
        object.__setattr__(self, 'model', None)  # 避免重复注册
```

### 为什么需要这个技巧?

- `nn.Module.__setattr__` 会自动将 `nn.Module` 注册为子模块
- 但 thinker 已经是子模块了,重复注册会导致 DDP 问题
- `object.__setattr__` 绕过这个机制

> 面试点:如果不绕过会怎样?→ thinker 会被重复注册为子模块,导致 DDP 梯度同步出错

---

## Q10. Talker 层复制初始化(`src/utils/multimodal.py:156-164`)

### 问题

Talker 模块需要从头训练,初始化不当会导致训练不稳定。

### 解决方案

```python
def init_talker_from_thinker(talker, thinker, num_layers):
    # 用 thinker 的后 N 层初始化 talker
    for i in range(num_layers):
        talker.layers[i].load_state_dict(thinker.layers[-(num_layers - i)].state_dict())
```

### 为什么用后 N 层?

- 后 N 层更接近输出,语义更丰富
- 前 N 层更关注低级特征,对语音生成帮助不大

> 面试点:为什么不随机初始化?→ 随机初始化会导致训练初期不稳定,用 thinker 的权重可以加速收敛

---

## Q11. 音频 code 范围设计(`src/models/vam/config.py`)

### 关键常量

```python
audio_vocab_size = 2112      # 音频词汇表大小
audio_pad_token = 2049       # 填充 token
audio_stop_token = 2050      # 停止 token
audio_spk_token = 2051       # 说话人 token
```

### 设计思路

- **0-2047**:正常音频 code(2048 个)
- **2048-2051**:特殊 token(pad, stop, spk)

### 为什么这样设计?

1. **正常 code 足够**:2048 个 code 足以表示音频特征
2. **特殊 token 分离**:避免与正常 code 混淆
3. **stop token 重要**:用于判断音频生成何时结束

---

## Q12. bridge_layer 选择(`src/models/vam/config.py:24`)

### 实现

```python
bridge_layer = num_hidden_layers // 2 - 1
```

### 为什么选中间层?

- **前层**:关注低级特征,语义不够丰富
- **后层**:关注高级特征,但可能过拟合
- **中间层**:语义丰富度适中,泛化能力最好

> 面试点:如果选最后一层会怎样?→ 可能过拟合,且梯度消失风险更高

---

## Q13. VLM 的 aux_loss 设计(`src/models/vlm/model.py:145`)

### 问题

在分布式训练中,如果 vision_proj 的参数没有出现在 loss 计算图中,DDP 梯度同步会出错。

### 解决方案

```python
def forward(self, input_ids, pixel_values=None, ...):
    # ... 前向传播 ...
    
    # 确保 vision_proj 参数出现在计算图中
    aux_loss = aux_loss + sum(p.sum() for p in self.vision_proj.parameters()) * 0
    
    return logits, aux_loss
```

### 为什么用 `* 0`?

- 保持计算图连通,让 DDP 可以同步梯度
- 实际值为 0,不影响 loss

> 面试点:为什么不用 `.requires_grad = True`?→ 那只是让参数可训练,但不会出现在计算图中,`* 0` 才能保证计算图连通

---

## Q14. 多模态训练的冻结策略

### VLM 冻结策略(`src/utils/multimodal.py:29-53`)

```python
# 阶段 1:只训练 vision_proj
for param in model.parameters():
    param.requires_grad = False
for param in model.vision_proj.parameters():
    param.requires_grad = True

# 阶段 2:解冻 LLM
if config.unfreeze_llm:
    for param in model.model.parameters():
        param.requires_grad = True
```

### VAM 冻结策略(`src/utils/multimodal.py:139-175`)

```python
# freeze_backbone 支持 'all' 和 'last1' 模式
if config.freeze_backbone == 'all':
    # 冻结所有 thinker 参数
    for param in model.thinker.parameters():
        param.requires_grad = False
elif config.freeze_backbone == 'last1':
    # 只解冻最后一层
    for param in model.thinker.layers[-1].parameters():
        param.requires_grad = True
```

> 面试点:为什么要分阶段冻结?→ 先对齐模态,再微调主干,避免灾难性遗忘

---

## Q15. 多模态数据处理

### VLM 数据集(`src/dataset/vlm.py`)

```python
class VLMDataset(Dataset):
    def __getitem__(self, idx):
        # 1. 加载图像
        image = Image.open(self.image_paths[idx])
        
        # 2. 加载文本
        text = self.texts[idx]
        
        # 3. Tokenize
        input_ids = self.tokenizer(text, return_tensors='pt')
        
        # 4. 图像预处理
        pixel_values = self.image_processor(image, return_tensors='pt')
        
        return input_ids, pixel_values
```

### VAM 数据集(`src/dataset/vam.py`)

```python
class VAMDataset(Dataset):
    def __getitem__(self, idx):
        # 1. 加载图像
        image = Image.open(self.image_paths[idx])
        
        # 2. 加载音频
        audio = torchaudio.load(self.audio_paths[idx])
        
        # 3. 加载文本
        text = self.texts[idx]
        
        # 4. Tokenize
        input_ids = self.tokenizer(text, return_tensors='pt')
        
        # 5. 音频特征提取
        audio_features = self.audio_encoder(audio)
        
        return input_ids, pixel_values, audio_features
```

> 面试点:多模态数据如何 batch?→ 使用 collate_fn 处理不同长度的序列,padding 到统一长度