一条命令:
python generate.py --task t2v-A14B --size 1280*720 \
--ckpt_dir ./Wan2.2-T2V-A14B --prompt "Two anthropomorphic cats boxing"
几分钟后得到一段 81 帧、720p、16fps 的视频。这篇文章把中间发生的每一步讲透。所有代码来自 Wan2.2 仓库原文,所有数字都能从配置文件算出来,我把算式也一并写出。读完你应该能回答这些问题:
- 为什么帧数必须是 4n+1,80 帧就不行?
- 14B 这个参数量是怎么从配置里数出来的?80GB 显存门槛的账怎么算?
- flow matching 和 DDPM 在数学上差在哪,为什么前者 40 步就能出片?
--sample_shift 12拧的是什么旋钮,它和 MoE 专家切换有什么看不见的联动?- 图生视频在文生视频的骨干上到底改了哪几行?
全文按"原理 → 数据 → 网络 → 采样 → 工程"的顺序展开,每一章只用到前面章节讲过的概念,建议顺读。默认你大致知道张量和 attention 是什么;完全零基础的话,先花十分钟把这两个词查清楚再来,这十分钟值得。
三个模型,一个舞台
视频生成不是一个模型干的,是三个:
| 模型 | 作用 | 在 Wan2.2 t2v-A14B 里 |
|---|---|---|
| 文本编码器 | 把 prompt 变成向量序列 | umT5-XXL 的 encoder,约 5.7B 参数 |
| 扩散主干 | 从噪声一步步"雕"出视频的压缩表示 | WanModel,DiT 架构,40 层,14B 参数 × 2 个专家 |
| VAE | 在像素和压缩表示之间来回翻译 | Wan2_1_VAE,时空压缩比 4×8×8 |
"扩散"这个词先用三句话垫上,第一章再展开:模型学的是"从一块噪点往干净数据修"的方向;推理时从纯随机噪声出发,问模型 40 次方向、挪 40 次,噪点就逐渐显影成视频;你的 prompt 参与每一次方向判断。记住这个骨架,后面每个部件都能挂上去。
分工清楚:T5 只在开头跑一次,VAE 的 encoder 在 t2v 里根本用不上(没有输入图像)、decoder 只在结尾跑一次,真正烧算力的是中间的扩散主干,它要跑 40 步、每步两次前向(为什么是两次,第五章展开)。

三个模型怎么串起来,wan/text2video.py 的 generate() 就是剧本:编码文本 → 采样噪声 → 循环去噪 → 解码成像素。这份剧本值得先记一个骨架,后面每一章都在展开它的某一行。
先解决一个前置问题:扩散为什么不直接在像素上做?算一笔账。81 帧 720p RGB 视频有
81 × 720 × 1280 × 3 ≈ 2.24 亿个数值
经过 VAE 压缩后,latent 只有
16 × 21 × 90 × 160 ≈ 484 万个数值
少了 46 倍。扩散主干要在这块数据上反复运算 80 次前向,数据小 46 倍,attention 和卷积的开销都跟着塌下来。在压缩空间里做生成、最后一步才解码回像素,这个思路叫 latent diffusion,从 Stable Diffusion 开始成为标配,视频生成把它从 2D 推广到了 3D。

第一章 去噪走的是一条直线
先不看任何代码,把最根本的问题解决掉:为什么"从噪声里一步步修"能凭空生成一段新视频?

为什么"加噪再去噪"能生成
生成模型要解决的问题是:从"自然视频的分布"里采样一个新样本。这个分布没有解析形式,直接采样无从下手。扩散类方法的思路是修一条路:一头是自然数据,一头是标准高斯噪声(后者随便采),再训一个网络学会沿这条路从噪声端走回数据端。加噪过程就是修路,它把复杂分布和简单分布连了起来;去噪网络是在这条路的每个位置学"往数据方向该怎么走"。训练时网络见过海量"位置 → 方向"的样本,推理时从随机噪声出发按学到的方向场走,终点就是一个看起来像训练数据、但不等于任何一条训练数据的新样本。prompt 参与每一步的方向判断(具体机制在第三、四章),把终点约束在"符合这段描述"的区域里。
从 DDPM 到 flow matching
经典 DDPM 这样定义这条路:前向过程按精心设计的方差表逐步加高斯噪声,反向过程训网络预测每步加进去的噪声,采样时按贝叶斯公式一步步剥。它能 work,但路径是随机游走出来的曲线,采样要上百步,方差调度表本身也是个需要调的超参。
flow matching(Wan2.2 用的是其中最简的 rectified flow)把问题改得直白:干净数据 x₀ 和纯噪声 x₁ 之间,直接用直线插值定义中间态,
x_t = (1 - t)·x₀ + t·x₁ , t ∈ [0, 1]
沿这条直线,任意时刻的"速度"是常数:
dx_t/dt = x₁ - x₀
训练目标于是简单到能写成四行伪代码:
x0 = 采一段真实视频的 latent
x1 = 采一份标准高斯噪声
t = 均匀采一个时间 ∈ [0, 1]
loss = || model((1-t)·x0 + t·x1, t, text) - (x1 - x0) ||²
没有方差调度表,没有 ELBO 推导,回归一个向量场。仓库里只有推理代码,但训练循环就长这样,模型的全部本事来自在亿级视频片段上重复这四行。
推理时反着走:从纯噪声 x₁ 出发,沿网络预测的速度做数值积分,积回 x₀。理想情况下轨迹是直线,一步就能到;实际上网络的速度场并不完美,走一步之后所处的位置对应的真实速度已经变了,所以还是要分步走、走一步问一次路。但直线先验让弯路远少于 DDPM 的随机游走:DDPM 像在没有 GPS 的城市里凭路人指路开车,每个路口都要停;flow matching 修的路本身是直的,隔一段确认一次方向就够。40 步出片,DDPM 时代同样质量常要上百步。

