☰
Stable Diffusion原理笔记:从CLIP到UNet的文生图全流程实战
2026/10/26 6:20:35 网站建设 项目流程

花了一周时间,把网络上那套四集精讲的Stable Diffusion原理课从头到尾刷完,边看边敲代码,一边记笔记。这篇文章就是我整理出来的完整笔记,顺带加上了自己跑实验时踩过的一些坑,适合那些不想只把SD当黑盒工具、想搞懂从文字到图像到底发生了什么的朋友。内容跟着课程的顺序走:先讲CLIP文本编码器,再讲扩散模型和UNet,最后落到文生图代码实战,每一段我都会补充原理和实操上的对应关系,保证你看完能理解,也能动手。

1. 四集精讲笔记总览:入门之前先搞清SD的底层链路

1.1 这套笔记到底讲了什么

四集的内容安排其实非常有逻辑,第一集把CLIP扒了个底朝天,重点解释为什么AI绘画需要CLIP而不是别的文本编码器;第二集开始进入扩散模型,从马尔可夫链和噪声调度讲起;第三集深入UNet结构,把卷积层、注意力模块、时间步嵌入这些东西一块块拆开;第四集则是最实用的部分,带你手写一个极简的Stable Diffusion推理pipeline,每一步都要知道张量长什么样、代码在哪一行发生作用。

我自己学习的过程中,最大的感受是SD并没有传言中那么玄学。它本质就是三个组件的组合:一个文本编码器(CLIP),它把自然语言变成一组向量;一个变分自编码器(VAE),它把512×512的图像压到低维潜在空间,再负责把潜在空间变回图像;还有一个UNet,它在潜在空间里根据文本条件一步步去噪。四集内容就是围绕这三个组件展开的,搞明白这条线,后面所有参数调节、模型微调、ComfyUI工作流设计都变得有依据了。

1.2 一句话总结SD工作流

用一句话概括Stable Diffusion的文生图流程:先由VAE把随机噪声编码到潜在空间(实际是在潜在空间直接初始化噪声),CLIP把提示词变成条件向量,UNet在潜在空间里以这个条件为引导、对着噪声预测逐步去噪,迭代若干步后把去掉噪声的潜在表示交给VAE解码,得到一张图。

看起来简单,但每个环节都有很多可以展开的细节。比如说CLIP输出的是一个77×768的张量,不是一整个句子向量;UNet除了时间步嵌入,还通过交叉注意力把CLIP的特征“注入”到每个分辨率层;VAE解码时虽然输出像素值,但会有约0.1的缩放问题。如果不理解这些细节,遇到提示词太长被截断、画面结构崩坏、颜色过艳这些问题时就只能盲猜。

我在笔记里反复强调一个观点:原理课讲的东西不是拿来背的,是拿来解释现象的。你知道了CLIP有77个token上限,就明白为什么提示词太长后面会被截断;你知道了UNet的交叉注意力机制,就明白为什么某些风格词会影响全局布局;你知道了VAE解码存在缩放,就明白为什么有些复现代码里要在decoder后除以0.18215。接下来我会把这部分内容逐步展开。

2. 第一集 CLIP模型:从对比学习到文本条件

2.1 为什么非得是CLIP

CLIP是OpenAI在2021年提出的多模态模型,原意是拿来度量图像和文字语义的相似度。它训练的时候没有直接做分类,而是用对比学习:把一张图对应的正确描述作为正样本,其他描述作为负样本,最大化匹配图文对的余弦相似度,最小化不匹配对的相似度。这样做的好处是,文本和图像最终被映射到同一个向量空间里。

在Stable Diffusion出现之前,很多文生图模型用的是简单的T5、BERT等语言模型来做条件,效果差强人意。为什么CLIP更适合?因为CLIP的向量空间是图像-文本对齐过的。举个例子,你写“a photo of a cat”,BERT输出的是一个偏向语法与上下文语义的表示,而CLIP输出的向量更接近“猫”这个视觉概念在语义空间中的位置。SD需要的正是这种视觉化的文本表征,让UNet在去噪时能准确朝着与文本匹配的图像内容走。

从我实际测试的经验看,用CLIP和用其他文本编码器做SD变体(比如用T5的FLUX就另当别论),最明显的差别是语义跟随度。换个不精确的说法:CLIP更像“看图说话”式的理解,它知道“fat tabby cat”和“slim cat”之间的差距在视觉上到底体现在哪里,这一点对生成图像的结构性内容至关重要。

