第28章 多模态模型训推实战

第28章 多模态模型训推实战

文本是信息的冰山一角。人类感知的世界里,视觉占了大脑皮层的 30%,语言只是近十万年的发明。多模态 AI 不是文本 AI 的”增强版”——它是从”读”到”看”的范式跃迁。

28.1 章节导入

2021 年,OpenAI 的 CLIP 模型做了一件看似简单的事:把图片和文字映射到同一个向量空间。但这个”简单”的突破催生了整个多模态 AI 生态——从 Stable Diffusion 到 GPT-4V,从 LLaVA 到 Sora,一切都始于”让模型理解图片与文字的对应关系”。

多模态模型的训练和推理与纯文本模型有根本性的不同:

  • 数据异构性:图像是连续的高维张量,文本是离散的 token 序列
  • 计算模式差异:视觉编码用卷积或 ViT,语言建模用 Transformer
  • 对齐挑战:让两种模态在同一个表示空间中”说同一种语言”

本章将深入四类核心多模态模型:CLIP(图文对齐)、LLaVA(视觉语言)、Stable Diffusion(文生图)和多模态推理服务架构。

28.2 CLIP:图文对齐的奠基者

28.2.1 架构设计

CLIP(Contrastive Language-Image Pre-training)的核心思想极其优雅:用对比学习把图片和文字拉到同一个向量空间

graph LR
    subgraph "训练数据"
        A["('a photo of a cat', 🐱)"]
        B["('a dog playing', 🐶)"]
        C["('sunset beach', 🏖️)"]
    end
    
    subgraph "双塔编码器"
        D["Image Encoder<br/>(ViT / ResNet)"]
        E["Text Encoder<br/>(Transformer)"]
    end
    
    A --> D
    A --> E
    B --> D
    B --> E
    C --> D
    C --> E
    
    D --> F["Image Embeddings<br/>I₁, I₂, I₃"]
    E --> G["Text Embeddings<br/>T₁, T₂, T₃"]
    
    F --> H["对比损失<br/>maximize: I₁·T₁, I₂·T₂, I₃·T₃<br/>minimize: I₁·T₂, I₁·T₃, ..."]
    G --> H

CLIP 使用 InfoNCE 损失(即对称的对比损失):

\[ \mathcal{L} = -\frac{1}{N} \sum_{i=1}^{N} \left[ \log \frac{\exp(I_i \cdot T_i / \tau)}{\sum_{j=1}^{N} \exp(I_i \cdot T_j / \tau)} \right] - \frac{1}{N} \sum_{i=1}^{N} \left[ \log \frac{\exp(T_i \cdot I_i / \tau)}{\sum_{j=1}^{N} \exp(T_i \cdot I_j / \tau)} \right] \]

简单说:在一个 batch 中,正确的图文对的相似度应该最高,错误的配对相似度应该低。

28.2.2 CLIP 的训练规模

CLIP 在 4 亿对图文上训练——这个数据量在当时是空前的:

# CLIP 训练配置(简化版)
clip_config = {
    "image_encoder": "ViT-L/14",  # 或 ResNet-50
    "text_encoder": "Transformer",
    "embed_dim": 768,
    "image_size": 224,
    "patch_size": 14,
    "text_max_len": 77,
    "temperature": 0.07,  # 可学习的温度参数
    "batch_size": 32768,  # 超大 batch!
    "dataset_size": "400M pairs",
    "epochs": 32,
}
Tip

为什么 batch size 要这么大? 对比学习的有效性取决于”负样本”的数量。在一个 batch 中,除了正确的配对,其他 N-1 个配对都是负样本。batch size 越大,负样本越多,学到的表示越有区分度。CLIP 的 32768 batch size 在当时是一个工程奇迹。

28.2.3 CLIP 的使用

from transformers import CLIPModel, CLIPProcessor
from PIL import Image
import torch

model = CLIPModel.from_pretrained("openai/clip-vit-large-patch14")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-large-patch14")

# 零样本图像分类
image = Image.open("cat.jpg")
candidate_labels = ["a cat", "a dog", "a bird", "a fish"]

inputs = processor(
    text=candidate_labels, images=image, 
    return_tensors="pt", padding=True
)

