最近我花了几个周末,把 Stable Diffusion 的源码和论文重新啃了一遍,整理出了一套四集精讲笔记,标题里的几个关键词基本就是这套笔记的地图:CLIP、扩散模型、UNet、文生图代码实战。今天把这份笔记的核心内容分享出来,算是给自己做个沉淀,也给想从“只会用”进阶到“知道它怎么跑起来”的朋友一条参考路径。
这套笔记到底适合谁?我觉得主要有两类人。一类是已经用 WebUI 或者 ComfyUI 出过不少图,开始好奇“为什么输入 prompt 就能生成匹配的图”,想弄懂底层机制但不想直接啃论文的人。另一类是准备做微调、二次开发、或者想复现训练流程的开发者,需要把模型链路彻底打通。这套笔记不会只停留在概念层面,每一集都会落到代码和公式上,很多参数我会直接给出实测结果和踩坑记录,方便你直接参考。
这套笔记的四集顺序,本身就代表了我推荐的理解路径:先让文本被模型看懂,再理解图像是如何“从噪声里长出来”的,接着认识真正干活的核心网络,最后用代码把整个链路串起来。下面按这个顺序完整展开。
1. 为什么按“CLIP→扩散模型→UNet→代码”这个顺序讲
我最早自学 SD 的时候,犯过一个很典型的错误:一上来就抱着 UNet 结构图硬啃,想把每个卷积、每个注意力层都搞清楚,结果越看越迷糊。后来我才意识到,UNet 虽然名字最响,但它在 SD 里只是一个零部件。真正让 SD 变成“文生图”系统的,是三个部件协同工作:CLIP 负责把文本变成模型能理解的向量,扩散模型负责定义“如何从噪声还原图像”的数学过程,UNet 则承担了每一步去噪的具体计算。不理解前两步,直接看 UNet 的代码等于看一个没有上下文的函数签名。
1.1 四集笔记的目录与定位
我最终把整套精讲拆成了四个主题,每个主题对应一个核心问题:
| 集数 | 主题 | 核心问题 | 前置要求 |
|---|---|---|---|
| 第一集 | CLIP 文本编码器 | 一句话是怎么变成矩阵的? | 了解 Transformer 基本概念 |
| 第二集 | 扩散模型原理 | 加噪、去噪的数学过程是什么? | 了解高斯分布、基础概率论 |
| 第三集 | UNet 结构与注意力机制 | 每一步去噪具体怎么计算? | 了解卷积神经网络 |
| 第四集 | 文生图代码实战 | 怎么把三个模块拼成完整流程? | 会跑 Python、有 PyTorch 基础 |
这样安排的好处是每一集都有明确的起点和终点。第一集学完,你看到“77”“768”这些数字不会再有玄学感。第二集学完,你才能明白为什么采样器和步数会影响出图质量。第三集学完,你才知道 prompt 到底注入到了网络的哪个位置。第四集直接把前面所有内容编译成一段可以运行的代码,前后呼应。
1.2 这套笔记和市面教程的区别
市面上的 SD 原理讲解经常走两个极端。一种是纯科普,把“扩散模型就像从噪声中作画”反复讲,听起来很有道理,但对写代码毫无帮助。另一种是纯代码贴片,把 diffusers 的 Pipeline 源码贴出来,每一行都有注释,但读者看完还是不知道为什么这里要1 / 0.18215,为什么 embedding 要 padding 到 77 个 token。
我这套笔记在写的时候,特意让每讲都遵循一个固定套路:先提一个具体的疑问,再用公式或代码解释机制,最后给一个可以直接实验的小例子。比如第一集讲 CLIP,不会只说“双塔结构”,而是直接算一遍text_encoder输入和输出的张量形状,让你亲手看到文本是怎么变成[batch, 77, 768]的。第三集讲 UNet 的交叉注意力,也会从Q、K、V哪个来自文本、哪个来自图像讲起,把这个最容易被忽略的细节掰开。
除了这些,我在每集末尾都加了一段“我踩过的坑”。这些坑包括维度不匹配、显存爆炸、CLIP 输出全零向量导致出图全灰等,普通教程不会写这些,但它们恰恰是动手复现时最容易卡住的地方。如果你也打算从原理入手研究 SD,我建议按这个顺序走,不要跳步。CLIP 部分如果理解不透,后面交叉注意力你会反复回查。
2. 第一站:CLIP——连接文本和图像的“翻译官”
CLIP 这个名字在 AI 绘图圈里出现的频率很高,但不少人其实没搞清楚它在 SD 里扮演的角色。CLIP 本身是 OpenAI 在 2021 年提出的多模态模型,训练方式是拿 4 亿对图文数据做对比学习。它的核心功能很简单:把一张图片和一句话映射到同一个向量空间,让相似的图文对在空间里离得近,不相似的离得远。
但请注意,SD 并不使用 CLIP 的图像编码器,它只借用 CLIP 的文本编码器部分。这一点很多人一开始会搞混。为什么 SD 只要文本塔?因为 SD 的任务是生成图像,图像这一侧已经由 UNet 和 VAE 承担了,它需要的只是一个能把 prompt 转成条件向量的工具。也就是说,CLIP 更像是整个系统里的“翻译官”:把人类语言翻译成 UNet 能读懂的向量序列。
2.1 CLIP 的文本编码器在 SD 里如何工作
具体到实现层面,SD 1.x 系列使用的是openai/clip-vit-large-patch14这个版本的文本编码器。它本质上是一个 Transformer,输入是 tokenized 之后的文本序列,输出是每个 token 对应的向量。在你输入"a beautiful sunset over the ocean"之后,分词器会先把它切分成若干 token,然后做两个关键操作:padding 和 truncate,把序列统一到 77 的长度。
为什么偏偏是 77?这不是拍脑袋定的,而是 CLIP 模型训练时采用的最大序列长度。文本编码器内部的 Transformer 有 12 层,隐藏层维度是 768。因此当一个句子被处理完,我们得到的是一个形状为[1, 77, 768]的张量:77 表示序列长度,768 表示每个位置的特征维度。这个张量会作为条件信息送到 UNet 的交叉注意力层,引导图像特征的生成。
注意,CLIP 文本编码器在 SD 训练过程中处于冻结状态,也就是说它的参数不参与梯度更新。这个设计是故意的:CLIP 已经学到了足够通用的文本语义表示,如果同时训练它,很容易过拟合到训练集的 prompt 上,导致模型失去泛化能力。很多早期二次元模型在某些风格词上表现不佳,就是因为训练时动了文本编码器,或者在微调时把整个编码器解冻了。
2.2 通过对比学习实现图文对齐的原理
要理解为什么 CLIP 能当“翻译官”,得简单讲一下对比学习。CLIP 的预训练目标是:给定一个 batch 的图文对,模型需要判断哪张图对应哪句话。实现方式是分别计算图像和文本的特征向量,然后计算它们之间的余弦相似度矩阵,对角线上的元素应该是最高分,其他位置分越低越好。这个训练过程让文本特征和图像特征在语义层面建立了对应关系。
比如“一只猫坐在椅子上”这句话的文本特征,会被拉近到与“猫坐在椅子上”的图片特征相近的位置。SD 在生成时,就是利用了这种对齐后的文本特征作为引导,让 UNet 在每一步去噪时都知道“当前图像内容应当与猫、椅子这些概念相关”。这比传统的分类标签条件要灵活得多,因为它能表达组合式的、开放式的语义,这也是文生图能处理复杂 prompt 的原因之一。
如果你做微调,比如 LoRA,也可以选择只训练 UNet,完全不碰文本编码器。这样训练成本低,且不容易破坏 CLIP 已有的语义空间。反过来,如果你希望模型能学会某个特定风格词汇,光靠训练 UNet 有时不够,因为 UNet 在交叉注意力层接收的特征本身语义不变,这时可以考虑用 LoRA 同时微调文本编码器。我个人的经验是:微调文本编码器的时候学习率一定要比 UNet 低很多,否则很容易出现“过拟合式崩坏”,出图风格会突然变得单一且怪异。
2.3 CLIP 微调和 ComfyUI 里的“CLIP 询问”玩法
热词里提到的“clip 模型微调”“comfyui clip 询问机”,其实都是 CLIP 在 SD 生态中的延伸用法。
先说说微调。当前生态中比较常见的做法是直接微调 CLIP 模型本身,让它在某个垂直领域的图文检索上表现更好,或者让文本编码器对特定风格的词汇敏感度更高。在 SD 里,更多场景是微调 text encoder 来提升提示词的响应能力。举个例子,如果你训练一个专注于建筑立面生成的 LoRA,你会发现某些描述建筑风格的形容词如果不额外训练,UNet 其实很难捕捉。这时把 text encoder 一起微调,让它对“粗野主义”“解构主义”这类词产生更明确的语义表示,生成效果会显著提升。
至于 ComfyUI 里的“CLIP 询问机”,我理解的是利用 CLIP 的文本或图像编码能力,对输入内容做特征查询或匹配的操作。比如 CLIP Text Encode 节点可以把文本编码成 embedding,CLIP Vision Encode 节点可以把参考图编码成图像 embedding,再通过一些自定义节点把这些 embedding 用于风格控制或特征注入。这种方式本质上就是在用 CLIP 做“语义探测”,先看看一句 prompt 在最基础的语义空间里长什么样,再决定如何调整权重。这个思路对调试复杂工作流很有用,因为它把“不可见的文本表征”变成了可以观察和操作的对象。
3. 第二站:扩散模型——从噪声到图像的“逆向工程”
扩散模型这个概念现在提得很多,但大多数人理解的其实是它的宏观叙事:先让图像变成噪声,再让模型学会把噪声变回图像。这个理解没毛病,但如果要上手写代码,就得把宏观叙事落到每一步的数学计算上。我在这部分会尽量少堆公式,但核心的加噪、去噪、采样三步,你用代码复现的时候一个都不能漏。
可以这么想:我们都见过把一张清晰照片逐渐高斯模糊的过程,扩散模型的加噪阶段与之类似,只不过加的不是模糊,而是高斯噪声。更关键的是,这个加噪过程是有“时间步”概念的,第 1 步只加一点点噪声,第 100 步图像已经几乎完全变成纯噪音。模型训练的目标,就是在任意一个加噪程度下,都能预测出“这一步往图像里加了多大的噪声”,然后反推出去掉噪声的样子。
3.1 前向过程:图像是如何一步步变成噪声的
扩散模型的前向过程,数学上定义为一个马尔可夫链。如果你有一张干净的图像 (x_0),每一步按预设的方差表 (\beta_t) 给它加上高斯噪声,得到 (x_t)。公式可以写成:
[ q(x_t | x_{t-1}) = \mathcal{N}(x_t; \sqrt{1-\beta_t} x_{t-1}, \beta_t \mathbf{I}) ]
这个公式看着吓人,本质就是:从 (x_{t-1}) 出发,均值缩小一点点,再叠加一点噪声。(\beta_t) 决定噪声强度,通常从很小值逐步增大,早期图像还很清晰,后期几乎全是噪声。
这里有一个很方便的性质:因为每一步都是高斯分布,而且加噪是线性操作,我们可以直接从 (x_0) 跳到任意第 (t) 步的 (x_t),不用一步步迭代。设 (\alpha_t = 1-\beta_t),(\bar{\alpha}t = \prod{i=1}^t \alpha_i),就能得到:
[ x_t = \sqrt{\bar{\alpha}_t} x_0 + \sqrt{1-\bar{\alpha}_t} \epsilon ]
其中 (\epsilon \sim \mathcal{N}(0, \mathbf{I}))。这个“重参数化”技巧非常关键,因为它让训练变得更高效:你不需要真的把前向过程跑 1000 步,只要随机抽一个时间步 (t),生成一个噪声,就能直接构造出当前步的加噪样本。我第一遍读代码时花了不少时间才绕出来,之前一直以为训练时要把每一步都跑一遍,后来才知道是直接随机抽样时间步。
3.2 逆向过程:让模型学会“倒放”
前向过程有明确的数学公式,但逆向过程 (p(x_{t-1} | x_t)) 是不知道的。如果从一张纯噪声图开始,理论上每一步都有无数种可能的“上一张图”,直接计算不可行。扩散模型的核心假设是:如果我们训练一个神经网络来预测“前向过程中加进去的噪声 (\epsilon)”,就可以近似构造逆向过程。
具体来说,给定当前的 (x_t) 和时间步 (t),网络输出一个噪声预测 (\epsilon_\theta(x_t, t)),然后根据这个预测,估计出去噪后的均值:
[ \mu_\theta(x_t, t) = \frac{1}{\sqrt{\alpha_t}} \left( x_t - \frac{\beta_t}{\sqrt{1-\bar{\alpha}t}} \epsilon\theta(x_t, t) \right) ]
这个均值的含义是:把 (x_t) 沿着噪声预测的相反方向“拽回”一步。推理时,我们就从一个纯噪声 (x_T \sim \mathcal{N}(0, \mathbf{I})) 开始,利用模型逐步去噪,采样 (T) 次,最终得到近似的 (x_0)。
这里有个细节值得注意:在实际采样中我们不止用均值,通常还会保留一个随机项,让生成过程具有多样性和概率性。这也是为什么同一 prompt、同一 seed 在不同采样器下可能得到略有差异的结果。采样器的设计就是如何在“逼近真实分布”和“步数有限”之间做权衡。
3.3 噪声调度:(\beta_t) 怎么选,影响出图速度和质量
(\beta_t) 的序列被称为噪声调度(noise schedule),它在训练时是预先定义好的。DDPM 原文使用线性调度,从 (\beta_1 = 0.0001) 线性增长到 (\beta_T = 0.02)。后续工作提出了余弦调度、缩放余弦调度等变体,它们在高分辨率图像上表现更稳定。
你可能在 WebUI 里见过采样器选择列表:DDIM、PLMS、DPM++、Euler A、Heun 等。这些采样器解决的根本问题是:原始 DDPM 反向采样需要 1000 步,每步都要过一次 UNet,太慢了。于是大家研究出各种“加速采样法”,希望用 20 到 50 步就逼近 1000 步的效果。
从我实际测试的经验看,不同采样器在相同步数下差异非常大。比如 Euler A 在 20 步下已经能生成构图合理的图像,但 DPM++ 2M Karras 在 20 步下细节明显更干净。而 DDIM 在小步数下容易出现“糊”的问题。如果你要批量出图,我会建议先固定一个采样器和步数,比如 DPM++ 2M Karras 加 28 步,然后只调 prompt 和 seed,这样变量少,容易判断修改效果。
3.4 为什么 SD 要在“潜在空间”扩散,而不是像素空间
早期扩散模型直接作用于像素空间,生成 256x256 的图像就需要数天训练,更别提 512x512 或 1024x1024。潜在扩散模型(LDM)的思路是:先用 VAE 把图像压缩到一个低维潜在空间,再在这个空间里做扩散。SD 就是 LDM 的一种实现。
SD 的 VAE 会把 512x512x3 的 RGB 图像压缩成 64x64x4 的潜变量,空间尺寸缩小为原来的八分之一。这个代表压缩处理方式,大幅降低了 UNet 计算量。在最终解码阶段,训练好的 VAE 解码器再把潜变量放大回像素空间。理解这一点后,你就能看懂代码里latents的 shape 为什么不是[1, 3, 512, 512],而是[1, 4, 64, 64]。
顺带说一句,热词里出现了“python 大气污染高斯扩散模型 代码”,这其实是两个完全不同的“扩散”:大气污染中的高斯扩散是物理现象,用来描述污染物在空气中的浓度分布;而这里的扩散模型是生成模型,通过加噪和去噪来完成图像生成。别把两者混为一谈。我见过有人搜扩散模型代码搜到大气模型上,虽然都带“高斯”二字,但应用的数学框架完全不同。
4. 第三站:UNet——扩散过程的主发动机
CLIP 负责翻译文本,扩散模型负责定义流程,但真正在每一时间步里承担“去噪”计算的,是 UNet。SD 里的 UNet 和最初医学图像分割用的 UNet 已经有很大差别,但核心骨架仍然一脉相承:编码器降采样、解码器升采样、跳跃连接保留细节。
我们可以把 UNet 想象成一个身兼数职的加工车间:输入是一张带噪声的潜在图和时间步信息,输出是预测的噪声;在这个过程中,它还要不断把文本条件(来自 CLIP 的 embedding)融合进去,让每一步去噪都“听 prompt 指挥”。没有这个融合机制,SD 就只是个随机噪声生成器,谈不上“受控生成”。
4.1 UNet 的基础骨架:编码器、解码器和跳跃连接
传统 UNet 的编码器部分通过逐步下采样来提取不同尺度的特征,下采样通常用 stride 为 2 的卷积或者池化操作。这个过程中,特征图分辨率变小、通道数变多,网络能够看到更大范围的上下文信息。解码器部分则相反,通过上采样逐步恢复分辨率,把编码器压缩后的特征还原成和输入一样大的输出。由于上采样会丢失部分细节,UNet 引入了跳跃连接,直接把编码器对应层的特征拼接或相加到解码器的同一尺度特征上。这让解码器在恢复时能直接利用高分辨率的浅层信息。
在 SD 中,输入是形状为[batch, 4, 64, 64]的潜变量,UNet 的输出形状和输入完全一致,因为目标输出就是预测的噪声。这里的预测噪声维度恰好是 4 通道,和 VAE 的潜空间通道数一致。你如果打印过 SD 的 UNet 结构,会看到它包含多个 ResNet 模块、SpatialTransformer 模块,以及跨步卷积组成的下采样块。
4.2 时间步嵌入:为什么 UNet 需要知道“现在是第几步”
你可能想过一个问题:UNet 在每一时间步执行的几乎是一样的操作,那它怎么知道当前噪声大还是小?答案是通过时间步嵌入。论文借鉴了 Transformer 中的位置编码思路,把时间步 (t) 编码成一个向量,再用一个 MLP 把它变换成和残差块匹配的尺度和形状,注入到每一层。
这样设计的原因很直观:在扩散早期(t 接近 1000),图像几乎是纯噪声,模型应该做大幅度重构;在扩散晚期(t 接近 0),图像已经很接近目标结果,模型只需要精细修整。如果网络不知道当前的时间步,它就无法针对不同噪声强度调整行为。从实现上讲,时间步嵌入通常被加到残差块的输入上,或者和残差块的输出相加。你可以把它理解成给 UNet 的每一层都发了一个“当前阶段”的提示卡,让每层都能根据进度调整处理方式。
我在第一版手动实现里,忘记把时间步嵌入传入 UNet,结果生成出来的图像在整体结构上崩坏,所有物体轮廓都像融化一样。排查半天后才发现是时间步参数传成了None。这类错误在看着源码复现时很容易出现,调试时先检查t有没有被正确传进去,比盲调采样器参数有效率得多。
4.3 交叉注意力:文本条件真正进入网络的地方
如果只看卷积层,UNet 是完全没有办法接受文本信息的。真正让 prompt 影响出图内容的,是交叉注意力模块。这个模块通常被放在 UNet 的某个中间特征分辨率层级,通过把来自图像的特征(比如[batch, 4096, 320])作为 Query,来自文本的 embedding([batch, 77, 768])作为 Key 和 Value,计算注意力权重。
注意力公式本质上就是加权求和:
[ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]
这里的 Q 来自图像侧,K 和 V 来自文本侧。QKV 都会经过独立的线性投影层,让模型可以学习从图像特征中挑选与当前文本概念最匹配的部分。也就是说,图像中某个区域的输出,是由文本中所有 token 的加权组合决定的。权重越大,表示该区域与某个 prompt 词相关度越高,比如“cat”这个词的向量会强烈影响图像中猫所在的区域。
理解了交叉注意力,你就能明白“为什么换一个少见词,整张图都变了”或者“为什么某些风格词会被完全忽略”。当某个 prompt 词在训练数据中很少出现时,CLIP 编码出的向量在语义空间中可能指向一个模糊方向,交叉注意力的注意力权重就会很平均,几乎没有引导作用。反之,高频词如“masterpiece”能强烈影响全局特征。这也是为什么很多提词技巧建议把重要概念前置、用逗号分割,本质上是在调节注意力分布。
4.4 UNet 模型改进方向:从 SD1 到 SDXL 和 ControlNet
很多刚接触 SD 的人会有个疑问:为什么不改进 UNet 来提高出图质量?其实社区一直在做这件事,只是有些改动融入了模型版本升级,有些则以附加模块的形式出现。
SDXL 就对 UNet 做了显著改动:UNet 参数从 SD1.x 的约 860M 增长到约 2.6B,增加了多个 Transformer 块,并且引入了两个交叉注意力层来处理不同来源的文本条件。同时,SDXL 的文本编码器不再是单一的 CLIP,而是使用两个文本编码器的综合输出,这让它对复杂 prompt 的解析能力更强。
另一个重要改进是 ControlNet。它并没有修改原有 UNet 的结构,而是复制了一份 UNet 的权重作为可训练分支,然后通过一个零卷积层把外部条件(如边缘图、深度图、姿态图)注入到原始 UNet 的每一层。这种做法让基础模型保持不变,通过附加条件控制生成结果的结构。我在实际使用时发现,ControlNet 的注入强度不宜太高,否则图像会显得呆板,失去扩散模型应有的多样性。
如果你打算自己改进 UNet,我的建议是先跑通 baseline,再考虑替换某个残差块为注意力模块,或者修改下采样倍率。一味堆参数不会让效果线性提升,这也是为什么 LoRA 这种低秩微调方案在资源有限的情况下反而更受欢迎。
5. 第四站:代码实战——用 diffusers 跑通完整文生图流程
前面三集把概念和原理讲完了,这一集就是真刀真枪把整个链路拼起来。现在大家的常规操作是用 WebUI 或 ComfyUI 一键出图,但在代码层面手动实现一次文生图,对理解原理很有价值。diffusers 库把很多环节封装得比较友好,我们可以用它的组件拆解出每一步,而不是只调用现成的StableDiffusionPipeline。
这样做有一个明显好处:当你需要调试、改采样器、改 CFG 逻辑、或者做自定义工作流时,你已经知道每个组件输入输出的形状和含义,不会再“凭感觉改参数”。
5.1 环境准备和模型下载
在动手之前,先把环境装好。我建议用 Python 3.10 或 3.11,PyTorch 2.x,diffusers 0.24 以上,transformers 4.30 以上,accelerate 0.20 以上。如果你用的是 Mac,还需要注意 MPS 后端是否可用,部分 PyTorch 版本在 MPS 上对某些算子支持不完整。
模型方面,你既可以从 Hugging Face 下载完整的stable-diffusion-v1-4或stable-diffusion-v1-5,也可以只下载子组件来逐模块加载。手动复现时,我推荐使用CompVis/stable-diffusion-v1-4的子目录,因为它的目录结构非常规范,vae、unet、text_encoder、tokenizer、scheduler都有独立子文件夹,方便单独加载和 inspect。
5.2 手动实现:从文本编码到最终出图的完整流程
下面这份代码框架是我手动拆分 Pipeline 后迭代出来的,你可以直接复制到 Jupyter 或脚本里跑,我加了足够多的注释,方便对照原理部分理解。
import torch from transformers import CLIPTextModel, CLIPTokenizer from diffusers import AutoencoderKL, UNet2DConditionModel, PNDMScheduler from PIL import Image # 1. 加载组件 model_id = "CompVis/stable-diffusion-v1-4" tokenizer = CLIPTokenizer.from_pretrained(f"{model_id}/tokenizer") text_encoder = CLIPTextModel.from_pretrained(f"{model_id}/text_encoder") vae = AutoencoderKL.from_pretrained(f"{model_id}/vae") unet = UNet2DConditionModel.from_pretrained(f"{model_id}/unet") scheduler = PNDMScheduler.from_pretrained(f"{model_id}/scheduler") # 2. 文本编码:得到 [1, 77, 768] 的条件向量 prompt = "a cat sitting on a chair, oil painting style" inputs = tokenizer( prompt, max_length=tokenizer.model_max_length, # 77 padding="max_length", truncation=True, return_tensors="pt", ) with torch.no_grad(): text_embeddings = text_encoder(inputs.input_ids)[0] print("text_embeddings shape:", text_embeddings.shape) # 3. 随机初始噪声:潜空间里的“画布” latents = torch.randn((1, 4, 64, 64)) scheduler.set_timesteps(50) # 按调度器初始化噪声,不同调度器可能有缩放 latents = latents * scheduler.init_noise_sigma # 4. 去噪循环:扩散模型的逆向过程 for t in scheduler.timesteps: with torch.no_grad(): noise_pred = unet(latents, t, encoder_hidden_states=text_embeddings).sample latents = scheduler.step(noise_pred, t, latents).prev_sample # 5. VAE 解码:从潜变量还原为像素图像 latents = 1 / 0.18215 * latents with torch.no_grad(): image = vae.decode(latents).sample # 反归一化回 [0, 1] 并转为 PIL 图像 image = (image / 2 + 0.5).clamp(0, 1) image = image.cpu().permute(0, 2, 3, 1).numpy() Image.fromarray((image[0] * 255).astype("uint8")).save("output.png")这份代码里,最值得关注的是第 5 步的1 / 0.18215。这 0.18215 是 SD1.x 的 VAE 潜空间缩放系数。在训练时,为了保证潜变量的数值范围稳定,LDM 对 VAE 编码器输出的潜变量乘了一个缩放因子。反过来,在解码时就要先除以这个因子,把 latents 恢复到和训练一致的尺度。如果你把它漏掉了,最常见的结果是生成出一张整体偏色、对比度异常、看起来像“底片”的图。这个 0.18215 就是从我踩坑的过程中记住的。
5.3 加入 CFG:让 prompt 真正“指挥”生成的引导机制
如果你想提高文本与生成结果的一致性,只靠上面的基础流程肯定不够,通常要加入 Classifier-Free Guidance。实现方式其实很简单:除了条件预测,你还得跑一次无条件的噪声预测,然后把两者按权重合并。我常用的写法是这样:
uncond_input = tokenizer( [""], max_length=77, padding="max_length", truncation=True, return_tensors="pt", ) with torch.no_grad(): uncond_embeddings = text_encoder(uncond_input.input_ids)[0] guidance_scale = 7.5 for t in scheduler.timesteps: with torch.no_grad(): noise_pred_cond = unet(latents, t, encoder_hidden_states=text_embeddings).sample noise_pred_uncond = unet(latents, t, encoder_hidden_states=uncond_embeddings).sample noise_pred = noise_pred_uncond + guidance_scale * (noise_pred_cond - noise_pred_uncond) latents = scheduler.step(noise_pred, t, latents).prev_sample这样做等于让网络在“无 prompt 引导”和“有 prompt 引导”之间做一个外插。guidance_scale 越大,生成的图像越贴合 prompt,但如果超过 15,往往会出现颜色过饱和、质感油亮的“塑料感”问题。CFG 的本质是放大条件信息和非条件信息的差异,所以一旦差异本身过于夸张,图像就会失真。我的经验是 SD1.x 用 7.5 左右、SDXL 用 5 到 7 比较稳妥,具体还是看题材。
5.4 关键参数实测与调优经验
很多刚接触代码复现的朋友会问:为什么我照抄了代码,出图效果还是不如 WebUI?多数情况下是参数组合问题。我用一组实验做个简单对照,方便你选择:
| 参数组合 | 采样器 | 步数 | 出图效果 |
|---|---|---|---|
| 基础配置 | PNDM | 50 | 细节充足,但耗时较长 |
| 快速出图 | Euler A | 20 | 构图完整,细节一般 |
| 高质量推荐 | DPM++ 2M Karras | 28 | 清晰度和色彩都较理想 |
| 极端省时 | DDIM | 15 | 偶有结构崩坏 |
固定 seed 是复现实验的关键。你需要用torch.manual_seed(42)设置随机种子,同时注意 PyTorch 在 GPU 上的某些算子并不保证完全确定性,因此同样的 seed 在不同显卡上不保证结果完全相同。如果你在 WebUI 里勾选了“xformers”或者开启某些优化,结果也会有细微差异。
另外,文本编码阶段默认会上报到 GPU,如果你的显存比较小,比如 6G 以下,建议使用vae.enable_slicing()以及unet.enable_attention_slicing(),或者把 VAE 解码部分放到推理结束前再加载。还有一个实用技巧是开启model_cpu_offload,它会把当前不用的模块暂时移回 CPU,省下不少显存。
6. 常见问题与排查技巧实录
我在这套笔记整理过程中,特地把自己和身边朋友踩过的报错做成了一份速查表。这些错误单看书本和教程基本遇不到,但在实际部署、复现、调试时出现的频率非常高。
6.1 高频报错与解决方案速查
| 报错信息或现象 | 常见原因 | 解决方案 |
|---|---|---|
ImportError: dlopen ... | 多见于 Mac 上 PyTorch 与 MPS 后端不兼容 | 先升级 PyTorch 到 2.x,再检查 diffusers 和 transformers 版本,必要时卸载重装 |
| CUDA out of memory | 显存不足,尤其在 VAE 解码阶段 | 开启 attention slicing、使用小分辨率、关闭 xformers 或换用精简模型 |
IndexError: index out of range | token 长度超过限制或 padding 参数错误 | 检查是否传了max_length=77和padding="max_length" |
| 生成图片全黑或全灰 | VAE 解码时没有除以缩放系数 0.18215 | 在解码前乘以1 / 0.18215 |
| 图片整体偏灰、对比度低 | 反归一化范围错误 | 用(image / 2 + 0.5).clamp(0, 1)恢复像素范围 |
| CFG 后图像过曝 | guidance_scale 太大 | 降到 5 到 10 之间,重新测试 |
| 中文 prompt 效果很差 | CLIP 的 tokenizer 对中文支持有限 | 先用翻译工具把中文 prompt 转成英文,或用支持中文扩展的模型 |
6.2 操作层面容易忽略但影响巨大的细节
第一,文本编码器输出的向量要放在同一个设备上。我在第一次复现时把text_embeddings留在了 CPU,而 UNet 在 CUDA 上跑,结果一进交叉注意力就报设备不匹配错误。这个错误其实很容易修复,但定位起来就是费时间。
第二,scheduler.set_timesteps()一定要在循环前调用,不能放在循环里。调度器会依据这个设置生成一步序列,如果你在每次迭代时重新调用,整个去噪流程会乱掉,图像大概率是一团噪声。
第三,关于prompt的 token 处理,我建议无论是 WebUI 还是手动代码,都要养成先渲染一个空字符串 embedding 的习惯,尤其在你准备启用 CFG 的时候。你可以在代码里创建一个uncond_embeddings,和条件 embedding 拼成一个 batch,一次前向同时得到两个预测,能减少一半的推理时间。如果循环里跑两次 UNet,成本会翻倍,对大图来说特别明显。
第四,Mac 用户常见的ImportError: dlopen问题,多半是torch的安装版本和 MPS 后端不匹配,或者缺少某些底层依赖。我建议直接在 conda 环境里重新安装最新版 PyTorch,不要手动拷贝旧的.so文件,因为这类文件依赖关系非常脆弱,修修补补很容易留下隐患。
6.3 调试技巧分享:先小后大、分开检查每个环节
如果你跑出来的图像效果不对,不要急着换参数,先做一个“部分链路测试”。具体做法是:把随机噪声固定住,比如设置为全 0 或者某个特定 seed,然后手动检查中间变量。你可以观察第一个时间步的输出是不是接近输入、随机初始化噪声预测的数值范围是否合理。如果 UNet 输出的噪声标准差异常巨大,说明模型加载有误或者输入张量没有归一化。
再比如,你可以直接用一张真实图片经过 VAE 编码,再直接解码,看看是否能够还原。如果这一步就有严重色偏,说明 VAE 部分有问题,和扩散模型无关。先把链路拆成“文本编码→噪声预测→VAE 解码”三段,每段单独验证,能极大缩小问题范围。这也是我调试任何生成模型时的通用习惯。
写在最后的一点个人体会
这套四集笔记整理完,我最深的感受是:Stable Diffusion 能火起来,不是因为它创造了某个全新概念,而是因为把 CLIP、扩散模型、UNet 这些已有组件以巧妙方式拼在了一起,并通过潜在空间设计让计算量变得可以接受。每个独立模块单独拿出来都不是 SD 首创,但它们的组合产生了一种全新的生成范式。这种“缝合式创新”其实也给工程师提了个醒:与其等着出现一个划时代的新模型,不如先把现有模块的边界条件、输入输出格式和连接方式吃透,很多新想法恰恰是在这个过程中冒出来的。
如果你打算继续往下走,我建议下一个重点是 LoRA 微调的原理,特别是文本编码器和 UNet 的适配层是怎么被注入的。之后再回头看 ControlNet 的条件注入方式,会发现很多模块之间的联系比想象中密切。这套笔记里的代码和参数组合我都实际跑过,你可以放心照着做,但根据显卡和模型版本不同,微调参数也是正常的。如果中途卡在某个报错上,欢迎按上面的排查表逐步对照,绝大多数问题都能找到对应解法。