我最早完整跑通一套AI绘图脚本,用的还是原版DDPM逐像素生成:256×256的图,单张GPU要跑接近十分钟,训练更是贵得离谱。后来 Latent Diffusion Model(LDM)的论文出来,我才意识到,把扩散过程搬进潜空间这个改动,不是小优化,而是让AI绘图从实验室走向大众的那一步。Stable Diffusion能成为整个生态的标杆,核心就在这套框架。
这篇笔记,我想从一个实操过LDM源码和diffusers库的角度,把它拆开来讲。不绕概念,直接回答几个实际问题:潜空间扩散的动机到底是什么,VAE、U-Net、文本编码器和调度器各自负责什么,一条提示词从输入到成图在每个环节经历了哪些张量变换,以及你日常调的采样步数、CFG、种子到底在改什么。适合已经能跑通Stable Diffusion、但对内部原理还停留在“大概懂”阶段的同学;如果你正准备基于SD做二次开发或微调,这篇也能帮你建立一张排查问题的地图。
1. 为什么扩散要躲进潜空间——LDM想解决的最核心问题
1.1 像素空间扩散的成本瓶颈
很多人没意识到,早期Diffusion模型最大的敌人不是生成质量,而是计算量。拿DDPM来说,它直接在像素空间做前向加噪和反向去噪。一张512×512的RGB图,模型要处理的张量维度是3×512×512,也就是786432个数值。U-Net要在这样的尺度上反复提炼特征、预测噪声,训练时一个batch就得吃下大量显存,推理时迭代几百步更是让人等到怀疑人生。
这不是单纯的硬件问题,而是维度灾难。图像的大部分像素其实承载着大量冗余信息——纹理、明暗、细节,这些信息对“这张图是什么”这个级别的语义判断并不重要。可扩散模型偏偏要在每个像素上都做精细的噪声预测,相当于你请了一个专家团队去逐格检查一面墙的每一块砖,却只为了确认墙上画的内容是猫还是狗。成本高,收益却有限。
1.2 感知压缩与语义生成的解耦
LDM论文里最核心的思路,是把图像信息分成两层:感知层和语义层。感知层负责那些人类肉眼能察觉、但对语义判断不产生影响的细节;语义层负责“画面里有什么、物体关系如何、风格是什么”。
这层思考直接导向了一个工程决策:先用一个预训练好的自编码器,把高维像素图像压缩成低维潜向量,然后在潜空间里做扩散。潜空间保留的是感知上重要的信息,同时丢弃大量无关紧要的高频冗余。语义生成这件事,交给扩散模型在潜空间里完成。
这个分离带来了两个直接好处。第一,扩散模型的输入输出维度大幅缩小,计算开销显著下降;第二,扩散模型可以专注于学习数据的高层语义分布,训练效率更高。
1.3 潜空间扩散的实际代价
当然,把扩散搬进潜空间不是没有代价。第一个需要面对的问题是:压缩是有损的。VAE在压缩过程中会丢掉一部分细节,如果VAE压缩率过高,图像的精细纹理、小尺寸文字、人脸五官就可能糊掉。Stable Diffusion最终把压缩倍数定在8倍左右,就是为了在效率和信息保留之间找平衡。
第二个问题是调试链路变长了。以前你在像素空间里做扩散,中间产物可以直接可视化;现在中间产物是潜向量,出了问题很难一眼看出是VAE的问题还是U-Net的问题。这个代价在实际开发中很真实,我后面会用自己的踩坑经历展开讲。
2. 四个核心组件的分工:VAE是压缩机,U-Net是主力,CLIP是翻译,调度器是节奏
2.1 VAE:潜空间与现实世界的桥梁
VAE(变分自编码器)在LDM里扮演的角色很容易被低估。大家平时显式感知到的VAE只有在“修复脸部细节”或“切换美化VAE”时才出现,但它其实承担了LDM里最基础的职责:把图片压缩成潜向量,再把潜向量还原成图片。
具体来说,VAE由编码器和解码器两部分组成。编码器把一张512×512×3的图片映射成一个64×64×4的潜表示,下采样倍率为8。这4个通道不是简单的RGB压缩,而是通过训练学习到的一种高维特征排布,包含了图像的结构和纹理信息。解码器则做相反的事,把64×64×4的潜表示还原成512×512×3的图片。
有个关键细节:在Stable Diffusion的完整链路中,VAE在训练时是冻结的。也就是说,扩散模型只负责在潜空间里做去噪,没有权限去修改VAE的编码解码逻辑。这就带来一个工程上的便利——你可以单独把VAE换成训练得更好、细节还原能力更强的版本,而不用重新训练U-Net。社区里很多“换VAE提升画质”的做法,本质上就是在换这个组件。
2.2 U-Net:潜空间里去噪的绝对主力
如果说LDM是一台车,U-Net就是发动机。它在整个生成过程中承担了几乎所有的计算量。
U-Net的结构延续了它在图像分割领域的经典设计:一个编码器-解码器结构,中间穿插着下采样和上采样层。和原始U-Net相比,LDM里的U-Net加入了两个重要机制:一是残差连接和时间步嵌入,用来感知当前去噪到了哪一步;二是交叉注意力层,用来接收文本条件向量。
U-Net的输入有三个:当前带噪的潜向量、当前时间步的嵌入向量、文本条件向量。它的输出是一个与输入潜向量形状完全相同的噪声预测。可以通俗理解为:U-Net在看一张被噪声污染的潜向量时,既知道现在还剩下多少噪声(时间步信息),又知道用户想要什么内容(文本条件信息),然后判断哪些部分是噪声、哪些部分是真实信号,把噪声预测出来交给调度器去减掉。
这个组件的参数量通常占到整个模型的大半。以SD 1.5为例,U-Net占了约860M参数,而VAE和文本编码器加起来也远远不及它。
2.3 文本编码器:把人类语言翻译成机器能理解的条件
文本编码器解决的是“怎么让模型听懂我在说什么”的问题。LDM使用的文本编码器通常是CLIP的文本分支,它的工作流程是:先通过分词器把提示词切成一个个token,然后映射成向量序列,再经过多层Transformer编码,最终输出一个形状为77×768的文本嵌入向量。
这里有个容易被忽略的细节:CLIP文本编码器有最大长度限制,一般是77个token。超出部分会被截断。所以你在写提示词时,如果堆了一长串描述,后面的内容很可能是无效的。这也是社区里很多“负面提示词不起作用”问题的根源之一——并不是功能坏了,而是token已经满了。
在LDM的训练和推理中,文本嵌入并不是直接和潜向量拼接,而是通过交叉注意力机制注入U-Net。具体来说,潜向量经过处理后作为Query,文本嵌入作为Key和Value,模型借此在去噪的每一步都“看一眼”文本说了什么,然后动态调整生成方向。这个设计让文本条件对生成过程的影响不是一次性的,而是全程参与、逐步强化的。
2.4 调度器:控制噪声增删的节奏大师
调度器常常被当成一个“可选设置”,但它实际上是扩散过程的数学骨架。训练时,调度器负责按照一个噪声计划,逐步向干净图片加入噪声,形成训练样本;推理时,调度器负责根据U-Net预测的噪声,一步一步把纯噪声恢复成潜向量。
不同调度器的差别在于去噪路径和步数规划。DDPM走的是马尔可夫链,每一步都依赖上一步,需要几百步才能出图;DDIM引入了“隐式”扩散模型的概念,大大压缩了步数,还能保持生成结果的确定性;Euler和DPM类采样器则把去噪过程当成常微分方程来求解,步数更少,效率更高。
实际使用中,调度器对成图风格的影响非常明显。同样的模型、同样的提示词、同样的种子,用不同采样器出图可能差别很大。这不是玄学,而是每个采样器对去噪轨迹的逼近方式不同,累积误差也不同。
| 调度器 | 典型步数 | 特点 | 是否确定性 |
|---|---|---|---|
| DDIM | 20-50步 | 结果稳定,社区标配 | 是 |
| Euler | 20-30步 | 速度快,风格偏“锐利” | 是 |
| DPM++ 2M | 20-30步 | 细节丰富,兼容性好 | 是 |
| Euler Ancestral | 20-30步 | 带随机噪声,多一种变化 | 否 |
| DDPM | 500-1000步 | 理论最严谨,速度最慢 | 否 |
3. 全链路数据流:一行提示词在LDM里走过的每一步
3.1 输入处理:从字符串到条件张量
把整个生成过程拆开看,你会看到一条非常清晰的张量流水线。我在最初研究SD时就感受到,搞懂了这条流水线,就搞懂了LDM这门手艺的八成。
第一步,提示词被分词器切成token。比如“a cat sitting on a chair”会被切成长度不等的token序列,然后填充或对齐到77个token的长度。接下来,文本编码器把这个序列转换成形状为(1, 77, 768)的嵌入向量。这个向量在后续的去噪中,就是U-Net的交叉注意力条件。
与此同时,一个形状为(1, 4, 64, 64)的纯噪声张量被初始化出来。它就是你最终成图的“底片”。4个通道、64×64的空间尺寸,对应VAE压缩8倍后的潜空间。
3.2 迭代去噪:U-Net与调度器反复拉锯
生成的关键阶段是去噪循环。调度器会先根据你设定的步数,把从高噪声到低噪声的时间步排列出来。以DDIM 20步为例,时间步会从接近1000的某个值(比如981)开始,逐步下降。每一步做三件事:
- 把当前带噪潜向量、时间步嵌入、文本条件嵌入送入U-Net;
- U-Net预测出当前潜向量中包含的噪声;
- 调度器根据这个噪声预测,算出去掉噪声后更清晰的潜向量。
这个过程重复20次,或者更多次,潜向量从一片纯噪声中逐渐显现结构,最终形成一个干净的64×64×4潜表示。这里要特别强调:U-Net每一步都是对“整张图”的内容做全局判断,交叉注意力机制让文本的引导能够影响每个位置的去噪决策,这也是为什么提示词里说“一只戴红帽子的猫”,最终真的会有一顶红帽子出现。
3.3 解码输出:潜向量回到像素世界
去噪循环结束后,得到的虽然已经是干净潜向量,但它还不能直接作为图片展示。你还需要VAE解码器把它还原成512×512×3的RGB图像。这一步本质上是把高维特征重建成可见像素。
看这张表就能把全链路串起来了:
| 阶段 | 输入 | 组件 | 输出 | 张量形状 |
|---|---|---|---|---|
| 文本编码 | 提示词字符串 | CLIP Text Encoder | 文本嵌入 | (1, 77, 768) |
| 初始化 | 随机噪声种子 | 无 | 初始潜向量 | (1, 4, 64, 64) |
| 去噪循环 | 噪声潜向量+时间步+文本嵌入 | U-Net | 噪声预测 | (1, 4, 64, 64) |
| 采样更新 | 噪声预测+当前潜向量 | Scheduler | 更新后潜向量 | (1, 4, 64, 64) |
| 解码 | 干净潜向量 | VAE Decoder | RGB图像 | (1, 3, 512, 512) |
这条数据流的意义在于:任何一个环节出问题,都可能表现为最终成图的异常。如果你连正常情况下每个张量是什么形状都不知道,排查问题就只能瞎猜。
4. 上手实测:拆开管线,逐个组件核对输出
4.1 准备环境并把组件单独加载出来
理论讲完,还是得上手摸一摸。我习惯用diffusers库做实验,因为它把VAE、U-Net、文本编码器和调度器都暴露成了独立对象,非常适合观察中间状态。环境只需要torch、diffusers和transformers,以及一张哪怕4GB显存的显卡。
import torch from diffusers import StableDiffusionPipeline model_id = "runwayml/stable-diffusion-v1-5" pipe = StableDiffusionPipeline.from_pretrained(model_id, torch_dtype=torch.float16) pipe = pipe.to("cuda") vae = pipe.vae unet = pipe.unet text_encoder = pipe.text_encoder tokenizer = pipe.tokenizer scheduler = pipe.scheduler注意,这里加载完整管线只是为了方便取组件。实际微调或二次开发时,你也可以只加载某个组件,不必每次都拉起全部。
4.2 用真实图片验证VAE的编码解码闭环
我之前在看VAE时犯过一个错误:认为VAE只是简单压缩,随便拿噪声就能当潜向量解码。后来用真实图片做了一次编码解码闭环,才真正理解VAE的输入输出分布约束。
from PIL import Image image = Image.open("test_cat.png").convert("RGB").resize((512, 512)) img_tensor = torch.from_numpy(np.array(image)).float().permute(2, 0, 1).unsqueeze(0) / 127.5 - 1 img_tensor = img_tensor.to("cuda", dtype=torch.float16) with torch.no_grad(): latent = vae.encode(img_tensor).latent_dist.sample() reconstructed = vae.decode(latent).sample观察输出你会发现两个重要事实。第一,latent的通道数确实是4,空间尺寸是64×64;第二,latent分布大致在0附近,标准差约等于1。这个分布特性很关键,因为调度器在推理时初始化的随机噪声,正好就是围绕0的高斯噪声。如果某天你从VAE里取出的latent均值偏离0很严重,解码出来的图像就会发灰甚至发黑。这个现象我在自己训练小型VAE时遇到过,排查半天才发现是分布没对齐。
4.3 手动跑一次去噪循环
接下来,我绕开pipeline的封装,手动模拟一次完整去噪过程。只有亲手控制每一个环节,你才能理解调度器说的“step”到底做了多少次数学运算。
prompt = "a cat sitting on a chair, high quality" tokens = tokenizer(prompt, return_tensors="pt").input_ids.to("cuda") with torch.no_grad(): text_embeddings = text_encoder(tokens)[0] latent = torch.randn((1, 4, 64, 64), dtype=torch.float16, device="cuda") scheduler.set_timesteps(20) for t in scheduler.timesteps: latent_model_input = latent.to(dtype=torch.float16) noise_pred = unet( latent_model_input, t.unsqueeze(0), encoder_hidden_states=text_embeddings ).sample latent = scheduler.step(noise_pred, t, latent).prev_sample with torch.no_grad(): image = vae.decode(latent).sample这段代码把pipeline内部循环摊开在面前:初始化噪声、设置时间步、U-Net预测噪声、调度器更新潜向量。每一步的输入输出形状完全一致,都是(1, 4, 64, 64)。如果你在这个循环里加入打印语句,就能看见潜向量如何从纯噪声一步步变成有结构的特征图。
我强烈建议读者跑一次这个循环,不是为了应付任务,而是因为只有在代码里亲眼看到“噪声逐渐让位于结构”的过程,你对LDM的理解才会从记忆公式升级为掌握直觉。
4.4 验证种子、调度器、CFG的影响
手动循环还有一个好处,就是可以控制变量做对比实验。固定种子,切换调度器,看结果;固定调度器,改变CFG,看结果;固定所有参数,只换提示词,看结果。这种“单一变量”的实验方法,能帮你迅速建立“哪个参数影响哪个环节”的心智模型。
在我自己的实测里,有三条结论让我记忆深刻:
- 固定一切变量,同一颗种子在一个调度器下是可复现的,但换到另一张GPU上,结果可能微妙不同,这跟某些算子是否启用TF32有关系;
- 采样步数从1步增加到20步,画面质量提升非常明显;但从20步增加到50步,收益很有限,高级调度器甚至会出现越跑越糊的反常现象;
- 很多“换模型”产生的风格差异,其实不仅仅来自U-Net,调度器默认设置不同也会强烈影响输出。
5. 参数与踩坑:采样步数、CFG、种子背后到底改了啥
5.1 CFG:文本条件对生成过程的影响力旋钮
CFG(Classifier-Free Guidance)的公式很简单:
predicted_noise = noise_uncond + scale * (noise_cond - noise_uncond)翻译成人话就是:模型分别以“无条件”(不输入提示词)和“有条件”(输入提示词)两个状态去预测噪声,然后按scale的比例,把两者之间的差距放大。scale为1,就是纯有条件生成;scale越高,越强制地向提示词靠拢。
这个参数直接控制着“提示词对生成过程的支配力”。调高了,画面会更符合描述,但可能构图生硬、颜色过饱和;调低了,画面会比较自由,但也可能偏离主题。社区里大家默认7左右的取值,本质上是质量和遵从度之间的折中。
这里有一个实际排查场景:如果你的图像频繁出现“凭空多出的物体”或“概念混乱”,可以考虑先查CFG,设置太高时,U-Net可能会过度解释文本中的弱关联词,比如把“cat”和“hat”强行画在同一个物体上。
5.2 采样步数与调度器选择:不是越多越好
采样步数本质上是调度器把去噪路径离散化的粒度。步数太少,路径逼近误差太大,画面粗糙;步数太多,部分调度器可能因为数值累积误差,反而引入伪影。
以DDIM为例,我在尝试时发现,20步是视觉收益的拐点;Euler则常以20到30步为宜;像DPM++这类高级求解器,15到20步就能达到很不错的效果。如果你用40步甚至80步,不是不行,但出图差异往往已经超出人眼能感知的范围。
我推荐一个快速选型方法:在固定种子和提示词的前提下,将同一张图分别用Euler、DDIM、DPM++ 2M各跑一遍。你会发现三张图在细节和明暗上有微妙差别,这种差别不是模型问题,而是采样路径的选择问题。把这个感觉记下来,以后出图不满意时,你就能多一个调整维度,而不用总是怀疑模型。
5.3 种子与可复现性:到底复现的是什么
种子控制的只有一件事:初始噪声张量的生成。相同种子、相同提示词、相同模型、相同调度器,理论上会生成一模一样的图像。这在交互动画、实验对比和案例复现中非常有用。
但可复现性有一个容易被忽略的前提:整个计算过程必须是确定性的。GPU上的某些算子默认使用自动混合精度或TF32,不同硬件、不同驱动版本可能产生不同的浮点结果。所以如果你需要严格可复现,建议固定torch的随机种子,并在模型推理时关掉一些不确定加速选项。当然,普通出图不用纠结这么多,真遇到需要复现的场景再按这条排查。
5.4 几个高频问题与排查链路
我把自己和周围朋友常遇到的问题整理成了一张排查表,遇到图像异常,按顺序查总没错。
| 异常表现 | 最可能的原因 | 建议动作 |
|---|---|---|
| 整张图黑色或局部灰色 | VAE精度问题、latent分布异常 | 换fp32 VAE,检查latent均值和标准差 |
| 图像模糊、噪点多 | 采样步数不足 | 先提到25步以上,再考虑换调度器 |
| 颜色过分饱和、构图怪异 | CFG过高 | 把scale降到7以下,逐步下调 |
| 提示词后半段完全无效 | token被截断到77个以内 | 精简提示词,或使用加权语法 |
| 固定种子但出图不一致 | 浮点不确定、混精度算子 | 固定torch种子,关闭TF32,必要时用fp32调用 |
| 生成人脸容易崩 | 压缩损失+注意力机制局限 | 换高细节VAE,先出小样再用局部重绘修脸 |
6. 从LDM到更远的边界:我的几条实战心得
6.1 LDM为什么能成为AI绘图的事实标准
回头看,LDM能成为AI绘图领域的事实标准,靠的不只是“省算力”。它把图像生成任务拆解成可以独立优化、独立替换的模块,这种架构上的灵活性才是生态繁荣的根本。VAE不行可以换VAE,文本编码器不够强可以换更大的,调度器可以随时调整,U-Net也可以单换。每个模块的演进,都能直接让最终出图质量受益。
对比来看,像素空间扩散模型在架构上更“纯粹”,但成本高、迭代慢;GAN类模型生成快,却难以承载复杂的文本控制和多模态扩展。LDM用一个潜空间桥梁,兼顾了质量、速度和可控性,这才是它胜出的核心逻辑。
6.2 我踩过的几个坑,希望你绕开
第一个坑是盲目改VAE。有段时间我嫌弃SD默认VAE细节不够,换了一个社区评分很高的VAE,结果画面色彩变得偏绿偏暗。后来一查,对方训练VAE时用了不同的潜空间分布约定,和Stable Diffusion的U-Net不完全兼容。所以换VAE前,要先确认它是不是针对当前模型发布的兼容版本。
第二个坑是同时训练U-Net和VAE。我自己尝试做一个小规模微调时,天真地认为两个组件一起训能共同进化,结果loss剧烈波动、图像颜色持续漂移。后来才想明白,LDM的设计哲学就是让VAE保持稳定,U-Net去适应它的潜空间。如果你不是专门做自编码器研究,最好别把两个训练绑在一起。
第三个坑是忽略调度器对结果的影响。有段时间我换了模型后觉得画风不对,怎么调提示词都别扭,最后发现只是默认调度器变化了。从DDIM换到Euler,同样的种子、同样的词,气质就完全不同。现在我看任何项目或插件,第一件事就是看它默认调度器和步数设置。
6.3 一点个人的使用建议
把这套组件理解透之后,我推荐你做两件事:一是用第四节的代码手动跑几次去噪循环,把每一步的latent打印出来,试着感受“噪声到结构”的变化速度;二是建立一个自己的参数基准库,记录固定的种子、调度器、步数、CFG组合在不同场景下的表现。这样当你需要复现或微调的时候,手里有一张可靠的对照表,而不是每次盲人摸象。
AI绘图这个领域现在迭代很快,新模型、新采样器、新微调方式层出不穷,但LDM的核心理念——感知压缩与语义生成解耦、条件注入、模块化替换——在很长一段时间内仍然是理解各种进阶工具的基础。把基本功打牢,以后再上任何新东西,你都能更快上手。