Stable Diffusion 原理详解:从 CLIP、扩散模型、UNet 到文生图代码实战(四集精讲笔记)
1. 基础概念:这套模型到底在做什么
在我最开始接触 Stable Diffusion 的时候,有个误区非常普遍:以为它是一个类似 GAN 的生成器,输入一段文字,直接输出一张图。直到我啃完一系列精讲笔记,把整个数据流从输入端到输出端完整过了一遍之后,才意识到这个模型的工作方式远比"输入—输出"要精巧得多,甚至可以说它是把整个迭代优化过程都显式地写进了生成流程里。
如果你也想真正吃透 Stable Diffusion,而不是停留在"调个参数、跑个脚本"的水平,那必须先搞清楚它身上的几个核心组件:CLIP、扩散模型、UNet、VAE,以及采样器(Sampler)和调度器(Scheduler)。它们之间的关系,用一句话概括就是:CLIP负责把文字变成模型能理解的条件,扩散模型负责定义"从噪声到图像"的路径,而 UNet 负责在这条路径上逐步去除噪声,最终由 VAE 解码器把隐空间特征还原成我们能看到的像素图。整个过程本质上就是一个"以文本为引导、以噪声为起点、逐步去噪求解"的优化过程。
这一系列精讲笔记适合谁?我觉得只要具备基本的 Python 语法和一点点 PyTorch 使用经验,就可以跟着走完。它不会上来就甩一堆数学推导,而是先用直观类比让你明白每一步在做什么,再配合代码把流程串起来。四集内容正好对应了四个子主题:CLIP 文本编码、扩散过程数学、UNet 去噪网络、以及文生图的完整代码实战。下面我把我在笔记中整理的核心内容,结合自己的实操经验,尽量按"先懂原理、再看代码、最后调参"的顺序分享出来。
2. 宏观架构拆解:四个组件各司其职
2.1 CLIP 文本编码器:文字是如何被"翻译"成生成条件的
自然语言本身是离散的、变长的,而 UNet 接收的条件往往是固定维度的向量或特征矩阵。CLIP 在这个系统中扮演的角色就是把任意长度的文字描述映射到一个语义向量空间里,并且这个空间和图像特征空间是对齐的。
具体来说,Stable Diffusion 用的是 CLIP 的文本编码器部分,通常是一个 Transformer 架构。它会先把输入文本用 BPE(Byte Pair Encoding)分词,转成 token IDs,然后经过若干层自注意力机制和前馈网络,得到一组特征向量。实际操作中,我们通常取最后一层输出的整个序列特征(而不是只取句首标记对应的特征),因为后续 UNet 需要通过 cross-attention 来"查看"文本的每个部分。
这里有个非常重要的细节:CLIP 文本编码器的输出尺寸是固定的,因为 Stable Diffusion 的 UNet 在 cross-attention 中会把这个特征序列当作 Key 和 Value。如果你用的是标准 SD 1.5 模型,文本编码器的最大 token 长度是 77。也就是说,一句话最长只能被编码成 77 个 token,超出部分在原始 pipeline 中会被截断。这就是为什么你在写提示词时,会发现关键信息放在前面效果更好,因为后面的内容可能根本没进到注意力计算里。
2.2 扩散模型与调度器:噪声与图像的"双向奔赴"
扩散模型的核心思想不算复杂:前向过程(加噪)就是把一张清晰图像逐步加入高斯噪声,经过足够多步之后,图像变成纯噪声;反向过程(去噪)就是学习如何从纯噪声出发,一步步还原出原始图像。Stable Diffusion 的创新在于,它并没有直接在像素空间做这个扩散过程,而是先在 VAE 编码器得到的低维潜空间里进行,这就是"潜在扩散模型"这个名字的由来。
这样做最大的好处是计算量大幅下降。像素空间通常是一张 512x512x3 的图像,而潜空间特征可能是 64x64x4,分辨率只有原来的八分之一,通道数只多了一维。模型只需要在这么小的特征图上做去噪,训练和推理的效率都上了一个量级。这也是为什么普通消费级显卡也能跑 Stable Diffusion 的原因之一。
调度器(Scheduler)则负责管理去噪过程中每一步的"节奏"。它根据当前步数计算出对应的噪声强度,并决定如何从 UNet 预测的噪声中恢复出下一步的潜向量。常见的调度器有 DDIM、DPM-Solver、Euler 等,它们的区别主要体现在步长策略和更新公式上。你在 WebUI 里看到的"采样方法"选项,本质上就是选择不同的调度器。
2.3 UNet 骨干网络:图像生成过程的"主力军"
UNet 是一种编码器-解码器结构的卷积神经网络,因为形状像字母 U 而得名。在 Stable Diffusion 中,UNet 的输入是带噪声的潜向量、时间步 t 的嵌入向量,以及从 CLIP 获取的文本条件。它的输出是预测的噪声(或者在某些变体中直接预测原始清晰图像,取决于训练目标)。
UNet 的关键结构包括三个部分:下采样路径、上采样路径和跳跃连接。下采样路径逐层降低空间分辨率同时增加通道数,提取越来越抽象的特征;上采样路径逐层恢复空间分辨率;跳跃连接则把下采样过程中各尺度的特征直接拼接到上采样对应层,这样模型既能保留高层语义,又不会丢失底层细节。
另外,UNet 中每个基础模块都加入了 cross-attention 机制,用来把 CLIP 文本特征注入到图像特征中。具体做法是:把图像特征作为 Query,文本特征作为 Key 和 Value,通过注意力计算让图像的每个位置都能"关注"到相关的文字信息。这就是"文生图"中文本能够控制图像内容的根本原因。
2.4 VAE 编解码器:潜空间与像素空间的桥梁
VAE(变分自编码器)在这里承担的角色非常单一却关键:编码器把像素图像压缩成潜空间特征,解码器把潜空间特征还原成像素图像。需要说明的是,这个 VAE 是单独训练的,与扩散模型的训练是解耦的。
在实际推理时,我们只用到了 VAE 的解码器部分:当 UNet 完成去噪过程后,得到一个干净的潜向量,再经 VAE 解码器上采样,就得到了最终的 RGB 图像。你也可以用 VAE 编码器把一张真实图片转成潜向量,再做图生图(img2img)操作。
这里有一个实操中很容易遇到的问题:默认的 VAE 有时会让生成图像的颜色偏灰,或者出现一些奇怪的伪影,尤其是人像场景。社区里很多人会推荐使用经过微调的 VAE 文件(例如某些修复版本),在加载模型后单独替换 VAE 权重,可以明显改善色彩表现。这一块在标准理论教程里很少讲,但实际出图时影响很大。
3. 核心机制深层解析:为什么这些组件缺一不可
3.1 从噪声到图像:逐步去噪到底在求解什么
扩散模型前向过程有一个非常优雅的性质:给定原始图像 x₀,经过 t 步加噪后的结果 x_t,可以直接用一个公式算出来,不需要真正迭代 t 次。因为每一步加噪都是独立的高斯噪声叠加,而独立高斯分布的叠加仍然是高斯分布,均值和方差是可以解析推导的。
反向过程则是在学习一个条件分布。每一小步的目标是:给定当前带噪潜向量 x_t、时间步 t、以及文本条件 c,预测出 added noise ε,然后从 x_t 中减去这个噪声的贡献,得到 x_{t-1}。重复这个操作足够多次,就会得到一个与文本条件一致的清晰潜向量。
我在学习到这里时,最大的感受是:这个模型其实并没有真的去记住"猫长什么样",它记住的是"猫在某个噪声水平下大概长什么样、以及如何把噪声去掉"。生成过程更像是用先验分布做约束的求解过程,而不是简单地从记忆库里检索图片。
3.2 时间步 t 的作用:如何告诉模型当前噪声程度
UNet 在处理不同 t 值时需要有不同的行为:在早期(t 接近最大步数),图像几乎全是噪声,模型的任务是从纯噪声中找出微弱的结构信号;在后期(t 接近 0),图像已经比较清晰,模型只需要做精细调整。为了让同一个 UNet 能同时胜任这两个极端,我们必须把 t 的信息显式传入网络。
这个时间嵌入在实现中通常是先用一个位置编码或者可学习的嵌入层把整数 t 映射成向量,然后通过若干层 MLP 得到时间嵌入特征,再在 UNet 的各个阶段以 addition 或者 cross-attention 的形式注入。整个推导过程中的除以一个递减系数、加一个方差调度参数,都是为了保证不同步数下的信噪比处于合理范围。
我在看代码时发现,很多初学者会忽略"时间步是否调节正确"这个问题。比如你手工把 t 从 0 到 999 均匀取 50 步,但实际上调度器会按照某种时间表(如 scaled_linear、karras 等)来生成这些步数,不同时间表会直接决定生成图像的风格和细节程度。这也是为什么使用不同采样器时,即使步数相同,出图效果也会差异很大。
3.3 Cross-Attention 是怎么把"文字"变成"画笔"的
文本条件注入虽然看起来只是在某个模块里加了 attention,但它是整个模型控制力的核心。在 UNet 的每个 stage 中,图像特征图会先经过自注意力(Self-Attention)处理,让图像不同位置互相参考;然后经过 cross-attention,让每个图像位置与文本特征进行交互。
从矩阵计算的角度看,假设图像特征序列有 H×W 个位置,文本序列有 L 个 token(L≤77),那么 cross-attention 的计算复杂度是 O(H×W×L)。这个计算量是可控的,因为它没有被用来做像素级别的 long-range dependency,而是专注于图像与文本之间的对齐。
这里有个值得思考的细节:如果提示词中存在多个主体,模型如何决定谁出现在图像的哪个位置?答案就藏在 attention map 里。你可以用 attention 可视化工具导出每一层每个 token 对应的注意力热力图,会发现"红色""帽子"等 token 几乎只关注图像中对应的区域。理解这一点之后,你就明白为什么负向提示词(negative prompt)里加入某些词能有效移除画面中的元素了,因为模型会在每一步去噪时主动避开与这些词匹配的特征模式。
4. 文生图代码实战:从零手写一个最简 Stable Diffusion 推理流程
4.1 环境准备与依赖选择
开始之前先把环境打底。我的建议是直接用 diffusers 库,它能让我们把理论中的每个环节都对应到具体的类和函数上。下面这段代码是环境检查:
import torch import diffusers from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler from transformers import CLIPTextModel, CLIPTokenizer import os print("PyTorch:", torch.__version__) print("Diffusers:", diffusers.__version__) print("CUDA available:", torch.cuda.is_available()) if torch.cuda.is_available(): print("GPU:", torch.cuda.get_device_name(0))我推荐使用 2G 显存以上的显卡,虽然理论上 CPU 也能运行,但速度会让人失去耐心。内存至少 16G,硬盘至少预留 10G 左右给模型文件(fp16 精度下的 SD 1.5 权重大约 4G,但转换和缓存会额外占空间)。
如果你在 Windows 上安装,建议用pip install diffusers transformers accelerate safetensors一条命令搞定。注意版本兼容性,某些老版本 diffusers 和 transformers 的接口不一致,会导致 pipeline 传参失败。
4.2 模型加载与组件初始化
在 diffusers 中,加载 Stable Diffusion 最省事的办法是:
model_id = "xxx/sd-v1-5" # 假设你有本地模型目录或某个 id pipe = StableDiffusionPipeline.from_pretrained( model_id, torch_dtype=torch.float16, safety_checker=None, ) pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) pipe = pipe.to("cuda")这段代码背后做的事情非常多。它会同时加载 CLIP 文本编码器、UNet、VAE 以及默认的调度器。torch_dtype=torch.float16是节省显存的关键,因为我实测在 float32 下生成一张 512 图显存占用能到 6G 以上,而 float16 基本稳定在 4G 左右。
safety_checker默认是一个 NSFW 检测器,如果你只想做技术演示,可以直接置为 None,省去每次推理的额外开销。如果你需要完整功能,删掉这行即可。
另外一个非常容易踩坑的点:如果你从某些渠道下载到的模型文件只是 UNet 部分(比如只有unet/diffusion_pytorch_model.safetensors),直接用 from_pretrained 会报错,因为缺少其他组件。你需要把整个目录结构补全,或者使用pipe.unet.load_state_dict()手动加载。
4.3 手工拆解 Pipeline:一步一步走完整个生成流程
先用高层 API 的方式快速跑一张图,建立直观感受:
prompt = "a red apple on a wooden table, soft lighting, high detail" image = pipe( prompt, num_inference_steps=25, guidance_scale=7.5, negative_prompt="blurry, low quality", ).images[0] image.save("demo.png")如果只是日常使用,到这步就够了。但如果你想"看见"代码背后每一步发生的事,我建议手动拆开 pipeline,亲自执行以下流程。下面这段代码是我在学习笔记基础上改写的,每一步都加了注释:
# 1. 加载组件 tokenizer = CLIPTokenizer.from_pretrained(model_id, subfolder="tokenizer") text_encoder = CLIPTextModel.from_pretrained(model_id, subfolder="text_encoder").to("cuda") vae = AutoencoderKL.from_pretrained(model_id, subfolder="vae").to("cuda") unet = UNet2DConditionModel.from_pretrained(model_id, subfolder="unet").to("cuda") # 2. 文本编码 def encode_prompt(prompt): tokens = tokenizer( prompt, padding="max_length", max_length=tokenizer.model_max_length, truncation=True, return_tensors="pt", ).to("cuda") with torch.no_grad(): text_embeddings = text_encoder(tokens.input_ids)[0] return text_embeddings # shape: (1, 77, 768) prompt_embeds = encode_prompt("a red apple on a wooden table, soft lighting") negative_prompt_embeds = encode_prompt("blurry, low quality")这里重点说下文本编码的几个细节。padding="max_length"会把所有文本填充到 77 token,不足部分用 padding token 占位。CLIP 文本编码器不会学到"忽略 padding token"这种机制,它在注意力计算时会对所有 token 一视同仁。所以,如果你在提示词中用了很多修饰词,占满了 77 个 token,那么 padding 就不存在,但也意味着没有余量了。这也是很多教程建议提示词控制在 50~70 个 token 左右的原因,留一些 padding 空间反而能减少干扰。
4.4 调度器设置:步数、时间表与引导强度
接下来是初始化调度器并准备随机噪声。这里的随机种子非常关键,我后面会有一节专门解释它。
scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) num_inference_steps = 30 scheduler.set_timesteps(num_inference_steps) latents = torch.randn( (1, 4, 64, 64), generator=torch.Generator(device="cuda").manual_seed(42), device="cuda", dtype=torch.float16, ) latents = latents * scheduler.init_noise_sigma这里(1, 4, 64, 64)是 SD 1.5 在 512x512 输入下的标准潜空间形状。4 是 VAE 编码器的潜在通道数(不是 RGB 的 3),64 是 512 除以 8 得到的空间尺寸。init_noise_sigma是调度器根据起始步数计算出来的噪声缩放系数,不乘它会导致后面去噪时能量不匹配。
4.5 手动迭代去噪:理解每个循环在做什么
下面这段循环是整个推理的核心。我强烈建议你在理解这段代码之后,再回去看调度器的源码,很多疑问会迎刃而解。
for i, t in enumerate(scheduler.timesteps): if i % 5 == 0: print(f"Step {i+1}/{len(scheduler.timesteps)}, t={t.item()}") # 把文本条件扩展成与 latents 匹配的 batch 形式 latent_model_input = torch.cat([latents] * 2) # 正向 + 负向 text_embeddings = torch.cat([prompt_embeds, negative_prompt_embeds]) # 在单个 timestep 上做推理 with torch.no_grad(): noise_pred = unet( latent_model_input, t, encoder_hidden_states=text_embeddings, ).sample # 拆分正向与负向预测,做 classifier-free guidance noise_pred_pos, noise_pred_neg = noise_pred.chunk(2) noise_pred = noise_pred_neg + guidance_scale * (noise_pred_pos - noise_pred_neg) # 调度器根据当前噪声预测计算下一步的 latents latents = scheduler.step(noise_pred, t, latents).prev_sampleclassifier-free guidance是整个控制力非常核心的技术。它在训练时同时接受"有条件"和"无条件"两种输入,推理时利用两者差值来放大文本约束的强度。你看到的guidance_scale参数越大,生成结果越贴近文本,但过度放大会导致色彩过饱和或画面失真。我实测 7.5 是一个比较通用的值,偏抽象或概念化的场景可以调到 10~12,而人像写实类建议 6~7。
调度器的step方法内部实现会根据不同采样方法给出不同公式。比如 DDIM 会用预测噪声和当前潜向量线性组合,而 DPMSolver 则会用多阶积分器的方式加速收敛。所以这里不需要我们自己写去噪公式,直接调用即可。
4.6 VAE 解码:把潜向量变成像素图
这一步很多人会忽略它的重要性。如果直接把潜向量转成 PIL 图像,只会看到一张满是噪点的色块图。必须经过 VAE 解码器。
latents = 1 / pipe.vae.config.scaling_factor * latents with torch.no_grad(): image = pipe.vae.decode(latents).sample image = (image / 2 + 0.5).clamp(0, 1) image = image.cpu().permute(0, 2, 3, 1).numpy() image = (image * 255).round().astype("uint8") from PIL import Image pil_image = Image.fromarray(image[0]) pil_image.save("manual_pipeline_demo.png")其中1 / vae.config.scaling_factor是 VAE 解码前的必要缩放。Stable Diffusion 训练时,潜向量和像素之间存在一个尺度差,如果不做这个逆变换,解码出的图像颜色和亮度会出现明显偏移。
手动跑完这个流程之后,再对比pipe(...)一行调用得到的结果,你会发现两者输出完全一致。这时你再回头看那些"细枝末节"的参数,比如init_noise_sigma、scaling_factor、text_embeddings维度,就会有一种豁然开朗的感觉。
5. 从理论到实践:调参与排错的经验总结
5.1 生成效果杂乱、主体不清晰怎么办
如果你发现生成图像中主体模糊、多个元素混杂,我首要建议检查的是提示词结构和引导强度。提示词不要写成没有逻辑的堆砌,应该用主语+环境+风格+质量的句式。例如,把 "apple red wooden table soft light" 改成 "a red apple placed on a wooden table, soft window lighting, high detail, photorealistic"。
如果问题仅限于对比度偏低,考虑替换 VAE。我在多次尝试后发现,某些微调 VAE 文件能让色彩更鲜艳,细节更锐利。具体操作很简单,加载完 pipeline 后,用pipe.vae = AutoencoderKL.from_pretrained("某个vae路径")替换即可。
5.2 显存不足,推理卡顿甚至崩溃
这个问题在本地部署时太常见了。首先是显存不足,建议按我前面的建议启用torch.float16。如果仍然不够,可以把输入分辨率调低到 384x384,或者启用pipe.enable_attention_slicing()。这个方法会切分注意力计算,减少峰值显存占用,代价是生成速度略有下降。
如果你的显卡只有 4G 显存,我还建议不要一次生成 batch 太大,尽量逐张生成,并定期调用torch.cuda.empty_cache()清理缓存。另外,很多调试场景下可以关掉需要额外显存的功能,比如safety_checker和某些后处理模型。
5.3 模型输出总是带奇怪伪影或水印
如果你发现生成图总是带着一种模糊的水印感,大概率是 VAE 的原因。Stable Diffusion 早期开源版本附带的默认 VAE 存在一定的解码瑕疵,会形成一种"雾蒙蒙"的视觉特征。社区有很多修复版 VAE,替换后通常能解决。另一个可能原因是采样步数太少,比如低于 15 步,模型还没有完全收敛就解码了,画面会有一种不自然的噪点感。
5.4 随机种子到底怎么影响出图效果
随机种子本质上决定了初始噪声latents的取值。同一个种子、同一个提示词、同一个参数组合,理论上生成结果完全一致。所以如果你看到满意的图,记录种子能让后续微调更容易。
请注意:不同调度器的输出会打破"同种子同结果"的预期,因为调度器内部的时间表不同,初始噪声虽然相同,但每一步的更新方式不同,最终结果会有差异。如果你需要严格固定生成效果,调度器、步数、引导强度、模型权重版本都要保持一致。
5.5 提示词超过 77 token 的后果与对策
前面说过,CLIP 文本编码器最大长度是 77 token。如果你在 WebUI 里写了一段很长的提示词,超出部分其实会被丢弃。很多人以为"越长越好",实际上超长提示词不仅不起作用,有时还会稀释前面关键 token 的注意力权重。
对策有几种:一是精简提示词,把不重要的环境影响词去掉;二是使用社区提供的长提示词扩展方案,比如通过重排序把两个 77 token 的向量拼接起来,但需要额外处理 UNet 的 cross-attention 维度,已经超出简单 pipeline 的范畴;三是在多个采样阶段分别用不同的短提示词控制画面局部变化,这个技术门槛较高,暂不展开。
6. 精讲笔记的扩展学习:四集之后的进阶建议
6.1 从推理到训练:微调和 ControlNet 的必要基础
当你彻底掌握推理流程之后,下一步有两条路可选。一条路是微调(Fine-tuning),比如用 LoRA 为某个特定人物或风格建立一个轻量级适配模型。LoRA 的核心原理是在 UNet 的 cross-attention 层中插入低秩矩阵,只更新这些小参数,训练量远小于全量微调,普通显卡也可实现。
另一条路是 ControlNet,它是在 UNet 外部增加一个额外的条件编码网络,可以把边缘图、姿态骨架、深度图等结构信息注入到生成过程。如果你已经理解了我前面讲的 cross-attention 注入方式,那 ControlNet 的原理对你来说也不难理解。它本质上就是额外增加了一路条件信号,在多个尺度上和原始潜向量特征融合。
6.2 自定义调度器:如何调出自己的风格倾向
不同调度器会影响出图风格。Euler 和 Euler a 偏简洁,适合快速迭代;DDIM 在低步数下表现比较稳定;DPMSolver 系列是速度和质量的均衡选择;而 Karras 调度器因为时间表改进了噪声分布,在细节表现上更优。
我在实际中一般分场景选择:快速测试构图时用 Euler a 20 步,出正式图用 DPM++ 2M Karras 25~30 步。步数继续增加收益很有限,反而会拉长生成时间,这属于边际效应递减。
6.3 更多组件之间的协同:img2img、inpainting 与视频模型扩展
理解了文生图流程,再去看图生图会很轻松:输入一张图像,先用 VAE 编码成潜向量,再按一定强度加入噪声(这个强度由 denoise 参数控制),然后执行同样的去噪循环。所以你在 WebUI 中调节的 denoise 参数本质上控制的是"输入图被破坏的程度",值越高,输出图和原图差异越大。
inpainting 模型其实只是在 UNet 输入上多拼接了掩码通道,让模型知道哪些区域需要重新生成,哪些区域保持原样。这个思路同样是在 low-dimensional latent space 做文章。至于视频生成模型,多数是在 UNet 基础上加入时间维度的 attention,并没有逃出 Stable Diffusion 这个大框架。
7. 实际操作中形成的核心体会
把四集精讲笔记和这段代码实战连起来之后,我最深的体会是:Stable Diffusion 并没有大家想象中那么玄学。它每一步都是可解释、可控制、可复现的。你遇到的大部分"奇怪效果",归根结底都能在潜空间初始化、噪声调度、文本条件编码、CLIP 截断、或者 guidance scale 这几个环节里找到原因。
另一个体会是,理论框架带来的收益是长期的。我在不了解 cross-attention 的时候,调提示词完全是靠蒙。比如"加权重语法((word:1.2))"为什么有效,我当时不懂;后来明白它是在文本编码层面对特定 token 的嵌入做缩放,才真正的理解了这个操作边界在哪里、什么时候会失效。
最后给后来者一个建议:不要急着下载几十个模型版本。先把官方 SD 1.5 的 pipeline 手动跑通,再改用自己拆开的组件,最后试着改调度器或者做简单的 LoRA 微调。你会发现,那些大几十GB的"整合包"并没有帮你理解任何东西,而这份原理+代码的底层积累,会在你走向高级用法时加倍回报。