with torch.no_grad():
    outputs = model(**inputs)
    
# 图文相似度
logits_per_image = outputs.logits_per_image  # [1, 4]
probs = logits_per_image.softmax(dim=-1)

for label, prob in zip(candidate_labels, probs[0]):
    print(f"{label}: {prob:.2%}")
# a cat: 95.3%
# a dog: 3.1%
# a bird: 1.2%
# a fish: 0.4%

28.2.4 CLIP 的工程意义

CLIP 不仅是一个模型,更是一个通用的视觉-语言接口

  1. 图像搜索引擎:用文字搜图(“找到所有包含日落的照片”)
  2. Stable Diffusion 的文本编码器:SD 使用 CLIP 的 text encoder 将文字编码为条件
  3. LLaVA 的视觉-语言桥接:LLaVA 使用 CLIP 的 vision encoder 提取图像特征
  4. 零样本分类:不需要训练,直接用自然语言做分类

28.3 LLaVA:视觉语言模型

28.3.1 架构设计

LLaVA(Large Language and Vision Assistant)是目前最流行的开源视觉语言模型。它的设计哲学是”最小改动,最大效果”——不设计新的多模态架构,而是将现有的 Vision Encoder 和 LLM 用一个简单的 Projection 层连接起来。

graph TD
    A["图像"] --> B["CLIP ViT<br/>(Vision Encoder)"]
    B --> C["视觉 Patch Tokens<br/>[N × D_vision]"]
    C --> D["Projection Layer<br/>(MLP or Linear)"]
    D --> E["对齐的视觉 Tokens<br/>[N × D_llm]"]
    
    F["文本输入"] --> G["Text Tokenizer"]
    G --> H["文本 Tokens<br/>[M × D_llm]"]
    
    E --> I["拼接: [视觉 Tokens] + [文本 Tokens]"]
    H --> I
    I --> J["LLM<br/>(LLaMA / Qwen / Mistral)"]
    J --> K["文本输出"]

28.3.2 LLaVA 的训练流程

LLaVA 的训练分为两个阶段,每一阶段都精心设计:

# LLaVA 训练阶段
stages:
  - stage: 1
    name: "Projection Layer 预训练"
    description: "冻结 ViT 和 LLM,只训练 Projection Layer"
    data: "558K 图文对(CC3M 子集)"
    trainable_params: "Projection Layer (~20M)"
    frozen: ["ViT", "LLM"]
    learning_rate: 2e-3
    epochs: 1
    
  - stage: 2  
    name: "指令微调"
    description: "解冻 LLM,端到端微调"
    data: "158K 多模态指令数据(GPT-4 生成)"
    trainable_params: "Projection + LLM"
    frozen: ["ViT"]  # ViT 始终冻结
    learning_rate: 2e-5
    epochs: 3
Tip

LLaVA 的数据生成秘诀:Stage 2 的指令数据是怎么来的?LLaVA 团队用 GPT-4 来生成!他们给 GPT-4 看图片的描述(caption + bounding boxes),让 GPT-4 生成”如果有人类在看这张图片时会问的问题”。这个方法叫 “machine-generated instruction following data”,是低成本获取高质量多模态数据的经典操作。

28.3.3 LLaVA 的视觉 Token 压缩

一个挑战:CLIP ViT 输出的 patch tokens 太多了。一张 224×224 的图片分成 14×14 = 256 个 patch,每个 patch 一个 token。如果有多张图片,视觉 tokens 会占满上下文窗口。

LLaVA-NeXT 引入了 AnyRes 策略——根据图片分辨率动态选择 patch 数量,并用 pooling 压缩:

# AnyRes 的简化逻辑
def process_image_anyres(image, vit_model, grid=(2, 2)):
    """将大图片分成多个子区域分别编码"""
    w, h = image.size
    sub_images = []
    
    # 将图片分成 grid 个子区域
    for i in range(grid[0]):
        for j in range(grid[1]):
            sub = image.crop((
                j * w // grid[1], i * h // grid[0],
                (j + 1) * w // grid[1], (i + 1) * h // grid[0]
            ))
            sub_images.append(sub)
    
    # 加上原图的整体编码
    sub_images.append(image.resize((224, 224)))
    
    # 每个子图分别编码
    all_tokens = []
    for sub_img in sub_images:
        tokens = vit_model(sub_img)  # [256, D]
        # Pooling 压缩(2x2 → 1 token)
        tokens = tokens.view(16, 16, -1).mean(dim=(0, 1))  # [1, D]
        # 实际中用更复杂的 pooling
        all_tokens.append(tokens)
    
    return torch.cat(all_tokens, dim=0)  # [grid[0]*grid[1]+1, D]

