☰
Stable Diffusion原理与代码实战:从CLIP到UNet全拆解
2026/10/27 17:25:17 网站建设 项目流程

整理这套 Stable Diffusion 笔记之前,我先说个大实话:网上讲 CLIP、扩散模型、UNet 的文章不少,但大多数要么只讲概念不给代码,要么贴一堆代码却不解释为什么这么写,真正能把“从文本到图像”这条链路完整串起来的内容并不多。这篇四集精讲笔记,就是冲着“原理能讲透、代码能跑通”去的。

它到底解决什么问题?如果你是刚入坑 AIGC 的开发者,想搞懂 Stable Diffusion 到底是怎么从一句 Prompt 生成图像的;或者你已经能用 WebUI / ComfyUI 出图,但一遇到崩图、显存不够、长文本失效就懵;再或者你准备做 LoRA、ControlNet 这类微调,却看不懂 UNet 和注意力机制在哪儿发力——这套笔记就是给你准备的。我会把 CLIP 文本编码、扩散模型的前向与反向过程、UNet 的网络结构、文生图代码实战四个层面逐个拆开,并在最后附上我在实际调试里踩过、也帮别人排查过的典型问题。

1. 整体思路与架构拆解

1.1 Diffusion 不是“一个模型”,而是一条流水线

很多人第一次接触 Stable Diffusion 时会被搞糊涂:一会儿说扩散模型,一会儿说 UNet,一会儿又冒出个 VAE,到底谁在干活?真正常用的 Stable Diffusion,其实是一整套生成流水线,而不是某一个单独的神经网络。

我用一套“剧组分工”的类比来拆解这件事。文生图任务的输入是一句话,输出是一张图,这中间需要几个不同工种协作:

  • CLIP 文本编码器:相当于“编剧”,负责把自然语言 Prompt 转换成模型能理解的向量表示。
  • 扩散模型 + 调度器:相当于“导演”,负责定义整个去噪流程的节奏,决定从纯噪声到成图要经历多少步、每一步怎么走。
  • UNet:相当于“美术组核心”,它是真正在每一个时间步里对噪声图片做“去除”和“细化”的主力干将。
  • VAE(自编码器):相当于“后期制作”,负责把潜空间里的小尺寸张量解码成人眼可见的 RGB 大图。

为什么要拆成这么多组件,能不能用一个网络一步到位?理论上有更直接的做法,但实际效果会很差。文本和图像本质上是两种模态,直接在像素空间做生成,不仅显存压力巨大,而且很难自然对齐语义。Stable Diffusion 之所以把“文本理解”“潜空间去噪”“像素解码”拆开,是因为每个环节都有各自成熟的预训练模型,组合起来既能控制成本,又能充分发挥各组件优势。

1.2 为什么要在潜空间做扩散,而不是像素空间

这里有个关键概念:Stable Diffusion 的完整名称其实是“潜在扩散模型(LDM)”。早期 DALL-E 等模型直接在像素空间做扩散,每一张 512×512 的彩色图,就是 512×512×3 个数值,模型要在这么大的张量上逐像素预测噪声,训练和推理的计算开销都非常高。

LDM 的思路是先使用一个预训练好的 VAE 把图像压缩到潜空间。比如一张 512×512 的 RGB 图像,经过 VAE 编码器后变成 4×64×64 的潜变量,分辨率只有原来的八分之一,通道数也大幅减少。在这样的小张量上做扩散过程,计算量小到可以接受,普通人用一张中端显卡也能跑起来。

选潜空间还有一个好处:VAE 本身已经是一个很优秀的压缩器。它编码出来的潜变量保留了图像的语义结构,丢掉的信息主要是高频纹理细节,而这些细节恰好在去噪过程中可以通过条件信息逐步补回来。实际体验中,这种“先在潜空间生成结构,再解码成像素”的方式,比纯像素扩散更可控,也更适合接 CLIP 这样的语言条件。

另外提醒一句,搜索“扩散模型”的时候经常会把两个完全不同领域的东西混在一起:气象学里有“大气污染高斯扩散模型”,那是模拟污染物在空气中的物理扩散过程,和图像生成里的 Diffusion Model 完全是两码事。初学者搜资料时如果看到一堆高斯烟羽、浓度分布,别被带偏,直接关掉就好。

2. CLIP:文本与图像之间的“翻译官”

2.1 CLIP 是怎么训练出来的,双塔结构在做什么