代码里能直接看到这套数学。调度器文件 fm_solvers_unipc.py(第五章的主角)的核心一行:
if self.config.prediction_type == "flow_prediction":
sigma_t = self.sigmas[self.step_index]
x0_pred = sample - sigma_t * model_output
推导很短:把插值式里的 t 记作 σ,x_t = (1-σ)x₀ + σx₁,模型输出 v = x₁ - x₀,那么
x_t - σ·v = (1-σ)x₀ + σx₁ - σx₁ + σx₀ = x₀
当前样本减去 σ 倍的速度预测,恰好就是干净数据的估计。一行代码,一行代数。
一把尺子的两种刻度
后文会同时出现 σ、t、1000、40 这几个数,先把它们的关系钉死。推导里的 σ ∈ [0, 1];代码里的时间步 t 是它乘 1000 后的刻度(配置 num_train_timesteps=1000),t=875 就是 σ=0.875,同一把尺子的两种刻度,t 越大越接近噪声、越小越接近成片。训练时模型在全部 1000 个刻度上都见过样本;推理只是从尺子上挑 40 个点停靠,每个点上模型照常工作。所以步数是采样期的自由参数,改 --sample_steps 成 50 步、20 步都合法,只是积分精度不同,不需要动模型。这也是"少步蒸馏"类工作的入口:既然模型在每个刻度都会走,能不能训它一步走得更远,把 40 步压到 4 步。
第二章 VAE:视频先被压小 46 倍
原理清楚了,回头看数据。第一章的 x₀ 不是像素,是 latent——这一章讲像素和 latent 怎么互相翻译,这次翻译决定了整条链路上所有张量的形状。
wan/configs/wan_t2v_A14B.py 里两行配置:
t2v_A14B.vae_stride = (4, 8, 8) # 时间×4, 高×8, 宽×8
t2v_A14B.patch_size = (1, 2, 2) # DiT 的 patch 大小
时间维压 4 倍,空间每维压 8 倍。text2video.py 用它算 latent 形状:
target_shape = (self.vae.model.z_dim, (F - 1) // self.vae_stride[0] + 1,
size[1] // self.vae_stride[1],
size[0] // self.vae_stride[2])
代入 F=81、720×1280,得到 (16, 21, 90, 160):16 个 latent 通道、21 个"latent 帧"、90×160 的空间网格。16 个通道没有红绿蓝那样的含义,是网络自己学出来的一套"内部速记",人类不可读,够用就行。
4n+1 的由来
注意时间维的公式是 (F - 1) // 4 + 1,不是 F // 4。视频的第一帧单独编码成一个 latent 帧,之后每 4 帧压成一个。81 帧 = 1 + 80,80 恰好整除 4,得到 1 + 20 = 21 个 latent 帧。传 80 帧或 82 帧,这个除法除不尽,张量形状对不上,直接崩。frame_num 必须是 4n+1,约束的源头就在这一行——CLI 参数里那些奇怪的默认帧数(81、121、77)没有一个是拍脑袋的,全是 4n+1。

为什么第一帧要特殊?因为压缩用的是因果卷积,第一帧前面没有历史可看,只能独立成帧。这个设计还有一个副产品:一张静态图片可以看成 F=1 的视频,恰好占一个 latent 帧,图像和视频在同一个 VAE 里统一了表示,第七章讲图生视频时会用到这一点。
因果卷积和流式推理
看 vae2_1.py 里 CausalConv3d 的实现,总共十几行:
class CausalConv3d(nn.Conv3d):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._padding = (self.padding[2], self.padding[2], self.padding[1],
self.padding[1], 2 * self.padding[0], 0)
self.padding = (0, 0, 0)
def forward(self, x, cache_x=None):
padding = list(self._padding)
if cache_x is not None and self._padding[4] > 0:
cache_x = cache_x.to(x.device)
x = torch.cat([cache_x, x], dim=2)
padding[4] -= cache_x.shape[2]
x = F.pad(x, padding)
return super().forward(x)
普通 3D 卷积在时间维前后对称补零,每帧的输出会"看到"未来帧。这里把时间维的 padding 全部挪到前面,每帧输出只依赖当前和过去。读这段代码有个 PyTorch 冷知识要垫上:F.pad 的参数从最后一维往前数,前四个数管宽和高,末尾那对 (2 * self.padding[0], 0) 才是时间维——前补两倍、后补零。
因果性换来的是流式处理:视频可以切成小块依次过网络,每块处理完,缓存最后 2 帧(源文件顶部的 CACHE_T = 2)作为下一块的 cache_x 拼在前面,数学上等价于整段视频一次卷完。于是 VAE 的显存占用和视频总长解耦,5 秒和 50 秒的视频,VAE 这一段的峰值显存一样。forward 里那个 padding[4] -= cache_x.shape[2] 就是在做账:缓存顶掉了多少补零,padding 就少补多少。

网络本体是标准的分级卷积自编码器,和 Stable Diffusion 的 VAE 同宗:三次空间下采样对应 8 倍空间压缩,后两级各压一半时间轴合成 4 倍,decoder 按同样的楼梯反着爬回去。和扩散主干比它小得几乎不占账(百 M 级参数对 14B),但它决定了生成质量的上限:VAE 重建不出来的细节,扩散模型雕得再好也到不了像素。重建质量、压缩率、解码速度三角互斥,是各家视频模型暗中比拼的地方。
latent 空间的"汇率"
VAE 和扩散模型是分开训练的两个系统,对接靠一组硬编码的统计量。vae2_1.py 里:
mean = [-0.7571, -0.7089, -0.9113, ..., -0.2921] # 16 个通道各自的均值
std = [ 2.8184, 1.4541, 2.3275, ..., 1.9160] # 16 个通道各自的标准差
self.scale = [self.mean, 1.0 / self.std]
这 32 个数是在训练集上统计出来的。VAE encode 的原始输出各通道量级差异很大(std 最小 1.13、最大 3.27,差三倍),而扩散模型假设自己面对的是接近标准正态的数据,所以 latent 进扩散模型前要逐通道标准化,解码前再乘回去。这组数字是这对 VAE/扩散模型的"汇率",换一版 VAE 就全部作废。仓库里恰好还有一版仅 ti2v-5B 用的 VAE:压缩比 4×16×16、通道 48——模型小一半不够,数据也得跟着小,这是 5B 能在 24GB 消费卡上跑 121 帧的关键;两版 VAE 的 checkpoint 互不通用,根子就在汇率上。VAE 单独训练后冻结,Wan2.1 的这版 VAE 被 Wan2.2 的四个任务直接沿用。
第三章 prompt 去了哪里
去噪的每一步都要听 prompt 的话,prompt 得先变成模型能算的东西。
Wan2.2 用 umT5-XXL(Google 的多语言 T5 变体)的 encoder 编码文本,约 5.7B 参数,bf16 存放约 11.4GB——这两个数记住,第六章算显存账要用。编码的调用路径很短,t5.py 里 T5EncoderModel.__call__ 一目了然:
def __call__(self, texts, device):
ids, mask = self.tokenizer(
texts, return_mask=True, add_special_tokens=True)
ids = ids.to(device)
mask = mask.to(device)
seq_lens = mask.gt(0).sum(dim=1).long()
context = self.model(ids, mask)
return [u[:v] for u, v in zip(context, seq_lens)]
分词、编码、再按 attention mask 的真实长度逐条裁掉 padding。prompt 变成一串最长 512 个的 4096 维向量,这就是 context。它在 DiT 里的用法只有一个:每层的 cross-attention 拿视频 token 当 query、拿 context 当 key/value。文本不直接画任何像素,它在每一步去噪时持续给模型指语义方向——prompt 不是开工前下达的命令,是全程挂在墙上的施工图。

为什么用 T5 而不是早期文生图惯用的 CLIP 文本塔?CLIP 的向量擅长"这段话大概在说什么",但 77 token 的上限塞不下细致的场景描述,对数量词和空间关系也很弱;T5 按文本自身结构逐 token 编码、上限 512,配合逐 token 的 cross-attention,模型能对"左边的猫戴红色拳套"这类细节做出区分。选多语言版 umT5 还有一层考虑:中文 prompt 是一等公民,官方负向 prompt 干脆就是中文写的。
说的就是这段负向 prompt,配置里的默认值原文如下:
wan_shared_cfg.sample_neg_prompt = '色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走'
一份"常见翻车现场"清单。它的用途到第五章 CFG 再讲,这里先记住:负向 prompt 也要过一遍 T5,得到 context_null。
最后一个前置环节:用户随手写的 prompt 往往只有一句话,而模型是在长而细的描述上训练的,喂太短的 prompt 出片质量会打折。--use_prompt_extend 会先拿一个 LLM(DashScope 在线 API 或本地 Qwen2.5)把你的 prompt 扩写成带镜头、光线、动作细节的长描述,再送 T5。多卡时扩写只在 rank 0 上跑一次再广播给其他卡——LLM 生成带随机性,8 张卡各扩写一遍会得到 8 个不同的 prompt,视频就没法拼了。
第四章 DiT:75600 个 token 的注意力
零件齐了:数据在 latent 空间里(第二章),文字变成了 context(第三章),要学的目标是速度场(第一章)。现在看那台预测速度的机器,14B 参数都在这里。
进入主干之前,值得先回答一个更根本的问题:视频模型和图像模型的本质差别在哪?
答案不是"多了一个维度"这么轻描淡写。逐帧调用图像模型生成 81 张图,每帧单独看都精美,连起来播放就是一场灾难:物体形状帧帧漂移、光照跳变、背景闪烁,行话叫 flicker。时间一致性必须由模型显式建模。业界走过两条路:分解式注意力(空间 attention 和时间 attention 分开做,先让每帧内部自洽,再沿时间轴对齐,便宜但一致性有天花板)和全 3D 联合注意力(所有帧的所有 patch 放进同一个注意力里,任何 token 直接看到任何时刻的任何位置,一致性最强,代价是序列长度爆炸)。Wan2.2 走的是第二条,这个选择直接导出了本章的主角:一个 75600 token 的序列。
从 latent 到 token
latent 进 DiT 前先切 patch(这一步叫 patchify)。model.py 里就是一个步长等于核大小的 Conv3d:
self.patch_embedding = nn.Conv3d(
in_dim, dim, kernel_size=patch_size, stride=patch_size)
patch_size = (1, 2, 2):时间维不切,空间上每 2×2 的 latent 块卷成一个 5120 维的 token。数一下总量:
每个 latent 帧的 token 数 = 90 × 160 / (2×2) = 3600
总 token 数 = 3600 × 21 帧 = 75600
75600,这是生成一段 5 秒 720p 视频时 self-attention 的序列长度。SDXL 生成一张 1024 图的序列长度是 4096,视频比图像长 18 倍。attention 的计算量随序列长度平方增长,18 倍长度就是 340 倍的 attention 开销,后面讲的一切显存问题、序列并行,根源都是这个数字。
14B 是怎么数出来的
配置给出 dim=5120, ffn_dim=13824, num_layers=40。每层的参数大头:
self-attention: Q/K/V/O 四个矩阵 = 4 × 5120² ≈ 1.05 亿
cross-attention: 同样四个矩阵 = 4 × 5120² ≈ 1.05 亿
FFN: 两个矩阵 = 2 × 5120 × 13824 ≈ 1.42 亿
每层合计 ≈ 3.51 亿,× 40 层 ≈ 14.05B
"A14B"里的 14B 就是这么来的,embedding 和调制参数只占零头。bf16 每参数 2 字节,一个专家 28GB;t2v-A14B 有两个专家(第六章讲为什么),光 DiT 权重就是 56GB,加上 T5 的 11.4GB、VAE 和采样过程的激活,80GB 的单卡门槛就是这么挤满的。
一个 block 的完整前向
WanAttentionBlock.forward 是整个模型的最小重复单元,值得逐行读。读之前提醒一句:参数 e 来自时间步 t——第一章那把尺子上的刻度,999 几乎纯噪声、0 是成片——它怎么变成 e,代码后面马上讲。
def forward(self, x, e, seq_lens, grid_sizes, freqs, context, context_lens):
assert e.dtype == torch.float32
with torch.amp.autocast('cuda', dtype=torch.float32):
e = (self.modulation.unsqueeze(0) + e).chunk(6, dim=2)
assert e[0].dtype == torch.float32
# self-attention
y = self.self_attn(
self.norm1(x).float() * (1 + e[1].squeeze(2)) + e[0].squeeze(2),
seq_lens, grid_sizes, freqs)
with torch.amp.autocast('cuda', dtype=torch.float32):
x = x + y * e[2].squeeze(2)
# cross-attention & ffn function
def cross_attn_ffn(x, context, context_lens, e):
x = x + self.cross_attn(self.norm3(x), context, context_lens)
y = self.ffn(
self.norm2(x).float() * (1 + e[4].squeeze(2)) + e[3].squeeze(2))
with torch.amp.autocast('cuda', dtype=torch.float32):
x = x + y * e[5].squeeze(2)
return x
x = cross_attn_ffn(x, context, context_lens, e)
return x
三段结构:Self-Attention(75600 个 token 互相看,建立时空一致性)、Cross-Attention(看文本 context,注入语义)、FFN。真正有讲头的是那个 e,它被 chunk 成 6 份,分别是:
| 用在哪 | 作用 | |
|---|---|---|
| e[0], e[1] | self-attn 前的 norm | shift 和 scale,norm(x) * (1+scale) + shift |
| e[2] | self-attn 输出 | gate,控制这一支往残差里加多少 |
| e[3], e[4] | FFN 前的 norm | 同上一组 shift/scale |
| e[5] | FFN 输出 | gate |
这 6 组参数从哪来?标量 t(比如 927)先过标准正弦编码展开成 256 维——网络对单个标量的分辨率很差,展开后高频通道管细粒度、低频通道管粗粒度——再过 MLP 和投影:
self.time_embedding = nn.Sequential(
nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim))
self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6))
dim * 6 就是那 6 份调制。这套手法叫 AdaLN(adaptive layer norm),DiT 论文的核心贡献之一:不把 t 当 token 拼进序列,而是让 t 去调制每一层归一化的尺度、偏移和残差门控。直觉回到第一章的去噪轨迹:t=999 时输入几乎是纯噪声,网络该大刀阔斧;t=10 时画面基本成形,网络只该微调。同一套权重,靠这 6 组随 t 变化的参数切换行为模式,像同一位厨师按出锅时间自动换火候。DiT 论文对比过"t 当 token 拼接"和"AdaLN 调制"两种方案,后者在同算力下 FID 明显更好,此后成了扩散 transformer 的默认写法。

