生成模型 / 条件生成 原理笔记进阶 8 分钟阅读 查看源文件 ↗

文本编码与条件注入#

本页内容3 个部分

1. CLIP 文本编码器#

1.1 什么是 CLIP#

CLIP (Contrastive Language-Image Pre-training) 是 OpenAI 于 2021 年发布的多模态模型。用 4 亿图文对做对比学习:让匹配的图文 embedding 距离近,不匹配的远。

LCLIP=−1N∑i=1Nlog⁡exp⁡(sim(vi,ti)/τ)∑j=1Nexp⁡(sim(vi,tj)/τ)\mathcal{L}_\text{CLIP} = -\frac{1}{N}\sum_{i=1}^{N}\log\frac{\exp(\text{sim}(v_i, t_i)/\tau)}{\sum_{j=1}^{N}\exp(\text{sim}(v_i, t_j)/\tau)}

viv_i = 图像 embedding,tit_i = 文本 embedding,τ\tau = 温度参数。InfoNCE loss

1.2 SD 中只使用文本编码器#

CLIP 由图像编码器(ViT)和文本编码器(Transformer)两部分组成。SD 只用了文本编码器部分——一个类似 GPT 的 causal Transformer。

各版本使用的文本编码器#

SD 版本 文本编码器 输出维度
SD 1.x CLIP ViT-L/14 文本端 77 × 768
SD 2.x OpenCLIP ViT-H/14 文本端 77 × 1024
SDXL CLIP ViT-L + OpenCLIP ViT-bigG(拼接) 77 × 2048
SD 3 CLIP ViT-L + OpenCLIP ViT-bigG + T5-XXL 多编码器融合

77 个 token 是 CLIP 的最大序列长度限制——超过 77 个 token 的 prompt 会被截断。

1.3 为什么用 CLIP 而不是 BERT?#

核心原因:CLIP 的文本编码器"见过"图像

BERT 只在纯文本语料上训练,它的 embedding 空间只反映语言语义。而 CLIP 的文本编码器在图文对比学习中训练,它的 embedding 空间和视觉空间对齐过。

例如,CLIP 的文本编码器知道 "golden retriever" 对应什么样的视觉特征——它在训练中见过无数金毛犬的图片。而 BERT 只知道 "golden retriever" 是一种狗的品种,但不"知道"它长什么样。

1.4 为什么 SD3 加入 T5 而不是 BERT?#

SD3 在双 CLIP 基础上额外加了 T5-XXL,而不是 BERT。原因有三层:

BERT T5-XXL
结构 Encoder-only,双向注意力 Encoder-Decoder,双向 Encoder
训练目标 MLM(遮 15% 词预测) 遮连续片段重建(更难,更完整)
输出用途 主要靠 [CLS] 做分类 Encoder 输出的每个 token 都服务于生成
参数规模 BERT-Large 仅 340M T5-XXL 达 11B,差 30 倍
token 限制 512 512+,长文本理解更强

💡 根本差异:为分类设计 vs 为生成设计

BERT 的 Encoder 输出是为了接一个分类头——它的训练目标决定了 [CLS] token 是信息汇聚点,单个 token 的表示并不需要对整句完整负责。

T5 的 Encoder 输出是为了让 Decoder 还原完整文本——每个 token 的 embedding 必须携带足够的上下文信息,因为 Decoder 要靠这些向量生成每一个输出词。这让 T5 Encoder 的 token-level 表示天然更适合作为生成模型的条件信号。

⚠️ 那 CLIP 和 T5 各自负责什么?

CLIP:视觉语义强,知道"红色""油画风格""猫"对应什么视觉特征,但 77 token 上限且复杂语法理解弱。

T5-XXL:语言理解强,能处理长句、空间关系、逻辑关系("左边的红球旁边有一个蓝色的盒子"),但不在视觉空间里,对颜色/风格感知弱。

SD3 把两者拼接——CLIP 负责画面感,T5 负责语义精度,互补而不是替代。

1.5 CLIP 文本编码的具体流程#

# 输入文本
text = "a cat sitting on a beach"

# 第一步:BPE 分词,切成子词
subwords = ["<|startoftext|>", "a", "cat", "sit", "ting", "on", "a", "beach", "<|endoftext|>"]
# 词频高的词保留整词(如 "cat"),词频低的拆开(如 "sitting" → "sit"+"ting")
# "photorealistic" 这种词会拆成 "photo" + "real" + "istic"

# 第二步:查词表,每个子词映射为 token ID
tokens = [49406, 320, 2368, 3822, 525,   320,  4556,   49407]
#       [SOT,   a,   cat,  sit,  ting,  on/a, beach,  EOT  ]

# 第三步:padding 到 77 个 token
tokens = tokens + [0] * (77 - len(tokens))

# 第四步:Transformer 编码(12 层,SD 1.x)
text_emb = clip_text_encoder(tokens)  # (1, 77, 768)