28.3.4 使用 LLaVA 推理

# 使用 LLaVA 进行多模态对话
from transformers import LlavaForConditionalGeneration, AutoProcessor

model = LlavaForConditionalGeneration.from_pretrained(
    "llava-hf/llava-v1.6-mistral-7b-hf",
    torch_dtype=torch.float16,
    device_map="auto"
)
processor = AutoProcessor.from_pretrained("llava-hf/llava-v1.6-mistral-7b-hf")

# 图文对话
image = Image.open("chart.png")
prompt = "USER: <image>\n请分析这个图表的趋势并预测下个季度的数据。\nASSISTANT:"

inputs = processor(images=image, text=prompt, return_tensors="pt").to(model.device)

output = model.generate(**inputs, max_new_tokens=512, temperature=0.7)
print(processor.decode(output[0], skip_special_tokens=True))

28.4 Stable Diffusion 训练与推理

28.4.1 扩散模型基础

Stable Diffusion 是一种潜在扩散模型(Latent Diffusion Model),它在压缩的潜在空间中而非像素空间中进行扩散:

graph TD
    subgraph "训练过程"
        A["图像 x"] --> B["VAE Encoder"]
        B --> C["潜在表示 z<br/>(64×64×4 for 512×512 image)"]
        C --> D["添加噪声<br/>z_t = √α_t·z + √(1-α_t)·ε"]
        D --> E["U-Net 预测噪声<br/>ε_θ(z_t, t, c)"]
        E --> F["损失: ||ε - ε_θ||²"]
    end
    
    subgraph "推理过程(采样)"
        G["随机噪声 z_T"] --> H["U-Net 去噪<br/>(条件: CLIP text embedding)"]
        H --> I["z_{T-1}"]
        I --> H
        H --> J["... 重复 20-50 步"]
        J --> K["z_0"]
        K --> L["VAE Decoder"]
        L --> M["生成图像 x_0"]
    end

28.4.2 关键组件

# Stable Diffusion 的核心组件
class StableDiffusion:
    def __init__(self):
        # 1. VAE:图像 ↔ 潜在空间
        self.vae = AutoencoderKL.from_pretrained(...)
        
        # 2. Text Encoder:CLIP text encoder
        self.text_encoder = CLIPTextModel.from_pretrained(...)
        
        # 3. U-Net:噪声预测网络
        self.unet = UNet2DConditionModel.from_pretrained(...)
        
        # 4. Scheduler:采样策略
        self.scheduler = DDIMScheduler.from_pretrained(...)
    
    def generate(self, prompt, num_steps=30, guidance_scale=7.5):
        # Step 1: 编码文本
        text_embeddings = self.text_encoder(prompt)  # [1, 77, 768]
        
        # Step 2: 无条件嵌入(用于 Classifier-Free Guidance)
        uncond_embeddings = self.text_encoder("")  # [1, 77, 768]
        
        # Step 3: CFG 组合
        text_embeddings = torch.cat([uncond_embeddings, text_embeddings])
        
        # Step 4: 从随机噪声开始采样
        latents = torch.randn(1, 4, 64, 64)
        
        # Step 5: 迭代去噪
        for t in self.scheduler.timesteps:
            # 双倍 batch(条件 + 无条件)
            latent_input = torch.cat([latents, latents])
            
            # U-Net 预测噪声
            noise_pred = self.unet(latent_input, t, text_embeddings)
            
            # CFG:放大条件方向
            noise_uncond, noise_cond = noise_pred.chunk(2)
            noise_pred = noise_uncond + guidance_scale * (noise_cond - noise_uncond)
            
            # 去噪一步
            latents = self.scheduler.step(noise_pred, t, latents).prev_sample
        
        # Step 6: VAE 解码
        image = self.vae.decode(latents)
        return image