CLIP 是 OpenAI 在 2021 年提出的多模态对比学习模型,核心目标很简单:让模型知道“一句文本描述”和“一张图像”在语义上匹不匹配。为了做到这一点,它采用了双塔结构——一个图像编码器负责把图片变成向量,一个文本编码器负责把文字变成向量,然后通过对比学习拉近匹配图文对的距离、推开不匹配图文对的距离。

训练过程可以这样直观理解:给一个 batch 的图文对,把图像编码器和文本编码器分别得到两组向量,然后计算它们两两之间的相似度矩阵。同一个样本里的图文对是正样本,理论上应该相似度最高;不同样本互相交叉组合就是负样本。训练目标就是让对角线上的相似度尽量大、其他位置的相似度尽量小。这种损失在学术界通常叫 InfoNCE 或对比损失,本质上是在做“图文检索”任务,只不过它的副产物——文本编码器——恰好成了文生图模型需要的语义理解工具。

说到 CLIP 的版本,Stable Diffusion 1.x 系列用的是 CLIP ViT-L/14 的文本编码器,也就是 14×14 patch、ViT-L 规模的那个版本。它在训练时见过海量的图文对,对自然语言描述的理解能力已经相当强。比如输入“一只戴墨镜的柴犬”,它能编码出包含“柴犬”“墨镜”“戴”这些语义特征的向量,这些向量之后会被 UNet 用来指导图像生成。

2.2 CLIP 在 Stable Diffusion 里到底扮演什么角色

在 Stable Diffusion 里,CLIP 只派出它的文本编码器,图像编码器完全用不到。整个流程是:Prompt 字符串先经过 BPE tokenizer 切成 token,再映射成 token id,然后进入 CLIP text encoder,输出一个形状为 [77, 768] 的文本嵌入向量序列。

这里的 77 和 768 很多人不知道含义,实际上对应两个重要概念。77 是文本编码器允许的最大 token 数,超过部分会被直接截断;768 是隐藏层维度,也就是每条文本被表示成 768 维的特征向量。很多人写长 Prompt 发现后半句完全没生效,没别的原因,就是 token 超限了。

有人把 CLIP 的文本输出称为 “conditioning embedding” 或 “text embedding”,它随后会被送入 UNet 的 attention 模块中。具体来说,UNet 中的 cross-attention 层会以图像特征作为 query,以文本 embedding 作为 key 和 value,从而实现“文本信息引导图像去噪方向”的效果。这个概念后续在 3.3 节还会详细展开。

2.3 CLIP 微调、CLIP 询问机和 CLIP Limit 的实战认知

网络上有不少关于 CLIP 微调、ComfyUI 里“CLIP 询问机”的说法,这里说一下我的实操判断。

CLIP 微调确实是可行的,但通常不是为了提升整体文生图质量,而是为了让模型更懂某些特定领域。比如你想让模型认识某个专属概念、某种特殊风格,可以针对这个领域构造图文对去微调文本编码器。但要注意,CLIP 文本编码器一旦微调过头,它输出的分布会和原有 UNet 训练时见到的不一致,结果反而可能让画面崩掉。所以我的建议是,除非你的任务非常垂直,否则优先尝试 Prompt 工程和 LoRA,而不是动 CLIP。

至于 ComfyUI 里被称为“CLIP 询问机”的东西,我理解它其实就是和 CLIP 模型交互的节点工具,可以用来查看文本编码结果、切换 model 或做一些信息检查。它本身不会“智能改写”你的 Prompt,也不会提升语义理解上限。很多人装了之后没看到神奇效果,这很正常,因为它本来就不是干这个的。

“CLIP limit”则是文本编码器输入长度上限的通俗叫法,SD 1.x 是 77 个 token,SDXL 很大程度上也遵循类似限制(虽然结构不同)。理解这个限制很重要,因为日常出图时每次都能碰到。超过 77 个 token 的 Prompt,要么被截断,要么需要用加权语法、关键词替换等方式重新组织。这不是 bug,是架构天花板。

3. 扩散模型与 UNet 的原理拆解

3.1 前向扩散过程:从原图到纯噪声

扩散模型有两个过程:前向过程和反向过程。前向过程也叫加噪过程,它的目标非常简单——拿一张原始图像,逐步往里添加高斯噪声,经过足够多步后,图像会变成完全的白噪声。

这里涉及一组成分:预定义的噪声调度(noise schedule),通常叫 beta schedule。每一步加噪的强度由 beta_t 控制,经过累计会得到 alpha_bar_t,它表示从 x0 到 xt 剩余多少原始信号比例。实际实现时,我们不会真的一步步加噪,而是利用重参数化技巧,直接由 x0 得到任意时间步 t 的带噪图像:

