Stable Diffusion原理与代码实战:从CLIP到UNet全拆解 整理这套 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 个 tokenSDXL 很大程度上也遵循类似限制虽然结构不同。理解这个限制很重要因为日常出图时每次都能碰到。超过 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, subfoldertokenizer) text_encoder CLIPTextModel.from_pretrained(runwayml/stable-diffusion-v1-5, subfoldertext_encoder) unet UNet2DConditionModel.from_pretrained(runwayml/stable-diffusion-v1-5, subfolderunet) vae AutoencoderKL.from_pretrained(runwayml/stable-diffusion-v1-5, subfoldervae) scheduler DDIMScheduler.from_pretrained(runwayml/stable-diffusion-v1-5, subfolderscheduler) prompt a cat sitting on a bench, sunlight, detailed tokens tokenizer(prompt, paddingmax_length, max_length77, return_tensorspt).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_statestext_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 个 tokentext_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 Scaleguidance 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_dtypetorch.float32跑一次如果好了那就是精度问题可以换用variantfp16版本并保持使用torch.float16处理。5.2 显存不够别急着换卡显存不够是新手最常遇到的硬伤。第一优先级是使用半精度加载模型大多数情况下能把显存需求砍半。第二优先级是开启 CPU offloaddiffusers 里提供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_ENDPOINThttps://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 完全不一样。原理的东西书上看十遍不如亲手推一遍代码。如果这套笔记能帮你少走一点弯路那就很值了。