2.2 CLIPTextModel的输入输出细节

SD1.x系列实际使用的是CLIP的文本编码器,确切地说是CLIP ViT-L/14的Text Transformer部分,由transformers库提供。加载方式一般是:

from transformers import CLIPTextModel, CLIPTokenizer tokenizer = CLIPTokenizer.from_pretrained("openai/clip-vit-large-patch14") text_encoder = CLIPTextModel.from_pretrained("openai/clip-vit-large-patch14")

输入提示词后,会先经过tokenizer变成token ids。CLIP的上下文长度上限是77个token,超出的部分会被截断,不足的部分会用padding token补齐。所以对于一个标准SD1.5的pipeline,你拿再长的提示词给它,能被文本编码器利用的也就是前77个token。

拿到token后,Text Transformer要做两件事:一是通过嵌入层把每个token转成向量,并加上位置编码;二是经过多层Transformer编码器,把整个序列信息做混合。最终我们取最后一层的输出,得到shape为[1, 77, 768]的张量。这个张量就是我们说的text embeddings,后面的UNet靠它来引导生成。

这里有个很重要的实操细节:CLIPTextModel输出有两种形态,一是last_hidden_state,二是pooler_output。SD用的是前者,也就是每个token都对应一个768维向量的完整序列,而不是只用一个句子级向量。因为UNet的交叉注意力需要“逐token”的条件信息,图片不同区域可以关注句子中的不同词。如果你误用pooler_output,会丢掉词级语义,生成质量会明显下降。

2.3 查看CLIP是否在SD中起作用

很多人以为CLIP只在最开始工作一次,后面就没它的事了。实际上不是,SD的Pipeline确实只在第一步把prompt编码成text_embeddings,但UNet在每次去噪迭代时都要用到这个向量。你可以做一个简单实验:用同样的文本条件,但把text_embeddings随机打乱,生成的图像会完全不可控;或者只保留前几个token,画面主题可能不变,但风格细节会差很多。

我在调试时习惯把CLIP的中间表示打印出来看,比如比较两个相似提示词在向量空间里的距离。用相似度超过0.95的同义词替换提示词,生成图一般不会有太大波动;但如果你替换成语义不一致的词,text_embeddings的距离会显著拉大,生成结果也会差距很大。这个做法在做提示词自动化搜索时特别有用,可以帮你判断到底是模型没有理解,还是你的表达不够准确。

3. 第二、三集 扩散模型与UNet:从数学公式到UNet的降噪艺术

3.1 前向扩散与反向去噪的直观理解

扩散模型的思想可以从物理过程理解。想象一张干净的图像,我们不断往上撒噪声,撒到一定程度,图像完全淹没在噪声里,这就是前向扩散过程。如果模型学会从“噪声图像”还原出“原始图像”,就相当于学会了创造图像。前向过程是预先定义好的,不需要学习,给一个干净数据x0,我们按公式将其变成xt:xt = sqrt(αt) x0 + sqrt(1-αt) ε。

反向过程则比较困难,因为从纯噪声开始的每一步,我们不知道原始图像是什么。但SD做了个聪明的简化:让UNet去预测每一步叠加的噪声ε,而不是直接预测x0。只要噪声预测得足够准,就能结合一个采样调度器(scheduler)逐步反推回x0。这种思路有严格的概率论推导,但做工程的朋友只需要记住一个结论:训练时加噪声,推理时去掉噪声,采样过程中的xt只是中间产物。

实际生成时我们不会做上千步,因为扩散过程有一定随机性,可以用更好的采样器减少步数。DDPM通常需要1000步才能出比较好的效果,而DDIM可以把步数压缩到20到50步,质量仍然可用。SD内置的采样器用了类似的技巧,所以我们在Pipeline里设置num_inference_steps=20,它能在一两秒内出图,靠的就是这种加速采样。

3.2 潜在扩散模型为什么能省显存

如果直接在512×512像素空间做扩散,内存开销大得惊人,训练和推理都会非常吃力。Stable Diffusion使用的是潜在扩散模型(Latent Diffusion Model),把扩散过程搬到了一个低维的潜在空间。VAE编码器把512×512×3的图像压缩成64×64×4的潜在张量,体积缩小了整整48倍,UNet就在这个64×64×4的低维空间里去噪。

