omni / docs /interview /multimodal.md
chenbhao's picture
refactor(docs): restructure docs with modular organization
f664f3f
|
Raw
History Blame Contribute Delete
15.1 kB

面试:多模态深度

本仓库 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

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

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

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

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 交错流式输出。

实现细节

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

支持的输入格式

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

实现细节

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 梯度同步会出错。

解决方案

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__ 会自动注册为子模块。

解决方案

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 模块需要从头训练,初始化不当会导致训练不稳定。

解决方案

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

关键常量

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

实现

bridge_layer = num_hidden_layers // 2 - 1

为什么选中间层?

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

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


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

问题

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

解决方案

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

# 阶段 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

# 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

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

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 到统一长度