# SD 用的是倒数第二层的输出(而非最后一层的 pooled output)
# 因为倒数第二层保留了更丰富的 token 级语义信息

面试要点:SD 1.x 用的是 CLIP 文本编码器倒数第二层的 token-level 输出(77×768),而非最后一层的 pooled 向量。因为 cross-attention 需要每个 token 的独立信息来与图像空间位置对齐。

为什么是"倒数第二层",不是"最后一层"?一个常见误解的澄清

先纠正一个容易搞混的点:CLIP 文本塔每一层的输出都是 (77, 768) 的 token-level 张量,最后一层也不例外。不存在"最后一层是句子向量、中间层是 token 向量"的区别。CLIP 对外那个"句子向量"是这样来的:

text_embed=LayerNorm(last_layer[eos_pos])⋅Wproj\text{text\_embed} = \text{LayerNorm}(\text{last\_layer}[\text{eos\_pos}]) \cdot W_{\text{proj}}

也就是只取最后一层 [EOS] 那一个 token 的向量,再过一个投影矩阵到 joint embedding 空间。因为 causal mask 下 [EOS] 是唯一能看到全句的 token,它被训练成"全句信息的汇聚点"。

那为什么不直接用最后一层的 77 个 token? 因为对比学习的梯度信号只从 [EOS] 位置回传,这会让最后一层产生"特化效应":

  • 最后一层被训练成"把整句语义往 [EOS] 那一格挤",其他 token 的表示变成"为 [EOS] 服务的中间产物"——非 EOS 位置的信息被抽空了
  • 倒数第二层还没经历这最后一次"汇总压榨",每个 token 保留着更干净的局部语义:"cat" 那一格还强烈编码着"猫","red" 那一格还强烈编码着"红"

而 cross-attention 里,U-Net 的每个空间 patch 要去问文本 token:"你们谁和我这块最相关?"——这需要每个 token 自己保有清晰的语义指纹,而不是都向 [EOS] 看齐。所以倒数第二层天然更适合。

来源:这个 trick 最早由 NovelAI 在社区中发现(俗称 clip_skip=2),他们发现用倒数第二层时 prompt 里细节词(颜色、部位、风格词)响应更强、更可控,后来被 SD 官方和 SDXL 采纳。SDXL 更进一步,并联了 CLIP-L 和 OpenCLIP-G 两个文本塔的中间层特征一起送入 cross-attention。

2. Cross-Attention:条件怎么注入 U-Net#

2.1 Cross-Attention 公式#

Attention(Q,K,V)=softmax ⁣(QKTd)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^T}{\sqrt{d}}\right)V

其中:

  • Q=WQ⋅zQ = W_Q \cdot z:图像特征投影到 query 空间
  • K=WK⋅cK = W_K \cdot c:文本 embedding 投影到 key 空间
  • V=WV⋅cV = W_V \cdot c:文本 embedding 投影到 value 空间

2.2 维度变化的具体例子#

以 U-Net 中间层 32×3232\times32 特征图 + 77 个 text token 为例:

# 图像特征 (展平空间维度)
z:     (B, 1024, C)      # 1024 = 32×32 个空间位置,C = 通道数(如 640)

# 文本 embedding
c:     (B, 77, 768)      # 77 个 token,768 维

# 投影到统一维度 d = 64(单头),heads = 8
Q = W_Q(z):  (B, 1024, 64×8)  →  8 heads × (B, 1024, 64)
K = W_K(c):  (B, 77,   64×8)  →  8 heads × (B, 77,   64)
V = W_V(c):  (B, 77,   64×8)  →  8 heads × (B, 77,   64)

# Attention 计算
QK^T:       (B, 1024, 77)     # 每个空间位置对 77 个 token 的注意力权重
softmax →   (B, 1024, 77)     # 归一化
× V →       (B, 1024, 64)     # 加权求和
# 8 个 head 拼接 → (B, 1024, 512) → reshape → (B, 32, 32, 512)

2.3 直觉理解#

Cross-attention 让图像的每个空间位置去"查询"文本中的相关信息:

  • 图像左上角区域的 query 可能和 "sky"(天空)这个 token 注意力权重高 → 该区域接收到"天空"的语义信息
  • 图像中央的 query 可能和 "cat"(猫)这个 token 注意力权重高 → 该区域接收到"猫"的语义信息

这就是 SD 实现"在图的某个位置画某个东西"的机制。

2.4 其他条件注入方式#

Cross-attention 不是唯一方式。对于不同类型的条件:

  • 时间步 tt:通过 sinusoidal embedding → MLP → 加到 ResBlock 中(类似 Transformer 的位置编码)
  • 类别标签:adaLN(Adaptive Layer Norm)或 adaGN——用条件 embedding 对特征做缩放和偏移
  • 文本序列:必须用 cross-attention,因为文本是变长序列,需要逐 token 对齐

为什么文本条件必须用 Cross-Attention?

