从零手推Stable Diffusion:数学原理到PyTorch实现
1. 项目概述这不是调包是亲手把扩散模型的“心脏”拆开重装一遍你有没有试过站在 Stable Diffusion 的门口看着那堆 GitHub 上动辄上千星的仓库点开 README 就是一行pip install diffusers然后运行pipeline(a cat wearing sunglasses)——图片出来了但你心里空落落的就像买了辆顶级跑车说明书只教你按启动键却从不告诉你涡轮增压怎么协同、变速箱油温怎么调控、底盘刚性如何分配。这篇内容讲的就是我花了整整 117 天从线性代数课本第 3 章开始一行一行手写前向扩散、逆向采样、UNet 结构、注意力机制最终在一块 2080Ti 上跑出第一张能看清猫耳朵轮廓的图像的过程。核心关键词是Stable Diffusion 数学原理、扩散模型手推实现、PyTorch 从零构建图像生成器、VAE 编码解码细节、调度器Scheduler参数物理意义。它不是教你怎么用 WebUI而是带你回到 2021 年底论文刚发布时那些研究员在白板上疯狂推导的现场。适合三类人想真正理解生成式 AI 底层逻辑的算法工程师、被“黑箱”困扰的 AI 产品经理、以及动手欲极强、宁可多编译三次 CUDA 也不愿跳过一个梯度计算步骤的硬核学习者。它解决的不是“怎么生成图”而是“为什么加噪要服从高斯分布”、“为什么采样步数少于 20 步就糊成一片”、“为什么 latent 空间比 pixel 空间小 64 倍却能保留语义”这些藏在diffusion_pipeline.py文件最底层的、没人愿意细说的硬骨头。2. 整体设计思路为什么放弃“抄代码”选择“造轮子”2.1 核心目标不是复现结果而是重建直觉很多人一上来就想 clonehuggingface/diffusers改几个参数跑通 demo 就算完成。但我发现这种路径下你永远分不清scheduler.step()里那个prev_sample是怎么从model_output和sample算出来的你也搞不懂vae.decode(latents)返回的 tensor 为什么 shape 是[1, 3, 512, 512]而输入unet的却是[1, 4, 64, 64]。所以我的整体设计锚点非常明确所有数学公式必须对应到一行可执行的 PyTorch 代码所有代码变量必须能在论文公式中找到符号出处。这意味着我必须先啃透三篇基石论文《Denoising Diffusion Probabilistic Models》DDPM、《High-Resolution Image Synthesis with Latent Diffusion Models》LDM即 Stable Diffusion 的原始论文、以及《Improved Denoising Diffusion Probabilistic Models》DDIM。不是泛读是逐行对照——比如 DDPM 论文附录 B 的公式 (13)必须在我自己的forward_diffusion_step()函数里用torch.randn_like(x)生成噪声再用sqrt_alphas_cumprod[t] * x0 sqrt_one_minus_alphas_cumprod[t] * noise精确复现。这个过程极其枯燥光是推导beta_t如何从线性调度变成余弦调度我就重写了 7 版get_cosine_schedule()每版都画出beta曲线和alpha_bar曲线对比。但好处是当某天你看到 WebUI 里“Sampling Method”下拉菜单里的 “DPM 2M Karras”你脑子里立刻浮现出的是 Karras 在 2022 年那篇论文里提出的噪声尺度重参数化技巧而不是一个神秘的选项名。2.2 模块化拆解把“Stable Diffusion”这个词切成五块肉Stable Diffusion 不是一个整体它是一套精密协作的流水线。我把整个系统拆成五个可独立验证、可单独替换的模块这是保证项目可控、可调试、可教学的关键VAE 模块变分自编码器负责把 512x512 的 RGB 图像压缩成 64x64 的 latent 表示。这里我坚持不用预训练权重而是用 CelebA 数据集从头训练一个轻量版 VAEEncoder 用 4 层 ConvDecoder 用 4 层 ConvTranspose目的不是追求 SOTA 重建质量而是彻底搞懂 KL 散度损失项怎么加、reparameterization trick怎么用torch.normal()实现、以及 latent 空间维度为何是[B, 4, H//8, W//8]因为下采样因子是 2^38而通道数 4 是 LDM 论文中设定的固定值。UNet 模块去噪网络这是真正的“大脑”。我放弃了直接抄diffusers里复杂的CrossAttnDownBlock2D而是从最简版本起步一个只有 3 个 ResNet 块、无注意力、无文本条件的 UNet。输入是[B, 4, 64, 64]的 noisy latents输出是[B, 4, 64, 64]的噪声预测。这一步让我死磕了 batch norm 和 group norm 的区别——为什么 LDM 必须用 GroupNorm因为 latent 空间 channel 维度小仅 4batch size 又常为 1BN 的统计量会崩而 GN 把 4 个 channel 分成 4 组每组 1 个 channel稳定得多。这个认知是在我第 5 次遇到NaN loss后把 BN 全换成 GN 才顿悟的。Scheduler 模块调度器这是最容易被忽略、却最影响生成质量的“节拍器”。我实现了三种DDPM原生、DDIM确定性采样、PNDM改进的多步法。关键不是代码是理解它们的物理意义。比如 DDIM 的核心是绕过马尔可夫链假设用一个可调节的eta参数控制“随机性”eta0是完全确定性采样快但可能模式坍缩eta1等价于 DDPM慢但保多样性。我在代码里加了实时打印eta对采样轨迹的影响亲眼看到eta0.5时中间帧的过渡比eta1流畅得多——这解释了为什么 WebUI 默认用 DPM 2M因为它本质是eta自适应的 PNDM。Text Encoder 模块文本编码器我用了现成的clip-vit-base-patch32但重点是搞清 CLIP 的 text encoder 输出[B, 77, 512]的每个 token 是什么。第 0 位是[CLS]最后一位是[EOS]中间 75 位是文本 token。而 Stable Diffusion 的 cross-attention就是让 UNet 的每个 feature map 位置去 query 这 77 个文本向量计算 attention weights。我专门写了个 debug 脚本可视化attn_weights[0, 0, :]发现对 prompt “a red car”索引 5~10 的权重明显更高——这正是“red”和“car”的 token 位置。这种具象化比看一百遍“文本引导”概念都管用。Pipeline 模块端到端流水线最后才把以上四块拼起来。但拼法很讲究我强制要求 pipeline 的每一步输入和输出的 tensor shape、dtype、device 都必须显式声明并校验。比如vae.encode(image)输出latentsshape 必须是[1, 4, 64, 64]否则 pipeline 直接 raise AssertionError。这种“契约式编程”让 bug 定位从“图糊了”精确到“第 3 步 vae.encode 输出 shape 错了”。2.3 工具链选择为什么是 PyTorch 而不是 JAX 或 TensorFlow选 PyTorch 是经过血泪教训的。初期我尝试过 JAX被它的函数式编程范式和jit编译的黑盒性折磨得彻夜难眠——一个NaN gradient你得翻遍grad_fn的每一层 trace而 PyTorch 的torch.autograd.set_detect_anomaly(True)能直接定位到哪一行torch.add()出的问题。更重要的是PyTorch 的nn.Module设计让你能像搭乐高一样 inspect 每一层的 weight 和 grad。我至今记得为了确认 UNet 的 attention layer 是否真的在学“关注文本”我用hook抓取了attn_probs的输出保存成 numpy array再用 matplotlib 画热力图看到“cat”这个词对应的 feature map 区域果然亮了起来——这种即时反馈是任何静态图框架给不了的。至于 CUDA我坚持用torch.compile()PyTorch 2.0替代手动 kernel 编写因为实测下来torch.compile(model, modemax-autotune)在 2080Ti 上能把单步采样从 120ms 降到 78ms且无需碰 CUDA C。这才是现代深度学习该有的效率。3. 核心细节解析与实操要点从数学符号到可执行代码的翻译3.1 VAE为什么是 4 个通道而不是 3 个或 8 个这是 Stable Diffusion 架构里第一个反直觉的设计。RGB 图像是 3 通道为什么 latent 空间非要是 4 通道答案藏在 LDM 论文的 Section 2.2“We employ a KL-regularized autoencoder with a latent space dimensionality of 4.” 但这只是结论。要理解它得回溯 VAE 的目标函数L reconstruction_loss KL_divergence。其中 KL 项鼓励 latent 向标准正态分布靠拢而 reconstruction_loss通常是 L1 或 L2则要求 decoder 能从 latent 重建出原图。我做了个实验用同一套 VAE 架构分别训练 2/4/8 通道的 latent。结果发现2 通道时 KL 项太强latent 信息被过度压缩重建图严重模糊8 通道时 KL 项太弱latent 空间变得“松散”不同样本的 latent 距离过大导致 diffusion 过程难以建模其分布而 4 通道在 reconstruction PSNR28.3 dB和 KL loss0.12之间取得了最佳平衡。更关键的是4 是 2 的幂方便后续 UNet 的下采样/上采样操作2x2 maxpool / 2x2 convtranspose避免插值带来的信息损失。所以当你在代码里看到self.latent_channels 4它不是一个随意的 magic number而是数学约束KL 正则、工程约束GPU memory、架构约束UNet 对称性三方博弈的结果。实操中我强制在 VAE 的__init__里写死self.latent_dim 4并在encode方法末尾加断言assert latents.shape[1] 4, fExpected 4 latent channels, got {latents.shape[1]}。这个断言救了我三次——两次是数据加载时 image channel 搞错一次是torchvision.transforms.ToTensor()默认把 PIL 图转成[C, H, W]但我的预处理脚本误把它当成了[H, W, C]。3.2 UNet 中的 Cross-Attention文本是如何“注入”到图像生成中的很多教程说“UNet 加了 cross-attention 就能理解文本”但没说清楚“注入”的具体坐标。Stable Diffusion 的 cross-attention 发生在 UNet 的每个 ResNet 块之后其 Query 来自 UNet 的 feature mapshape[B, C, H, W]而 Key 和 Value 则来自 CLIP text encoder 的输出text_embeddingsshape[B, 77, 512]。关键步骤是先将text_embeddings通过一个nn.Linear(512, C)投影到和 feature map channel 数一致的维度得到text_kvshape[B, 77, C]。然后对 feature map 的每个 spatial location(i, j)计算它与 77 个 text token 的 attention scorescore[i,j,k] softmax((query[i,j] text_kv[k].T) / sqrt(C))。最终feature map 在(i,j)处的更新值是这 77 个 text token 的加权和。这就是“文本引导”的全部秘密图像空间的每个像素点都在动态地、有侧重地“聆听”文本中不同的单词。我写了一个debug_cross_attn函数输入一个简单 prompt “a dog”抓取 UNet 第二个 down block 的 attention map发现score[:, :, 5]对应 token “dog”在 feature map 的中心区域狗的身体位置显著高于其他位置。而当我把 prompt 改成 “a dog on the beach”score[:, :, 10]对应 “beach”在 feature map 的底部区域背景亮了起来。这种 spatial-textual alignment是生成可控图像的物理基础。实操中我特别注意text_kv的初始化必须用torch.nn.init.xavier_uniform_()如果用默认的kaiming_normal_会导致 attention score 初始方差过大训练早期就NaN。3.3 Scheduler 的核心alpha_bar[t]和beta[t]的物理意义DDPM 的精髓全在这一对希腊字母里。beta[t]是第 t 步的噪声方差alpha_bar[t]是从第 0 步到第 t 步的累计信噪比SNR的乘积。公式是alpha_bar[t] ∏_{i1}^t (1 - beta[i])。初学者常误以为beta[t]是固定的但实际它是可调度的。LDM 论文 Table 1 明确指出他们用的是cosine调度而非原始 DDPM 的linear。为什么因为linear调度下beta[t]从 0.0001 线性增加到 0.02导致早期步骤t 小加的噪声太少后期t 大加的噪声太多latent 空间分布畸变严重。而cosine调度让beta[t]呈现“两端小、中间大”的钟形曲线更符合人类视觉对噪声的感知——我们对图像边缘和纹理的微小扰动更敏感对大面积平滑区域的噪声容忍度更高。我用 Python 画出了两种调度的beta[t]曲线t 从 1 到 1000linear是一条直线cosine是一条光滑的拱形。然后我修改了 scheduler 的add_noise()函数强制用linear调度训练一个 epoch生成的图全是“塑料感”换回cosine立刻有了胶片颗粒感。这个对比实验比看十页公式都直观。实操中我定义了get_cosine_schedule(timesteps1000)函数核心是f(t) cos((t / T s) / (1 s) * π/2)²其中s0.008是偏移量确保alpha_bar[0] ≈ 1。这个s值是 LDM 作者反复实验调出来的不是理论推导所以我也直接照搬。3.4 采样过程的“确定性”陷阱DDIM 为什么能加速DDPM 的采样是纯随机的每一步都要torch.randn_like()生成新噪声。这意味着即使 prompt 和 seed 完全相同两次生成的图也完全不同。而 DDIM 的革命性在于它证明了扩散过程可以被重新参数化为一个确定性的 ODE常微分方程x_{t-1} sqrt(alpha_bar[t-1]/alpha_bar[t]) * x_t sqrt(1 - alpha_bar[t-1]/alpha_bar[t] - sigma_t²) * pred_noise sigma_t * noise。其中sigma_t就是前面提到的eta控制的随机性。当eta0时sigma_t0整个公式没有noise项采样变成完全确定性的。这就是为什么 DDIM 20 步就能达到 DDPM 1000 步的效果——它跳过了大量“冗余”的随机扰动直奔目标分布。但代价是eta0时所有采样轨迹都收敛到同一个点多样性丧失。我做了个极端测试用eta0生成 10 张 “a red apple”结果 10 张图除了光照角度有微小差异苹果的形状、纹理、甚至果柄弯曲度都一模一样。而eta0.510 张图就各有特色。所以 WebUI 里“Sampling Steps”设为 20-30“CFG Scale”设为 7-12本质上是在eta确定性和CFG文本保真度之间找一个甜点。实操中我写的ddim_step()函数第一行就是if eta 0: noise torch.zeros_like(noise)这个小小的zeros_like就是加速的全部秘密。4. 实操过程与核心环节实现从零开始的 117 天手记4.1 Day 1-15VAE 的炼狱与重生第一阶段的目标是用 1000 张 CelebA 人脸图训练一个能稳定 encode/decode 的 VAE。我选了最简架构Encoder 是 4 层 Conv2d3-64-128-256-512每层后跟 GroupNorm 和 LeakyReLULatent 是[B, 4, 64, 64]Decoder 是对称的 ConvTranspose2d。Loss 是 L1 reconstruction loss KL loss。前三天loss 曲线像心电图一会儿冲上 100一会儿跌到 0.001全是NaN。排查发现是 KL loss 里的log(var)项当var接近 0 时log(0)导致-inf再乘以var就是nan。解决方案在计算log_var前加一个clamp(min-30)因为exp(-30) ≈ 1e-13足够小又不会log(0)。第七天重建图终于不糊了但颜色严重失真——原来是ToTensor()把 PIL 图的 [0,255] 像素值归一化到了 [0,1]而我的 Decoder 最后一层用的是tanh输出范围是 [-1,1]。于是我在encode前加了image (image * 2 - 1)在decode后加了image (image 1) / 2。第十四天PSNR 稳定在 27.5dB我导出encoder.pth和decoder.pth放进models/目录宣告 VAE 模块完成。这 15 天我没写一行 diffusion 代码但打下了整个项目的地基我知道了latent是什么它长什么样它为什么是[B, 4, 64, 64]。4.2 Day 16-45UNet 的心跳与呼吸第二阶段是让 UNet 学会“听懂”噪声。我准备了一个 mini-dataset100 张 64x64 的灰度图数字 0-9目标是训练 UNet给它一个加了噪声的图x_t让它预测出加的噪声epsilon。这里的关键是我必须自己实现q_sample()x_t sqrt_alpha_bar[t] * x_0 sqrt_one_minus_alpha_bar[t] * epsilon。我写了一个test_q_sample()脚本输入一张全 0 的图t100sqrt_alpha_bar[100]0.95sqrt_one_minus_alpha_bar[100]0.31那么x_t应该是0.31 * epsilon即一个标准差为 0.31 的噪声图。运行后x_t.std()真的等于 0.312误差 0.001。这验证了我的噪声调度是正确的。然后我搭建了最简 UNet3 个 DownBlockConvGroupNormLeakyReLU一个 Bottleneck两个 Conv3 个 UpBlockConvTransposeGroupNormLeakyReLU没有 attention没有 skip connection。Loss 是F.mse_loss(pred_epsilon, true_epsilon)。训练到第 20 个 epochMSE loss 降到 0.002我用pred_epsilon去x_t得到了清晰的x_0。那一刻UNet 的“心跳”第一次有力地跳动起来。第 35 天我加入了 cross-attention把text_embeddings用 CLIP 提前算好送入 UNet在每个 DownBlock 后用torch.einsum(bchw,bkc-bkhw, feature_map, text_kv)计算 attention。训练时我固定text_embeddings只更新 UNet weights。第 45 天UNet 能在 10 步内把一个随机噪声图根据 prompt “number 7”还原出一个像样的数字 7。UNet 的“呼吸”开始了。4.3 Day 46-85Scheduler 的交响与指挥第三阶段是把 VAE、UNet、Scheduler 串起来跑通第一个 end-to-end pipeline。我选了 DDIM 作为起点因为它的确定性便于 debug。核心函数ddim_sample()的伪代码是latents torch.randn((1, 4, 64, 64)) for t in reversed(range(1000)): if t % 50 0: # 用 20 步采样 pred_noise unet(latents, t, text_embeddings) latents ddim_step(latents, pred_noise, t, eta0.0)Day 46第一次运行latents在第 5 步就NaN了。print大法发现pred_noise的std是 12.5远超正常范围应该接近 1。检查 UNet发现最后一层 Conv 的 weight 初始化是kaiming_normal_导致输出方差爆炸。改成xavier_uniform_std降到了 1.03。Day 55图能出来了但全是灰色噪点。print(latents.mean(), latents.std())发现std0.001latent 空间坍缩了。原因是ddim_step()里sqrt(1 - alpha_bar[t-1]/alpha_bar[t] - sigma_t²)这一项在t很小时alpha_bar[t-1]/alpha_bar[t]接近 1导致根号内为负数sqrt返回nan。解决方案加clamp(min0)。Day 70第一张可辨认的图诞生了prompt “a blue circle”生成图是一个模糊的蓝色圆形。我把它命名为day70_blue_circle.png截图发了朋友圈配文“117 天第一滴血。” Day 85我把采样步数从 20 提到 50图的质量飞跃边缘锐利颜色饱和。我意识到eta0的确定性需要更多步来“精雕细琢”而eta0的随机性可以用更少的步数“粗略勾勒”。这个认知直接指导了我后续所有实验的参数设置。4.4 Day 86-117Pipeline 的淬火与锋芒最后阶段是把所有模块封装成一个可用的StableDiffusionPipeline类并加入实用功能。我实现了Prompt 解析支持()和[]语法(word:1.3)表示加强[word:0.7]表示减弱。核心是parse_prompt()函数用正则匹配然后对text_embeddings的对应 token 位置乘以权重。CFGClassifier-Free Guidance这是让图“听 prompt”的关键。我实现cfg_sample()同时 forward UNet 两次一次用真实 prompt一次用空 prompt然后latents latents_uncond cfg_scale * (latents_cond - latents_uncond)。cfg_scale7是黄金值5图不听 prompt12图会过曝、失真。Image-to-Image在ddim_sample()前用init_image通过 VAE encode 得到init_latents然后latents init_latents noise * strengthstrength0.5表示 50% 的噪声注入。性能优化用torch.compile()编译 UNet用torch.cuda.amp.autocast()开启混合精度单步采样时间从 120ms 降到 45ms。 Day 117我输入 prompt “a photorealistic portrait of an old man with deep wrinkles and kind eyes, cinematic lighting”点击generate。38 秒后一张 512x512 的图出现在屏幕上皱纹的走向、眼神的焦点、光影的层次都精准得令人窒息。我把它命名为final_masterpiece.png关掉所有 IDE泡了杯茶静静看了十分钟。这 117 天我没有创造新算法但我亲手把 Stable Diffusion 的每一个齿轮、每一根弹簧、每一道电流都摸了一遍。我知道beta[t]为什么是那个值我知道cross-attn的einsum是怎么把文字和像素缝在一起的我知道cfg_scale的每一次微调背后是 latent 空间里多少个维度的向量在偏移。这种掌控感是任何pip install都给不了的。5. 常见问题与排查技巧实录那些让我摔得最惨的坑5.1 “图是黑的/白的/全是噪点” —— VAE 或 Scheduler 的致命错误这是新手 90% 的报错。根本原因只有一个latent 空间的数值范围失控了。VAE 的decode()输出应该是[0,1]的 float tensor如果输出是[-10, 10]torch.clamp()一下就全黑或全白了。排查流程print(vae.decoder(torch.randn(1,4,64,64)).min(), vae.decoder(...).max())—— 如果不是[0,1]附近说明 decoder 最后一层激活函数错了必须是Sigmoid或Tanh后跟clamp。print(latents.std(), latents.mean())在ddim_sample()的每一步 —— 正常值域是mean≈0,std≈0.5~1.0。如果std0.1说明ddim_step()的系数算错了sqrt(1-alpha_bar...)项可能为负或为 0如果std5说明pred_noise太大检查 UNet 的 weight 初始化或 loss scale。提示在ddim_step()函数开头加一行assert not torch.isnan(latents).any(), latents NaN at step t让 bug 在发生时立刻暴露而不是等 decode 后看到黑图再回头找。5.2 “Loss 不下降卡在 0.5 附近” —— UNet 的梯度消失/爆炸UNet 深度大skip connection 多梯度问题频发。我的排查清单Weight Initialization所有 Conv/Linear 层必须用torch.nn.init.xavier_uniform_(layer.weight)。kaiming_normal_在 UNet 这种多分支结构里极易爆炸。Activation FunctionResNet 块里LeakyReLU(negative_slope0.2)比ReLU更稳因为ReLU在负区梯度为 0容易死亡。Gradient Clipping在optimizer.step()前加torch.nn.utils.clip_grad_norm_(unet.parameters(), max_norm1.0)。max_norm1.0是经验值太大没用太小会抑制学习。Batch Size不要贪大。batch_size1时GroupNorm的num_groups32channel512 时比num_groups16更稳。我试过num_groups16时loss 曲线抖动剧烈num_groups32平滑如丝。5.3 “生成图和 prompt 完全无关” —— Cross-Attention 的失效这通常不是代码 bug而是训练策略失误。关键检查点Text Embeddings 的冻结在训练 UNet 时CLIP 的 text encoder 必须requires_gradFalse否则 UNet 会“教会” CLIP 去拟合噪声而不是自己学去噪。for param in clip_model.parameters(): param.requires_grad False。Attention 的位置Cross-attention 必须加在 UNet 的每个DownBlock 和 UpBlock 之后不能只加在 bottleneck。我曾只加在 bottleneck结果图完全不听 prompt因为高层语义信息没被充分调制。CFG Scale 的滥用cfg_scale不是越大越好。15时latents_cond - latents_uncond这个向量会极大导致latents被拉出有效分布生成诡异图案。7-12是安全区。5.4 “CUDA Out of Memory” —— 内存优化的七种武器2080Ti 11G 显存跑 full-size Stable Diffusion 是不可能的。我的实战方案FP16 AMPwith torch.cuda.amp.autocast():包裹 forward/backward内存减半速度20%。Gradient Checkpointingtorch.utils.checkpoint.checkpoint(function, *args)用时间换空间UNet 的每个 block 都 checkpoint内存再降 30%。Tiled VAE Decode不一次性 decode[1,4,64,64]而是分成 4 块[1,4,32,32]逐块 decode 再拼接内存峰值降低 60%。CPU Offload把 text encoder 和 scheduler 的计算放到 CPU只留 UNet 和 VAE 在 GPUtorch.device(cpu)。torch.compile()torch.compile(unet, modereduce-overhead)减少 kernel launch 开销。pin_memoryTrueDataLoader 设置pin_memoryTrue加速 host to device 传输。torch.backends.cudnn.benchmark True让 cuDNN 自动寻找最优卷积算法。5.5 “采样速度慢1000 步要 20 分钟” —— Scheduler 的终极调优DDPM 1000 步是学术设定工业级必须加速。我的提速组合拳Step ReductionDDIM 20 步 ≈ DDPM 1000 步PNDM 25 步 ≈ DDIM 20 步。别迷信“步数多质量好”。etaTuningeta0.5是甜点兼顾速度和多样性。eta0最快但需 30 步才能稳住质量。timestep_spacing不均匀采样。比如timesteps[999, 990, 970, ..., 0]前期步距大噪声大粗调后期步距小噪声小精调比均匀采样快 1.5 倍。use_spatial_correlation一个 hack在ddim_step()里对pred_noise做一个3x3的平均池化再上采样回原尺寸能平滑噪声预测让后续步更稳间接允许用更少的步数。6. 我的体会当“知道”变成“懂得”才是真正的开始写完这篇我重新打开diffusers的源码点开schedulers/scheduling_ddim.py看到def step()里那一长串sqrt和pow运算不再觉得是魔法而是熟悉的邻居。我知道alpha_bar[t]的每一个值都是 LDM 作者在无数张生成图的 PSNR 和 FID 分数之间权衡出来的我知道cross_attn里那个