☰
多模态生成Backbone选型:UNet与Transformer的深度对比与SD从零搭建
2026/10/27 6:47:52 网站建设 项目流程

多模态大模型走到今天,架构选型这件事已经不像两年前那样"Transformer一把梭"了。我最近在复现一个图文生成的小项目时,重新把扩散模型这条线捋了一遍,发现很多刚入门的朋友卡在一个很具体的问题上:做多模态生成,Backbone到底该用UNet还是Transformer?这两个词在各种论文和开源项目里反复出现,但真正动手从零搭一个Stable Diffusion(下称SD)的时候,你会发现选型不是拍脑袋,而是由你要解决的任务、算力预算、以及你想不想复用现成权重共同决定的。这篇就围绕这个核心问题展开,把SD的骨架拆开给你看,顺带把从零搭建一个最小可用SD的完整路径走一遍。不管你是刚接触扩散模型的新手,还是已经跑过几个Demo但没深究内部结构的老手,应该都能从里面拿到点能直接抄的东西。

1. 先把Backbone这个词放回它该在的位置

1.1 Backbone在生成模型里到底指什么

"Backbone"这个词在不同语境下含义差别很大。在分类任务里,它通常指ResNet、ViT这类特征提取主干;但在扩散模型里,Backbone指的是承载去噪过程的主网络结构,它接收带噪的latent(或像素)和时间步信息,输出对噪声的预测。换句话说,它是整个生成流程里真正"干活"的那部分,参数量的大头都在它身上。

很多人第一次看SD的架构图会懵,因为图里既有VAE,又有UNet,还有CLIP文本编码器。这三者分工完全不同:VAE负责像素空间和latent空间之间的压缩与还原,CLIP负责把文本变成条件向量,而UNet才是那个在latent空间里一步步去噪的Backbone。所以当有人问"SD的Backbone是什么",准确答案是UNet,而不是Transformer——尽管UNet内部大量使用了Transformer的注意力机制。这个区分很关键,后面讲选型的时候会反复用到。

我见过不少初学者把SD里的Cross-Attention模块误认为是"用了Transformer做Backbone",其实那只是UNet中间层插入的注意力块,主干依然是卷积下采样加跳连的结构。搞清楚这一点,你才不会在选型时被名词带偏。

1.2 为什么这个问题现在值得单独拎出来说

放在2022年,这个问题几乎不用讨论,图像生成领域UNet是绝对主流。但到了多模态大模型时代,情况变了。DiT(Diffusion Transformer)这一类工作证明了纯Transformer结构在扩散模型里同样能打,甚至在scaling上表现更好;Sora这类视频生成模型也把Transformer架构推到了台前。于是就有了一个很现实的困惑:我从零搭SD,到底该照着经典UNet抄,还是直接上Transformer?

这个选择的背后其实是三笔账:算力账、复用账、可控账。算力账决定你能不能训得动;复用账决定你能不能站在别人肩膀上;可控账决定你调试时能不能快速定位问题。下面几节我会把这三笔账一笔笔算给你看,而不是给你一个"看情况"的万能答案。

2. UNet作为SD Backbone的结构拆解

2.1 编码器-解码器加跳连:UNet的骨架逻辑

UNet之所以叫U-Net,是因为它的结构画出来像个U:左边一路下采样把特征图尺寸压小、通道数拉大,右边一路上采样把尺寸还原,中间用跳连(skip connection)把左右两侧同尺度的特征拼起来。这个设计最初是为医学图像分割做的,但放到扩散模型里意外地合适。

原因在于去噪这个任务本身需要同时理解全局语义和局部细节。下采样路径负责抓全局——这张图整体是什么构图、什么风格;上采样路径负责还原细节——边缘、纹理、小结构。跳连则保证在下采样过程中丢失的高频信息能直接传回解码端,不至于全靠网络自己"回忆"。你可以把它理解成:编码器是"看远",解码器是"画细",跳连是"别把看远时记下的细节弄丢"。

在SD里,这个UNet还额外插了两类条件注入:时间步embedding通过加法或FiLM方式注入每个残差块,文本条件则通过Cross-Attention注入中间层。所以严格说,SD的UNet是一个条件UNet,不是原始那版纯图像输入的UNet。从零搭建时,这两条条件通路是必须自己接的,也是最容易出错的地方。

2.2 时间步与文本条件是怎么塞进去的

时间步的处理相对直接:把标量t做正弦位置编码,过两层MLP得到embedding,然后在每个ResBlock里通过scale-shift的方式调制特征。这个做法借鉴了FiLM的思想,好处是几乎不增加多少参数就能让网络感知"现在是第几步去噪"。

文本条件复杂一些。SD用的是Cross-Attention:把UNet中间层特征作为Query,把CLIP输出的文本token序列作为Key和Value,算注意力。这样每个空间位置都能"查询"文本里哪些词和自己相关。这里有个实操细节——Cross-Attention一般只插在UNet的中低分辨率层,高分辨率层不插,原因是高分辨率特征图空间位置太多,做注意力的计算量会爆炸,而且高分辨率层更需要的是局部细节而非语义对齐。