28.4.3 Classifier-Free Guidance(CFG)

CFG 是扩散模型推理最重要的技术之一。它的思想是:同时运行条件生成和无条件生成,然后放大它们之间的差异

\[ \epsilon_{\text{guided}} = \epsilon_{\text{uncond}} + s \cdot (\epsilon_{\text{cond}} - \epsilon_{\text{uncond}}) \]

其中 \(s\) 是 guidance scale(通常 7-10)。\(s\) 越大,生成结果越忠于 prompt,但多样性越低。

Guidance Scale 效果 适用场景
1.0 完全多样性 创意探索
3-5 平衡 通用
7-10 强对齐 精确控制
15+ 过饱和 不推荐

28.4.4 SD 性能优化

# 使用 xFormers 加速 Attention(2-3x 速度提升)
pip install xformers

# 使用 SDPA (Scaled Dot Product Attention) - PyTorch 2.0+
# 无需额外安装,直接启用
# 使用 diffusers 库 + 优化
import torch
from diffusers import StableDiffusionPipeline, EulerAncestralDiscreteScheduler

# 加载模型并启用优化
pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
    safety_checker=None,  # 禁用安全检查(加速)
).to("cuda")

# 优化 1: 启用 xFormers / SDPA
pipe.unet.enable_xformers_memory_efficient_attention()

# 优化 2: 启用 Attention Slicing(减少显存)
pipe.enable_attention_slicing()

# 优化 3: 换更快的 Scheduler
pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)

# 优化 4: CPU Offload(显存不足时)
# pipe.enable_model_cpu_offload()

# 生成
image = pipe(
    "a cat sitting on a chair, digital art",
    num_inference_steps=20,  # 20 步够用了(配合 Euler a)
    guidance_scale=7.0,
    width=512, height=512,
).images[0]

28.4.5 LoRA 微调 Stable Diffusion

# 使用 kohya-ss 训练 SD LoRA
accelerate launch train_network.py \
  --pretrained_model_name_or_path="runwayml/stable-diffusion-v1-5" \
  --train_data_dir="./dataset" \
  --output_dir="./lora_output" \
  --resolution=512 \
  --train_batch_size=1 \
  --learning_rate=1e-4 \
  --max_train_steps=2000 \
  --network_module=networks.lora \
  --network_dim=32 \
  --network_alpha=32

28.5 多模态推理服务架构

28.5.1 统一服务架构

在生产环境中,多模态 AI 服务通常需要同时处理文本、图像、音频甚至视频。设计一个统一的服务架构是关键挑战:

graph TD
    subgraph "客户端"
        A["Web / App / SDK"]
    end
    
    subgraph "API Gateway"
        B["认证 / 限流"]
        C["请求路由"]
    end
    
    subgraph "文本推理"
        D["LLM Service<br/>(vLLM / TGI)"]
    end
    
    subgraph "视觉推理"
        E["VLM Service<br/>(LLaVA / Qwen-VL)"]
        F["Image Generation<br/>(SD / Flux)"]
    end
    
    subgraph "语音推理"
        G["ASR Service<br/>(Whisper)"]
        H["TTS Service<br/>(CosyVoice)"]
    end
    
    subgraph "共享层"
        I["向量存储<br/>(图文 Embedding)"]
        J["对象存储<br/>(图像/音频文件)"]
        K["Redis Cache<br/>(Embedding Cache)"]
    end
    
    A --> B --> C
    C --> D
    C --> E
    C --> F
    C --> G
    C --> H
    
    D <--> I
    E <--> I
    D <--> K
    E <--> K
    F <--> J
    G <--> J

28.5.2 模型编排示例

# 多模态推理编排服务
from typing import Optional
import httpx