xt = sqrt(alpha_bar_t) * x0 + sqrt(1 - alpha_bar_t) * epsilon

其中 epsilon 是标准高斯噪声。这样做的意义在于,训练时可以随机取样任意时间步 t,直接计算对应的带噪样本和真实噪声,大幅提升训练效率。可以说,前向过程其实只是“制造训练数据”的过程,它并不参与生成。

为什么 DDPM 论文里前向过程要设置成“逐步加噪”?因为只有保证任何一步的 xt 都服从可控的分布,反向过程才有机会学出逆变化的规律。如果一步加噪太猛,图像直接变成噪声,模型根本学不到中间结构;太慢又会浪费训练步数。所以噪声调度表就是整个扩散过程的“节奏表”。

3.2 反向去噪过程:从噪声一步步“雕”出图像

反向过程是真正生成图像的过程。你可以把它理解成“倒放加噪影片”:从一张纯高斯噪声 xt 开始,每一步预测这个噪声是什么样的,然后从当前图像中扣除一部分,加上一点可控随机性,反复执行若干步,最后得到干净的图像。

模型如何学会去噪?思路是:训练时让 UNet 输入带噪潜变量 xt、时间步 t(以及可选的文本条件),输出对噪声的预测,然后用均方误差(MSE)让预测噪声逼近真实噪声。这里最关键的是,UNet 预测的目标是“噪声”而不是“干净图像”,虽然最终殊途同归,但预测噪声在数值分布上更平稳,训练更容易收敛。

推理时,DDPM 原始采样过程形如:

x_{t-1} = 1/sqrt(alpha_t) * (x_t - beta_t / sqrt(1 - alpha_bar_t) * epsilon_theta) + sigma_t * z

公式看起来吓人,但含义很直接:根据预测的噪声估算出 x0 的一个近似值,然后把它重新放到当前时间步应有的状态,再叠加一部分随机扰动。这个 sigma_t 控制每一步随机性的大小,随着时间步靠近 0,随机性逐渐减弱。

后续大家常用的 DDIM、DPM-Solver 等采样器,本质上是优化这个反向过程的步进方式,让模型用更少的步数完成采样。它们改变了步进公式和方差设定,但不改变“去噪”这个核心逻辑。

关于 classifier-free guidance(无分类器引导),这是让文本条件真正“指挥”画面的关键。训练时模型同时学习条件生成和无条件生成,推理时把两者组合起来:

epsilon_hat = epsilon_uncond + guidance_scale * (epsilon_cond - epsilon_uncond)

当 guidance_scale 大于 1 时,会放大“文本条件”带来的差异方向,让画面更服从 Prompt。调太低会出现语义漂移,调太高则容易色彩过饱和甚至崩图,这个参数在实战中非常重要,后面会讲具体数值经验。

3.3 UNet 的网络结构,以及 Cross-Attention 在哪儿注入条件

UNet 的名字来自它的 U 形结构:左侧一层层下采样,右侧一层层上采样,中间用跳跃连接(skip connections)把同尺度的特征拼起来。下采样路径提取越来越多的高层语义特征,空间分辨率越来越小;上采样路径再把特征逐步恢复到原始分辨率。跳跃连接的加入,让上采样过程保留更多低级细节,避免图像结构丢失。

在 Stable Diffusion 中,UNet 的操作对象不是像素图,而是 4×64×64 的潜变量。它的输入除了带噪潜变量外,还有两个重要条件:时间步 t 的 embedding 和文本条件的 embedding。时间步 embedding 负责告诉模型“现在噪声程度多大”,通常经过一个全连接映射后加到所有层的基础特征里;文本条件的 embedding 则通过 cross-attention 注入。

Cross-Attention 是贯穿整个 UNet 的核心机制。每一个 TransformerBlock 中,query 来自图像特征本身的映射,而 key 和 value 来自文本 embedding 的映射。这样每个图像位置都能根据文本语义去“检索”需要关注的信息,从而决定哪里该生成什么物体、哪里该留白、哪里该有什么颜色。多个层反复执行这样的交互,文本对图像的控制力是逐步增强的。

替换文本编码器的时候要特别注意,SD 1.x 的 UNet 是在 CLIP ViT-L/14 的 768 维文本输出上训练的,如果你换成别的文本模型,维度和分布都可能不匹配。UNet 输入输出的格式也很固定,训练或者微调前一定要确认 latent 形状、time embedding 维度和 text embedding 维度跟配置一致,否则往往能跑,但结果会奇奇怪怪。

4. 文生图代码实战:从原理到跑通

4.1 环境准备与依赖安装