类别标签是单个向量,可以用简单的加法/缩放注入。但文本是 77 个 token 的序列,每个 token 携带不同语义。Cross-attention 的注意力权重矩阵自动学会了哪个空间位置该关注哪个 token——这是 adaLN 无法实现的。

2.5 adaLN / adaGN 详解#

从普通 LayerNorm 说起#

普通 LayerNorm 对特征归一化后,用两个可学习参数 γ(缩放)和 β(偏移)还原表达能力:

y=γ⋅x−μσ+βy = \gamma \cdot \frac{x - \mu}{\sigma} + \beta

γ 和 β 虽然可训练,但训练完就固定——推理时对所有输入用同一组值,不随条件变化

adaLN:把 γ 和 β 从固定参数升级为网络输出#

本质上只做了一件事:把两个标量参数,升级成一个可训练网络(MLP)的输出。参数量增加了,但换来的是 γ/β 可以随条件 c 动态变化,且经过 MLP 的非线性,条件向量各维度之间可以交互组合,表达能力远比固定参数丰富:

# 普通 LayerNorm:γ/β 是固定参数,推理时不随输入变化
gamma = nn.Parameter(torch.ones(d_model))    # 可训练,但推理时固定
beta  = nn.Parameter(torch.zeros(d_model))

# adaLN:γ/β 由 MLP 实时生成,随条件 c 动态变化
class AdaLN(nn.Module):
    def __init__(self, d_model, d_cond):
        self.norm = nn.LayerNorm(d_model)
        self.mlp  = nn.Linear(d_cond, d_model * 2)  # 同时生成 γ 和 β

    def forward(self, x, c):
        # c: 条件向量(时间步 embedding / 类别 embedding 等)
        gamma, beta = self.mlp(c).chunk(2, dim=-1)   # 各 d_model 维
        return gamma * self.norm(x) + beta            # 动态缩放+偏移

💡 为什么这样能注入条件?

γ 控制"哪个特征维度重要",β 控制"特征的基准偏向哪里"。条件不同,MLP 生成不同的 γ/β,整个特征空间的侧重点就不同:

  • 条件是"狗" → 生成一组 γ/β,让网络关注毛发、四肢相关的特征维度
  • 条件是"汽车" → 生成另一组 γ/β,让网络关注金属边缘、轮廓相关的特征维度

类比:普通 LN 的 γ/β 是一盏亮度固定的灯;adaLN 的 γ(c)/β(c) 是一盏智能灯,根据条件 c 自动调节亮度和色温。

adaLN 注入的条件:不只是类别标签#

adaLN 适合任何单个向量形式的条件,类别标签只是 DiT 原论文的实验设定。实际扩散模型里最常见的是:

条件来源 向量怎么来 注入方式
时间步 t(几乎所有模型都有) Sinusoidal embedding → MLP adaLN / adaGN
类别标签(ImageNet 分类生成) Embedding 查表 adaLN
文本全局向量 T5/CLIP 输出做 pooling adaLN(可选)
文本逐 token 序列 T5 输出的完整序列 ❌ 必须用 Cross-Attention

⚠️ CogVideoX 没有类别标签,为什么还用 adaLN?

因为时间步 t 永远存在——每一步去噪都需要告诉网络"现在噪声有多大"。时间步 embedding 是一个向量,天然适合 adaLN 注入。类别标签只是 adaLN 的一种应用场景,不是唯一用途。

adaGN / adaLN / adaRMSNorm 对比#

原理完全相同,只是底层归一化操作不同:

归一化操作 β 项 典型使用场景
adaGN GroupNorm 有 扩散模型 U-Net(SD 1.x / 2.x)
adaLN LayerNorm 有 DiT、SD3、CogVideoX
adaRMSNorm RMSNorm(去掉均值项) 通常省略 Flux 等新模型
adaLN-Zero LayerNorm + MLP 零初始化 有 DiT(训练初期更稳定)

"MLP 零初始化"具体就是把生成 γ/β/α 的那层线性变换的权重和偏置全部置零:

# adaLN-Zero:初始化时让 MLP 输出全 0
# 这样训练开始时 γ=0, β=0, α=0,每个 DiT block 退化为恒等映射
nn.init.zeros_(layer.weight)
nn.init.zeros_(layer.bias)

RMSNorm 去掉了均值中心化,公式为 y=x1d∑xi2+ϵ⋅γy = \frac{x}{\sqrt{\frac{1}{d}\sum x_i^2 + \epsilon}} \cdot \gamma,无 β 项。在纯语言模型(Llama)中已基本取代 LN,但在扩散模型里因为 adaLN 的 β 本身已承担偏移职责,两者影响差异不大,尚未形成统一标准。

adaLN vs Cross-Attention 适用场景:条件是单个向量(时间步、类别)→ adaLN/adaGN,全局调制整张特征图。条件是序列(文本逐 token)→ 必须用 Cross-Attention,让不同空间位置关注不同 token。adaLN-Zero 详见 DiT 一节。

延伸阅读#