# 面试:多模态深度 > 本仓库 `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 到统一长度