# 把视频生成的账算清：Wan2.2 源码精读

一条命令:

```sh
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 步、每步两次前向(为什么是两次,第五章展开)。

![三台机器一条流水线:T5 吃 prompt 吐 context,DiT 循环 40 步,VAE 解码出 81 帧](https://img.leeguoo.com/media/ccb3b6d2-2fef-4e61-baed-93b0eb44742f/blog-three-models.png)

三个模型怎么串起来,`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。

![左边清晰的 720p 像素帧,右边同构图的模糊 latent 鬼影,小 46 倍](https://img.leeguoo.com/media/67f7c691-9f5f-4dcd-b465-8378f5ef6648/blog-latent-ghost.png)

## 第一章 去噪走的是一条直线

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

![暗房显影四个托盘:step 1 纯噪点到 step 40 清晰成片,每泡一次=两次模型前向](https://img.leeguoo.com/media/ac3f4082-f06e-434f-bc7b-c4ace1822351/blog-darkroom.png)

### 为什么"加噪再去噪"能生成

生成模型要解决的问题是:从"自然视频的分布"里采样一个新样本。这个分布没有解析形式,直接采样无从下手。扩散类方法的思路是修一条路:一头是自然数据,一头是标准高斯噪声(后者随便采),再训一个网络学会沿这条路从噪声端走回数据端。加噪过程就是修路,它把复杂分布和简单分布连了起来;去噪网络是在这条路的每个位置学"往数据方向该怎么走"。训练时网络见过海量"位置 → 方向"的样本,推理时从随机噪声出发按学到的方向场走,终点就是一个看起来像训练数据、但不等于任何一条训练数据的新样本。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 时代同样质量常要上百步。

![两张地图:DDPM 的百步弯路 vs flow matching 的 40 步直路,起点噪声终点视频](https://img.leeguoo.com/media/756fc895-1733-47cd-aa24-6f37f057873e/blog-flow-straight.png)

代码里能直接看到这套数学。调度器文件 `fm_solvers_unipc.py`(第五章的主角)的核心一行:

```python
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` 里两行配置:

```python
t2v_A14B.vae_stride = (4, 8, 8)    # 时间×4, 高×8, 宽×8
t2v_A14B.patch_size = (1, 2, 2)    # DiT 的 patch 大小
```

时间维压 4 倍,空间每维压 8 倍。`text2video.py` 用它算 latent 形状:

```python
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。

![81 帧装箱:第 1 帧单独一小盒,其余每 4 帧一大盒,共 21 个 latent 帧](https://img.leeguoo.com/media/cd608474-6107-4c16-b7ff-5bc8f43048c6/blog-vae-boxes.png)

为什么第一帧要特殊?因为压缩用的是**因果卷积**,第一帧前面没有历史可看,只能独立成帧。这个设计还有一个副产品:一张静态图片可以看成 F=1 的视频,恰好占一个 latent 帧,图像和视频在同一个 VAE 里统一了表示,第七章讲图生视频时会用到这一点。

### 因果卷积和流式推理

看 `vae2_1.py` 里 `CausalConv3d` 的实现,总共十几行:

```python
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 就少补多少。

![传送带上的分块视频过因果卷积,每块留 2 帧缓存拼给下一块,禁止偷看未来](https://img.leeguoo.com/media/418b03cc-b964-4a5e-8111-0b771f7d0507/blog-causal-conv.png)

网络本体是标准的分级卷积自编码器,和 Stable Diffusion 的 VAE 同宗:三次空间下采样对应 8 倍空间压缩,后两级各压一半时间轴合成 4 倍,decoder 按同样的楼梯反着爬回去。和扩散主干比它小得几乎不占账(百 M 级参数对 14B),但它决定了生成质量的上限:VAE 重建不出来的细节,扩散模型雕得再好也到不了像素。重建质量、压缩率、解码速度三角互斥,是各家视频模型暗中比拼的地方。

### latent 空间的"汇率"

VAE 和扩散模型是分开训练的两个系统,对接靠一组硬编码的统计量。`vae2_1.py` 里:

```python
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__` 一目了然:

```python
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 不是开工前下达的命令,是全程挂在墙上的施工图。

![工地围墙上挂着 512 token 的施工图,每个施工的视频 token 抬头看图,已到第 17/80 轮](https://img.leeguoo.com/media/de05fec8-f64b-42c6-ad71-41e850ff7922/blog-prompt-blueprint.png)

为什么用 T5 而不是早期文生图惯用的 CLIP 文本塔?CLIP 的向量擅长"这段话大概在说什么",但 77 token 的上限塞不下细致的场景描述,对数量词和空间关系也很弱;T5 按文本自身结构逐 token 编码、上限 512,配合逐 token 的 cross-attention,模型能对"左边的猫戴红色拳套"这类细节做出区分。选多语言版 umT5 还有一层考虑:中文 prompt 是一等公民,官方负向 prompt 干脆就是中文写的。

说的就是这段**负向 prompt**,配置里的默认值原文如下:

```python
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:

```python
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`,代码后面马上讲。

```python
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 和投影:

```python
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 的默认写法。

![同一位厨师:t=999 猛火,t=10 文火,灶台 6 个旋钮由时钟自动设定](https://img.leeguoo.com/media/26b32031-c570-4c8d-9a8e-9a8d8142e10f/blog-adaln-stove.png)

代码里还有个容易忽略的细节:所有调制运算都包在 `autocast float32` 里,主干可以跑 bf16,但调制这几步强制全精度。去噪是几十步的迭代过程,调制参数的数值误差会逐步累积,这是作者花显存买稳定的地方。

### 3D RoPE:让 token 知道自己在哪

75600 个 token 排成一维序列,attention 本身不知道谁挨着谁。位置信息由 RoPE(旋转位置编码)注入,视频版的特殊之处是坐标有三个轴。构造时把每个头的 128 维切成三段:

```python
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 的分配意味着时间轴拿到的频率通道和单个空间轴差不多,时间关系和空间关系被同等对待。

![电影院观众各戴 row/seat/show 三枚座位章,比对时直接读出相对距离](https://img.leeguoo.com/media/6b343842-6e4b-4352-8ba7-c4f066a04a35/blog-rope-seats.png)

细节:旋转的实现(`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 变换:

```python
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:每步为什么要跑两次前向

![两位画家:一位拿正向 prompt,一位拿负向清单,两幅画的差值放大 3.5 倍画到终稿](https://img.leeguoo.com/media/5b6d9ab6-3eff-4b80-9a95-4edc0afd168c/blog-cfg-painters.png)

采样循环的核心(`text2video.py`):

```python
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 系列藏着两个,这一章讲它俩怎么分工,以及这为什么直接决定了你的显卡够不够用。*

![两位雕塑师傅接力:大锤开料的 high-noise 专家干 1-26 步,t=875 交棒给细凿精修的 low-noise 专家,角落双层床写着 both live in VRAM](https://img.leeguoo.com/media/33e3bbc5-c6a3-400d-8f7f-34c085fe694c/blog-moe-relay.png)

Wan2.2 A14B 系列的招牌设计:两个 14B 专家分工跑不同的噪声区间。配置里:

```python
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 行里合体:

```python
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 逻辑:

```python
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%** |

![两条 0-1000 数轴:shift=1 的 40 个点均匀分布,shift=12 挤向 1000 端,875 右侧圈出 26 步](https://img.leeguoo.com/media/aa417179-78a2-4af9-99e7-019a90533fe7/blog-shift-squeeze.png)

预算的大头给了定结构的高噪声专家,低噪声专家用剩下 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 不是免费的。

## 第七章 条件注入:图生视频改了哪几行

*到这里,文生视频的完整链路你已经走通了。剩下四个任务(图生、文图、语音、动画)不是四套新模型,而是往这同一根骨干上换不同的"题目"。*

加条件的方式无非三种:

1. **cross-attention**:文本走的路,条件作为 key/value 被注意
2. **通道拼接**:条件张量直接和噪声 latent 在通道维拼起来,一起进 patch embedding
3. **AdaLN**:条件混进时间步 embedding,调制每层的 norm

图生视频(i2v-A14B)用的是第二种。看 `image2video.py` 里怎么构造条件:

```python
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 开头:

```python
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 帧围绕它展开运动,这就是"让图片动起来"的机制。

![81 格胶片:第 1 格钉着照片 mask=1,其余空格 mask=0,折叠成 21 帧 latent 加 mask 层,拼成 36 通道进 DiT](https://img.leeguoo.com/media/c2e92926-d9e9-4221-bfd1-5bd2722f248c/blog-i2v-mask.png)

### 其他任务的条件从哪来

同样的思路读其余三个任务,重点看"条件是什么、从哪个口子进模型":

**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`,一个维度交换:

```python
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。

![8 位阅卷人每人一份 75600 token 的全卷,各改自己负责的 heads,两轮传卷 all_to_all](https://img.leeguoo.com/media/a8ea4e98-e3cc-49b3-b0be-72f4dd6c4495/blog-ulysses-graders.png)

序列长度这边也有一个为多卡准备的细节。`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 内部分辨五根手指的边界。文字更惨,笔画是像素级的。解药只有降低压缩比或提高分辨率,两者都直接撞算力墙,这是当前这代架构的结构性短板,不是训练数据的锅。

![手部叠上粗网格,一格=16×16 像素,整根手指落进一格,放大镜下模型看不清格内细节](https://img.leeguoo.com/media/e4c78b08-dd40-424e-98aa-d16841774564/blog-finger-tokens.png)

**长视频后段漂移**。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)去读,零件不同、拧法雷同——各家真正的护城河在数据配方,恰好是开源仓库里看不到的部分。

如果全文只允许带走三句话,带这三句:

1. **生成发生在缩略图上**:视频先被 VAE 压小 46 倍,大模型全程只碰压缩表示,最后一步才放大回像素。
2. **去噪是问路 40 次的直线导航**:模型每次报一个方向(还要正反各问一遍取差值,这是 CFG),调度器按方向挪一步,前 26 步定结构、后 14 步磨细节,两位专家在第 875 刻度交接。
3. **所有工程难题都源于一个数**: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) | 全部 | 这套东西如何组装成视频模型 |

动手路径比论文更快建立手感,推荐这个顺序:

1. 用 ti2v-5B 在消费卡上跑通 demo,固定 `--base_seed`
2. 只改 `--sample_shift`(比如 3 / 5 / 12 各跑一遍),同 seed 对比,肉眼看"结构 vs 细节"的预算挪动
3. 只改 `--sample_guide_scale`(1 / 3 / 7),看贴合度和过饱和的此消彼长
4. 在 `text2video.py` 的采样循环里下断点,每步打印 `t.item()`、`latents[0].std().item()` 和当前专家,对照第五、六章的表格,把 26/14 的分配亲手复现一遍
5. 把 16 通道的 latent 任取三个通道当 RGB 画出来,看"视频的鬼影"逐步显形——"扩散在 latent 空间雕结构、VAE 补细节"从口号变成直觉
6. 读 `image2video.py` 的 mask 构造,对照第七章,然后自问:如果要做"给定首尾两帧生成中间"的任务,mask 该怎么改?业界管这个任务叫 FLF2V,Wan2.1 发布过这个变体,可以拿它的实现对答案

工程实现上这套代码还有不少和文档脱节的坑,踩坑清单在另一篇:[读完 Wan2.2 全部源码之后](https://blog.leeguoo.com/zh/posts/wan22-source-code-deep-dive/)。原理和坑,配着读正好。