实战前先把环境搞定。我的建议是 Python 3.10,配合 CUDA 版 PyTorch。装完 PyTorch 后先运行一下torch.cuda.is_available(),如果不是 True,后面调试会非常痛苦。基础依赖主要是这几个:diffusers、transformers、accelerate、sentencepiece、safetensors。

pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install diffusers["torch"] transformers accelerate sentencepiece safetensors

如果机器没有 NVIDIA 显卡,也不是完全没有路。可以考虑用 stable-diffusion.cpp 这类 CPU 推理方案。它会把模型量化成 GGUF 格式,在内存足够的情况下跑得动,速度虽然比 GPU 慢很多,但胜在折腾门槛低。CPU 推理时建议把步数适当减少、分辨率控制在 512 级别,否则等待时间会让人怀疑人生。

4.2 用 diffusers 一行调用,到底发生了什么

先看最省事的调用方式:

from diffusers import DiffusionPipeline pipe = DiffusionPipeline.from_pretrained("runwayml/stable-diffusion-v1-5") image = pipe("a cat sitting on a bench, sunlight, detailed").images[0] image.save("demo.png")

这段代码背后做了几件事:加载 tokenizer、CLIP text encoder、UNet、VAE 和 scheduler;把 Prompt 编码成文本 embedding;以随机噪声为起点,利用 UNet 和 scheduler 执行去噪循环;最后把潜变量交给 VAE 解码成图片。很多人第一次跑通后就以为万事大吉,但如果你不理解这里面每个环节,遇到问题还是会抓瞎。

用熟了可以直接pipe.to("cuda")和pipe.enable_attention_slicing(),前者把模型搬到 GPU,后者在不明显损失质量的前提下减少显存峰值。还有一个细节:diffusers 默认会下载模型文件到~/.cache/huggingface,第一次运行时间较长是正常的。

4.3 手写一个去噪循环,彻底理解采样过程

调用库很省事,但我还是强烈建议大家手动拆开 Pipeline 跑一遍。这样做的好处是,你会真正看到 latent 的形状变化、时间步的推进方式,以及每个组件拿到的输入是什么。

import torch from transformers import CLIPTokenizer, CLIPTextModel from diffusers import AutoencoderKL, UNet2DConditionModel, DDIMScheduler tokenizer = CLIPTokenizer.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="tokenizer") text_encoder = CLIPTextModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="text_encoder") unet = UNet2DConditionModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="unet") vae = AutoencoderKL.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="vae") scheduler = DDIMScheduler.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="scheduler") prompt = "a cat sitting on a bench, sunlight, detailed" tokens = tokenizer(prompt, padding="max_length", max_length=77, return_tensors="pt").input_ids text_embeddings = text_encoder(tokens)[0] # [1, 77, 768] latents = torch.randn((1, 4, 64, 64)) # 512*512 图像对应潜变量形状 scheduler.set_timesteps(50) 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 with torch.no_grad(): image = vae.decode(latents / 0.18215).sample

第一步,tokenizer 把 Prompt 截断或填充成 77 个 token,text_encoder 输出 [1, 77, 768] 的 embedding,这就是后面 cross-attention 要用的 key/value。第二步,初始化一个 [1, 4, 64, 64] 的标准正态噪声,这就是采样起点,也是潜空间里的“随机种子”。第三步,遍历 50 个时间步,每一步用 UNet 预测当前噪声,再用 scheduler 更新 latent。第四步,预测出的 latent 要经过 normalize(除以 0.18215),这个数值是 VAE 训练时统计出来的缩放系数,不除会在解码时颜色偏移。

理解了这一步,你基本就能看懂任何文生图框架在干什么了。区别无非是 scheduler 不同、embedding 怎么拼,以及是否做了 Negative Prompt / CFG 处理。

4.4 必调参数:Steps、CFG、Seed、Scheduler

如果只记四个参数,我建议记 Steps、CFG Scale、Seed、Scheduler。

Steps 决定去噪循环走多少步。SD 1.5 模型下默认 50 步已经很稳,20 步左右配合好的 scheduler 也能出图。步数过少会粗糙,但超过 100 步收益微乎其微。

CFG Scale(guidance scale)上面讲过,取值范围常见 5 到 10。我自己的经验是 7 附近最平衡,低于 5 容易“不管 Prompt 只管自己嗨”,高于 12 色彩可能炸,噪点明显。这个值不是越大越好,因为它本质上是在降低多样性,过度增强条件会导致分布偏移。

Seed 没啥好说的,设定固定值就能复现同一张图。它影响的是初始噪声的取值。做 Prompt 对比测试时,记得固定 Seed,否则很难判断到底是 Prompt 改变了结果还是随机噪声变了。