提示:自己搭的时候,Cross-Attention的插入位置直接决定显存占用。我最初图省事在所有层都插了,结果batch size只能开到1,后来按SD官方配置只在中低分辨率层插,显存立刻松了一大截。

2.3 从零写一个最小UNet的实操路径

真要动手,我建议先别追求和SD完全一致,先写一个能跑通的最小版本。核心模块就三个:ResBlock(含时间步调制)、Downsample/Upsample、Cross-Attention。下面是一个极简ResBlock的骨架,用PyTorch写:

import torch import torch.nn as nn class ResBlock(nn.Module): def __init__(self, in_ch, out_ch, temb_dim): super().__init__() self.norm1 = nn.GroupNorm(8, in_ch) self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.temb_proj = nn.Linear(temb_dim, out_ch) self.norm2 = nn.GroupNorm(8, out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity() def forward(self, x, temb): h = self.conv1(torch.nn.functional.silu(self.norm1(x))) h = h + self.temb_proj(torch.nn.functional.silu(temb))[:, :, None, None] h = self.conv2(torch.nn.functional.silu(self.norm2(h))) return h + self.skip(x)

这个块里时间步是通过广播加法注入的,比FiLM简化了一些,但足够跑通。搭完整UNet时,按通道数[320, 640, 1280, 1280]分四级,每级堆2到3个ResBlock,级间下采样,解码端对称上采样并拼接跳连特征。这套配置基本对齐SD 1.x的量级,参数量在8亿左右,单卡24G显存能训小分辨率。

我踩过的一个坑是GroupNorm的组数。一开始随手设了32组,结果通道数320时每组才10个通道,归一化效果很差,训练loss抖得厉害。后来改成8组,稳定多了。这个细节文档里很少提,但实际影响不小。

3. Transformer当Backbone:DiT路线的取舍

3.1 DiT把UNet的归纳偏置换成了什么

DiT(Diffusion Transformer)的核心思路是:既然Transformer在别的领域都能scale,扩散模型为什么不行?它把UNet的卷积下采样换成了Patchify——把latent切成一个个patch,展平成token序列,然后直接喂给标准Transformer块。跳连没了,U形结构没了,取而代之的是纯注意力的全局建模。

这个改动带来的最大变化是归纳偏置的转移。卷积天然带局部性和平移等变性,这是UNet的"先天优势";Transformer没有这些假设,一切靠数据学。好处是模型容量上限更高,scaling曲线更漂亮;坏处是同等参数量下,Transformer需要更多数据才能学到卷积"免费"给你的那些先验。

我在一个小数据集上对比过两者:UNet收敛明显更快,前几千步就能出像样的轮廓,而DiT要到更后面才开始"开窍"。这不是说DiT差,而是说它的优势要在数据和算力都充足时才显现。你要是只有几张卡、几万张图,硬上DiT大概率是给自己找罪受。

3.2 什么情况下该认真考虑Transformer

有三种情况我会建议你认真评估Transformer路线。第一种是你要做的是视频或多帧生成,时序建模用注意力比用卷积自然得多,这也是很多视频生成模型选Transformer的现实原因。第二种是你打算做超大规模训练,参数量往几十亿走的时候,Transformer的scaling行为更可预测,工程上也更容易做并行切分。第三种是你想复用现成的大语言模型权重,把文本侧和生成侧统一到一个Transformer范式里,架构上更干净。

反过来,如果你的目标是快速出一个能用的图像生成Demo、算力有限、还想直接加载社区现成的SD权重,那UNet几乎是唯一理性选择。这不是保守,是算账算出来的。

3.3 一个常被忽略的中间路线

其实还有第三条路:UNet为主干,但在中间层用Transformer块替换部分卷积块。SD本身就已经这么干了——它的UNet中间层插了Transformer块做空间自注意力和文本交叉注意力。所以"UNet还是Transformer"这个二选一本身有点伪命题,真实工程里更多是混合。

我个人的经验是,从零搭的时候先按纯UNet跑通,等baseline稳了,再逐步把中间层换成Transformer块做消融。这样每一步改动的影响都可控,出问题也知道是哪一步引入的。一上来就搞混合架构,调试成本会高很多。

4. 从零搭一个SD:完整链路与关键决策点

4.1 三个组件各司其职,别混为一谈

搭SD不是只搭Backbone,完整链路包含三块:VAE、文本编码器、去噪Backbone。VAE把512×512×3的图像压成64×64×4的latent,压缩比48倍,这一步决定了后续所有计算都在低维空间进行,是SD能在家用卡上跑的根本原因。文本编码器把prompt变成77×768的条件序列。Backbone在latent空间里做去噪。

从零搭的时候,我的建议是VAE和文本编码器直接加载预训练权重,只自己搭Backbone。原因很实在:VAE的重建质量直接决定生成图的清晰度上限,自己训一个够用的VAE成本极高;文本编码器更是如此,CLIP那种图文对齐能力不是小项目能训出来的。把精力集中在Backbone上,才是性价比最高的做法。

4.2 训练目标与噪声调度:容易被跳过的地基

扩散模型的训练目标说穿了很简单:给图像加噪,让网络预测加进去的噪声,算MSE。但噪声调度(noise schedule)的选择很讲究。SD用的是scaled linear beta schedule,beta从0.00085线性增到0.012,而且是对beta的平方根做缩放。这个细节直接影响模型在不同噪声水平上的表现。

我最初照搬了DDPM原始的linear schedule,结果生成图在高噪声阶段糊成一团。后来换成SD的scaled linear,同样的网络结构,效果肉眼可见地好了。这说明调度策略不是可有可无的超参,而是和Backbone同等重要的设计决策。从零搭的时候,这一块千万别图省事随便填。

训练循环的核心逻辑大概是这样:

# 伪代码,突出关键步骤 x0 = vae.encode(images) # 图像 -> latent t = torch.randint(0, T, (bs,)) # 随机时间步 noise = torch.randn_like(x0) xt = sqrt_alphas_cumprod[t] * x0 + sqrt_one_minus[t] * noise pred = unet(xt, t, text_emb) # Backbone预测噪声 loss = mse_loss(pred, noise)

看着简单,但每一步都有坑。比如latent要乘一个scaling factor(SD里是0.18215),这个系数是VAE latent的统计标准差倒数,不乘的话训练会不稳定。这种细节论文里一句话带过,实操时不知道就会卡很久。

4.3 采样阶段:Backbone之外的另一半工程

训练完只是第一步,采样器(sampler)的选择同样影响最终效果。DDPM采样要上千步,慢得没法用;DDIM能压到几十步;到了DPM-Solver这类高阶求解器,20步左右就能出不错的结果。这些采样器本质上都是在用Backbone的噪声预测做数值积分,Backbone不变,换采样器就能显著改变速度和质量。

我的建议是训练阶段用最简单的DDPM目标,采样阶段直接上DPM-Solver或多步DDIM。这样训练逻辑干净,采样效率也够。自己实现采样器的时候,注意classifier-free guidance的实现——它需要对条件和无条件两次前向做插值,guidance scale设太高会过饱和,设太低条件不起作用,7.5左右是个常见起点。

5. 实操中真正会卡住你的几个地方

5.1 显存与精度的现实约束

从零搭SD,第一个撞上的墙几乎都是显存。8亿参数的UNet,fp32下光权重就3G多,加上梯度和优化器状态,24G卡训512分辨率很勉强。几个实用的缓解手段:用混合精度(fp16或bf16)训练,权重和激活都能省一半;用gradient checkpointing,用计算换显存;batch size压到1到2,靠梯度累积凑等效batch。

我实测下来,bf16比fp16更稳,不容易出NaN,前提是你的卡支持。另外VAE的latent在训练时最好detach,别让梯度回传到VAE,否则显存和计算都会翻倍,而且没必要——VAE是冻结的。

5.2 条件注入的维度对齐问题

文本条件注入是最容易出维度错误的地方。CLIP输出的序列长度是77,维度768(SD 1.x),而UNet中间层特征通道可能是320或640。Cross-Attention里Query的维度要和Key/Value的维度通过投影矩阵对齐,这个投影层必须自己加,不能想当然直接算。我第一次写的时候忘了加投影,报了个维度不匹配的错,查了半天才发现是这里。

还有一个隐蔽的坑:文本编码器输出的padding token。CLIP的77长度里,实际prompt可能只占十几个token,剩下都是padding。Cross-Attention如果不做mask,padding token也会参与注意力计算,虽然影响不大但会让结果略微偏移。SD官方实现里是处理了这个的,自己写的时候留意一下。

5.3 训练不收敛时的排查顺序

训练loss不降或者生成图是噪声,排查要有顺序,别乱试。我的习惯是:先确认数据管线——latent是不是正确编码的、scaling factor乘了没;再确认噪声调度——加噪公式的系数有没有算错;然后看条件注入——把文本条件置空,看模型能不能至少学会无条件生成;最后才怀疑网络结构。

这个顺序的逻辑是从简单到复杂、从数据到模型。数据问题最常见也最好查,网络结构问题最少见但最难改。我见过有人一上来就怀疑UNet设计有问题,结果折腾一周发现是latent没乘scaling factor。这种坑,按顺序排查十分钟就能定位。

6. 选型结论:没有最优架构,只有最合适的账本

回到最初那个问题——Backbone用UNet还是Transformer。我的实际体会是,这个选择不该脱离你的具体约束来谈。算力紧、要快速出结果、想复用社区权重,UNet是理性答案;做视频、做超大规模、要统一多模态范式,Transformer值得投入。而真实工程里,混合架构往往是更务实的落点,SD本身就是最好的例子。

从零搭一个SD的过程,本质上是在理解每个设计决策背后的权衡:为什么VAE要冻结、为什么Cross-Attention只插中低层、为什么噪声调度要用scaled linear。这些"为什么"搞清楚了,你换任何Backbone都能快速上手,因为变的只是承载去噪的那个壳,不变的是扩散模型这套加噪去噪的底层逻辑。我自己搭完一遍之后最大的收获,不是得到了一个能出图的模型,而是再看到任何新的生成架构,都能一眼看出它在哪一层做了什么样的取舍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询