class MultiModalService:
    def __init__(self):
        self.llm_url = "http://llm-service:8000/v1"
        self.vlm_url = "http://vlm-service:8001/v1"
        self.sd_url = "http://sd-service:8002/generate"
        self.asr_url = "http://asr-service:8003/transcribe"
        
        self.client = httpx.AsyncClient(timeout=60)
    
    async def chat_with_image(
        self,
        text: str,
        image_url: Optional[str] = None,
        generate_image: bool = False,
    ):
        """图文对话 + 可选的图像生成"""
        
        # 如果有图片,先理解
        if image_url:
            vlm_response = await self.client.post(
                f"{self.vlm_url}/chat/completions",
                json={
                    "messages": [{
                        "role": "user",
                        "content": [
                            {"type": "image_url", "image_url": {"url": image_url}},
                            {"type": "text", "text": text},
                        ]
                    }]
                }
            )
            return vlm_response.json()
        
        # 如果需要生成图片
        if generate_image:
            sd_response = await self.client.post(
                self.sd_url,
                json={"prompt": text, "num_steps": 25}
            )
            return {"image": sd_response.json()["image_url"]}
        
        # 纯文本对话
        llm_response = await self.client.post(
            f"{self.llm_url}/chat/completions",
            json={
                "model": "qwen2.5-72b",
                "messages": [{"role": "user", "content": text}]
            }
        )
        return llm_response.json()
    
    async def voice_to_voice(self, audio_data: bytes):
        """语音到语音的全链路"""
        # 1. ASR
        asr_resp = await self.client.post(
            self.asr_url, 
            content=audio_data,
            headers={"Content-Type": "audio/wav"}
        )
        text = asr_resp.json()["text"]
        
        # 2. LLM 推理
        llm_resp = await self.client.post(
            f"{self.llm_url}/chat/completions",
            json={"messages": [{"role": "user", "content": text}]}
        )
        reply = llm_resp.json()["choices"][0]["message"]["content"]
        
        # 3. TTS
        tts_resp = await self.client.post(
            self.tts_url,
            json={"text": reply, "voice": "alloy"}
        )
        
        return tts_resp.content  # 返回音频

28.5.3 性能优化策略

策略 适用场景 效果 复杂度
模型量化 所有模型 FP16→INT8: 2x 速度
请求批处理 文本/视觉 LLM 3-5x 吞吐
Embedding 缓存 CLIP / 文本编码 省去重复编码
模型并行 大模型 减少延迟
异步流水线 多步任务 减少总延迟
Spot Instance 非实时任务 降低成本
Warning

多模态服务的常见陷阱: 1. 大文件传输:图像和音频文件可能很大,使用对象存储 + URL 传递比直接传二进制好 2. 超时管理:图像生成可能需要 10-30 秒,需要设置合理的超时和重试策略 3. 内容安全:文本和图像都需要安全过滤——不要只过滤文本 4. 计费复杂度:不同模态的成本差异很大,需要有差异化的计费策略

28.6 实践建议

Tip

多模态项目的技术选型: 1. 图文对话 → LLaVA / Qwen-VL:开源方案成熟,质量够用 2. 文生图 → Stable Diffusion 3 / Flux:开源质量已接近 Midjourney 3. 语音识别 → Whisper:开源标杆,多语言支持好 4. 语音合成 → CosyVoice / GPT-SoVITS:中文质量优秀 5. Embedding → CLIP:图文检索的标准方案 6. 统一接口 → 自建编排层:用 FastAPI + 消息队列串联各服务

28.7 小结

多模态 AI 的核心挑战不在于单个模态的能力,而在于让不同模态在同一个表示空间中对齐。CLIP 通过对比学习实现了这一点,LLaVA 通过 Projection Layer 实现了这一点,Stable Diffusion 通过交叉注意力实现了这一点——方法不同,但目标一致。

从工程角度看,多模态推理服务的关键是编排——将不同类型的模型组合成一条完整的处理链路,每一步都做到最优,然后在整体层面优化延迟和成本。

28.8 延伸阅读

  • Radford et al. (2021). Learning Transferable Visual Models From Natural Language Supervision (CLIP). ICML 2021
  • Liu et al. (2023). Visual Instruction Tuning (LLaVA). NeurIPS 2023
  • Rombach et al. (2022). High-Resolution Image Synthesis with Latent Diffusion Models. CVPR 2022
  • Ho et al. (2020). Denoising Diffusion Probabilistic Models. NeurIPS 2020 — 扩散模型基础
  • Ramesh et al. (2022). Hierarchical Text-Conditional Image Generation with CLIP Latents (DALL-E 2)
  • diffusers 文档:https://huggingface.co/docs/diffusers
  • LLaVA 项目:https://github.com/haotian-liu/LLaVA