花了一个周末加两个晚上,把四集精讲从头到尾看了两遍,一边看一边抄笔记。看完之后顺手打开编辑器,调了调代码,把每个子模块的输入输出、每行关键代码的意图都拆开跑了一遍——很多看视频时以为懂了的概念,真到自己手上跑的时候才发觉理解得并不扎实。这篇文章就是这次拆解过程的完整记录。
如果你已经在用相关生图工具、熟悉 WebUI 或 ComfyUI 的操作,但不太清楚里面的 CLIP、UNet、VAE 和调度器各自在做什么;或者你刚接触 Stable Diffusion 原理,正需要一份带着代码的入门笔记——那么这篇内容应该能帮你把"会用"升级成"大致懂"。我会尽量少堆数学公式,用工程视角把每个环节讲清楚,并附上可直接运行的代码骨架和动手建议。
1. SD 不是单一模型,而是一条四条腿的流水线
很多人第一次打开某个生图 WebUI 时会以为里面只有一个"画图模型"。实际上,Stable Diffusion 在推理时至少调用了四个独立的子模块:分词器和文本编码器(对提示词做语义化)、变分自编码器 VAE(压缩和解压图像空间)、UNet 去噪网络(真正生成图像语义)、以及采样调度器(控制每步去噪的节奏)。这四者配合紧密,缺一不可。
我个人更喜欢把这条流水线理解为四个角色分工的一次协作:
- 文本塔:把一句提示词变成一张"语义地图"——也就是一组向量,告诉模型"画面里该有什么、关系大致如何"。
- 作画者(UNet):拿着一张满是随机噪点的画布,照着语义地图,一步一步把噪点擦除成有结构的画面。
- 翻译(VAE):负责把作画者工作的那个压缩空间,解码成用户直接能看的像素级图片。
- 导演(调度器):决定"每步擦掉多少噪声、怎么擦",直接影响出图的风格和稳定性。
这四个角色对应的模块和数据流顺序大概是:提示词 -> 分词器 -> 文本编码器 -> 语义向量 -> 初始噪声 latent -> UNet 多次降噪 -> 干净 latent -> VAE 解码 -> 像素图片。弄清楚这条链路之后,再去调整参数的时候会清晰得多,因为你至少知道自己调的那个参数作用在哪个环节。
1.1 为什么 SD 要在"压缩空间"里干活
一个大家最容易忽略的前提:SD 的 UNet 并不是直接在 512×512×3 的像素图上做计算,而是在一个 64×64×4 的 latent 空间里操作。这是什么意思呢?可以想象成把一张图的信息提炼成一份"浓缩摘要",原本 78 万多个数值被压缩到 1.6 万个左右,计算负担降低了一个数量级。这正是 Latent Diffusion 模型名字里 latent(潜空间)的由来。
VAE 的编码器能完成这个压缩。它不是简单缩小分辨率,而是学习如何保留关键语义、丢掉无关细节,把图像映射到一个更紧凑的向量表示。等到 UNet 降噪完成之后,再由 VAE 的解码器把这个向量解压回一张 512×512 的图片。
这样设计除了算力上的好处,还有另一个信号层面的优势:在压缩空间里,模型不会过度纠结于像素级的高频噪音,而能更专注于画面的结构、构图和语义。这也是 latent 扩散方法相较像素扩散方法更高效、且在低算力下也能训练大模型的重要原因。
1.2 数据流的"体积"大概是怎样的
为了后续拆代码时不迷路,这里把各环节的关键数据尺寸先记录下来。以 SD 1.x 系列为例:
| 环节 | 数据结构 | 形状示例(batch=1) | 说明 |
|---|---|---|---|
| 提示词 | 文本字符串 | "a cute dog" | 原始输入 |
| 分词结果 | token id 序列 | [1, 77] | 固定 77 个 token,超出截断 |
| 文本语义 | 向量序列 | [1, 77, 768] | CLIP 文本编码器输出 |
| 初始噪声 | 高斯噪声 latent | [1, 4, 64, 64] | 4 通道压缩图 |
| 每步降噪后 | 逐步精化的 latent | [1, 4, 64, 64] | 调度器按步更新 |
| 解码结果 | 像素图片 | [1, 3, 512, 512] | VAE 解码后 |
记住这张表,后面每个代码片段出现什么样的张量,你就知道它处在整条链路的哪个位置了。
2. CLIP 文本编码器:一句提示词如何变成模型看得懂的向量
第一个需要拆解的角色是 CLIP 的文本编码器部分。在 SD 里实际用到的只是 CLIP 双塔结构中的"文本塔",用来把自然语言映射成向量空间中的语义特征。它不做跨模态对齐,只负责"读句子",理解能力决定了模型对语义的把握上限。
2.1 中文想法到英文 token 的转化过程
如果你习惯用中文写提示词,那么在实际操作中早就发现了:很多基础模型对中文的响应远不如英文。原因就藏在这个环节——CLIP 的分词器是基于英文和拉丁语系语料训练的 BPE(Byte Pair Encoding)分词器,它的词表里几乎没有中文常用汉字。
我在动手切换时都会建议:把中文提示词先翻译成英文再交给模型。例如"一只在窗台上晒太阳的猫",可以先转成 "a cat sunbathing on a windowsill"。这样分词更干净,语义编码也更准确。很多人看到别人的 prompt 都是英文,以为是"玄学",其实就是分词器的适应性差异在起作用。
分词器把文本切成若干子词,再映射成对应的 token id。CLIP 模型最大接受 77 个 token,超出部分会被截断。这意味着提示词里越靠前的内容越"占优势",重要的主体描述应该放在开头,风格、环境、画质词放在后面,这是提升出图效果最容易被忽视的小技巧。
2.2 从 77 个 token 到 77×768 的语义矩阵
拿到 token id 之后,文本编码器会先把每个 token id 转换成对应的 embedding 向量,再加上位置编码,然后经过 12 层 Transformer 层层交互。最终输出的形状是 [1, 77, 768],你可以把它理解成"一句话里每个词(token)在 768 维语义空间中的一个定位"。
为什么不直接用一个大向量?因为之后 UNet 在生成图像的每一处像素区域时,需要根据"画面不同部位对应的语义"去决定画什么。保留序列结构,才能在交叉注意力机制里做到"图中某块区域重点关注句子中的某些词"。如果你把整个句子压成一个 768 维的全局向量,模型虽然能知道"图里大概要画一只狗",但很难精确知道"狗应该在左边、背景是草地"这种空间对应关系。
我在代码里实际取一下文本编码器的输出,可以非常直观地看到:
import torch from transformers import CLIPTokenizer, CLIPTextModel tokenizer = CLIPTokenizer.from_pretrained("your-model-path", subfolder="tokenizer") text_encoder = CLIPTextModel.from_pretrained("your-model-path", subfolder="text_encoder") prompt = "a cute dog sitting on grass" inputs = tokenizer( prompt, padding="max_length", max_length=tokenizer.model_max_length, truncation=True, return_tensors="pt", ) with torch.no_grad(): text_embedding = text_encoder(inputs.input_ids)[0] print(text_embedding.shape) # torch.Size([1, 77, 768])注意这里的 your-model-path 要替换成你本地模型的实际路径,一般是一个包含 text_encoder、tokenizer、unet、vae、scheduler 等子目录的目录。加载 CLIP 文本塔后输出的这个 [1, 77, 768] 张量,后续就会作为交叉注意力的 key/value 输入到 UNet 的每个 Attention 层。
2.3 文本编码器到底"懂不懂"语义
这是我在学习中反复琢磨的一个问题。CLIP 文本塔之所以能对语义有较好的理解,核心在于它原本的训练方式是图文对比学习——同一个 batch 里,文本和图片会被拉近,不同图文会被推远。因此它学到的语义向量天然带有"视觉相关性"。
但要注意,它并非真正意义上的语义理解。它更擅长捕捉视觉概念的表层关联,比如"猫"和"鱼"都跟"宠物""食物"等概念有相关性,但缺乏因果推理能力。这也就解释了为什么模型经常会在画面里"无缘无故"多出一些和主词相关又不太合理的元素。在实操中,我会用 negative prompt(反向提示词)来抑制这些多余元素,这就是后话了。
3. 扩散模型不是玄学:前向加噪、噪声预测与逆向去噪
讲完文本怎么进来,再看核心生成机制——扩散模型。扩散模型的思想其实并不复杂:如果我们能学会"从干净图一步一步加噪到纯噪声"这个过程,那么反过来"从纯噪声一步一步去噪到干净图"就等价于生成。
3.1 前向过程:给图片逐步添加噪声
前向扩散过程是把一张干净图片 x_0 逐步加噪成接近标准高斯分布的过程。数学上可以表达为:
x_t = sqrt(α_t) * x_0 + sqrt(1 - α_t) * ε
其中 ε 服从标准正态分布,α_t 是一个随时间步 t 变化的衰减系数。这个公式的价值在于:你不需要真的从 x_0 一步步加噪到 x_t,而是可以直接用一次采样就得到任意时刻 t 的带噪图像——这个过程在工程上叫"重参数化"。
如果用生活化类比来理解,你可以想象一杯纯净水,每一时刻往里面滴入少量墨水。前几步水还能看出是清澈的,滴到几百步之后,整杯水就完全变成黑的了。α_t 控制的就是"每一时刻墨水滴入的比例"。这个比例不是随便定的,它决定了一个重要关系:t 越接近 0,图越接近原图;t 越大,噪声占比越高。
在训练时,我们随机采样一个时间步 t,生成带噪图 x_t,再让网络去预测加入的噪声 ε。通过不断练习"看到某一步的噪声状态,判断这一步加的是什么噪声",模型就学到了图像的许多统计规律。你可能会好奇:为什么不直接让模型去预测 x_0?原因是预测噪声在目标上更平滑、更容易优化——噪声在所有像素上都是相对均匀的,而干净的图片有大量结构和边缘细节,直接从带噪状态预测干净图像往往会出现模糊或错误结构。
3.2 逆向过程:UNet 化身去吧噪的"猜谜专家"
生成阶段的逆向过程,就是从一个完全随机的噪声点出发,依次执行很多个"去噪小步",最后得到干净图像。每一步的核心操作都是:
- 输入当前带噪的 latent 表示
- 输入当前步数 t 的编码
- 输入文本语义信息
- 输出预测的噪声
- 用预测噪声修正当前 latent
而完成"预测噪声"这个任务的网络,正是 UNet。它知道自己处在整个扩散过程的哪个阶段(因为 t 作为条件输入),也知道用户想要画什么(因为文本向量被注入到注意力层中)。正是这两个"条件"的存在,让原本只能随机生成图像的网络,变成了可以在文本引导下可控生成的模型。
3.3 从 DDPM 到 DDIM:采样步数的加速之路
最初的 DDPM(Denoising Diffusion Probabilistic Models)采样需要上千步才能获得稳定效果,因为每一步只允许去掉一点点噪声,否则会产生大量随机误差。但在实际工程中,如果生成一张图要跑 1000 次 UNet 前向计算,算力消耗是难以接受的。
DDIM(Denoising Diffusion Implicit Models)的改进在于:它改变了逆向过程的随机性。DDIM 认为,逆向过程的随机噪声项可以被减小甚至消去,只要重建目标可以被显式表达为:
x_0_pred = (x_t - sqrt(1 - α_t) * ε_pred) / sqrt(α_t)
再根据 x_0_pred 和当前预测的噪声,重新推导出下一步的 latent。由于省去了随机项,DDIM 的过程变得确定,而且往往 20~50 步就能得到相当好的效果。确定性还有一个额外好处:同样的种子和配置,能生成完全一致的结果,做对比实验时非常方便。
如果要在 diffusers 里确认当前用的调度器是否是 DDIM,可以这样加载:
from diffusers import DDIMScheduler scheduler = DDIMScheduler.from_pretrained("your-model-path", subfolder="scheduler") print(scheduler.timesteps) # 可以看到预置的时间步序列,默认通常是从 1000 按步长递减4. UNet 才是真正干活的:下采样、上采样与条件注入结构拆解
既然逆向过程要由 UNet 来执行,那么我们就该仔细看看这个网络内部到底怎么组织。UNet 的命名来自它的 U 形结构:左边是一路压缩(下采样),右边是一路还原(上采样),中间通过跳跃连接把对应分辨率的信息接起来。
4.1 U 形结构和跳跃连接的价值
这个结构的直觉在于:下采样时,网络看到的范围越来越大,可以理解画面的整体结构;上采样时,网络逐步恢复细节精度,而跳跃连接则把下采样各阶段保存下来的细节信息直接传入上采样的对称阶段,避免细节丢失。你可以理解为:左边是"看懂大纲",右边是"细化笔画",中间的跳跃连接相当于每次细化时都能参考一开始的大纲。
在 SD 的 UNet 里,下采样路径逐级把分辨率从 64×64 降到 32×32、16×16、8×8,与此同时通道数不断增多。上采样路径再一步步从 8×8 恢复到 64×64。每个阶段都堆叠了若干 ResNet 块和注意力块。ResNet 块的残差连接保证了深层的梯度流动,注意力块则负责建立长距离的依赖关系。
4.2 两类注意力:自注意力和交叉注意力
很多人一看到 UNet 里有 attention 就头大,但实际用大白话解释并不难:
- 自注意力:让 latent 的各个位置互相"商量"。比如画面里有一张人脸,那么脸的轮廓位置需要和眼睛、嘴巴的位置保持风格一致,自注意力就是在做这种内部的协调。
- 交叉注意力:让 latent 的每个位置"看看"文本向量。比如某个位置原本不知道要画什么,经过交叉注意力之后,它会根据文本中"猫""草地"这些词的权重来确定自己的内容。
交叉注意力层里,latent 承担 query 的角色,文本向量作为 key/value 传入。这也就是 CLIP 文本编码器的输出维度(768)必须和 UNet 的 cross_attention_dim 相匹配的原因。如果两个维度对不上,模型根本无法把文本信息注进去。
4.3 时间步信息如何注入网络
还有一个重要但容易被忽略的输入:时间步 t。UNet 怎么知道当前是噪声还很多的早期(t 大),还是接近完成的晚期(t 小)?实现方式是先把 t 编码成一个时间步 embedding 向量,再通过线性层映射后加到每个 ResNet 块的输入上。
这个机制直接决定了去噪步长的风格差异:早期时间步噪声大,网络应该更关注"从混乱中建立轮廓";晚期时间步噪声小,网络则应该更关注"精细纹理和边缘修正"。它像一个进度条告诉网络"你现在画到哪一步了",让网络在每个阶段采用不同的处理策略。
在代码中你可以比较直观地看到时间步 embedding 的作用:
import torch from diffusers import UNet2DConditionModel unet = UNet2DConditionModel.from_pretrained("your-model-path", subfolder="unet") # 模拟一份带噪 latent、时间步、文本 embedding latent = torch.randn(1, 4, 64, 64) t = torch.tensor([500]) # 表示扩散到中期 text_embedding = torch.randn(1, 77, 768) with torch.no_grad(): noise_pred = unet(latent, t, encoder_hidden_states=text_embedding).sample print(noise_pred.shape) # torch.Size([1, 4, 64, 64])这整段代码模拟的就是降噪过程中的一次前向计算:输入带噪 latent、当前步数 t、文本语义向量,输出预测噪声。形状和输入 latent 完全一致,这也正是调度器能通过"减去预测噪声"来更新 latent 的前提。
5. 从 pipeline 黑盒到手动采样循环:代码级复现文生图核心流程
理解完各模块,最重要的当然是把代码跑通。最常见的做法是用 diffusers 的 StableDiffusionPipeline 直接一步完成,但如果你想弄清楚里面到底发生了什么,手动拆开 pipeline 更重要——这一节我就带你把整条流程用代码显式写一遍。
5.1 最省事的 pipeline 版本
如果你只想快速验证效果,pipeline 版本确实是最省事的:
from diffusers import StableDiffusionPipeline import torch pipe = StableDiffusionPipeline.from_pretrained( "your-model-path", torch_dtype=torch.float16, ) pipe = pipe.to("cuda") result = pipe( "a cute dog sitting on grass", negative_prompt="blurry, low quality", num_inference_steps=30, guidance_scale=7.5, seed=42, ).images[0] result.save("output.png")这段代码你平时跑的应该不陌生。num_inference_steps 是去噪步数,guidance_scale 是 CFG 强度,seed 是随机种子。pipeline 会在内部完成前面提到的分词、编码、初始化噪声、循环采样、解码、后处理一整条链路。
但问题也在于它太省事了,一旦你遇到"效果不对"或者"显存不够"的情况,很难知道该往哪里排查。所以我强烈建议往下看第二步。
5.2 手动拆解版:每一步都看得见
手动拆解版的完整流程大概是这样的:
这个流程里的每行代码都对应着第一部分里描述的某个环节。我把每一步的意图在代码里标注清楚:
import torch from transformers import CLIPTokenizer, CLIPTextModel from diffusers import AutoencoderKL, UNet2DConditionModel, DDIMScheduler # 1. 依次加载四个核心子模块 tokenizer = CLIPTokenizer.from_pretrained("your-model-path", subfolder="tokenizer") text_encoder = CLIPTextModel.from_pretrained("your-model-path", subfolder="text_encoder") vae = AutoencoderKL.from_pretrained("your-model-path", subfolder="vae") unet = UNet2DConditionModel.from_pretrained("your-model-path", subfolder="unet") scheduler = DDIMScheduler.from_pretrained("your-model-path", subfolder="scheduler") # 把模块切到半精度并放到 GPU 上 text_encoder = text_encoder.to("cuda", dtype=torch.float16) vae = vae.to("cuda", dtype=torch.float16) unet = unet.to("cuda", dtype=torch.float16) scheduler.set_timesteps(num_inference_steps=30) # 2. 编码提示词 prompt = "a cute dog sitting on grass" negative_prompt = "blurry, low quality" def encode_prompt(text): tokens = tokenizer( text, padding="max_length", max_length=tokenizer.model_max_length, truncation=True, return_tensors="pt", ) return text_encoder(tokens.input_ids.to("cuda"))[0] text_embeddings = encode_prompt(prompt) # [1, 77, 768] uncond_embeddings = encode_prompt(negative_prompt) text_embeddings = torch.cat([uncond_embeddings, text_embeddings], dim=0) # 3. 初始化随机噪声 latent latents = torch.randn(1, 4, 64, 64, device="cuda", dtype=torch.float16) latents = latents * scheduler.init_noise_sigma # 4. 去噪循环 for t in scheduler.timesteps: # 把时间步信息广播为 batch_size=2(条件和无条件各一份) t_expanded = torch.tensor([t] * 2, device="cuda", dtype=torch.long) latent_model_input = torch.cat([latents] * 2, dim=0) latent_model_input = scheduler.scale_model_input(latent_model_input, t) with torch.no_grad(): noise_pred = unet( latent_model_input, t_expanded, encoder_hidden_states=text_embeddings, ).sample # 5. CFG:让"有条件"预测偏离"无条件"预测 noise_pred_uncond, noise_pred_text = noise_pred.chunk(2) noise_pred = noise_pred_uncond + 7.5 * (noise_pred_text - noise_pred_uncond) # 6. 调度器更新 latent latents = scheduler.step(noise_pred, t, latents).prev_sample # 7. VAE 解码回像素空间 latents = latents / 0.18215 with torch.no_grad(): image = vae.decode(latents.to(vae.dtype)).sample image = (image / 2 + 0.5).clamp(0, 1).squeeze(0).permute(1, 2, 0).cpu().numpy() # 8. 转成可保存的格式 from PIL import Image image = (image * 255).round().astype("uint8") Image.fromarray(image).save("manual_output.png")5.3 手动拆解让你获得了什么
跑完这份手动代码,你会发现它和 pipeline 生成的结果几乎一致,但你能看到每个中间张量的形状、每一步的变化。当未来遇到问题需要排查时,你会知道:
- 如果整张图都是噪声、没有结构,问题大概率出在 UNet 没有接收到正确的文本信息或 CFG 计算有误。
- 如果图很模糊,可能 VAE 解码部分的 scale 参数不对。
- 如果图的内存格式错乱,多半是 permute 和 [0,1] 像素范围处理出了问题。
排查"为什么图片发绿发紫"这类问题时,你也能立刻反应到:检查 latent 输出是否经过 1 / 0.18215 缩放,或者 VAE 在 fp16 下可能产生数值溢出。
5.4 显存优化:这几行代码让推理门槛降低不少
如果你的显卡显存比较紧张,可以在手动代码中插几个小改进。首当其冲是尽量把不需要自动梯度的环节用 no_grad 包起来,这在上面已经做了。其次,推理全程使用 fp16 可以减少一半显存占用。
另外还有一个常用手段是打开注意力切片。diffusers 里可以通过 pipeline 很方便地开启:
pipe.enable_attention_slicing()如果用手动流程,想省显存也可以临时把 UNet 处理过程分段执行,或者降低 batch 为 1 逐步推理。不过如果连 fp16 加 no_grad 还是放不下,我一般会建议尝试 offload 机制,将文本编码器在计算完成后从显存中释放,等 UNet 阶段再加载回来。这个方法复杂一些,但对低显存用户很值得研究。
6. 你每天都在用的参数,背后的作用逻辑
跑通代码之后,再回头看那些平时总会配置的参数,就会明白它们为什么要这样设置。
6.1 CFG(Classifier-Free Guidance)是怎么拉高"控图力"的
CFG 全称是无分类器引导,虽然名字听起来很学术,但公式很简单:
noise_final = noise_uncond + guidance_scale × (noise_text - noise_uncond)
也就是说,模型会针对同一个 latent 同时计算两份预测噪声:一份是完全不看文字、只看图像先验的噪声,另一份是结合了文本条件的噪声。两者之差,代表了"文字带来的方向修正"。guidance_scale 越大,最终去噪路径就越偏向文本作用的修正,图像和提示词的吻合度更高,但也更容易出现过曝、颜色过饱和等副作用。
我常用的 range 一般在 7 到 9 之间,超过 12 之后出图会明显发"硬"、发闷,甚至扭曲。反过来,如果调低了 CFG,图像会显得更自然、更"发散",但可能和提示词内容对不上。
6.2 采样步数和调度器的关系
步数不是越多越好。DDIM 在 30 步左右通常已经收敛很好,50 步的收益基本看不见;如果你换用了其他调度器,比如带噪声估计的 DPM++,甚至 10~20 步就能有不错效果。调度器差异的本质在于对噪声去除和重建目标的权衡方式不同:有的调度器偏爱确定性路径,有的会注入少量随机性来丰富纹理。这也是为什么在 WebUI 里换一个采样器,画面风格会明显变化。
实际操作上,我不会盲目堆步数。更合理的做法是找到自己常用调度器的"甜点区间",比如 DDIM 用 30,DPM++ 用 15,然后固定下来,用同样的步数去横向比较提示词和 CFG 的改动。步数不固定的话,其他变量再准确也无法做对比。
6.3 种子(seed)到底固定住了什么
种子控制的是第一步初始噪声的生成。同样的种子,在确定性的采样策略下(DDIM 属于此类),会得到完全一样的初始 latent 和去噪路径,因此复现性很强。这时候只要改动提示词的一个词,就能看到"仅语义变化带来的差异",是调参和排查问题的利器。
如果你在同一个种子下更换了采样器,初始噪声虽然一样,但后续去噪的路径完全不同,出图自然也会大变——所以做对比实验时,最好连采样器也一起固定。实际项目中,我会把种子、CFG、步数、采样器名称、模型版本作为一个五元组记录,方便任何时刻都能重现某张图的效果。
6.4 Negative Prompt 为什么能抑制多余内容
Negative Prompt(反向提示词)在代码里其实就是另一个文本编码的输出。它和正向提示词一起被送进 UNet 参与计算,CFG 公式中的"无条件项"这里就被替换成了"负向条件"。模型会尽量避开反向词描述的内容。在实际使用中,常驻的 negative prompt 可以是 "low quality, blurry, distorted, ugly",需要排除特定元素时再把对应词加进去。不过也要提醒一句:negative prompt 不是万能橡皮擦,描得太满还会反过来影响整体构图。
7. 看完全部原理后,我踩过的那些坑和总结的技巧
最后记录几个我实际动手过程中遇到的坑。如果你刚上手,这些信息或许能帮你少走一些弯路。
第一个坑是关于提示词长度。我一开始以为 77 个 token 很快会超,于是把整段长描述全部堆进去,结果发现尾部根本不起作用。后来才明白,CLIP 会从开头开始截断,前面的 token 才真正主导整个画面。解决方法是:把最重要的主体、动作、位置放到开头,风格描述放中间,画质词放最后;如果确实有非常复杂的场景描述,要用长文本能力更好的模型或拆分提示词,而不是硬堆。
第二个坑和中文 prompt 有关。之前为了图省事直接用中文描述"一只猫坐在椅子上的照片",出来的图经常出现乱码般的元素,后来才理解这是分词器和训练语料导致的语义错位。现在我在项目里都保留一个"中文想法 -> 英文提示词"的转换习惯,并且会针对关键名词检查一下英文翻译是否会产生歧义。
第三个坑是 VAE 的 fp16 溢出问题。有些模型自带的 VAE 在 fp16 下解码时会出现 NaN 黑图,这时最简单的解决办法是换一个稳定版 VAE(社区中有很多修复版本,通常名称里带 "fp16-fix" 字样),或者在解码时强制使用 fp32。问题虽然不大,但如果你不了解原因,排查起来会相当费时间。
如果你想从这份笔记获得最大价值,我建议这样做:先把 5.2 节的手动代码在本地跑通,然后在循环里逐行打印板块的形状变化,你会对整条流水线的感知比任何图解都更深刻。接着再尝试改 CFG、步数、种子,观察每个参数对最终图像的具体影响,形成自己的调参手感。最后,如果你有精力,可以试着替换 UNet 的后半部分或尝试训练自己的文本编码器适配层——到这一步,你基本已经完全可以基于自己的理解去改造 Stable Diffusion 生成流程了。这篇笔记写到这里,对我来说收获最大的仍然是"手动拆过一次 pipeline"这件事。希望你也拿出周末的一点时间,亲自动手拆一次。