1. 从一张图说起:为什么 Stable Diffusion 值得每个技术人亲手拆一遍
第一次跑通 Stable Diffusion 文生图的时候,我盯着屏幕上那张从纯噪声里“长”出来的图,脑子里只有一个念头:这玩意儿到底是怎么把一堆随机数变成有语义的画面的?后来花了大概两周时间,把 CLIP、扩散模型、UNet 这三块核心拼图挨个啃了一遍,又自己动手写了一份最小可运行的文生图代码,才算真正把这条链路串起来。
这篇笔记就是那次拆解过程的完整记录。核心关键词围绕Stable Diffusion、CLIP、扩散模型、UNet、文生图展开,我会从整体架构讲到每个模块的具体职责,再落到可运行的代码实战。适合两类人看:一类是已经会用 WebUI 或 ComfyUI 出图,但想知道背后到底发生了什么的产品和运营同学;另一类是想自己改模型、做微调、甚至从零复现一个简化版文生图流程的算法工程师。
先说结论性的认知:Stable Diffusion 不是一个单一模型,而是一套由多个组件协同工作的系统。文本编码器负责把你说的话翻译成机器能理解的语义向量,UNet 负责在潜空间里一步步去噪,VAE 负责在像素空间和潜空间之间来回搬运,调度器负责控制去噪的节奏。少了任何一个,这张图都出不来。很多人调参调不明白,本质上是因为不知道每个参数作用在哪一环。
我见过太多人卡在“装不上环境”或者“跑出来是黑图”这种问题上,其实只要理解了数据在系统里的流动路径,大部分报错都能自己定位。所以这篇笔记不会只讲概念,每个模块我都会配上能直接跑的代码片段和参数说明,让你看完就能自己搭一个最小版本出来。
2. 整体架构拆解:三个模型如何接力完成一次文生图
2.1 为什么是“潜空间扩散”而不是像素空间扩散
最早的扩散模型是在像素空间直接做去噪的,比如一张 512x512 的 RGB 图,就是 512×512×3 约 78 万个数值,每一步去噪都要在这个维度上算一遍。扩散过程通常要几十到上千步,算力和显存消耗非常夸张。Stable Diffusion 的核心创新之一,就是引入了潜空间扩散(Latent Diffusion)的思路。
具体做法是先用一个 VAE 的编码器把图像压缩到一个更小的潜空间表示。以常见的 SD 1.5 为例,一张 512×512×3 的图会被压缩成 64×64×4 的潜变量,数值量直接降到原来的约六十四分之一。去噪过程全部在这个低维空间里进行,最后再用 VAE 的解码器把潜变量还原成像素图。这就是为什么同样一张图,Stable Diffusion 能在消费级显卡上跑起来,而像素空间的扩散模型基本只能活在论文里。
注意:潜空间压缩是有损的,所以 VAE 的重建质量会直接影响最终出图的细节。有些图看起来“糊”或者“脏”,不一定是 UNet 的问题,可能是 VAE 解码环节的损失。
2.2 CLIP 文本编码器:把提示词翻译成语义向量
CLIP 的全称是 Contrastive Language-Image Pre-training,它本身是一个对比学习训练的模型,包含文本编码器和图像编码器两部分。在 Stable Diffusion 里,我们主要用它的文本编码器,把用户输入的提示词转成一串向量。
这里有个关键细节:CLIP 的文本编码器输出的不是单个向量,而是一个序列向量,长度通常是 77 个 token,每个 token 对应 768 维(SD 1.5)或 1024 维(SD 2.x)的向量。UNet 在做交叉注意力的时候,会拿图像特征去“查询”这 77 个文本向量,从而让图像生成过程受到文本语义的引导。
为什么是 77 个 token?这是 CLIP 训练时设定的最大序列长度,超出部分会被截断。所以写提示词的时候,如果关键词太多,后面的内容可能根本没被模型看到。这也是为什么有些人发现提示词加了半天没效果,其实是超长被截断了。
2.3 UNet:去噪过程的主力网络
UNet 是整个系统里参数量最大、计算最密集的部分。它的结构顾名思义,是一个 U 形网络:左边是下采样路径,逐步降低空间分辨率、增加通道数;右边是上采样路径,逐步恢复分辨率;中间用跳跃连接把对应层的特征拼起来。
在 Stable Diffusion 里,UNet 的输入是当前时刻的噪声潜变量、时间步 embedding,以及 CLIP 输出的文本 embedding。输出是预测的噪声残差。训练目标就是让这个预测噪声尽可能接近真实加入的噪声。推理时,我们根据预测噪声去更新潜变量,一步步逼近干净图像。
UNet 里还嵌入了交叉注意力层,这是文本条件真正起作用的地方。图像特征作为 Query,文本 embedding 作为 Key 和 Value,通过注意力机制让图像每个位置都能“看到”相关的文本信息。比如提示词里有“红色”,那么生成红色物体的区域就会在注意力上被强化。
2.4 调度器:控制去噪节奏的指挥棒
调度器(Scheduler)决定了每一步去噪时,噪声要减去多少、学习率怎么变化、总共走多少步。常见的调度器有 PNDM、DDIM、Euler、DPM-Solver 等。不同调度器在速度和质量上有明显差异。
DDIM 是早期常用的一种,确定性采样,步数可以压缩到 50 步左右。DPM-Solver 系列则能在 20 到 30 步内达到不错的效果。Euler 系列实现简单,很多新模型默认用它。调度器的选择会直接影响出图速度和细节丰富度,这个后面在代码实战里会具体演示。
2.5 三个模型的协作流程
把上面几块串起来,一次完整的文生图流程是这样的:
- 用户输入提示词,CLIP 文本编码器把它转成 77×768 的文本 embedding。
- 从标准正态分布里采样一个随机潜变量,形状是 64×64×4。
- 调度器设定时间步序列,比如从 999 递减到 0。
- 每一步,UNet 接收当前潜变量、当前时间步、文本 embedding,预测噪声。
- 调度器根据预测噪声和当前时间步,计算下一步的潜变量。
- 循环直到时间步走完,得到干净的潜变量。
- VAE 解码器把潜变量还原成 512×512×3 的像素图。
整个流程里,CLIP 只跑一次,UNet 跑 N 次(N 等于采样步数),VAE 解码跑一次。所以采样步数是影响推理时间的主要因素。
3. 核心模块深入:CLIP、UNet、VAE 的关键细节
3.1 CLIP 文本编码器的 tokenizer 机制
CLIP 用的 tokenizer 是基于字节对编码(BPE)的,和 GPT 系列类似。它会把文本拆成子词单元,然后映射到词表 ID。特殊 token 包括起始 token、结束 token 和填充 token。
实际使用中有一个容易踩的坑:CLIP 对大小写和标点比较敏感。比如“a photo of a cat”和“A Photo Of A Cat”编码出来的向量会有差异。虽然模型有一定泛化能力,但在精细控制时,保持提示词风格一致会更稳定。
另外,CLIP 的 77 token 限制是包含起始和结束 token 的,所以实际可用的内容 token 是 75 个。写提示词时可以用一些工具统计 token 数,避免关键信息被截断。
3.2 UNet 的网络结构与注意力机制
UNet 的下采样和上采样通常各包含 4 个阶段,每个阶段有若干残差块。以 SD 1.5 的 UNet 为例,基础通道数是 320,每个阶段通道数翻倍,最高到 1280。时间步 embedding 通过全连接层注入到每个残差块里,让网络知道当前处于去噪的哪个阶段。
交叉注意力层通常放在每个阶段的中间位置。文本 embedding 先经过一个线性投影,变成 Key 和 Value;图像特征经过另一个投影变成 Query。注意力计算完成后,结果再经过一个输出投影,加回原特征。
这里有个工程上的细节:为了节省显存,很多实现会把注意力计算放在低分辨率阶段进行,高分辨率阶段只用自注意力。这也是为什么 UNet 能在有限显存下处理 512 甚至 768 分辨率的图。
3.3 VAE 的编码与解码
VAE 在 Stable Diffusion 里主要用它的解码器。编码器在训练阶段用来把图像压到潜空间,推理阶段一般不需要。解码器的结构是若干上采样块加卷积层,最后输出三通道图像。
VAE 的潜空间是 4 通道的,这是为了在压缩率和重建质量之间取平衡。通道数太少,细节丢失严重;通道数太多,压缩效果不明显。4 通道是实验下来比较合适的值。
提示:有些微调模型会附带自己的 VAE,比如一些动漫风格的模型。如果出图颜色发灰或者对比度不对,可以尝试换一个 VAE 试试,这是很常见的排查手段。
3.4 文本引导的无分类器引导机制
无分类器引导(Classifier-Free Guidance)是提升文本控制力的关键技术。训练时,模型会随机丢弃文本条件,学习在无条件和有条件两种情况下预测噪声。推理时,同时算两次预测:一次带文本条件,一次不带,然后按引导系数做外推。
公式大致是:最终预测 = 无条件预测 + 引导系数 × (有条件预测 - 无条件预测)。引导系数越大,文本控制越强,但过大容易导致画面过饱和、细节崩坏。常用的引导系数在 7 到 12 之间,具体看模型和提示词。
这个机制解释了为什么引导系数是一个这么重要的参数。它本质上是在“忠于提示词”和“保持画面自然”之间做权衡。
4. 文生图代码实战:从零搭一个最小可运行版本
4.1 环境准备与依赖安装
我用的环境是 Python 3.10,PyTorch 2.0 以上,搭配 diffusers 和 transformers 库。diffusers 是 Hugging Face 出的扩散模型工具库,封装了调度器、UNet、VAE 的加载和推理逻辑,非常适合做原型验证。
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install diffusers transformers accelerate safetensors如果你的显卡显存小于 8GB,可以加上--fp16相关的选项,或者用enable_attention_slicing来降低显存占用。Mac 用户如果遇到 dlopen 相关的 ImportError,通常是 PyTorch 版本和系统架构不匹配,建议用 conda 重新装一个对应版本的 PyTorch。
4.2 加载模型与组件拆解
下面这段代码把 Stable Diffusion 的各个组件分别加载出来,方便你看到每个部分长什么样。
import torch from diffusers import StableDiffusionPipeline, DDIMScheduler, UNet2DConditionModel, AutoencoderKL from transformers import CLIPTextModel, CLIPTokenizer model_id = "runwayml/stable-diffusion-v1-5" tokenizer = CLIPTokenizer.from_pretrained(model_id, subfolder="tokenizer") text_encoder = CLIPTextModel.from_pretrained(model_id, subfolder="text_encoder") unet = UNet2DConditionModel.from_pretrained(model_id, subfolder="unet") vae = AutoencoderKL.from_pretrained(model_id, subfolder="vae") scheduler = DDIMScheduler.from_pretrained(model_id, subfolder="scheduler") device = "cuda" if torch.cuda.is_available() else "cpu" text_encoder.to(device) unet.to(device) vae.to(device)加载完成后,你可以打印一下 UNet 的结构,看看它的下采样、上采样和注意力层是怎么排布的。这一步对理解模型非常有帮助。
print(unet)4.3 文本编码与潜变量初始化
接下来把提示词编码成文本 embedding,并初始化随机潜变量。
prompt = "a photo of a cat sitting on a wooden table, soft lighting, detailed fur" height, width = 512, 512 text_input = tokenizer( prompt, padding="max_length", max_length=tokenizer.model_max_length, truncation=True, return_tensors="pt" ) with torch.no_grad(): text_embeddings = text_encoder(text_input.input_ids.to(device))[0] # 无条件 embedding,用于无分类器引导 uncond_input = tokenizer( [""], padding="max_length", max_length=tokenizer.model_max_length, return_tensors="pt" ) with torch.no_grad(): uncond_embeddings = text_encoder(uncond_input.input_ids.to(device))[0] # 拼接条件和无条件 embedding text_embeddings = torch.cat([uncond_embeddings, text_embeddings]) # 初始化潜变量 latents = torch.randn( (1, unet.config.in_channels, height // 8, width // 8), device=device )这里height // 8和width // 8是因为 VAE 的下采样倍数是 8。512 的图对应 64 的潜变量尺寸。
4.4 去噪循环与调度器步进
这是整个流程的核心部分。每一步都要做两次 UNet 前向:一次无条件,一次有条件,然后按引导系数合并。
guidance_scale = 7.5 num_inference_steps = 30 scheduler.set_timesteps(num_inference_steps) latents = latents * scheduler.init_noise_sigma for t in scheduler.timesteps: latent_model_input = torch.cat([latents] * 2) latent_model_input = scheduler.scale_model_input(latent_model_input, t) with torch.no_grad(): noise_pred = unet( latent_model_input, t, encoder_hidden_states=text_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) latents = scheduler.step(noise_pred, t, latents).prev_sample这段代码里,scheduler.scale_model_input会根据当前时间步对输入做缩放,不同调度器的缩放方式不一样。scheduler.step则负责根据预测噪声计算上一步的潜变量。
4.5 VAE 解码与图像保存
去噪完成后,用 VAE 解码器把潜变量还原成图像。
latents = 1 / 0.18215 * latents with torch.no_grad(): image = vae.decode(latents).sample image = (image / 2 + 0.5).clamp(0, 1) image = image.cpu().permute(0, 2, 3, 1).numpy() from PIL import Image import numpy as np image = (image * 255).astype(np.uint8) Image.fromarray(image[0]).save("output.png")那个 0.18215 是 SD 1.5 VAE 的缩放因子,不同版本的 VAE 可能不一样,用 diffusers 加载时会自动处理,这里手动写出来是为了让你看到这个细节。
4.6 参数调优的实操记录
我拿同一组提示词做了几组对比实验,记录如下:
| 采样步数 | 调度器 | 引导系数 | 出图效果 | 耗时 |
|---|---|---|---|---|
| 20 | DDIM | 7.5 | 结构基本正确,细节偏少 | 约 3 秒 |
| 30 | DDIM | 7.5 | 细节明显提升 | 约 4.5 秒 |
| 50 | DDIM | 7.5 | 细节提升有限,耗时增加 | 约 7 秒 |
| 30 | Euler | 7.5 | 画面更锐利,风格略有差异 | 约 4.5 秒 |
| 30 | DDIM | 12 | 文本控制更强,颜色偏饱和 | 约 4.5 秒 |
| 30 | DDIM | 4 | 画面自然但偏离提示词 | 约 4.5 秒 |
实测下来,30 步加 DDIM 加 7.5 引导系数是一个比较均衡的默认配置。如果追求速度,20 步也能用,但细节会打折扣。引导系数超过 12 之后,画面容易出现明显的过曝和伪影。
5. 常见问题与排查技巧实录
5.1 出图全黑或全灰怎么办
这是新手最常见的问题。排查顺序建议是:先确认 VAE 是否正确加载,再检查潜变量缩放因子是否匹配,最后看解码后的数值范围是否做了正确的归一化。如果用的是自己拼的流程,很容易漏掉1 / 0.18215这一步,导致解码出来的数值范围完全不对。
另一个可能原因是提示词编码后全是填充 token,也就是文本编码器没正常工作。可以打印一下text_embeddings的均值和方差,正常应该有明显波动。
5.2 显存不够怎么优化
几个立竿见影的手段:开启注意力切片pipe.enable_attention_slicing(),用半精度torch.float16,降低分辨率到 384 或 448,减少采样步数。如果还是不够,可以考虑用enable_sequential_cpu_offload()把部分层放到内存里,代价是速度会慢一些。
注意:半精度在某些老显卡上可能出现数值不稳定,如果出图有异常色块,可以换回全精度试试。
5.3 提示词不生效的排查思路
先确认 token 数有没有超过 75。然后检查引导系数是不是太低。再就是看提示词里有没有互相冲突的描述,比如同时写“白天”和“夜晚”,模型会无所适从。最后,不同模型对提示词的敏感度不一样,有些模型对风格词反应强烈,有些对具体物体描述更敏感,需要针对模型调整写法。
5.4 调度器选择速查表
| 调度器 | 推荐步数 | 特点 | 适用场景 |
|---|---|---|---|
| DDIM | 30-50 | 确定性,稳定 | 通用,适合复现 |
| Euler | 20-30 | 简单快速 | 快速预览 |
| DPM-Solver | 20-30 | 少步数高质量 | 追求效率 |
| PNDM | 50 | 早期默认 | 兼容旧流程 |
5.5 模型微调时的注意事项
如果要做 CLIP 模型微调或者 UNet 微调,有几个点要特别注意。学习率不能设太大,CLIP 微调常用 1e-5 到 5e-6,UNet 微调常用 1e-5 左右。训练数据要保证文本和图像对应准确,噪声太大的数据会拖垮效果。另外,微调时最好冻结 VAE,只训练文本编码器和 UNet,这样显存压力小很多。
我踩过的一个坑是:微调时没有固定随机种子,导致每次验证效果波动很大,后来固定种子并统一验证集,才看出真实的训练趋势。
6. 从理解到改造:后续可以怎么扩展
把这条链路跑通之后,能做的事情就多了。想改文本控制力,可以动 CLIP 的投影层或者调整交叉注意力的注入方式。想提升出图速度,可以换更高效的调度器,或者用蒸馏方法减少采样步数。想做风格定制,可以只微调 UNet 的注意力层,保留其他部分不动。
ComfyUI 这类工具之所以灵活,就是因为它把每个模块都拆成了节点,你可以自由替换其中的 CLIP、UNet 或 VAE。理解了底层原理之后,再去看那些节点连线,就不会觉得是一团迷雾了。
我个人在实际操作中的体会是,不要一上来就追求端到端跑通大模型,先用小分辨率、少步数把流程验证一遍,确认每个环节的输出形状和数值范围都正常,再逐步放大。这样排查问题的成本会低很多。另外,养成打印中间变量形状和统计量的习惯,比盲目调参有效得多。