这解释了为什么普通显卡也能跑SD:UNet的计算量大部分消耗在Attention层上,64×64的分辨率比512×512小了8倍,注意力矩阵大了8倍还是小很多,整体计算量是数量级下降。同时,VAE本身也不轻,但相比UNet低分辨率下的计算,它的开销相对可控。很多人以为SD是“在像素上扩散”,这其实是误区,搞清楚这一点对调参和优化速度很有帮助。

在实践里,VAE也常被替换。原版SD1.5内置的VAE偶尔会因为负空间编码问题产生灰蒙蒙的画面,很多人换成ema或者msdec,也就是更精细微调过的VAE,色彩会干净很多。在代码里做替换也不难,直接Pipeline里把vae组件换掉即可。但一定要记住,VAE的输入输出范围和缩放系数必须匹配,不能随便拿SDXL的VAE配SD1.5的UNet。

3.3 UNet结构深入拆解

UNet名字来自网络形状像字母U:左边不断下采样压缩特征图,右边不断上采样恢复分辨率,同层之间有skip连接把细节传回去。这种设计在医学图像分割、超分、去噪等领域都是标准方案,因为它既能保留全局语义,又能恢复局部细节。

在SD里,UNet不是标准的UNet,而是加了时间步嵌入和交叉注意力机制的版本。时间步嵌入告诉网络“当前是在第几步去噪”,不同阶段对应需要恢复不同尺度的纹理,所以UNet每个block都会把timestep embedding加进去。交叉注意力则是关键:把编码后的文本特征作为key和value,图像隐特征作为query,让每个空间位置都能“看到”提示词里面的相关信息。

如果你扒开源代码,会发现UNet2DConditionModel内部有很多子模块:down_blocks、mid_block、up_blocks、conv_in、conv_out。每个down block包含若干个ResnetBlock2D和Transformer2DModel,部分block之间还有downsampler。ResnetBlock负责卷积特征学习,Transformer2DModel负责全局关系建模,其中时间嵌入和文本条件都通过不同的方式注入。对这种结构不熟的朋友,可以直接用以下方式加载并查看:

from diffusers import UNet2DConditionModel unet = UNet2DConditionModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="unet") print(unet.config)

我看完第三集后最深的感受是:理解UNet内部结构,不是为了手写一遍,而是为了知道为什么某些LoRA只能改画风,某些LoRA能改人物特征。LoRA本质就是给UNet里的cross-attention层或resnet层注入低秩参数,不同层权重不同,影响也不同。如果你将来想自己训练LoRA,这个理解就是基础。

4. 第四集 文生图代码实战:手写最小Pipeline

4.1 用diffusers三行出图

第四集先讲了最简单的调用方式。安装依赖后,加载现成Pipeline,几行代码就能生成图片:

pip install diffusers transformers accelerate safetensors
import torch from diffusers import StableDiffusionPipeline pipe = StableDiffusionPipeline.from_pretrained( "runwayml/stable-diffusion-v1-5", torch_dtype=torch.float16, safety_checker=None ) pipe = pipe.to("cuda") image = pipe( "a cute corgi sitting on the grass, sunset, high quality", num_inference_steps=25, guidance_scale=7.5, seed=42 ).images[0] image.save("output.png")

这里要注意,from_pretrained会下载模型文件和配置文件,包括UNet、CLIP、VAE和scheduler,全部放到缓存目录。第一次运行可能比较慢,建议先用huggingface-cli把模型下好。torch_dtype=float16是为了节省显存,但要注意部分老显卡不支持half计算,比如一些GTX 10系会提示错误,这时候改成float32或开启attention_slicing。

seed参数在diffusers里不是pipeline的显式参数,这里给的seed=42其实不会生效,除非使用torch.manual_seed。所以我后来写代码都会单独设置随机种子,避免踩坑。我会在后面给出完整的手动实现,那样每一步都更透明。

4.2 手动复刻Pipeline完整流程

为了把原理和代码对上,我把Pipeline里的关键步骤全部拆开,重写了一个最简单版本。