Scheduler 推荐 DDIM 和 DPMSolver++。DDIM 可以大步长走,DPM++ 在低步数下质量更稳,训练和推理时需要注意跟模型是否搭配合适。稳定性和效果都可能变化,建议根据实际画面效果调整。

5. 常见问题与排查技巧实录

5.1 崩图、噪点、色彩失真到底怎么查

出图崩了先别怪模型,以下排查路径覆盖了我遇到过的大部分情况。

第一,CFG Scale 过高。色彩过艳、出现颗粒感、背景乱纹,多半是引导强度太大导致分布偏离。先降到 7 以下试。第二,Steps 过少。从几十步降到个位数,会看到画面粗糙、轮廓破碎,这种直接加步数就好了。第三,Negative Prompt 写得太强。有些用户把 Negative Prompt 写得过于密集,结果模型为了回避所有负面词汇而牺牲了构图,这种情况把负面词精简到真正的“坏东西”上。第四,模型本身被过度微调。社区下载的融合模型经常别人调试到某个特定风格,新手拿默认参数跑容易翻车,可以换回官方基础模型验证。

另外提示一下 fp16 精度问题。半精度计算确实省显存,但某些算子上可能出现数值溢出,表现是图像上出现规律性噪点。如果别的参数都正常,试试把torch_dtype=torch.float32跑一次,如果好了,那就是精度问题,可以换用variant="fp16"版本并保持使用torch.float16处理。

5.2 显存不够,别急着换卡

显存不够是新手最常遇到的硬伤。第一优先级是使用半精度加载模型,大多数情况下能把显存需求砍半。第二优先级是开启 CPU offload,diffusers 里提供enable_model_cpu_offload(),它会在推理时把不用的模块暂时搬到 CPU,显存占用可以大幅下降,代价是速度变慢。第三是开启 attention slicing,或者使用 xformers 这类内存高效的注意力实现,在 8GB 显存下也能勉强跑 512 分辨率。

还有一个思路是把输出分辨率控制在模型擅长的范围内。SD 1.x 是在 512×512 上训练的,你偏要出 768 甚至 1024,不仅显存翻倍,构图也容易怪。想高清,先生成 512,再用放大模型超分,这是性价比最高的路径。如果显卡连 512 都跑不动,实在没招就用 CPU 推理方案,只是等待时间确实长。

5.3 模型下载慢、缓存路径、部署常见坑

国内环境下载 Hugging Face 模型经常慢到让人崩溃。最省心的办法是设置镜像环境变量:

export HF_ENDPOINT=https://hf-mirror.com export HF_HOME=/your/data/huggingface

这样依赖库和模型都会走镜像下载,速度会快很多。设置HF_HOME可以把模型缓存到大磁盘分区,避免系统盘被撑爆。

部署时另一个大坑是版本不一致。比如 diffusers 升级后,老模型的配置文件可能不再兼容,或者scheduler.config里的默认参数变了。跑不起来的时候,先确认diffusers和transformers的版本,再看模型仓库里model_index.json是否和你代码里加载的组件对上。社区模型尤其容易踩这种雷,因为作者常常是用自己的一套环境导出的。

5.4 想深入 UNet 改进方向,可以看哪些开源代码

原理讲完,最后提一下怎么继续深入。网络上关于“unet模型改进”的讨论非常多,但真正值得看的项目其实就那几个。

ControlNet 就是把 UNet 的输入做了一层额外控制分支,在不改原 UNet 权重的前提下,通过可训练副本注入边缘、深度、姿态等条件。LoRA 则是给 UNet 的 cross-attention 层加低秩矩阵,用极小的参数量微调出风格模型。想读懂这些,建议直接从源码入手,推荐三个项目:ComfyUI 的节点式流程方便观察每个组件的数据流动;stable-diffusion-webui 适合看参数如何从前端传进采样器;官方 diffusers 库则最接近“标准流水线”,适合把UNet2DConditionModel、DDIMScheduler逐行看完。

读源码时注意注意力模块的维度变化,尤其to_q、to_k、to_v的映射逻辑,以及不同时间步下 residual 和 cross-attention 顺序的差异。看懂了这些,后面做模型合并、蒸馏、加速采样都会有底气得多。

最后分享一点自己的体会。我第一次手动跑通去噪循环时,看着 latent 一点点从纯噪声变成猫的轮廓,那种感觉和调个现成 Pipeline 完全不一样。原理的东西,书上看十遍不如亲手推一遍代码。如果这套笔记能帮你少走一点弯路,那就很值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询