代码里还有个容易忽略的细节:所有调制运算都包在 autocast float32 里,主干可以跑 bf16,但调制这几步强制全精度。去噪是几十步的迭代过程,调制参数的数值误差会逐步累积,这是作者花显存买稳定的地方。
3D RoPE:让 token 知道自己在哪
75600 个 token 排成一维序列,attention 本身不知道谁挨着谁。位置信息由 RoPE(旋转位置编码)注入,视频版的特殊之处是坐标有三个轴。构造时把每个头的 128 维切成三段:
d = dim // num_heads # 5120 // 40 = 128
self.freqs = torch.cat([
rope_params(1024, d - 4 * (d // 6)), # 时间轴: 44 维
rope_params(1024, 2 * (d // 6)), # 高度轴: 42 维
rope_params(1024, 2 * (d // 6)) # 宽度轴: 42 维
], dim=1)
应用时,每个 token 按自己的 (f, h, w) 三个坐标取三段旋转量,拼成完整的 128 维旋转,用复数乘法一次施加到 q 和 k 上。attention 算 q·k 内积时,旋转的相对性让内积只依赖两个 token 的坐标差:整个画面平移两格,所有相对关系不变,模型学到的规律照常适用,平移不变性是白送的。这是图像 2D RoPE 向视频的直接推广,44/42/42 的分配意味着时间轴拿到的频率通道和单个空间轴差不多,时间关系和空间关系被同等对待。

细节:旋转的实现(rope_apply)全程 float64 并显式关闭 autocast,旋转是纯几何运算,半精度的相位误差在长序列上会放大,又一处花算力买数值稳定的取舍。另外 WanSelfAttention 里 q 和 k 过线性层后各接一个 RMSNorm(QK-Norm),锁住内积量级防止 softmax 饱和——推理代码里看不出它的价值,但没有它,14B 规模的 DiT 很难训稳。
一次前向的形状账
把整章串成一张表,数据从进模型到出模型的每次变形(t2v-A14B,81 帧 720p,单卡):
| 阶段 | 张量形状 | 说明 |
|---|---|---|
| 带噪 latent | 16 × 21 × 90 × 160 | 第二章 VAE 定义的空间 |
| patch_embedding(Conv3d) | 5120 × 21 × 45 × 80 | 空间 2×2 卷成一个位置 |
| 展平成序列 | 75600 × 5120 | 21×45×80 = 75600 |
| 40 × WanAttentionBlock | 75600 × 5120 | 形状不变,内容反复精炼;每层内部 q/k/v 拆成 40 头 × 128 维,RoPE 只旋转 q 和 k |
| Head | 75600 × 64 | 64 = patch 体积 (1×2×2) × 16 通道 |
| unpatchify | 16 × 21 × 90 × 160 | 折回 latent 形状 |
进什么形状,出什么形状,中间是 40 层等宽的精炼。输出的语义就是第一章的速度,和输入 latent 逐点对应:每个位置的值指示"这个位置的内容该往哪个方向挪"。文本 context(512 × 4096 经 text_embedding 投到 512 × 5120)全程只作为 cross-attention 的 key/value 存在,不占序列位置。
至此,"给一个带噪 latent 和一个 t,吐一个速度预测"这台机器就完整了。下一章看这个预测怎么被采样循环用起来。
第五章 采样循环:40 步,每步两次前向
机器每次只报一个方向,真正把视频从噪声里走出来的是围着它转的循环:选 40 个停靠点,每个点问两次路。
UniPC:怎么走得又快又稳
有了速度场,还差一个数值积分器:每步拿模型报的速度,决定往前挪多远——这个组件在代码里叫调度器(scheduler),fm_solvers_unipc.py 就是它。最朴素的积分是欧拉法,每步假设速度不变,x ← x - Δσ·v,一阶精度,速度场实际是弯的,步子越大偏得越远。Wan2.2 默认的 UniPC 是一个多步预测-校正器:predictor 用最近两步(配置 solver_order=2)的模型输出拟合速度的变化趋势,外推着走下一步;corrector 在新位置的模型输出算出来之后,回头把刚才那一步重新修一遍,不多花前向白捡一阶精度。同样 40 次模型调用,局部误差从欧拉的一阶小量降到三阶,"可接受质量"的步数预算就是这么从上百砍到几十的——视频生成每步是两次 14B 前向,省下的每一步都是真金白银。
shift:40 个停靠点落在哪
调度器还负责决定 40 个采样点的位置。set_timesteps 先均匀铺点再做 shift 变换:
sigmas = shift * sigmas / (1 + (shift - 1) * sigmas)
这个变换把点往高噪声端(σ 大的一端)挤。挤到什么程度,算中位数:
| shift | 中位时间步 |
|---|---|
| 1(不挤) | 512 |
| 5 | 840 |
| 12(t2v 默认) | 926 |
shift=12 时,一半的采样点落在 t>926 的区间里。真实的时间步序列开头长这样(shift=12、40 步,自己跑一遍 set_timesteps 能验证,输出取整成整数):
999, 997, 995, 993, 990, 988, ...
前几步的间距只有 2 上下,走得极密;到低噪声段,间距拉大到几十,几步跨完。
为什么这样分配?高噪声阶段决定构图、物体和运动走向,是"从无到有"的阶段;低噪声阶段只是磨细节。把预算倾斜给难的阶段,是视频生成比图像更需要的取舍:图像的 shift 一般取 3 左右,视频的运动一致性难得多,Wan2.2 直接拉到 12。同仓库里 i2v 用 5.0(首帧已经给定,结构压力小),ti2v-5B 用 5.0,s2v 用 3,每个任务的 shift 都在讲自己的难度分布。--sample_shift 不改变步数,只改变步数怎么分配,调它等于在"结构更稳"和"细节更精"之间挪预算——它还和下一章的专家分工咬合得很紧,数字账到那里再算。
CFG:每步为什么要跑两次前向

采样循环的核心(text2video.py):
noise_pred_cond = model(latent_model_input, t=timestep, **arg_c)[0]
noise_pred_uncond = model(latent_model_input, t=timestep, **arg_null)[0]
noise_pred = noise_pred_uncond + sample_guide_scale * (
noise_pred_cond - noise_pred_uncond)
同一个 latent、同一个 t,跑两次:一次带你的 prompt(arg_c 里是 context),一次带负向 prompt(arg_null 里是 context_null)。这就是 classifier-free guidance(CFG)。
原理值得展开一层数学。生成"符合条件 c 的样本",按贝叶斯公式拆:
p(x|c) ∝ p(x) · p(c|x)
取对数求梯度(score):
∇log p(x|c) = ∇log p(x) + ∇log p(c|x)
第一项是无条件方向(数据本身长什么样),第二项是"让 x 更像属于条件 c"的分类器梯度——早期做法真的专门训一个分类器来提供它,又贵又难训。CFG 的洞察是这一项可以白嫖:模型同时学过条件和无条件预测(训练时随机丢掉文本即可),两者相减,
∇log p(c|x) = ∇log p(x|c) - ∇log p(x) ≈ cond 预测 - uncond 预测
分类器梯度就藏在两次前向的差值里。采样时把这一项放大 w 倍:
最终方向 = uncond + w · (cond - uncond)
对照前面贴的三行代码,一模一样,sample_guide_scale 就是 w。
w 怎么选是纯粹的权衡。w=1 退化成条件生成,画面自然但常常"没听懂"prompt;w 拉高,模型被强推向"分类器更确信"的区域,构图服从性上升,代价是色彩过饱和、细节焦糊、动作僵硬,因为 latent 被推离了训练分布的高密度区。图像模型常用 7 上下,视频对时序自然度更敏感,Wan2.2 的 A14B 双专家系列只用 3.0~4.0(ti2v-5B 用 5.0,s2v 用 4.5)。
负向 prompt 在这套推导里的位置也清楚了:标准 CFG 的 uncond 用空文本,Wan2.2 换成那串"过曝、变形、三条腿"的描述,差值方向从"贴近你的 prompt"变成"贴近你的 prompt 且远离这些毛病",一次前向同时干两件事。
成本同样清楚:40 步 × 2 次前向 = 80 次 14B 模型的完整推理。视频生成慢,一半的账记在 CFG 头上。
第六章 两个 14B 专家接力
上一章的去噪循环和 CFG,默认背后是一个模型在跑。其实 A14B 系列藏着两个,这一章讲它俩怎么分工,以及这为什么直接决定了你的显卡够不够用。

Wan2.2 A14B 系列的招牌设计:两个 14B 专家分工跑不同的噪声区间。配置里:
t2v_A14B.boundary = 0.875
t2v_A14B.sample_guide_scale = (3.0, 4.0) # low noise, high noise
采样循环每步判断 t >= 0.875 × 1000:成立用 high_noise_model,否则用 low_noise_model。设计动机接着第五章的观察:高噪声阶段从混沌里定结构,低噪声阶段在半成品上磨纹理,一个网络要同时精通两件事,不如两个网络各精一件。而且这份分工从第一层就成立:高噪声专家的输入几乎是纯高斯,前几层在"从统计里猜结构";低噪声专家的输入已是半成品,前几层在"读懂现有画面"。输入分布差这么远,共享底层只会互相拖累,所以两个专家彻底独立、分别在各自噪声区间上训练,推理时按 t 接力。
这就是官方"27B 总参数、14B 激活"的含义(27B 是精确口径,第四章的 14.05B 只数了每层大头):容量是两倍,但任何一步只有一个专家在跑,算力成本等于 14B 稠密模型。别被"MoE"这个词带偏:Mixtral 那类 LLM MoE 是每层、每个 token 用学出来的 router 选专家;这里是时间步级路由、阈值写死、连 router 都没有,全部实现就是一个 if。粗粒度换来两个好处:扩散的时间步天然就是难度标签,不用训 router;两个专家可以独立训练、独立换代。
把完整的采样循环贴出来,前面各章讲的所有零件在这 20 行里合体:
for _, t in enumerate(tqdm(timesteps)):
latent_model_input = latents
timestep = [t]
timestep = torch.stack(timestep)
model = self._prepare_model_for_timestep(
t, boundary, offload_model)
sample_guide_scale = guide_scale[1] if t.item(
) >= boundary else guide_scale[0]
noise_pred_cond = model(
latent_model_input, t=timestep, **arg_c)[0]
noise_pred_uncond = model(
latent_model_input, t=timestep, **arg_null)[0]
noise_pred = noise_pred_uncond + sample_guide_scale * (
noise_pred_cond - noise_pred_uncond)
temp_x0 = sample_scheduler.step(
noise_pred.unsqueeze(0), t,
latents[0].unsqueeze(0),
return_dict=False, generator=seed_g)[0]
latents = [temp_x0.squeeze(0)]
每轮五件事:选专家、选 guide scale、两次前向、CFG 合成、调度器走一步。_prepare_model_for_timestep 里还有单卡救命的 offload 逻辑:
if offload_model or self.init_on_cpu:
if next(getattr(self, offload_model_name).parameters()).device.type == 'cuda':
getattr(self, offload_model_name).to('cpu')
if next(getattr(self, required_model_name).parameters()).device.type == 'cpu':
getattr(self, required_model_name).to(self.device)
开了 --offload_model True,每次专家切换时把下岗的搬回内存、上岗的搬上显卡。28GB 权重过一次 PCIe 要好几秒,好在整个采样过程只切换一次(t 单调下降,跨过 boundary 就不回头),这个代价一次性付清。
boundary 和 shift 的联动
这是调参时最容易看漏的一笔账。boundary=0.875 字面上像"只有 12.5% 的时间步归高噪声专家",但第五章讲过 shift 把采样点狠狠挤向高噪声端。以 t≥875 为界数一数 40 步的归属:
| shift | 高噪声专家的步数(t ≥ 875) |
|---|---|
| 1 | 5 / 40 |
| 5 | 17 / 40 |
| 12(t2v 默认) | 26 / 40,占 65% |

预算的大头给了定结构的高噪声专家,低噪声专家用剩下 14 步收尾。单独调 --sample_shift 而不动 boundary,两个专家的工作量分配会静默变化,生成质量的波动会让你摸不着头脑。两档 guide_scale 也是为分工服务的:高噪声阶段用 4.0(结构阶段贴紧 prompt),低噪声阶段降到 3.0(细节阶段少些强推,画面更自然)。
最后厘清一个高频误会:每个 timestep 只有一个专家登场,那两次前向是 CFG 的 cond/uncond,专家从不在同一步同时工作。"每步双前向"和"双专家"是两件正交的事。
显存账和三件套
重算一遍:两个专家 bf16 权重 56GB,T5 encoder 11.4GB,VAE 和激活、KV 若干,80GB 卡刚好挤下。单卡跑不动时的三件套,每一件都对着账上的一项:--offload_model True 把当前不用的专家搬回内存(砍掉 28GB 常驻)、--t5_cpu 让 T5 待在内存里只在编码时上卡(砍 11.4GB)、--convert_model_dtype 压低权重精度。
怎么组合,README 的示例命令给了官方答案:单卡跑 A14B 的示例本身就带 --offload_model True --convert_model_dtype(并标注至少 80GB 显存);4090 跑 ti2v-5B 三件全加(--t5_cpu 也上,5B 场景 T5 的 11.4GB 占比反而最大)。README 在 ti2v-5B 小节还有一句反向提示:显存超过 80GB 时把这些 flag 去掉,能明显提速,offload 不是免费的。
第七章 条件注入:图生视频改了哪几行
到这里,文生视频的完整链路你已经走通了。剩下四个任务(图生、文图、语音、动画)不是四套新模型,而是往这同一根骨干上换不同的"题目"。
加条件的方式无非三种:
- cross-attention:文本走的路,条件作为 key/value 被注意
- 通道拼接:条件张量直接和噪声 latent 在通道维拼起来,一起进 patch embedding
- AdaLN:条件混进时间步 embedding,调制每层的 norm
图生视频(i2v-A14B)用的是第二种。看 image2video.py 里怎么构造条件:
msk = torch.ones(1, F, lat_h, lat_w, device=self.device)
msk[:, 1:] = 0
msk = torch.concat([
torch.repeat_interleave(msk[:, 0:1], repeats=4, dim=1), msk[:, 1:]
], dim=1)
msk = msk.view(1, msk.shape[1] // 4, 4, lat_h, lat_w)
msk = msk.transpose(1, 2)[0]
...
y = torch.concat([msk, y])
逐行拆。msk 是"哪些帧已知"的标记:第一帧(输入图像)标 1,其余 80 帧标 0。接下来那串 repeat_interleave 加 reshape 在干一件事:把 81 帧的像素级 mask 折算到 21 帧的 latent 时间轴上。还记得第二章 VAE 的压缩规则吗,第一帧独占一个 latent 帧、之后每 4 帧并一个。mask 也要按同样的规则折叠:把第一帧复制 4 份凑齐分组,再按每 4 帧一组折起来,得到和 latent 对齐的 4×21×h×w。
y 则是输入图像本身过 VAE encoder 的结果(不足的帧用零补齐)。mask 和 y 拼起来共 20 个通道,和 16 通道的噪声 latent 在通道维再拼一次,进模型。model.py 的 forward 开头:
if y is not None:
x = [torch.cat([u, v], dim=0) for u, v in zip(x, y)]
i2v 版的 WanModel 的输入通道数是 36(16 噪声 + 16 图像 latent + 4 mask),patch embedding 的第一层卷积比 t2v 版宽,其余 40 层结构完全一样。图生视频对骨干的全部改动,就是输入通道从 16 变 36。
模型从这 36 个通道里学到的行为是:mask=1 的位置照抄给定内容,mask=0 的位置生成,并保证两者时空连贯。第一帧钉死了画面的主体和风格,后 80 帧围绕它展开运动,这就是"让图片动起来"的机制。

其他任务的条件从哪来
同样的思路读其余三个任务,重点看"条件是什么、从哪个口子进模型":
ti2v-5B(文图统一)。同一个 5B 骨干配 4×16×16 的高压缩 VAE,不改架构就同时支持 t2v 和 i2v:给了图,就把图编码后钉进 latent 的第一帧位置并用 mask 标记已知;没给图,纯噪声起步。一个模型两副面孔。
s2v-14B(语音驱动)。条件是音频:wav2vec2 把波形变成逐帧特征,按视频帧率重新分桶对齐后注入模型,驱动口型和肢体节奏。视频长度跟着音频走,超长音频切成多段自回归生成,每段拿上一段结尾几帧的 latent 当"动量条件"衔接。这是仓库里唯一的长视频方案。
animate-14B(角色动画/替换)。条件最重的任务:骨骼姿态序列驱动动作、人脸特征序列驱动表情、参考图走 CLIP 视觉编码进 cross-attention 锁定长相,替换模式下还有背景视频和 mask,多路条件各走各的口子。
条件来源五花八门,注入手法翻来覆去就是那三种:通道拼接(空间对齐的条件,如 mask、姿态、背景)、cross-attention(全局语义条件,如文本、参考图特征)、逐帧特征注入(时序对齐的条件,如音频)。看懂一个任务,其余的都是排列组合。
第八章 75600² 是个什么概念
模型怎么工作、怎么加条件,都讲完了。剩下一个纯工程的问题:这么大的计算量,单张卡扛不住怎么办?这一章回到第四章那个 75600,看它如何逼出多卡方案。
把 attention 的账算细一点。单层 self-attention 的核心计算是 QK^T 和 AV 两次矩阵乘,FLOPs 约为
2 × 2 × L² × dim = 4 × 75600² × 5120 ≈ 1.17 × 10¹⁴
(两个 2:一个是两次矩阵乘,一个是每对元素乘、加各计一次 FLOP。)一层 117 TFLOPs,40 层、80 次前向,仅 attention 一项就是 3.7 × 10¹⁷ FLOPs 量级。一张 A100 的 bf16 峰值算力约 312 TFLOPS,理想利用率下这部分也要跑二十分钟,还没算 FFN。这就是"5 秒视频要生成几分钟到几十分钟"的物理底账。
单卡扛不住,就切。Wan2.2 的主力方案是 Ulysses 序列并行,核心通信原语在 distributed/util.py,一个维度交换:
def all_to_all(x, scatter_dim, gather_dim, group=None, **kwargs):
world_size = get_world_size()
if world_size > 1:
inputs = [u.contiguous() for u in x.chunk(world_size, dim=scatter_dim)]
outputs = [torch.empty_like(u) for u in inputs]
dist.all_to_all(outputs, inputs, group=group, **kwargs)
x = torch.cat(outputs, dim=gather_dim).contiguous()
return x
attention 要求每个 token 看到完整序列,所以不能简单切序列了事;Ulysses 的答案是"切头不切序列"。8 卡跑一遍流程,跟踪张量形状(B=1,L=75600,40 头):
| 阶段 | 每卡持有 |
|---|---|
| 进入 block 前,序列被切 | L=9450,40 头 |
| all_to_all(scatter 头维, gather 序列维) | L=75600,5 头 |
| 本地 flash attention | 完整序列,1/8 的头 |
| all_to_all 换回 | L=9450,40 头 |
通信两次,换来每卡只算 1/8 的注意力头,attention 的计算和激活显存都降到 1/8。头数必须能被卡数整除(40 头配 8 卡正好),generate.py 里有对应的 assert。

序列长度这边也有一个为多卡准备的细节。text2video.py 里 seq_len 的算式尾巴上挂着一对 ceil(... / sp_size) * sp_size,把 token 数向上取整到卡数的整数倍:75600 恰好整除 8,这里不变;换个分辨率算出 75601 个 token,就补到 75608,让每张卡分到等长的切片。单卡时 sp_size=1,这段是恒等变换,多卡语境下才显形。
通信的代价也算得出来:每层 q/k/v/输出四次 all_to_all 约 3.1GB 数据,40 层 × 80 次前向累计约 10TB。听着吓人,NVLink 的卡间带宽是每秒几百 GB 量级,摊到整个生成过程里,通信时间远小于计算时间,这买卖划算;走 PCIe 的机器就不一定了,多卡加速比会明显缩水。顺带一提竞品 Ring Attention:切序列、K/V 绕圈传,不受头数整除限制但通信更复杂;Ulysses 的上限是卡数不能超过 40 个头,64 卡集群就得换方案。
参数放不下是另一条轴:FSDP(fully sharded data parallel)把模型权重切成 N 份分驻各卡,前向算到某一层时全体卡先 all-gather 把这层参数凑齐,算完立即释放。8 卡下每卡的 DiT 权重常驻从 56GB 降到 7GB,代价是每层一次 all-gather,NVLink 上这个开销可以和计算重叠掉大半。
把单卡和 8 卡的显存账并排放一起看:
| 项目 | 单卡(不 offload) | 8 卡(fsdp + ulysses) |
|---|---|---|
| 两个 DiT 专家权重(bf16) | 56 GB | 7 GB / 卡 |
| T5 encoder 权重 | 11.4 GB | 分片或 --t5_cpu 后趋近 0 |
| attention 激活 | 全序列 | 1/8 序列 |
| 结论 | 80GB 卡贴边 | 中端卡即可 |
序列并行管算力,FSDP 管显存,两者正交,--dit_fsdp --ulysses_size 8 可以同时开。README 里 8 卡命令把它们全带上,不是随手写的,每个 flag 都对着账上的一行。
第九章 模型为什么会犯它常犯的错
视频生成的典型翻车,大都能用前面的原理归因。这一章把"现象"和"机制"连上,比背 checklist 有用。
手指、文字、精细结构崩坏。两级压缩合谋的结果。VAE 把 8×8 像素压成一个 latent 位置,patchify 再把 2×2 latent 合成一个 token,一个 token 管着 16×16 像素。手指宽度经常就十几个像素,整根手指落在一两个 token 里,attention 再强也无从在 token 内部分辨五根手指的边界。文字更惨,笔画是像素级的。解药只有降低压缩比或提高分辨率,两者都直接撞算力墙,这是当前这代架构的结构性短板,不是训练数据的锅。

长视频后段漂移。t2v 一次性生成 81 帧,帧间一致性由全 3D attention 硬保证,5 秒内没问题。更长的视频(s2v 的分段自回归)靠"上一段结尾几帧"传递状态,每段传递都是有损的:误差逐段累积,角色的脸慢慢变样、衣服颜色慢慢漂。和 LLM 长文本的漂移同构,自回归的原罪。
运动幅度小、镜头呆。多重因素往同一个方向压:CFG 把 latent 推向"分类器最确信"的区域,而静态构图永远比大动作"安全";训练数据里平缓镜头占多数;负向 prompt 里"静态、静止"这些词就是在对抗这个倾向(默认负向 prompt 里"静止不动的画面"出现了,作者显然被这个问题烦过)。降 guide_scale、prompt 里写明镜头运动,都是在给运动松绑。
物理不一致(穿模、液体诡异、影子乱飘)。模型学的是"像素统计上什么样的下一帧常见",没有任何显式物理引擎。物理正确只在训练数据覆盖密集的场景里涌现,数据稀疏处(复杂遮挡、流体、镜面反射)就露馅。这是纯数据驱动路线的已知边界,业界在探索的世界模型方向,针对的就是这一层。
同 prompt 每次出片差异大。生成的起点是随机噪声,prompt 只约束"终点落在哪个语义区域",区域内部的具体样貌由 seed 决定。这是特性不是 bug:固定 seed 才能做受控对比,文末的动手实验全都要求固定 seed,原因在此。
第十章 症状对旋钮:一张调参速查表
原理都通了,把它翻译成"出片不满意时该拧哪里"。每一行的依据都在前面的章节里:
| 症状 | 该动的旋钮 | 原理出处 |
|---|---|---|
| 画面结构混乱、物体崩坏 | --sample_shift 调大,或 --sample_steps 加步 | 第五章:结构在高噪声段决定,shift 往那边倾斜预算 |
| 画面呆板、运动幅度小 | --sample_guide_scale 调低 | 第五章:w 过大把 latent 推离自然分布,动作先僵 |
| 完全不贴 prompt | --sample_guide_scale 调高;prompt 写细,或开 --use_prompt_extend | 第五章 CFG + 第三章扩写器 |
| 色彩过饱和、细节焦糊 | --sample_guide_scale 调低 | 第五章:w 过大的典型症状 |
| 细节糊但结构对 | --sample_shift 调小,给低噪声段多留几步 | 第五章:细节在低噪声段打磨 |
| 出片太慢 | --sample_steps 减步(质量换速度);多卡加 --ulysses_size | 第五章 80 次前向的账 + 第八章 |
| OOM | --offload_model True --convert_model_dtype --t5_cpu 三件套,或换 ti2v-5B | 第六章显存账 + 第二章两版 VAE |
| 复现同一段视频 | 固定 --base_seed | 噪声由 seed 决定,同 seed 同输入同输出 |
| 帧数报形状错误 | --frame_num 改成 4n+1 | 第二章 VAE 压缩规则 |
还有一条超出表格的提醒:调 --sample_shift 会静默改变两位专家的工作量分配,见第六章的联动账。
把账算完
回到开头那条命令,现在每个环节都有数了:
- prompt 经 umT5(5.7B)变成 ≤512 个 4096 维向量,负向 prompt 同样处理
- 随机噪声 latent:16 × 21 × 90 × 160,由
--base_seed决定,同 seed 可复现 - patchify 成 75600 个 5120 维 token
- 40 步 UniPC 采样,shift=12 把 26 步挤进 t≥875 的高噪声区,由高噪声专家(14B)执笔,后 14 步换低噪声专家
- 每步 2 次前向(CFG,guide_scale 高噪声档 4.0、低噪声档 3.0),全程 80 次 14B 推理,attention 计算量 10¹⁷ FLOPs 量级
- latent 经因果卷积 VAE 流式解码回 81 帧 720p,libx264 编码落盘
时间的大头是中间 80 次 14B 前向,占掉全程九成以上;T5 和 VAE 各只跑一次。所以一切提速手段都冲着采样循环去:减步数、少 CFG(蒸馏)、多卡切 attention,没有人费劲优化 T5 和 VAE,账不在那里。
这套系统里没有魔法,只有四样东西的组合:压缩器(VAE)、在压缩空间里学速度场的大网络(DiT)、走得又快又稳的数值积分(flow matching + UniPC + shift)、把语义捏进去的条件机制(cross-attention + CFG + 各任务的注入)。骨架是 2024 年之后的行业公约数,换一个视频模型(可灵、Sora、Veo)去读,零件不同、拧法雷同——各家真正的护城河在数据配方,恰好是开源仓库里看不到的部分。
如果全文只允许带走三句话,带这三句:
- 生成发生在缩略图上:视频先被 VAE 压小 46 倍,大模型全程只碰压缩表示,最后一步才放大回像素。
- 去噪是问路 40 次的直线导航:模型每次报一个方向(还要正反各问一遍取差值,这是 CFG),调度器按方向挪一步,前 26 步定结构、后 14 步磨细节,两位专家在第 875 刻度交接。
- 所有工程难题都源于一个数:75600 个 token 的平方级 attention,显存、速度、多卡方案,全是在跟它搏斗。
接下来读什么、动手做什么
论文按这条线读,每篇对应本文的一到两章:
| 论文 | 对应本文 | 一句话 |
|---|---|---|
| DDPM(Ho et al., 2020) | 第一章前半 | 扩散生成的奠基框架 |
| Flow Matching(Lipman et al., 2022)/ Rectified Flow(Liu et al., 2022) | 第一章 | 把随机曲线拉成直线 |
| Latent Diffusion(Rombach et al., 2021) | 第二章 | 为什么在压缩空间做生成 |
| DiT(Peebles & Xie, 2022) | 第四章 | transformer 扩散主干和 AdaLN |
| Classifier-Free Guidance(Ho & Salimans, 2022) | 第五章 | 两次前向的差值就是分类器梯度 |
| Wan 技术报告(arXiv:2503.20314) | 全部 | 这套东西如何组装成视频模型 |
动手路径比论文更快建立手感,推荐这个顺序:
- 用 ti2v-5B 在消费卡上跑通 demo,固定
--base_seed - 只改
--sample_shift(比如 3 / 5 / 12 各跑一遍),同 seed 对比,肉眼看"结构 vs 细节"的预算挪动 - 只改
--sample_guide_scale(1 / 3 / 7),看贴合度和过饱和的此消彼长 - 在
text2video.py的采样循环里下断点,每步打印t.item()、latents[0].std().item()和当前专家,对照第五、六章的表格,把 26/14 的分配亲手复现一遍 - 把 16 通道的 latent 任取三个通道当 RGB 画出来,看"视频的鬼影"逐步显形——"扩散在 latent 空间雕结构、VAE 补细节"从口号变成直觉
- 读
image2video.py的 mask 构造,对照第七章,然后自问:如果要做"给定首尾两帧生成中间"的任务,mask 该怎么改?业界管这个任务叫 FLF2V,Wan2.1 发布过这个变体,可以拿它的实现对答案
工程实现上这套代码还有不少和文档脱节的坑,踩坑清单在另一篇:读完 Wan2.2 全部源码之后。原理和坑,配着读正好。

微信
支付宝
评论
评论发布后会立即公开,如触发规则可能被审核下架。