Stable Diffusion原理拆解:CLIP、UNet与扩散模型完整实战 最近不少朋友在评论区问 Stable Diffusion 的原理但大多数教程只教怎么“用”很少讲清楚“为什么”。很多人在实测的时候调了半天参数还是出图不稳定本质上还是没搞懂这套系统内部的分工逻辑。这篇笔记基于我之前梳理的四集精讲内容整理而成核心就是把 CLIP、扩散模型、UNet 和代码实战这条主线彻底讲透。看完之后你不仅能明白提示词是怎么进入模型的还能自己写出一个完整的文生图推理流程。这套东西适合谁看两类人最合适一是被各种参数和采样器弄晕的入门玩家二是想从调用 API 过渡到深入理解模型内部结构的开发者。我尽量少用公式轰炸多用类比和可视化的方式解释保证你从头跟到尾思路是连续的。1. 整体架构思路拆解Stable Diffusion 到底在做什么1.1 从生成式模型的演进看设计逻辑要理解 Stable Diffusion先得回答一个核心问题它和之前的 GAN、VAE 有什么区别早期 GAN 的思路是生成器和判别器互相博弈生成器负责造假图判别器负责辨别真假。这个方案的问题在于训练极其不稳定生成器经常“骗过”判别器的方式不是画出逼真的细节而是钻空子输出重复模式。VAE 的思路稍微不同它先把图像压缩成潜在空间的分布再从分布里采样重建但生成的图片普遍模糊缺乏高频细节。Stable Diffusion 属于扩散模型的范畴它的思路更接近“先毁坏再修复”。训练阶段我们拿一张干净图片不断加噪直到它几乎变成纯噪声图生成阶段我们从一个纯噪声开始通过神经网络一步步预测噪声并去除最终还原出一张图片。这个过程听起来简单但有一个致命的技术瓶颈。1.2 为什么要引入“潜在空间”和分层处理如果不做任何优化直接在 512×512 像素的 RGB 空间里做扩散每一步去噪的计算量都非常惊人。这就是 Diffusion 模型早期只能在算力充裕的场景落地而难以在个人显卡上跑的原因。Stable Diffusion 的关键破局点在于先用一个 VAE 的编码器把 512×512×3 的图像压缩成 64×64×4 的潜在向量再在这个缩小了 48 倍的潜在空间里执行扩散和去噪过程。最后用 VAE 的解码器把潜在向量还原成像素图像。这就好比你不用整面墙练习书法而是先在巴掌大的纸上练好间架结构再放大到墙上。模型的大部分计算量集中在压缩后的向量上显存占用和计算时间自然大幅下降。也正因如此Stable Diffusion 的训练分成了两个阶段第一阶段训练一个高质量的 VAE负责图像和潜在空间的互相转换第二阶段冻结 VAE只在潜在空间里训练扩散模型 UNet。这种分层设计让后续的微调和推理都变得灵活你不用重新训练整个模型只需要替换或微调其中一部分。1.3 三驾马车CLIP、UNet、VAE 如何各司其职Stable Diffusion 的完整架构可以粗略看作三个核心模块的配合CLIP Text Encoder文本编码器负责把人类语言翻译成模型能理解的“条件向量”也就是把一句话变成一串高维向量。这个向量不是简单的词向量它经过了对比学习的训练具备跨模态对齐的能力文本和图像在同一个向量空间里有可比性。UNet扩散主干网络在潜在空间里负责“逐步去噪”。它同时接收噪声潜在向量和 CLIP 条件向量每一步预测“当前噪声长什么样”然后从潜在向量里减掉它如此往复。VAE变分自编码器负责图像和潜在空间的转换是压缩和重建的核心。你可以把这三个模块理解为CLIP 是编剧负责描述场景UNet 是画家负责一步一步把画面从雾里画出来VAE 是装裱师负责把草图放大成最终的精修图。理解了这个分工再看后面各种参数和报错思路会清晰很多。2. CLIP 文本编码器提示词如何变成模型能理解的条件向量2.1 从对比学习说起为什么文字和图像能在同一空间对齐CLIP 全称是 Contrastive Language-Image Pre-training它的设计初衷不是为了给扩散模型做文本编码器而是解决“图像和文字如何对应”的问题。传统做法是给每张图打标签用监督学习训练一个分类器。但标签的覆盖范围有限经常出现“白头海雕”和“啄木鸟”这种容易被混淆的情况。CLIP 换了一种思路不再预测类别而是学习图片和文本的匹配关系。它同时编码一张图和一句描述把图文对映射到同一个向量空间然后拉近匹配对的距离推远不匹配对的距离。这个训练过程是纯自监督的数据直接来源于互联网上海量的图文配对。这个设计的直接好处是CLIP 拿到的向量空间非常鲁棒即使某个概念在训练时没见过详细描述只要它见过相关的图文对也能在向量空间里找到合理的位置。对 Stable Diffusion 来说这意味着你输入“一只穿着宇航服的柴犬”它不需要精确匹配某个标签只需要在向量空间中接近“柴犬”和“宇航服”的组合方向。2.2 Text Encoder 的冻结策略为什么训练时不更新它刚开始接触 Stable Diffusion 代码时很多人会疑惑UNet 在训练CLIP 也在训练吗答案是否定的。Stable Diffusion 的官方训练流程中CLIP 文本编码器是冻结的不参与梯度更新。这个设计有非常现实的原因CLIP 是在大规模的图文对数据集上预训练出来的它的语义理解能力已经足够丰富如果在扩散模型的训练阶段继续更新它很容易过拟合到当前的图文数据集丢失通用语义。可以把 CLIP 想象成一个已经通晓百科全书的翻译官。你做扩散模型训练本质上是教画家如何听翻译官的话去画画而不是让翻译官重新学一门新语言。所以代码中的 text_encoder 在 train 阶段也会被设置成 eval 模式确保 BatchNorm 等层级在推理状态工作避免训练时的统计量扰动。2.3 Prompt 的 token 化过程从字符串到 77 维向量张量实际操作中我们输入的提示词长度是变化的但模型输入要求的是定长序列。Stable Diffusion 用的是 CLIP 自带的 BPE tokenizer编码后固定截断或填充到 77 个 token。这个 77 是官方配置的 maximum length既考虑了计算效率也覆盖了日常文本的大部分长度。如果超过 77 个 token模型会直接截断所以你会发现某些长提示词的结尾部分对最终图像几乎没影响。如果你的生成效果总是和期望有偏差先检查一下是不是提示词被截断了。CLIP 的输出是一个 77×768 的张量OpenAI 的 ViT-L/14 版本这个张量会作为 CrossAttention 的 key 和 value 输入到 UNet 的每一层。这里有个很容易忽略的技术点CLIP 的最后一层隐藏状态包含了整句的全局信息但不同位置的 token 仍然保留着各自的位置编码信息。也就是说“一只猫和一只狗”和“一只狗和一只猫”这两个句子虽然 token 相同但向量空间里的表达方向完全不同后续的注意力机制会对这种差异做出不同的反应。3. 扩散模型与去噪过程数学直觉和实际实现3.1 前向加噪与反向去噪的本质扩散模型的数学原理是整篇笔记中最容易劝退人的部分也是理解整个系统最重要的部分。我先尽量用非公式的语言讲清楚前向过程。前向加噪可以想象成往一杯清水里滴墨水第一步滴一滴还能看出原来水的样子滴到第 100 步整杯水完全变黑没有任何信息保留。每一步加噪都是由一个固定的、预先设定好的“噪声调度表”控制的这个调度表决定了每一步噪声的强度一般记作 beta_t从 0.0001 逐渐增加到 0.02。如果你理解了这个调度表就会发现一个重要事实前向过程完全不需要一步步迭代。因为噪声是高斯分布的叠加高斯噪声仍然是高斯分布。所以直接用一个公式就能从任意一步的干净图像算出加噪后的结果不需要真的从第 1 步跑到第 t 步。这就是扩散模型训练速度比传统逐帧模拟快得多的原因。反向去噪才是真正需要神经网络的地方。前向过程是用公式毁灭信息反向过程等于从噪声里猜“之前一步的噪声长什么样”。给定一个纯噪声和一条文本描述模型要回答的问题是“如果我在这一步去噪应该移除多少噪声”所以 UNet 的输出不是直接生成一张图而是生成一个“噪声预测值”。3.2 为什么预测噪声而不是直接预测图像你可能会有个疑问为什么不直接让模型输出图像直接预测图像的难度在于图像的可能性空间极其巨大哪怕是同一个语义场景像素级表达也有无数种可能直接回归很难收敛。而噪声预测的目标函数要简单得多因为在 DDPM 的数学推导中每一步都可以写成“真实噪声”和“预测噪声”之间的均方误差通过简单的 L2 loss 就能稳定回归。拿现实类比你想让一个实习生学会修照片直接让他凭空画出一张完美照片难度太大如果你递给他一张满是划痕的图只让他找出“哪些部分被划伤了”他就容易上手得多。去噪预测本质上就是一个“找出被污染的部分”的任务。在代码实现中这个特点也很直观。训练时用到的损失大概是这样# 大致形式非完整官方代码 noise torch.randn_like(latents) noisy_latents scheduler.add_noise(latents, noise, timesteps) noise_pred unet(noisy_latents, timesteps, encoder_hidden_states).sample loss F.mse_loss(noise_pred, noise)模型学的是“给定某个时间步的噪声图预测当时的噪声场”这个函数。至于最终图像是从噪声逐步减去噪声预测由 scheduler 通过迭代公式算出来的。3.3 Scheduler 是什么采样器如何影响出图风格很多人在界面上看到一堆采样器名字DDIM、PLMS、Euler、DPM-Solver、DPM 2M Karras一脸懵。它们本质上是“用不同的数值计算方法来求解扩散模型的反向微分方程”。扩散模型的反向过程在数学上对应一个常微分方程求解这个方程有不同的数值近似方法。Euler 是基础的一阶方法速度快但步数多了容易积累误差DDIM 是一种支持跳步采样的方法在较少步数下也能保持不错的图像质量DPM-Solver 这类高阶方法则通过多项式近似在 20 到 30 步内就能达到相当好的效果。实际使用经验是步数开得越高采样器之间的差距越小。采样器的主要差距体现在 30 步以内这个区间。如果你的显卡不错用 DPM 2M Karras 配 25 到 30 步质量和速度的平衡基本是最好的如果追求出图速度或者显存不够20 步的 Euler 也是稳妥选择。3.4 CFGClassifier-Free Guidance为什么它决定图像跟提示词的贴合度CFG 是秋叶启动器等工具中最常被调整的参数之一代码中通常记为 guidance_scale。它做的事情是同时计算有条件预测和无条件预测然后通过两者的差异来放大或弱化提示词的影响。具体来说推理时模型会做两次预测一次输入你的提示词另一次输入空文本或者负向提示词分别得到 noise_pred_text 和 noise_pred_uncond。最终的预测是final_pred noise_pred_uncond guidance_scale * (noise_pred_text - noise_pred_uncond)当 guidance_scale 很大时模型会强烈倾向于与提示词匹配但代价是多样性下降容易出现过饱和、过锐化甚至伪影当 guidance_scale 接近 1 时图像会更多地保留“无条件的自然分布”也就是更接近模型本身偏好生成的图像但可能与提示词的关联度降低。我这里用 7.5 作为默认值大多数场景下效果和多样性比较平衡。4. UNet 结构与 CrossAttention图像条件化的核心引擎4.1 为什么选 UNet 而不是其他网络结构在潜在空间执行扩散任务时输入和输出都是多通道的特征图这个任务天然具备空间密集预测的性质。UNet 之所以适合这项工作是因为它采用了编码器-解码器加跳跃连接的对称结构。编码器部分通过逐步下采样来捕捉高层次语义解码器部分通过逐步上采样来恢复空间分辨率。跳跃连接把编码器的浅层特征直接接到解码器对应的层解决了深层网络里梯度消失和细节丢失的问题。这就像一篇论文的框架摘要提供了全局结构而正文各章节通过引用保留了细节两者结合才能保证最后成文有骨有肉。在 Stable Diffusion 中UNet 使用的基本单元是 ResNetBlock由卷积层、GroupNorm、SiLU 激活和一个残差连接组成。残差连接允许梯度直接流过网络避免深层网络训练时的退化问题。4.2 时间步嵌入模型怎么知道当前去噪到哪一步了扩散过程是一个时间相关的过程第 5 步去噪和第 50 步去噪需要的处理策略完全不同。早期加噪少主要恢复细节纹理后期噪声大需要先构筑图像轮廓。UNet 必须知道当前处于哪一步所以每个 ResNetBlock 都需要接收 timestep embedding。时间步嵌入通常使用正弦位置编码类似 transformer 里的位置编码把整数时间步映射成一个高维向量然后通过一个 MLP 变换后加到特征上。这样模型就可以根据当前时间步来调整特征处理策略。代码调试时如果你发现某张图在高步数区域出现奇怪的结构变形可以怀疑是时间步嵌入相关的投影层出了问题。4.3 从 Self-Attention 到 CrossAttention提示词如何影响图像UNet 里最核心的结构是注意力模块。其中 Self-Attention 在空间维度上计算特征图自己内部的关联让模型能理解“远距离像素之间的关系”比如猫的左耳应该和右耳有形状呼应。CrossAttention 则是整个 Stable Diffusion 实现文生图的关键它拿图像特征作为 Query拿 CLIP 文本向量作为 Key 和 Value。用更通俗的话说CrossAttention 做的事情就是在生成图像特征的每个位置时“问”一下所有文本 token 里哪些词和这个位置最相关。比如你在某个区域生成“宇航服”的纹理时CrossAttention 的注意力权重会集中在 token “astronaut” 或 “suit” 上这些文本信息被动态地融合进了图像特征。在代码中这个模块大概长这样# 简化表示仅为帮助理解 q image_features_proj # 来自图像特征 k text_features_proj # 来自CLIP文本特征 v text_features_proj # 来自CLIP文本特征 attention_scores q k.transpose(-1, -2) attention_scores attention_scores / sqrt(dim_head) attention_weights softmax(attention_scores, dim-1) output attention_weights v严格来说 kv 通常进行不同的线性投影后再拼接但理解这个逻辑足以看清全貌。模型在解码器各层级不同分辨率上都会插入 CrossAttention低分辨率层负责全局语义对齐高分辨率层负责局部细节生成。4.4 潜在噪声的尺寸和信息密度输入 UNet 的是 VAE 编码器生成的潜在向量尺寸一般是 64×64×4其中 4 代表潜在通道数。很多初学者看到会疑惑4 个通道能装下 RGB 图像那么多信息吗实际上VAE 的潜在空间不是简单的丢信息压缩它通过重参数化把图像编码为一系列均值和方差相当于对图像做了有损但信息高效的表征。只要 VAE 训练得足够好这 4 个通道的特征足以让解码器重建出 512×512 的清晰画面。这也凸显了为什么训练扩散模型时一定要冻结 VAE如果在扩散训练阶段微调了 VAE潜在表征的分布变了解码器就不知道怎么重建了。5. 文生图代码实战完整推理流程拆解5.1 环境准备与依赖安装实操一定是这里的重头戏。为了避免折腾环境我建议在 Python 3.10 的虚拟环境中进行并确保 GPU 有至少 8GB 显存。安装依赖时我用的是 diffusers 加 transformers 的组合pip install diffusers transformers accelerate safetensors如果你需要加载旧版 ckpt 格式的模型还需要额外安装pip install opencv-python需要注意的是diffusers 的版本迭代很快API 有时会有调整。我实测比较稳定的是 0.21 到 0.27 的版本区间如果你的代码在使用 scheduler 时反复提示参数名不匹配检查下版本是很正常的操作。5.2 加载模型本地路径和自动下载两种方式使用 diffusers 加载 Stable Diffusion 模型非常简单核心是两行代码from diffusers import StableDiffusionPipeline import torch pipe StableDiffusionPipeline.from_pretrained( 模型ID或者本地路径, torch_dtypetorch.float16, safety_checkerNone, ) pipe pipe.to(cuda)如果你用的是官方模型 IDdiffusers 会自动下载模型文件并缓存到本地下次加载会直接走缓存。如果你从第三方平台下载了 safetensors 文件需要先转换成 diffusers 格式结构比较标准网上也有不少一键转化脚本。如果只想测试效果而显卡显存不够可以在加载时加上variantfp16参数或者使用pipe.enable_model_cpu_offload()它能自动把暂时用不到的模块移回 CPU对大显存模型尤其好用。5.3 一步步复现 txt2img 推理流程当你调用pipe(prompt)时实际发生的一系列步骤比大多数人想象的要丰富得多。我把这个过程完整拆开。第一步文本编码。CLIP 会把提示词编码成encoder_hidden_statesshape 是 1×77×768。代码里可以通过pipe._encode_prompt查看或调用。第二步初始化随机潜在变量。随机生成一个1×4×64×64的张量这个张量就是初始的纯噪声你可以通过generator参数控制随机种子让相同提示词和参数下生成的图像可复现。第三步时间步循环。scheduler 生成一个从 999 到 0 的时间步序列在每个时间步循环里latent_model_input torch.cat([latents] * 2) # 因为要做CFG所以重复两份 with torch.no_grad(): noise_pred pipe.unet( latent_model_input, timestep, encoder_hidden_statestext_embeddings, ).sample # 分理出有条件预测和无条件预测然后合并 noise_pred_uncond, noise_pred_text noise_pred.chunk(2) noise_pred noise_pred_uncond guidance_scale * (noise_pred_text - noise_pred_uncond) # 调用scheduler执行一步去噪 latents pipe.scheduler.step(noise_pred, timestep, latents).prev_sample第四步VAE 解码。循环结束后latents已经是干净潜在向量了但它是 64×64×4 的张量不是图像。调用pipe.vae.decode(latents / pipe.vae.config.scaling_factor)得到图像张量再经过image_processor.postprocess转成 PIL 图像你才能真正看到一张 512×512 的图。可以看到pipe()一行封装了编码、循环、解码全流程但如果你只想修改其中某个中间量直接拆开 pipeline 写循环要灵活得多。5.4 img2img 与 inpainting 的变体流程img2img 的任务是“给一张图换个风格或局部修改”它的流程和 txt2img 非常像唯一的区别是初始潜在变量不再是纯随机噪声而是输入图的潜在表示加上一定强度的噪声。实现路径是先用 VAE 编码器把 PIL 图片变成潜在向量再根据strength参数决定加噪比例strength0.5表示保留一半信息只重绘后半段去噪过程。这也是为什么strength值越高输出图与原图差异越大。inpainting 专门用于修补图像的特定区域。这种模型在 UNet 输入上多了一个 mask token 通道让模型知道哪些区域需要重绘。如果你要在代码实现 inpainting通常会额外准备一张 mask 图片然后把 mask 拼接进潜在通道中作为第 5 个通道输入。5.5 常用高级操作负向提示词 CFG 与组合控制负向提示词的本质可以理解为“把无条件预测的基线改了”。正常情况下无条件预测是空文本效果偏向模型的自然先验如果填入负向提示词它会变成“我不要这些特征”的方向。在代码层面uncond_embeddings不再是一个空 token 序列的编码而是负向提示词的编码。组合控制方面比如使用 ControlNet 控制线稿或深度图本质上是在 UNet 的输入上多加了几个通道的条件特征影响每个 CrossAttention 层的特征表达。它没有改变模型的核心扩散逻辑只是在亲和力上做了额外引导。6. 常见问题排查与经验技巧实录6.1 生成图像全是噪点或灰蒙蒙的这个问题一般出现在参数配置错误的时候而且排查率最高的两个方向是推理时使用了错误的 VAE 缩放因子。latents / scaling_factor这一步如果漏掉图像亮度会整体偏移严重时几乎看不出内容。VAE 未正确加载diffusers 自带的 VAE 有时系数不够好最常见的是生成画面偏灰、色彩不饱和。解决方案是下载专门优化的 VAE如 clear vae并覆盖到pipe.vae。如果出现图像呆滞、锐度异常怎么调采样器都没用先排查 VAE 绝对是最务实的顺序。6.2 显存不足和速度太慢显存不足的常规解法是开启pipe.enable_attention_slicing()它把注意力计算切块减少单次峰值显存代价是速度下降一点点。如果你的显存低于 6GB也可以把图像尺寸设置成较低分辨率生成再用放大模型二次增强而不是一次性让模型生成超清晰大图。速度慢和你的 sampling steps 设置直接相关steps 从 30 调到 20速度接近提升三分之一。另一个容易忽略的点是如果你强制使用torch_dtypetorch.float16在较新的 GPU 架构上通常能比 fp32 快 20% 到 40%一定要确保模型加载时指定了 fp16。6.3 特征坍缩与“千篇一律”问题很多人发现不同提示词生成的图长得越来越像这通常是 CFG 参数设置过高导致的。过高的 CFG 会把模型逼向“最安全”的表达方向抑制多样性。如果你想要更多变化把 CFG 降到 4 到 5 左右或者调高输入随机噪声的差异都能显著改善。如果是训练自定义模型时出现特征坍缩那就得从数据集多样性和去噪损失权重入手这是另一个更大的话题但这篇笔记至少能帮你排除推理侧的配置因素。6.4 为什么跑出来的效果和别人差异很大同一条提示词不同的采样器、步数和 CFG 出来效果差异能大到像两个模型。步骤越少差异越明显。如果你看到别人分享的效果图想复现除了种子号还必须对照采样器、步数、CFG、VAE 等完整参数少一个变量都可能跑偏。另外模型版本不同例如某个社区微调模型基于不同底模也会导致实机效果和预设差异很大。社区模型的能力上限往往取决于底模能力而不是模型名本身。6.5 提示词截断、大小写和标点的坑CLIP tokenizer 对大小写不敏感但你如果用了英文括号(和)描述概念权重在部分实现里会被解析为语义强调容易造成意外效果。我在实操中发现最稳定的方式是尽量用纯自然语言描述把强调词结构化表达而不是堆砌一堆带括号的权重符号。提示词超过 77 个 token 会被静默截断如果你觉得尾部描述完全没生效可以打印一下 tokenizer 的编码长度来确认。很多自动化脚本会直接把提示词 hash 成一个字符串但你永远无法通过“多加几个词”来绕过截断限制。最后的实操心得这套四集精讲笔记的整理过程也让我重新验证了自己在实际项目里的一些习惯。我个人目前跑 SD 的推荐组合是底模 1.5 代或 2.1 代sampler 固定 DPM 2M Karrassteps 设 25CFG 设 6.5 到 7.5VAE 换成修复过的版本出图尺寸 512×512 或 640×640。这套参数在绝大多数场景下质量和速度的平衡都很省心。如果你刚开始接触代码推理建议别直接调pipe()一行生成的封装试着像上面那样拆开每一步把循环里的张量尺寸打出来逐步看。我讲过很多次看十遍文档不如亲手解析一次中间维度很多困惑当场就能解开。后面有时间我再专门写一篇关于 ControlNet 条件控制的深度解析把“如何让模型精确按照构图生成”这件事讲透。如果这篇笔记对你有帮助也可以在评论区留下你经常踩的坑我会挑重点继续补充。