1. 文本端到端解析:CLIP 如何把一句中文提示词变成机器能懂的数字向量
先聊一个很多初学者容易忽略的事实:Stable Diffusion 并不是直接读你输入的这句话,也不是靠某个“魔法词典”把词变成图块。真正干活的第一步,是把文本交给 CLIP 文本编码器,转成一组带着语义信息的高维向量。我看完那套四集精讲笔记之后,最大的感触是:CLIP 在整个扩散流程里的作用被绝大多数资料严重低估了。
CLIP 的训练逻辑可以概括为一句话:让图片和文本在同一个向量空间里“对位”。它用了对比学习的方式,同时喂入一组图片和一组文字描述。训练时从对应批次里构建正样本对和负样本对,正样本是某张图和它真正对应的描述,负样本是这张图配了其他张图的描述。模型要从大量候选里把正样本对挑出来,把相似度拉高,把不相关样本对的相似度压低。经过这种训练之后,CLIP 的文本编码器输出的向量,天然就嵌在了和图像特征同一个语义坐标系里。
放到 Stable Diffusion 里,CLIP 的任务不是产出最终图像,而是给后续的 UNet 一张“文字地图”。这张地图其实就是一组向量矩阵,通常我们叫它prompt_embeds或者hidden_states。值得注意的是,它并不是简单地把整句话压成一个向量,而是保留了每个 token 对应的独立向量序列。这样做的好处非常直接:UNet 在做交叉注意力的时候,可以知道当前应该重点“看”哪部分文本。
实际操作中有一个非常容易踩的坑,就是 CLIP 的输入长度限制。原始文本编码器的最大上下文长度是 77 个 token,一个中文词往往会被拆成两三个甚至更多 token,所以你写很长很长的提示词时,超出部分会被直接截断。我之前调试一个角色一致性项目时,把提示词写到了快 300 个 token,结果发现后半段确实完全没参与生成,画面里该有的元素根本没出现。后来我一律养成了先len(tokenizer(text)["input_ids"])看一眼实际 token 数的习惯,超过限制就拆分或精简描述,而不是硬塞。
还有一个细节值得注意。CLIP 编码器通常会给两种输出:一种是池化后的向量pooled_output,另一种是完整序列last_hidden_state。在常规文生图流程里,UNet 需要的是后者,因为它保留了序列位置和局部语义信息,而池化向量往往只用于全局分类或对比用途。如果你看到有人拿pooled_output去当encoder_hidden_states喂给 UNet,大概率运行会立刻报错,或者图像内容和提示词完全对不上。
如果你真的想深入理解这个过程,建议在本地打印一次各个张量的 shape。CLIP 输入是[batch, 77]的 token id,经过 embedding 层后变成[batch, 77, 768],再过 Transformer 编码器,输出仍然是[batch, 77, 768]。UNet 拿到的就是最后这个序列向量。弄明白这一步,后面看 UNet 内部的交叉注意力,就会顺滑很多。
2. 噪声螺旋:扩散模型的正向加噪与反向求解核心思路
扩散模型本身是一个两阶段的故事。正向过程不做任何学习,只是把一张干净图片逐步加入高斯噪声,直到它变成一张接近纯噪声的图。这个过程在数学上是一个马尔可夫链,每一步只和当前状态有关,和更早的步骤无关。加噪的节奏由一个预设好的方差调度表控制,每一层加噪时都会记录当前步数t,以及一个和总噪声水平相关的累积系数。
常见的调度表是cosine schedule或scaled linear schedule,它们决定了每一步保留多少原图信息、混入多少随机噪声。t从 0 走到 1000 或者 1024,越往前走,原始结构信息保留得越少。如果你在训练时把某一步的原图伪装成带噪样本,网络要学的就是去预测这个噪声状态对应的时间步。
反向生成则是整个流程的核心看点。给定一个纯随机的高斯噪声向量,我们希望模型一步步把它“雕刻”成符合文本描述的图像。但这里有一个非常反直觉的设定:模型不去直接预测最终图像,也不去预测上一张图长什么样,而是去预测正向过程中混入的那个噪声到底是什么。这个过程很像你在猜覆盖在真实画面上的水印图案,知道了水印,就能从带噪画面中反推出原本长什么样。
为什么这么设计?原因主要是训练稳定性。预测噪声比预测原始图像更容易收敛。预测噪声的标签是精确的、可计算的,而预测原始图像在低信噪比时几乎不可能。因此训练目标函数被定义为预测噪声和真实噪声之间的均方误差,配合t采样随机数,模型被迫在不同噪声强度下去理解“图像结构”与“噪声纹理”的边界。
实际推理时的迭代和训练是镜像过程。在代码里你会看到类似这样的循环:
for t in scheduler.timesteps: noise_pred = unet(latent, t, encoder_hidden_states).sample latent = scheduler.step(noise_pred, t, latent).prev_sample每一步,UNet 拿到当前 latent 和对应的时间步t,输出一个预测噪声,然后由调度器根据这个预测噪声更新 latent。调度器在这里承担了非常重的工作,不同的采样器甚至会改变更新公式,比如 DDIM、DPM-Solver、Euler 等,它们决定了从第t步到第t-1步的转换方式。
不少初学者把这里的t理解成某种“进度百分比”,严格来说不准确。t描述的是目标噪声状态,不是剩余步数。如果你想判断当前画面有多接近成品,更合适的方式是看scheduler内部的累乘系数。理解这一点之后,调参数会轻松很多。
另外分享一个我在本地测试多模型时的观察。不同调度器对同一个 seed 的最终产出影响很大,甚至比更换 UNet 权重的影响还明显。DPM-Solver 这类采样器能在 20 步内给出不错的结果,而 Euler 通常需要 30 步以上才稳定。这个差异不是随机抖动,而是数学更新方式不同导致的收敛速度不同。后面我会单独列一张采样器对比表,方便你直接抄作业。
3. UNet 骨架拆解:为什么降采样再升采样能逐级还原出清晰图像
UNet 是整条文生图流水线里算力开销最大的部分,也是我花最多时间读源码的部分。它的整体结构不是全新的,最初是医学图像分割领域常用的编码器-解码器结构,两侧中间靠跳跃连接打通信息通路。Stable Diffusion 之所以选用 UNet 作为噪声预测骨干,关键在于它对多尺度特征的控场能力很强。
先看整体骨架。左侧编码器负责逐步降采样,把输入 latent 的尺寸一路缩小;右侧解码器负责逐步升采样,把压缩后的特征逐步放大回原尺寸;中间通过跳跃连接将每一层编码器的特征直接拼接到解码器对应位置。这种“降采样再升采样”的形态让感受野逐层扩大,高层理解整体结构,低层保留细节轮廓。
为什么跳跃连接这么重要?试想如果纯粹用编码器压到底、再全靠解码器独自恢复,细节信息早就丢光了。跳跃连接相当于给解码器送上一份“高分辨率白名单”,让它知道哪些边缘、哪些纹理还保留着。在扩散生成场景里,这种设计正好呼应了画面逐步从噪声中浮出的过程:局部纹理需要对齐,但全局布局不能被局部细节干扰。
再往内部拆一层,UNet 块由几种基础组件堆叠而成:ResNet 块用于特征变换,下采样或上采样模块用于调整分辨率,以及空间注意力或交叉注意力模块。空间注意力让特征图内部的远近位置能够互相参考,这样即使初始噪声完全没有空间对应关系,模型也能在一次次更新里建立像素之间的长程依赖。
交叉注意力是文本进入 UNet 的关键通道。在每一层注意力块中,查询向量来自图像特征,键和值向量来自 CLIP 文本编码器输出。查询向量在寻找“要关注什么内容”,键值对则提供具体语义信息。二者做点乘得到注意力权重,再把权重应用到值向量上,相当于让图像特征根据文本描述进行“局部聚焦”。
我刚开始接触时,总以为交叉注意力是直接改变 UNet 输出的噪声,原理上并不是这样。它调整的是特征响应强度,让网络更倾向于在某个区域保留或生成某种结构。换句话说,文本更像一个“指挥棒”,而 UNet 本身依然在完成图像结构的复原。这个认知帮我在后续做文本权重调节时避免了很多错误假设。
Stable Diffusion 实际运行时并不会直接在 512×512 像素上操作。输入 latent 的尺寸只有 64×64,通道数为 4。这是因为扩散过程发生在 VAE 编码后的潜在空间里,而不是像素空间。VAE 解码器在最后阶段再把 64×64×4 的 latent 放大回 512×512 的三通道图像。这个设计的存续力体现在效率上:在潜在空间里做扩散,算力消耗直接降了一个量级。
4. 从三件套到文生图流程:原理部件如何拼成可运行的推理管线
理解完 CLIP、扩散模型和 UNet 之后,最后一步是把它们拼成完整的文生图流程。这个过程可以用一个简单的流水线公式来描述:先随机初始化 latent,然后反复让 UNet 去噪,最后交给 VAE 解码成图片。
很多人讲流程时会把 VAE 一笔带过,但实际上它是最后出图必不可少的一块。VAE 编码器负责把高分辨率图像压缩到潜在空间,解码器负责把潜在向量还原回高分辨率像素图。在文生图推理时,随机噪声是一个 64×64×4 的 latent,和图片编码后的形状完全一致。整个扩散循环都发生在这个压缩空间里,只有最后一步调用decoder把它放大成 512×512 的图像。
整个管线里还有一个容易被忽略的组件:无分类器引导。它并不是这里所说的完全独立组件,而是通过两次预测融合实现文本语义增强。具体做法是,对同一个 latent 分别做“有文本”和“无文本”两种条件下的噪声预测,然后用一个缩放系数把两者的差值拉大,再把差值加回到无条件预测的噪声上。公式大概是下面这样:
noise_fused = noise_uncond + guidance_scale * (noise_cond - noise_uncond)这个公式的含义很直观:如果guidance_scale设得越大,模型对文本的响应就越“用力”,图像会更符合描述,但也容易出现色彩过浓、边缘生硬、内容过拟合的伪影;设得越小,图像会更自由,但可能偏离提示词。通常默认值在 7 到 8.5 之间,我自己的习惯是先用 7 起步,效果极端强或弱时再做微调。
跑完整流程之前,必须确认三个维度已经对齐:一是 latent 的噪声分布要和调度器预期一致,二是 CLIP 输出的hidden_states维度要和 UNet 交叉注意力块的键值维度一致,三是最后的冻结尺度要匹配 VAE 解码器的缩放条件。
把完整流程跑通一次之后,你会发现所谓文生图,本质上并不是“从无到有”,而是“从噪声到结构再到成品”的一连串约束求解过程。CLIP 提供了语义约束,扩散过程提供了中间状态,UNet 提供了去噪能力,VAE 提供了像素渲染。四者缺一不可。
5. 从零写一个极简文生图脚本:核心代码逐行拆解与运行实测
理论讲得再多,不如直接跑一段代码。下面这个脚本故意去掉了工程层面的包装,只保留最核心的推理链路,方便你把每一行和前面的原理对应起来。
import torch from transformers import CLIPTextModel, CLIPTokenizer from diffusers import AutoencoderKL, UNet2DConditionModel, DPMSolverMultistepScheduler # 加载三个核心组件 tokenizer = CLIPTokenizer.from_pretrained("模型路径") text_encoder = CLIPTextModel.from_pretrained("模型路径") vae = AutoencoderKL.from_pretrained("模型路径") unet = UNet2DConditionModel.from_pretrained("模型路径") scheduler = DPMSolverMultistepScheduler.from_pretrained("模型路径") # 超参数 prompt = "一只戴着宇航头盔的柴犬,赛博朋克风格" guidance_scale = 7.0 num_inference_steps = 25 height = 512 width = 512 # 文本编码 text_input = tokenizer(prompt, return_tensors="pt") enc_out = text_encoder(text_input.input_ids) encoder_hidden_states = enc_out.last_hidden_state # 初始化随机 latent latents = torch.randn((1, 4, height // 8, width // 8)) latents = latents * scheduler.init_noise_sigma # 设置无条件输入 uncond_input = tokenizer([""], return_tensors="pt") uncond_emb = text_encoder(uncond_input.input_ids).last_hidden_state # 扩散去噪循环 scheduler.set_timesteps(num_inference_steps) for t in scheduler.timesteps: latent_model_input = torch.cat([latents] * 2) latent_model_input = scheduler.scale_model_input(latent_model_input, t) with torch.no_grad(): noise_pred = unet(latent_model_input, t, encoder_hidden_states=torch.cat([uncond_emb, encoder_hidden_states])).sample noise_uncond, noise_cond = noise_pred.chunk(2) noise_fused = noise_uncond + guidance_scale * (noise_cond - noise_uncond) latents = scheduler.step(noise_fused, t, latents).prev_sample # VAE 解码 latents = 1 / vae.config.scaling_factor * latents with torch.no_grad(): image = vae.decode(latents).sample这段代码虽然短,但每一行都承担了一项关键任务。encoder_hidden_states这一行对应前文说的 CLIP 输出;torch.randn是随机噪声和扩散起点;torch.cat([latents] * 2)是为了同时算有文本和无文本的两个分支;noise_pred.chunk(2)分开后做 CFG 融合;scheduler.step完成单步更新;最后的scaling_factor是为了防止数值范围偏差。
我最初跑类似脚本时,犯过一个低级错误:忘了把latent_model_input用scheduler.scale_model_input先行缩放。结果就是图像灰蒙蒙的,好像蒙了一层雾。后来对比调试才发现,不同调度器对输入有额外缩放逻辑,直接跳过这一步会导致模型输入的噪声分布和训练时不一致。别看这个细节小,它直接决定了画面最终是干净的还是糊的。
关于步数,DPM-Solver 的 25 步已经能出非常稳定的图像。再往上加步数,画质提升有限,但显存占用和耗时都在涨。如果使用普通 DDIM,建议把步数拉到 40 左右。这里没有绝对标准,因为不同调度器收敛步伐不一样,你得结合自己显卡的实际表现来选。
整个脚本跑一遍,你会发现一次完整生成的耗时基本集中在两个阶段:UNet 迭代和 VAE 解码。UNet 迭代反复执行了几十次,每次都要做完整的特征传播和注意力计算;VAE 解码虽然只调用一次,但频繁的卷积上采样也占了显存峰值。理解这个构成后,做工程优化时方向就很明确了。
6. 笔记之外容易踩的深水区:采样步数、CFG 与潜在空间的经验复盘
最后这部分,我把自己看过四集笔记之后实际折腾项目踩到的坑做一个复盘。
采样步数是影响出图速度和画质的直接杠杆。我对比过同一张图在 10 步、25 步、50 步下的差异。10 步时细节明显缺失,脸部结构容易崩;25 步是一个甜点区间,纹理、边缘和光影关系基本打成平衡;50 步踏步提升不再显著,但复杂构图下的微小结构稳定性会更好。如果你的需求是做批量预筛,10 步快速出雏形是划算的,但最终交付图别用这么少的步数。
CFG 参数可能是最容易被新手无脑调高的旋钮。很多人觉得把提示词权重拉满,图就会更听话,实际上 PF 值超过 12 之后,图像色彩饱和度明显上升,背景容易形成油渍般的高光伪影,甚至物体表面出现不自然的金属感。反过来,CFG 调到 2 以下时,画面虽然柔和,但很可能完全忽略提示词中后半段的关键物体。我给不同场景的经验参考大致是:写实人物推荐 6 到 8,幻想风格 7.5 到 10,需要精细控制的商业素材 9 到 10.5。
潜在空间理解不当,还容易引发一个常见幻觉:认为改变初始噪声张量的值就能精准控制构图。实际上初始噪声确实携带了构图玄机,但它是高维且高度耦合的,任意一个参数的微小变化都会让最终图像彻底转向。“种子”这个概念对复现有用,但对精确控制并不够用。想要控制物体位置,更靠谱的方法是引入参考图或布局引导,而不是手调随机噪声。
对于显存有限的用户,VAE 解码是很容易触发爆显存的环节。可以在解码时切成切片模式,逐块处理而不是一次拼出整张图片。UNet 块也可以考虑环境变量开启跨设备卸载,把不参与当前计算的部分流到内存。这些操作虽然让单张出图时间变慢一些,但能避免中途崩溃,实际使用起来很划算。
如果你正在做模型融合或反复换权重测试,建议固定一个随机种子和调度器,只让权重成为变量。否则你很难判断画质变化是权重造成的,还是随机性造成的。我在做风格模型对比实验时,就吃过这个亏,最后把所有实验参数写进配置文档,才终于拿到可对比的结果。
学 Stable Diffusion 最忌讳的就是把工具当黑盒。只有把 CLIP、扩散模型、UNet 和调度器的分工边界画清楚,再遇到奇怪现象时你才能准确定位到底哪一环出了问题。