import torch from diffusers import AutoencoderKL, UNet2DConditionModel, DDIMScheduler from transformers import CLIPTextModel, CLIPTokenizer tokenizer = CLIPTokenizer.from_pretrained("openai/clip-vit-large-patch14") text_encoder = CLIPTextModel.from_pretrained("openai/clip-vit-large-patch14").to("cuda") vae = AutoencoderKL.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="vae").to("cuda") unet = UNet2DConditionModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="unet").to("cuda") scheduler = DDIMScheduler.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="scheduler") prompt = "a cute corgi sitting on the grass, sunset" text_input = tokenizer(prompt, max_length=77, padding="max_length", truncation=True, return_tensors="pt") text_input = {k: v.to("cuda") for k, v in text_input.items()} with torch.no_grad(): text_embeddings = text_encoder(text_input.input_ids)[0] height = width = 512 latents = torch.randn((1, 4, height // 8, width // 8), dtype=torch.float16, device="cuda") latents = latents * scheduler.init_noise_sigma scheduler.set_timesteps(25) for t in scheduler.timesteps: latent_model_input = torch.cat([latents] * 2) 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 + 7.5 * (noise_pred_text - noise_pred_uncond) latents = scheduler.step(noise_pred, t, latents).prev_sample latents = latents / 0.18215 with torch.no_grad(): image = vae.decode(latents).sample image = (image / 2 + 0.5).clamp(0, 1) # 再转成PIL即可

这段代码里其实藏着几个关键点。torch.cat([latents]*2)是为了同时计算无条件(empty prompt)和有条件下的噪声预测,然后按CFG公式混合。CFG的全称是Classifier-Free Guidance,guidance_scale越高,生成结果越贴近提示词,但会牺牲多样性,太大容易过曝或产生伪影。latents通过vae.decode前要除以0.18215,这是SD官方训练时VAE编码器的缩放系数,相当于把latent从标准正态分布“解缩放”回VAE decoder习惯的范围。

还要注意,我在循环中直接用了unet的输入shape:latent_model_input还要加一个batch维度吗?不需要,初始latents已经是[1,4,64,64],unet会自动换成[1,4,64,64]处理。如果我们传入形状错误,diffusers会报维度不匹配。另外timestep t是整数或张量,传进去即可,不需要自己编码,UNet内部有时间嵌入。

我在第一次手动复现时卡了很久,因为漏了scheduler.init_noise_sigma这一项。DDIM的init_noise_sigma一般是1.0,但某些采样器如EulerAncestral会大于1,如果不乘,最终图像会偏亮或者对比度不对。这个细节在Pipeline内部都被封装了,手动拆开就暴露出来了。

4.3 核心参数调优经验

参数调优本质是在平衡“语义一致性”和“图像美感”。guidance_scale建议从7到8.5开始调,低于3基本看不出提示词约束,高于12容易颜色过饱和、出现塑料感。采样步数我一般DDIM用25到30步,步数多了收益递减,反而浪费时间。分辨率方面,SD1.5默认512×512,你直接生成768×768或1024×1024也可以,但UNet的卷积和注意力不是严格尺寸不变量,有时会出现重复物体或构图问题,最好用过拟合的HighresFix或ControlNet来辅助。

seed固定是个好习惯。做对比实验时,固定seed可以保证只有提示词在变化,排查问题更高效。我在分享中会推荐大家用一个seed = 42的基准,然后每隔一次测试换不同seed观察随机性。注意在diffusers中,如果你不设置torch.manual_seed,即便代码里传入seed也没用。

5. 实战中的坑:手部修复难题与显存拯救指南

5.1 手部为什么画不好

这几乎是所有刚玩SD的人都会碰到的痛点。原因可以归结为三点:第一,训练数据里手部区域经常被遮挡,许多图片标注也不够精确;第二,CLIP文本编码器本身对“手指数量”这样的细粒度描述响应很弱,文本条件对手部姿态约束不够;第三,UNet在下采样过程中容易丢失局部高频细节,而手部的关节变化特别复杂,光靠一般去噪难以准确生成。

交叉注意力在这里的作用也很微妙。模型可能注意到“hand”这个token,但是不知道它具体应该对应多少根手指、什么朝向,所以会出现六指、手指粘连、手腕弯曲不自然等问题。这跟画师画手难有相似之处,不是模型不聪明,而是细节约束在低维潜在空间里太容易混淆。

5.2 三招解决手部崩坏

第一招,提示词工程。在负面提示词里明确写入“bad hands, missing fingers, extra fingers, deformed hands”,再用正面提示词加“perfect hands, detailed fingers”。这个方法能明显减少严重手部崩坏比例,但没法做到100%精准,因为负面提示词只是让模型远离这些特征,没有强制生成正确结构。

第二招,局部重绘。稳定出图后用img2img配合inpaint,把之前崩坏的手部区域单独框出来,用较低的重绘幅度(比如0.35到0.45),配合描述手部动作的提示词重新生成。实践中适合用ComfyUI里的蒙版重绘节点,可以先画一个粗糙蒙版,只保留手部区域参与重绘。这种方式比全图重roll省事得多。

第三招,ControlNet OpenPose。这是最靠谱的一招,先用OpenPose姿态估计抽出手部关键点,再把骨架图作为ControlNet条件,让UNet沿着给定骨架生成。如果搭配深度图或Canny边缘,还能进一步锁定手部轮廓。用SDXL或SD1.5都可以跑,关键是ControlNet模型要与底模对应。从我的测试看,OpenPose对“正确性”提升最明显,线条图对“美观度”提升更大,两者结合效果最佳。

这三招并不是互相替代,而是可以叠加使用。我是建议先写负面词,再根据结果判断崩坏程度,局部重绘仍然不行再上ControlNet。日常出图如果对精度要求不高,第一招就够了,能省下不少折腾时间。

5.3 显存不足与常见报错速查

普通8G显存的卡跑SD1.5还是比较轻松的,但一旦开启ControlNet、多批量生成或者加载SDXL,瞬间就会爆显存。我遇到最烦的报错是out of memory,代码直接崩掉,中间的图都没保存。解决方式有几类优先级:先确保使用torch_dtype=float16,再把batch_size调成1,然后开启attention_slicing或model_cpu_offload。

代码片段:

pipe.enable_attention_slicing() pipe.enable_model_cpu_offload()

enable_model_cpu_offload会把不同的模型模块在需要时搬到GPU,计算完再移回CPU,效果显著但会增加一点耗时。如果你的显存只有4G,建议用SD-Turbo或者LCM采样器大幅减少步数,或者利用tiled VAE来缓解解码时显存压力。

另一个常见坑是negative_prompt长度不能超过77个token,否则也会被截断。还有seed不生效的问题,stable diffusion是PyTorch计算,需要在执行前调用torch.manual_seed(seed),而不是在pipe()里传一个字符串。还有一个我踩过很多次的坑:加载SD1.5原版模型总会出现NSFW过滤器或其他安全检查报错,这不是模型本身的问题,而是pipeline里带了一个safety_checker组件,如果你只是做艺术创作,可以直接传safety_checker=None来禁用。

下表汇总我常用的显存策略:

显存大小推荐配置参考step数
4GB使用fp16 + enable_attention_slicing + CPU offload20-25
6GBfp16 + batch_size=1 + enable_model_cpu_offload25-30
8GBfp16,可开启多个ControlNet25-30
12GB以上自由使用SDXL或并行生成20-35

注意,这些不是硬性规则,但按这个基准调整能少走很多弯路。如果你用的是Windows + CUDA环境,注意检查PyTorch版本和CUDA版本是否匹配,否则会提示CUDA不可用,这是个很容易被忽略的环境坑。

6. 写在最后:从模仿到创新的心得

把这四集内容吃透之后,我看待AI绘画的方式发生了很大变化。以前我只会用现成的WebUI预设模板,遇到图崩了就反复改关键词,效率低且没有方向感。现在我会先想清楚是哪一层出了问题:如果是提示词理解不对,就去查CLIP向量空间;如果是构图有问题,就去关注UNet的交叉注意力;如果是细节崩坏,就想办法加条件控制。这种分层排查的思路,才是这套课程带给我最大的价值。

最后再分享一个我常用的实验小技巧:准备一组固定seed的测试提示词,专门用来比较不同版本模型、不同VAE、不同LoRA权重的影响。每次只改一个变量,记录图像输出,这样你很快就能建立起关于SD各部分“手感”的经验。不要一上来就追求训练自己的大模型,先把手上的pipeline玩熟,等你看懂了每个组件的作用,后续遇到新的模型架构也不会慌。

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

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

立即咨询