扩散模型与多模态:AI 如何生成图像、看懂图文音
扩散模型与多模态:AI 如何生成图像、看懂图文音
如果说大语言模型解决的是"AI 如何理解和生成文字",那么扩散模型和多模态技术解决的就是"AI 如何理解和生成图像、声音,以及如何把这些不同形式的信息统一起来处理"。从 Stable Diffusion、Midjourney 生成的惊艳插画,到 GPT-4V、Gemini 这类"能看图会说话"的模型,背后都离不开这两块拼图。本文用尽量少的数学、尽量多的类比,把这两个知识点讲透。
🎨 文生图:扩散模型的去噪魔法
一个反直觉的核心思想
扩散模型(Diffusion Model)解决"如何生成一张图片"的思路非常特别:它不是让模型直接"画"出一张图,而是让模型学会"从一堆噪点中,一步步把图片还原出来"。
类比一下:想象你有一张清晰的照片,你把它放进"搅拌机",每搅拌一次就往里面加一点点随机噪声。搅拌几百次之后,这张照片彻底变成了一团看不出任何内容的雪花噪点。这个"不断加噪声、直到变成纯噪点"的过程,就是扩散模型的前向过程(Forward Process)——它本身并不需要学习,只是一个数学上定义好的加噪规则。
真正需要"学习"的是反向过程(Reverse Process):训练一个神经网络(通常是 U-Net 或近年的 Transformer 结构),让它学会做一件事——"给定一张加了噪声的图片,猜一猜这一步到底加了多少噪声,然后把这部分噪声减掉"。如果模型每一步都能准确地"去掉一点点噪声",那么从纯噪点开始,反复调用这个模型几十次,就能一步步"雕刻"出一张清晰的图像。
为什么这个看似绕远路的方法反而好用
直接训练一个模型"从随机噪声一步到位生成图片"是非常困难的——这相当于要求模型一次性解决所有问题(构图、纹理、光影、细节)。而扩散模型把这个超难的任务拆解成了几十上百个"微小的去噪"小任务,每一步只需要做一件很简单的事:判断当前图里的噪声长什么样,然后去掉一点点。这就像雕塑家不是一凿子把石像刻出来,而是通过成百上千次精细的小动作逐渐显露出雕像的轮廓。每一小步的难度都很低,模型很容易学好,但把这些小步骤累积起来,就能完成极其复杂的生成任务。这也是为什么扩散模型生成的图像质量普遍优于早期的 GAN(生成对抗网络)——它用"步步为营"换来了"步步精细"。
文本如何指挥去噪的方向
只会"去噪声"还不够,我们还需要让模型按照文字描述(比如"一只戴墨镜的柯基在沙滩上冲浪")生成对应内容,这就是文本条件引导(Text-to-Image Conditioning)。
做法大致是:先用一个文本编码器(例如 CLIP 的文本塔,或者 T5 这类语言模型)把提示词变成一组向量,这组向量在每一步去噪时都会"喂"给去噪网络,相当于在模型耳边不断提醒"记住,你要还原的是柯基冲浪的画面,不是别的"。技术上常用交叉注意力(Cross-Attention)机制来实现这种"提醒":图像的中间特征去"查询"文本向量,把与文字最相关的图像区域强化、不相关的区域弱化。这样一来,同样的去噪起点(一团随机噪声),配上不同的文字提示,就能雕刻出完全不同的画面。
Stable Diffusion 与 Midjourney 的技术脉络
简化版扩散去噪流程
下面用伪代码勾勒一次"文生图"推理的核心步骤,帮助建立直观印象(不追求数学严谨):
# 简化版:Stable Diffusion 式文生图推理流程
def text_to_image(prompt, num_steps=30):
# 1. 把文字提示编码成向量,作为去噪过程的"导航仪"
text_embedding = text_encoder.encode(prompt)
# 2. 在潜在空间里生成一个纯随机噪声,作为起点
latent = random_noise(shape=(4, 64, 64))
# 3. 反复调用去噪网络,一步步减少噪声
for step in reversed(range(num_steps)):
noise_level = schedule[step] # 当前这一步理论上的噪声强度
predicted_noise = denoise_unet(
latent,
timestep=step,
condition=text_embedding # 交叉注意力:让文字指挥去噪方向
)
# 用预测出的噪声,把 latent 往"更干净"的方向推进一点
latent = remove_noise(latent, predicted_noise, noise_level)
# 4. 把去噪完成的潜在表示,用解码器还原成像素图片
image = vae_decoder.decode(latent)
return image整个过程可以理解为一条"流水线":文字 → 向量导航仪 → 一团噪声 → 循环去噪(每步都参考导航仪)→ 干净的潜在图 → 解码成真实像素图片。步数越多,去噪越精细,但也越耗时,这也是为什么现在很多产品都在研究"少步甚至一步生成"的加速技术。
无分类器引导:让生成结果更"听话"
实际的文生图系统里还有一个常用小技巧,叫无分类器引导(Classifier-Free Guidance,CFG)。做法是:每一步去噪时,模型分别预测"有文字提示"和"没有文字提示(空提示)"两种情况下的噪声,然后把两者的差值放大后叠加回去,公式大致是:
最终预测噪声 = 无条件预测噪声 + CFG强度 × (有条件预测噪声 - 无条件预测噪声)CFG 强度调得越高,生成结果就越贴合文字描述,但过高会让画面显得生硬、失真;调得太低则画面自由发挥、可能偏离提示词。这也是为什么很多文生图工具会暴露一个"提示词相关度""引导系数"之类的滑杆参数,本质上调的就是这个 CFG 值。
扩散模型 vs GAN:两种生成范式的对比
在扩散模型流行之前,图像生成的主流是 GAN(生成对抗网络,Generative Adversarial Network):训练一个"生成器"造假图,一个"判别器"辨真假,两者对抗着共同进步。两种范式各有取舍:
从图像到视频:扩散模型的自然延伸
理解了"给图片加噪去噪"之后,"给视频生成"的思路也就不难理解了——可以把视频看作"多帧图像 + 时间维度",扩散模型只需要在时间轴上增加一层注意力(Temporal Attention),让模型在去噪时不仅参考同一帧内的空间信息,还参考前后帧的连贯性,从而保证生成的视频画面连贯、不闪烁。Sora 等文生视频模型正是把这一思路和 Diffusion Transformer 结合,将视频也切成时空patch 一起处理。
🖼️ 多模态:AI 同时看懂图文音
图片也能变成"token"
大语言模型的世界里,一切输入最终都被切成一串 token(词元),再变成向量交给 Transformer 处理。多模态模型的第一个关键突破,就是让图片也能被切成"token",从而复用同一套 Transformer 架构。
具体做法是图像分块(Patch Embedding):把一张图片(比如 224×224 像素)切成一个个小方块(例如 16×16 像素一块),这样一张图就变成了几百个小图块,就像把一段文字切成一个个字词。每个小图块经过一个视觉编码器(通常是 ViT,Vision Transformer)压缩成一个向量,这个向量的形式和文本 token 的向量在维度上是一致的。于是,"一张图片"就被转换成了"一串图像 token",可以和文字 token 拼在一起,喂给同一个 Transformer 去处理。这就是"图片也是一种语言"这个说法的技术来源——只是图像的"词汇"不再是汉字或字母,而是一个个视觉图块的抽象表示。
视觉编码器如何与语言模型对接
光把图片切成 token 还不够,还得让语言模型"看懂"这些视觉 token 到底代表什么语义,这里有两条主流技术路线:
路线一:CLIP 式图文对比学习。 CLIP(Contrastive Language-Image Pre-training)的训练方式很巧妙:拿海量的"图片—文字描述"配对数据(例如网络上的图片和它的 alt 文本),训练两个编码器——一个专门编码图片,一个专门编码文字——让匹配的图文对在向量空间里的距离尽可能近,不匹配的图文对距离尽可能远。类比一下,这就像训练两个不同语言的翻译官,让他们各自把内容翻译成同一种"通用语言"(向量空间),只要说的是同一件事,翻译出来的向量就应该几乎重合。训练完成后,CLIP 的图像编码器就具备了"用语言能理解的方式描述图片内容"的能力,可以直接被复用到下游任务中。
路线二:Vision Encoder + Projection Layer 接入 LLM。 这是目前多模态大模型(如 LLaVA 系列思路)常用的架构:
可以把这个架构理解为:"一位精通视觉的翻译官(视觉编码器)先把图片内容翻译成一份摘要,一个小小的适配器(投影层)把这份摘要转换成语言模型习惯的措辞,然后交给语言模型(大脑),大脑结合这份视觉摘要和用户的文字提问,给出综合回答"。
用伪代码大致表示这个流程:
# 简化版:多模态大模型"看图说话"推理流程
def multimodal_chat(image, question):
# 1. 图像分块并编码:图片 -> 一串视觉特征向量
patches = split_into_patches(image, patch_size=16)
visual_features = vision_encoder(patches) # 类似 CLIP 的图像塔
# 2. 投影层:把视觉向量翻译成语言模型的"词向量空间"
visual_tokens = projection_layer(visual_features)
# 3. 文本 token 化
text_tokens = tokenizer.encode(question)
# 4. 拼接视觉 token 与文本 token,一起喂给语言模型
input_tokens = concat(visual_tokens, text_tokens)
answer = llm.generate(input_tokens)
return answer"看图说话""听声辨意"是怎么实现的
有了上面的架构,"看图说话"(Image Captioning / Visual Question Answering)就顺理成章了:模型把图像 token 和"请描述这张图片"这句文字 token 拼在一起,语言模型部分依然按照训练好的语言建模能力,一个词一个词地生成回答——只不过它在生成每个词时,注意力机制可以同时参考图像 token 和已经生成的文字,从而做到"依据图片内容组织语言"。这也解释了为什么这类模型能做更复杂的任务,比如"图里这个红色按钮在哪个位置""这张财报截图里营收同比涨了多少"——本质上都是"文字 token + 视觉 token 联合注意力"的应用。
音频模态走的是类似思路:一段语音先经过特征提取(例如梅尔频谱图 Mel-Spectrogram),把连续的声音信号切分成一个个时间片段,每个片段编码成一个向量,这一步和"图像切 patch"在思想上完全一致——都是把连续、非离散的原始信号,转换成一串离散的、语言模型能处理的"音频 token"。之后同样通过一个投影层对接到语言模型,模型就能"听懂"语音内容,结合文字指令做语音识别、语音问答,甚至在语音助手场景里做到"听声辨意"——不仅识别出说了什么字,还能感知语气、情绪等副语言信息。
概括来说,多模态大模型能"通吃"图文音的关键,就在于所有模态最终都被转换成统一形式的向量 token 序列,再交给同一个 Transformer 主干去做注意力计算——差异只在于"前端"用什么样的编码器(图像用 ViT / CLIP,音频用频谱编码器,文本用普通的词表 embedding),"后端"的语言模型主干可以是完全共享的。
音频的离散化:从连续波形到"音频词表"
进一步看,近年的语音大模型(如一些端到端语音助手)会把音频编码得更彻底——用 EnCodec、SoundStream 这类神经音频编解码器,把连续的声音波形压缩成一串离散的音频编码(Discrete Audio Tokens),效果上很像给声音也建了一个"词表"。这样一来,音频 token 不仅能像文本 token 一样被语言模型"预测下一个",甚至可以直接复用语言模型训练时用的自回归目标(预测下一个 token),使模型既能"听懂"语音,也能"说出"语音,实现真正意义上的语音到语音直接对话,而不需要"语音转文字 → 文字处理 → 文字转语音"这样绕一圈的传统流水线。
多模态与扩散模型的交汇
值得一提的是,文生图和多模态理解这两条技术线正在相互靠拢:一方面,像 DALL·E 3 这样的文生图系统会先用一个语言模型把用户简短的提示词"扩写"成更详细、更准确的描述,再交给扩散模型去生成,本质上是"多模态理解能力反哺生成质量";另一方面,GPT-4o 等原生多模态模型开始尝试让同一个模型同时具备理解和生成图像的能力,图像既是输入 token,也可以作为输出 token 被模型直接"生成"出来,图像生成和语言生成在模型内部逐渐统一到同一套 token 预测框架下。这提示了一个趋势:未来"理解"和"生成"可能不再是两套独立系统,而是同一个多模态大模型的一体两面。
实际应用一览
| 能力 | 依赖技术 | 典型场景 |
| --- | --- | --- |
| 文生图 | 扩散模型 + 文本条件引导 | Stable Diffusion、Midjourney 插画创作 |
| 图生图 / 局部重绘 | 扩散模型 + 部分加噪 | 电商图修图、AI 换装 |
| 看图问答 | Vision Encoder + Projection Layer + LLM | 拍照识题、截图问答 |
| 图文检索 | CLIP 对比学习 | 以文搜图、以图搜图 |
| 语音助手 | 音频编码 + 多模态 LLM | 实时语音对话、会议纪要 |
| 文生视频 | 扩散模型 + 时序注意力 | Sora 类视频生成 |
❓ 常见误区澄清
误区一:"扩散模型就是给图片加滤镜去噪"。 实际上扩散模型的"去噪"只是训练时的一种数学手段,真正的目的是让模型学会"从任意随机噪声出发,生成符合训练数据分布的全新图像",而不是修复某张已有的、被污染的照片(虽然它确实也能做图像修复类任务)。
误区二:"多模态模型是把图像识别模型和语言模型简单拼接"。 单纯拼接两个独立训练的模型很难让它们"理解"彼此,关键在于通过对比学习(CLIP)或投影层 + 联合指令微调,让视觉特征真正对齐到语言模型能理解的语义空间,这一步"对齐"训练才是多模态能力的核心难点。
误区三:"步数越多,图片一定越好"。 去噪步数增加确实能让细节更精细,但超过一定步数后收益迅速递减,反而会显著拖慢生成速度,实际产品中往往会结合加速采样算法,在少量步数(如 20~30 步甚至更少)内就达到接近收敛的效果。
🧮 扩散模型的数学基础:把"加噪去噪"写成公式
前面用"搅拌机加噪点"讲了直觉,这里把关键公式补齐。理解了这几条公式,你就能看懂几乎所有扩散论文的核心。
前向过程:一步步加噪的闭式解
前向过程定义了一个马尔可夫链:给定原始干净图像 x_0,每一步都往里掺一点高斯噪声,噪声强度由调度参数 beta_t 控制(t 从 1 到 T,通常 T=1000)。单步定义为:
q(x_t | x_{t-1}) = N(x_t; sqrt(1 - beta_t) * x_{t-1}, beta_t * I)如果每一步都真的迭代 1000 次去采样,训练会慢到无法接受。DDPM 最漂亮的一点是:由于高斯分布的可加性,可以直接一步跳到任意时刻 t,得到闭式解。令 alpha_t = 1 - beta_t,alpha_bar_t = 累乘(alpha_1 alpha_2 ... * alpha_t),则:
q(x_t | x_0) = N(x_t; sqrt(alpha_bar_t) * x_0, (1 - alpha_bar_t) * I)
等价的重参数化采样写法(epsilon 是标准正态噪声):
x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * epsilon这条公式的意义:训练时想造出"第 t 步的加噪图",不需要循环 1000 次,一行代码就能算出来。alpha_bar_t 从接近 1(几乎没噪声)单调降到接近 0(几乎纯噪声),它就是"当前保留了多少原图信息"的度量。
噪声调度:linear 与 cosine
beta_t 怎么随 t 变化,就是"噪声调度(noise schedule)"。两种最常见的方案:
| 调度类型 | 定义方式 | 特点 | 适用 |
| --- | --- | --- | --- |
| linear | beta 从 1e-4 线性增到 2e-2 | 实现最简单,DDPM 原版采用 | 像素空间 256 分辨率 |
| cosine | alpha_bar_t 按余弦曲线下降 | 中后段加噪更平缓,信息破坏更均匀 | 高分辨率、improved DDPM |
| sigmoid / scaled linear | 介于两者之间 | SD 系列 latent 空间常用 scaled_linear | 潜在扩散 |
linear 调度在高分辨率下有个问题:后期加噪太快,很多步实际上"已经全是噪声",浪费了模型容量。cosine 调度让 alpha_bar_t 下降更缓,把有效信息更均匀地分散到各个 timestep,improved DDPM 论文实测能明显提升 FID。
import torch
import math
def linear_beta_schedule(timesteps, beta_start=1e-4, beta_end=2e-2):
return torch.linspace(beta_start, beta_end, timesteps)
def cosine_beta_schedule(timesteps, s=0.008):
# improved DDPM 的 cosine 调度
steps = timesteps + 1
x = torch.linspace(0, timesteps, steps)
alphas_bar = torch.cos(((x / timesteps) + s) / (1 + s) * math.pi * 0.5) ** 2
alphas_bar = alphas_bar / alphas_bar[0]
betas = 1 - (alphas_bar[1:] / alphas_bar[:-1])
return torch.clip(betas, 0.0001, 0.9999)
T = 1000
betas = cosine_beta_schedule(T)
alphas = 1.0 - betas
alphas_bar = torch.cumprod(alphas, dim=0) # alpha_bar_t 累乘
sqrt_alphas_bar = torch.sqrt(alphas_bar)
sqrt_one_minus_alphas_bar = torch.sqrt(1.0 - alphas_bar)DDPM 训练目标:预测噪声 epsilon
核心洞见:反向过程 p(x_{t-1}|x_t) 也可以近似成高斯分布,而它的均值可以用"网络预测的噪声"表示。经过一堆推导,DDPM 把复杂的变分下界(ELBO)简化成一个极其简单的目标——让网络 epsilon_theta 去预测"当初加进去的那团噪声 epsilon",损失就是均方误差:
L_simple = E_{x_0, t, epsilon} [ || epsilon - epsilon_theta(x_t, t) ||^2 ]
其中 x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * epsilon就这么朴素。训练一个回归器去猜噪声,比 GAN 的对抗博弈稳定太多。下面是一个完整可读的 PyTorch 训练循环骨架:
import torch
import torch.nn.functional as F
def q_sample(x_0, t, noise, sqrt_ab, sqrt_1m_ab):
"""前向加噪:一步得到 x_t(利用闭式解)"""
# t 是一个 batch 的时间步索引,用 gather 取出对应系数
a = sqrt_ab[t].view(-1, 1, 1, 1)
b = sqrt_1m_ab[t].view(-1, 1, 1, 1)
return a * x_0 + b * noise
def train_step(model, x_0, optimizer, T, sqrt_ab, sqrt_1m_ab, device):
model.train()
batch = x_0.shape[0]
# 1. 每张图随机采一个 timestep
t = torch.randint(0, T, (batch,), device=device).long()
# 2. 采一团标准正态噪声
noise = torch.randn_like(x_0)
# 3. 前向:一步造出加噪图 x_t
x_t = q_sample(x_0, t, noise, sqrt_ab, sqrt_1m_ab)
# 4. 让 U-Net / DiT 预测这团噪声
predicted_noise = model(x_t, t)
# 5. 均方误差损失(predict-epsilon 目标)
loss = F.mse_loss(noise, predicted_noise)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return loss.item()
# 训练主循环
for epoch in range(num_epochs):
for x_0 in dataloader:
x_0 = x_0.to(device) # 干净图 batch,范围归一化到 [-1, 1]
loss = train_step(model, x_0, optimizer, T,
sqrt_alphas_bar.to(device),
sqrt_one_minus_alphas_bar.to(device), device)除了预测噪声(epsilon-prediction),后来还出现了 v-prediction(预测速度项 v)和 x0-prediction(直接预测干净图)两种参数化。v-prediction 在高噪声段数值更稳定,是 SD 2.x、蒸馏模型和很多视频模型的默认选择。
🎛️ 采样器全家桶:从 DDPM 到 DPM-Solver
训练只关心"预测噪声",但推理时"怎么用预测的噪声一步步走回干净图"有很多不同算法,这就是采样器(sampler / scheduler)。它们的区别在于把反向 ODE/SDE 离散化的方式不同,直接决定了"要几步才能出好图"。
| 采样器 | 类型 | 典型步数 | 是否随机 | 特点 |
| --- | --- | --- | --- | --- |
| DDPM | 随机 SDE | 250 - 1000 | 是 | 原版,质量高但极慢 |
| DDIM | 确定性 ODE | 20 - 50 | 否 | 确定性、可插值、种子可复现 |
| Euler / Euler a | ODE / 加噪 | 20 - 40 | Euler a 带随机 | k-diffusion 系,社区最常用 |
| DPM-Solver++ | 高阶 ODE | 15 - 25 | 否 | 二阶/多步,少步高质量 |
| UniPC | 预测校正 | 8 - 15 | 否 | 极少步仍稳定 |
| LCM | 一致性蒸馏 | 2 - 8 | 否 | 需配 LCM 权重,秒级出图 |
一句话经验:想快选 DPM-Solver++ 或 UniPC;想稳复现选 DDIM;想极致速度上 LCM/Turbo(下文详述)。DDIM 的更新公式(确定性版本,eta=0)很值得记:
x_{t-1} = sqrt(alpha_bar_{t-1}) * x0_pred
+ sqrt(1 - alpha_bar_{t-1}) * epsilon_theta(x_t, t)
其中 x0_pred = (x_t - sqrt(1 - alpha_bar_t) * epsilon_theta) / sqrt(alpha_bar_t)因为完全没有随机项,同样的种子 + 同样的提示词,DDIM 每次输出像素级一致,这对做 A/B、做可控编辑非常重要。
📦 潜在扩散与 VAE:为什么能在消费级显卡上跑
Stable Diffusion 的核心工程突破是"潜在扩散":不在像素空间做扩散,而是先用 VAE 把图压到一个小得多的潜在空间。看一组具体数字(SD 1.5 / SDXL 的 VAE,下采样因子 f=8):
| 项目 | 像素空间 | 潜在空间 | 压缩比 |
| --- | --- | --- | --- |
| 512×512 RGB | 512×512×3 = 786432 | 64×64×4 = 16384 | 约 48 倍 |
| 1024×1024 RGB | 1024×1024×3 = 3145728 | 128×128×4 = 65536 | 约 48 倍 |
VAE 把 3 通道 512 边长压成 4 通道 64 边长,空间维度各降 8 倍、总元素降约 48 倍。扩散过程在这个"缩微沙盘"里进行,算力需求断崖式下降,这才让文生图能塞进 8GB 显存的消费级显卡。
# 潜在空间与像素空间的往返(diffusers 的 AutoencoderKL)
from diffusers import AutoencoderKL
import torch
vae = AutoencoderKL.from_pretrained(
"stabilityai/sd-vae-ft-mse", torch_dtype=torch.float16
).to("cuda")
# 编码:像素图 -> 潜在表示(注意 SD 的缩放因子 0.18215)
image = torch.randn(1, 3, 512, 512, device="cuda", dtype=torch.float16)
with torch.no_grad():
latent = vae.encode(image).latent_dist.sample() * 0.18215
print(latent.shape) # torch.Size([1, 4, 64, 64])
# 解码:潜在表示 -> 像素图
with torch.no_grad():
recon = vae.decode(latent / 0.18215).sample
print(recon.shape) # torch.Size([1, 3, 512, 512])VAE 是把双刃剑:压缩比越高越省算力,但重建细节(尤其是文字、人脸、密集纹理)损失也越大,这也是早期 SD 画不好小字、画崩手指的部分原因。SD3、FLUX 改用 16 通道 VAE(而非 4 通道)来提升重建保真度。
🕹️ 条件控制进阶:让生成"指哪打哪"
纯文本提示词的可控性有限。工业界靠一系列附加模块做精细控制,全部可在 diffusers 里组合使用。
ControlNet:用结构图约束构图
ControlNet 复制一份 U-Net 编码器权重作为可训练旁路,接收额外的结构条件(边缘图 Canny、深度图、人体骨架 OpenPose、语义分割等),通过"零卷积(zero-convolution)"渐进注入主干,既保留原模型能力又新增结构控制。
from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
import torch, cv2, numpy as np
from PIL import Image
# 加载 Canny 边缘的 ControlNet
controlnet = ControlNetModel.from_pretrained(
"lllyasviel/sd-controlnet-canny", torch_dtype=torch.float16
)
pipe = StableDiffusionControlNetPipeline.from_pretrained(
"runwayml/stable-diffusion-v1-5",
controlnet=controlnet, torch_dtype=torch.float16
).to("cuda")
# 从原图提取 Canny 边缘作为结构条件
src = np.array(Image.open("pose_ref.png").convert("RGB"))
edges = cv2.Canny(src, 100, 200)
edges = np.stack([edges] * 3, axis=-1)
control_image = Image.fromarray(edges)
image = pipe(
prompt="a cyberpunk knight, neon light, ultra detailed",
image=control_image,
controlnet_conditioning_scale=0.8, # 结构约束强度
num_inference_steps=25,
).images[0]
image.save("controlnet_out.png")IP-Adapter:用参考图控制风格/主体
IP-Adapter 把参考图经 CLIP 图像编码后,通过解耦的交叉注意力注入,实现"以图控图"——给一张人脸或一种画风参考,生成结果就带上它的特征,常用于风格迁移和角色一致性。
LoRA:低秩微调,几十 MB 定制风格
LoRA(Low-Rank Adaptation)不训练全部权重,只在注意力层旁挂两个低秩矩阵 A、B(W' = W + B·A,秩 r 通常 4~64),冻结原权重只训 A、B。好处:训练显存和产物体积都极小,一个画风 LoRA 通常只有 10~150MB,却能显著改变输出风格。
# diffusers 里加载并叠加多个 LoRA
pipe.load_lora_weights("path/to/style_lora.safetensors", adapter_name="style")
pipe.load_lora_weights("path/to/character_lora.safetensors", adapter_name="char")
pipe.set_adapters(["style", "char"], adapter_weights=[0.8, 0.6]) # 各自权重
image = pipe("a portrait in the trained style", num_inference_steps=25).images[0]Inpainting / Outpainting:局部重绘与画布外扩
Inpainting(局部重绘)用一张 mask 指定"哪里要重画",模型只在 mask 区域做去噪、mask 外保持原样,用于去除路人、换装、修补。Outpainting 则是把画布向外扩展、让模型补全边界之外的内容。
from diffusers import StableDiffusionInpaintPipeline
import torch
from PIL import Image
pipe = StableDiffusionInpaintPipeline.from_pretrained(
"runwayml/stable-diffusion-inpainting", torch_dtype=torch.float16
).to("cuda")
init_image = Image.open("room.png").convert("RGB").resize((512, 512))
mask = Image.open("mask.png").convert("RGB").resize((512, 512)) # 白色=重绘区
result = pipe(
prompt="a vase of fresh flowers on the table",
image=init_image,
mask_image=mask,
strength=0.9, # 重绘强度
num_inference_steps=30,
).images[0]
result.save("inpaint_out.png")⚡ 少步生成:把 30 步压到 1~4 步
扩散最大的痛点是慢。一系列蒸馏 / 一致性方法把采样步数从几十步压到个位数,实现"秒级甚至实时"出图。
| 方法 | 步数 | 相对质量 | 核心思想 |
| --- | --- | --- | --- |
| 标准 SDXL | 25 - 40 | 基准(最好) | 常规多步采样 |
| LCM | 4 - 8 | 略降 | 一致性蒸馏,学"直接跳到解" |
| LCM-LoRA | 4 - 8 | 略降 | 以 LoRA 形式即插即用 |
| SDXL-Turbo | 1 - 4 | 中等 | 对抗蒸馏 ADD |
| SDXL-Lightning | 1 - 8 | 中高 | 渐进式 + 对抗蒸馏 |
| FLUX.1-schnell | 1 - 4 | 高 | 时间步蒸馏 |
一致性模型(Consistency Model)的核心:训练网络满足"自洽性"——沿同一条 ODE 轨迹上的任意点,都应映射到同一个终点(干净图)。学会这种映射后,理论上一步就能从噪声跳到图像。
# 用 LCM-LoRA 让任意 SDXL 模型秒级出图
from diffusers import StableDiffusionXLPipeline, LCMScheduler
import torch
pipe = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16
).to("cuda")
pipe.load_lora_weights("latent-consistency/lcm-lora-sdxl")
pipe.scheduler = LCMScheduler.from_config(pipe.scheduler.config)
image = pipe(
prompt="astronaut riding a horse on mars, cinematic",
num_inference_steps=4, # 仅 4 步
guidance_scale=1.5, # LCM 需要很低的 CFG
).images[0]注意:LCM/Turbo 类模型需要把 CFG 调到 1~2(几乎关闭),因为高 CFG 会破坏蒸馏学到的一致性,这是新手最容易踩的坑。
🏗️ 架构演进:U-Net → DiT → SD3 → FLUX
去噪骨干网络本身也在快速迭代:
| 模型 | 骨干 | 参数量级 | VAE 通道 | 训练目标 | 许可证 |
| --- | --- | --- | --- | --- | --- |
| SD 1.5 | U-Net | 约 0.9B | 4 | epsilon | CreativeML OpenRAIL-M |
| SDXL | U-Net | 约 2.6B | 4 | epsilon | OpenRAIL++-M |
| SD3 Medium | MMDiT | 约 2B | 16 | Rectified Flow | Stability 社区许可 |
| FLUX.1-dev | DiT (flow) | 约 12B | 16 | Flow Matching | 非商用研究许可 |
| FLUX.1-schnell | DiT (flow) | 约 12B | 16 | 蒸馏 flow | Apache 2.0 |
许可证差异对商用很关键:schnell 是 Apache 2.0 可商用,而 dev 仅限非商用研究,落地前务必确认。
🎬 文生视频:时空 patch 与时序注意力
视频 = 图像 + 时间。文生视频模型(Sora、可灵、Runway Gen-3、Pika 等)的关键是在扩散去噪里同时建模空间和时间。Sora 的做法是把视频编码进潜在空间后,切成"时空 patch(spacetime patches)"——不仅在长宽上切,也在时间轴上切,得到一串统一的时空 token,再用 DiT 去噪。
# 伪代码:时空 patch 化 + 时序注意力的思想
def spacetime_patchify(video_latent, ps=2, pt=1):
# video_latent: [B, C, T, H, W] (时间 T,空间 H×W)
# 在 T、H、W 三个维度上切 patch,展平成 token 序列
B, C, T, H, W = video_latent.shape
tokens = rearrange(
video_latent,
"b c (t pt) (h ps1) (w ps2) -> b (t h w) (c pt ps1 ps2)",
pt=pt, ps1=ps, ps2=ps,
)
return tokens # [B, num_tokens, dim]
class SpatioTemporalBlock(nn.Module):
def forward(self, x, t_emb, text_emb):
# 1. 空间注意力:同一帧内 patch 互相看
x = x + self.spatial_attn(self.norm1(x))
# 2. 时序注意力:跨帧同位置 patch 互相看,保证连贯不闪烁
x = x + self.temporal_attn(self.norm2(x))
# 3. 交叉注意力:注入文本条件
x = x + self.cross_attn(self.norm3(x), text_emb)
x = x + self.mlp(self.norm4(x))
return x时序注意力是"画面连贯不闪烁"的关键——它让第 t 帧的某个区域去参考前后帧的同一区域,从而保持物体身份和运动一致。实际推理成本很高:生成 5 秒 720p 视频往往需要数十 GB 显存、几十秒到几分钟不等。
用 diffusers 跑一个开源文生视频(如 CogVideoX)大致如下:
from diffusers import CogVideoXPipeline
from diffusers.utils import export_to_video
import torch
pipe = CogVideoXPipeline.from_pretrained(
"THUDM/CogVideoX-5b", torch_dtype=torch.bfloat16
).to("cuda")
pipe.enable_model_cpu_offload() # 显存不够时分层卸载到内存
video = pipe(
prompt="A panda playing guitar by a lake at sunset, cinematic lighting",
num_frames=49,
num_inference_steps=50,
guidance_scale=6.0,
).frames[0]
export_to_video(video, "panda.mp4", fps=8)🔗 CLIP 对比学习:InfoNCE 损失与 ViT patch embedding
多模态理解的地基是 CLIP。它的训练目标 InfoNCE 是理解"图文对齐"的钥匙。一个 batch 里有 N 个图文对,计算图像 embedding 和文本 embedding 的相似度矩阵(N×N),对角线是正样本(真配对),其余是负样本,用对称交叉熵拉近正样本、推远负样本:
import torch
import torch.nn as nn
import torch.nn.functional as F
def clip_infonce_loss(image_features, text_features, logit_scale):
# image_features / text_features: [N, D]
# 1. L2 归一化到单位球面
image_features = F.normalize(image_features, dim=-1)
text_features = F.normalize(text_features, dim=-1)
# 2. 相似度矩阵,乘上可学习温度 logit_scale = exp(t)
logits_per_image = logit_scale * image_features @ text_features.t() # [N, N]
logits_per_text = logits_per_image.t()
# 3. 对角线是正确配对,标签就是 0..N-1
labels = torch.arange(image_features.shape[0], device=image_features.device)
# 4. 图->文 与 文->图 两个方向的交叉熵取平均
loss_i = F.cross_entropy(logits_per_image, labels)
loss_t = F.cross_entropy(logits_per_text, labels)
return (loss_i + loss_t) / 2温度参数 logit_scale 会随训练一起学,控制相似度分布的"锐利程度"。CLIP 的图像塔常用 ViT,其第一步 patch embedding 就是把图切块再线性投影:
class PatchEmbed(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_ch=3, embed_dim=768):
super().__init__()
self.num_patches = (img_size // patch_size) ** 2 # 14*14 = 196
# 用一个 stride=patch 的卷积一步完成"切块+线性投影"
self.proj = nn.Conv2d(in_ch, embed_dim,
kernel_size=patch_size, stride=patch_size)
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
self.pos_embed = nn.Parameter(
torch.zeros(1, self.num_patches + 1, embed_dim))
def forward(self, x):
B = x.shape[0]
x = self.proj(x) # [B, 768, 14, 14]
x = x.flatten(2).transpose(1, 2) # [B, 196, 768] 每块变一个 token
cls = self.cls_token.expand(B, -1, -1)
x = torch.cat([cls, x], dim=1) # 拼上 [CLS] token -> [B, 197, 768]
x = x + self.pos_embed # 加位置编码
return x一张 224×224 的图,16×16 的 patch,正好切成 14×14=196 个块,加上 [CLS] 就是 197 个 token,之后完全走标准 Transformer——这就是"图片也是一种语言"的底层实现。
🧠 多模态大模型架构:LLaVA / Qwen-VL / GPT-4V
主流"看图说话"大模型的通用范式:视觉编码器(冻结的 CLIP-ViT)→ 投影层(对齐维度)→ LLM。差异主要在投影层设计和训练数据。
| 模型 | 视觉编码器 | 连接器 | LLM 主干 | 特点 |
| --- | --- | --- | --- | --- |
| LLaVA-1.5 | CLIP ViT-L/14 | 两层 MLP | Vicuna 7B/13B | 开源、数据配方公开 |
| Qwen-VL | ViT-bigG | 单层 cross-attn 重采样 | Qwen | 支持中文、细粒度定位 |
| Qwen2-VL | 原生动态分辨率 ViT | MLP | Qwen2 | 任意分辨率、视频理解 |
| GPT-4V / 4o | 未公开 | 未公开 | GPT-4 系 | 原生多模态、能力最强 |
投影层可以简单到只有两层 MLP(LLaVA-1.5 的核心其实非常朴素):
import torch.nn as nn
class VisionProjector(nn.Module):
"""把 CLIP 视觉特征投影到 LLM 词向量空间(LLaVA-1.5 式两层 MLP)"""
def __init__(self, vision_dim=1024, llm_dim=4096):
super().__init__()
self.proj = nn.Sequential(
nn.Linear(vision_dim, llm_dim),
nn.GELU(),
nn.Linear(llm_dim, llm_dim),
)
def forward(self, image_features):
# image_features: [B, num_patches, vision_dim]
return self.proj(image_features) # [B, num_patches, llm_dim]
# 前向拼接:视觉 token 直接当成一段"外语词"插进文本序列
# input_embeds = [text_emb_前, projected_visual_tokens, text_emb_后]训练分两阶段:先"特征对齐预训练"——冻结视觉塔和 LLM,只训投影层,用图文 caption 数据教投影层说 LLM 的语言;再"指令微调"——用多模态对话数据解冻投影层和 LLM 一起训。指令数据长这样:
{
"image": "coco/000000123.jpg",
"conversations": [
{"from": "human", "value": "<image>\n这张图里有几个人,他们在做什么?"},
{"from": "gpt", "value": "图中有三个人,他们围坐在野餐垫上分享食物,背景是公园。"}
]
}其中
🔊 音频多模态:Whisper 与 EnCodec
音频侧有两条互补路线:连续特征理解(Whisper)与离散 token 生成(EnCodec/SoundStream)。
# Whisper 做语音识别(transformers)
from transformers import pipeline
import torch
asr = pipeline(
"automatic-speech-recognition",
model="openai/whisper-large-v3",
torch_dtype=torch.float16,
device="cuda",
)
result = asr("meeting.wav", generate_kwargs={"language": "chinese"})
print(result["text"])# EnCodec 把波形压成离散 code,再还原(音频"tokenize")
from transformers import EncodecModel, AutoProcessor
import torch
model = EncodecModel.from_pretrained("facebook/encodec_24khz")
processor = AutoProcessor.from_pretrained("facebook/encodec_24khz")
inputs = processor(raw_audio=wav_array, sampling_rate=24000, return_tensors="pt")
encoded = model.encode(inputs["input_values"], inputs["padding_mask"])
codes = encoded.audio_codes # 离散音频 token,形如 [B, n_codebooks, T]
# 语音到语音:LLM 在这些 code 上做自回归,再用 model.decode 还原成波形
audio_out = model.decode(encoded.audio_codes, encoded.audio_scales,
inputs["padding_mask"])[0]真正的语音到语音大模型(如 GPT-4o 语音、Moshi)正是靠离散音频 token 打通"听—想—说",省掉传统"ASR→LLM→TTS"三级流水线的延迟和信息损耗,实现数百毫秒级的实时对话。
🧪 真实可运行推理代码合集
下面几段是可以直接复制运行的 diffusers / transformers 推理代码,覆盖四类高频任务。
文生图(SDXL)
from diffusers import StableDiffusionXLPipeline
import torch
pipe = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0",
torch_dtype=torch.float16, variant="fp16", use_safetensors=True,
).to("cuda")
image = pipe(
prompt="a majestic snow leopard on a cliff, golden hour, 85mm photo",
negative_prompt="blurry, lowres, deformed, extra limbs, watermark",
num_inference_steps=30,
guidance_scale=7.0,
generator=torch.Generator("cuda").manual_seed(42), # 固定种子可复现
).images[0]
image.save("leopard.png")图生图(img2img)
from diffusers import StableDiffusionXLImg2ImgPipeline
from diffusers.utils import load_image
import torch
pipe = StableDiffusionXLImg2ImgPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16
).to("cuda")
init = load_image("sketch.png").resize((1024, 1024))
image = pipe(
prompt="oil painting, vivid colors, impressionist style",
image=init,
strength=0.6, # 0 完全保留原图, 1 几乎重画; 越大越自由
num_inference_steps=30,
guidance_scale=7.5,
).images[0]
image.save("img2img_out.png")看图问答(LLaVA / transformers)
from transformers import AutoProcessor, LlavaForConditionalGeneration
from PIL import Image
import torch
model = LlavaForConditionalGeneration.from_pretrained(
"llava-hf/llava-1.5-7b-hf", torch_dtype=torch.float16, device_map="cuda"
)
processor = AutoProcessor.from_pretrained("llava-hf/llava-1.5-7b-hf")
image = Image.open("chart.png")
prompt = "USER: <image>\n这张财报截图里,营收同比增长了多少? ASSISTANT:"
inputs = processor(text=prompt, images=image, return_tensors="pt").to("cuda", torch.float16)
out = model.generate(**inputs, max_new_tokens=200)
print(processor.decode(out[0], skip_special_tokens=True))CLIP 图文检索(以文搜图)
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import torch
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
images = [Image.open(p) for p in ["cat.jpg", "dog.jpg", "car.jpg"]]
texts = ["a photo of a cat", "a photo of a dog", "a photo of a car"]
inputs = processor(text=texts, images=images, return_tensors="pt", padding=True)
with torch.no_grad():
outputs = model(**inputs)
# logits_per_image[i][j] = 第 i 张图与第 j 段文字的相似度
probs = outputs.logits_per_image.softmax(dim=1)
print(probs) # 对角线应最高,说明图文正确匹配📊 真实应用案例与性能数据
一些落地场景的量级参考(数值随硬件/配置浮动,仅供建立直觉):
| 场景 | 模型/配置 | 分辨率 | 单张耗时 | 显存 | 备注 |
| --- | --- | --- | --- | --- | --- |
| 电商主图批量生成 | SDXL + LCM-LoRA, 4 步 | 1024×1024 | 约 0.6 秒 (A100) | 约 10 GB | 吞吐优先 |
| 高质量海报 | SDXL 40 步 + refiner | 1024×1024 | 约 4 秒 (A100) | 约 12 GB | 质量优先 |
| 局部重绘修图 | SD1.5 inpaint 30 步 | 512×512 | 约 1.5 秒 (3090) | 约 6 GB | 交互式 |
| 短视频生成 | CogVideoX-5B, 50 步 | 720×480, 6s | 约 90 秒 (A100) | 约 24 GB | CPU offload |
| 看图问答 | LLaVA-1.5-7B | 336px 输入 | 约 1 秒/答 | 约 16 GB | 单图 |
成本粗估:以云端 A100(约每小时 1.5~2 美元)跑 SDXL-Turbo,单张 1024 图分摊成本可低至千分之几美元;而 FLUX.1-dev 12B 40 步高质量出图,单张成本会高一个数量级。选型时务必按"质量下限 + 吞吐目标 + 显存预算"三角权衡。
🕳️ 常见坑清单
| 坑 | 现象 | 解法 |
| --- | --- | --- |
| CFG 配错采样器 | LCM/Turbo 出图糊、过曝 | 蒸馏模型 CFG 设 1~2 |
| 忘了负面提示 | 多手指、水印、畸形 | 补 negative prompt |
| 步数盲目拉高 | 变慢但没变好 | 20~30 步够用,边际递减 |
| 种子未固定 | 结果无法复现 | 用 manual_seed 固定 generator |
| VAE 缩放因子漏乘 | 图像发灰/发花 | 编码乘 0.18215,解码除回 |
| 分辨率偏离训练分布 | SD1.5 出双头、重复 | 贴近 512;SDXL 贴近 1024 |
| fp16 溢出黑图 | 输出全黑 | 换 VAE 或用 fp16-fix / bf16 |
| LoRA 权重叠太满 | 风格互相打架 | 降低 adapter_weights |
| 商用误用 dev 许可 | 合规风险 | 商用选 Apache 2.0 权重 |
✅ 最佳实践清单
📋 总结对照表
| 维度 | 生成侧(扩散) | 理解侧(多模态) |
| --- | --- | --- |
| 核心任务 | 从噪声生成内容 | 把多模态输入对齐到 LLM |
| 关键模块 | VAE + 去噪骨干 + 采样器 | 视觉编码器 + 投影层 + LLM |
| 训练目标 | 预测噪声 / flow matching | 对比学习 (InfoNCE) + 指令微调 |
| 骨干趋势 | U-Net → DiT → MMDiT | ViT + Transformer LLM |
| 提速手段 | 蒸馏 / 一致性 / 少步采样 | KV cache / 动态分辨率 |
| 代表系统 | SD3, FLUX, Sora, LCM | CLIP, LLaVA, Qwen-VL, GPT-4o |
| 主要瓶颈 | 采样步数与显存 | 视觉 token 数量与对齐质量 |
✨ 小结
扩散模型解决的是"如何从无到有生成高质量内容":通过"加噪—去噪"的迂回路径,把一个困难的生成任务拆解成大量简单的小步骤,再靠文本条件引导,让生成过程听从文字指挥。多模态技术解决的是"如何让 AI 同时理解不同形式的信息":核心思路是把图像、音频这些连续信号都转换成和文本 token 形式统一的向量序列,再借助对比学习(如 CLIP)或投影层,把不同模态的"语言"对齐、接入同一个大脑(LLM)。这两条技术路线共同支撑起了今天"文生图""看图说话""语音交互"等丰富的 AI 应用场景,也是理解 Stable Diffusion、GPT-4V、Gemini 这类系统工作原理的两把钥匙。