# 读完 Wan2.2 全部源码之后：这套视频生成代码在哪些地方会咬你

把 Wan2.2 仓库从 `generate.py` 一路读到调度器之后,真正值得写下来的不是"这个模型怎么用",而是这份代码在你不注意的地方会怎么咬你。

Wan2.2 是阿里 Wan 团队开源的视频生成模型库,一个 `generate.py` 入口,靠 `--task` 分流成五条推理管线:文生视频、图生视频、文图统一生视频、语音生视频、角色动画。表面上看是"一个模型五种玩法",翻开代码之后更像五份平行世界,靠一层薄薄的 CLI 粘在一起。下面按读代码的顺序过一遍,坑标在原处,关键的地方直接贴源码。

这篇讲的是工程坑。想先弄懂视频生成本身的原理(VAE 压缩、flow matching、CFG、MoE 双专家的数学和数字),看姊妹篇:[跟着 Wan2.2 源码学视频生成](https://blog.leeguoo.com/zh/posts/wan22-how-video-generation-works/),两篇配着读。

![Wan2.2 全貌](https://img.leeguoo.com/media/2c77d3ee-9df8-4f63-899a-212ea30d79ff/wf-00-index.png)

## 五个任务,五套输入,五套坑

Wan2.2 是一套任务矩阵。`t2v-A14B`、`i2v-A14B`、`ti2v-5B`、`s2v-14B`、`animate-14B` 各自要求的素材完全不同:纯文本、参考图、参考图加音频、甚至一段视频加预处理产出的姿态素材包。用哪个任务、要准备什么输入,答案在 `generate.py` 的参数校验代码里,文档只写了一半。

![业务理解](https://img.leeguoo.com/media/2e3f8a01-72db-421a-a1c7-1f99ed31954f/wf-01-business-understanding.png)

先看一段 `generate.py` 里的默认值兜底逻辑:

```python
if args.prompt is None:
    args.prompt = EXAMPLE_PROMPT[args.task]["prompt"]
if args.image is None and "image" in EXAMPLE_PROMPT[args.task]:
    args.image = EXAMPLE_PROMPT[args.task]["image"]
```

不传 `--prompt`,代码不报错,静默套用写死的示例文案,一只戴墨镜的白猫冲浪。`s2v-14B` 和 `i2v-A14B` 还共用同一段示例。没传 prompt 分别跑这两个任务,出来的东西风格一样,因为两个任务都在拿官方 demo 素材空转。`--image`、`--audio`、`--tts_*` 全有类似兜底,新手第一次跑通 demo 之后误以为自己的配置生效了,这是头号误会来源。

`--size` 的语义在不同任务里对不上。`t2v`/`ti2v` 里它是分辨率,`i2v`/`s2v` 里它实际是目标像素面积,最终画面的宽高比跟着输入图片走,填 `1280*720` 不保证输出是这个尺寸。`frame_num` 必须满足 `4n+1`,三个任务的默认值还各不相同(t2v/i2v 是 81,ti2v-5B 是 121,animate-14B 是 77),背后是 VAE 时序压缩比不同,这个数字不能随手改。`Wan-Animate` 是两阶段流程:先跑独立的 `preprocess_data.py` 产出 pose/mask/reference 素材包,再拿这个目录喂给 `generate.py`。漏跑预处理、直接拿原始视频路径去跑,是这套代码最常见的翻车方式。

## 五层架构,谁粘住谁

拆开 `wan/` 包:`configs` 决定用什么权重、什么形状跑模型;`modules` 是纯网络定义,不知道"任务"这个概念;`distributed` 是给 pipeline 用的并行工具箱;`utils` 是采样数学和杂项。把它们粘起来的是 `text2video.py`、`image2video.py` 这些 pipeline 类。

![架构总览](https://img.leeguoo.com/media/e0a93afd-e50a-40a4-ab67-edd3fb237e54/wf-02-architecture-overview.png)

这一层留了几处历史包袱。`wan/distributed/__init__.py` 是个空文件,所有 pipeline 绕开它直接从子模块 import,包名只剩命名空间占位的作用。`wan/utils/__init__.py` 的 `__all__` 里写了 `HuggingfaceTokenizer`,但这个类实际从 `wan/modules/tokenizers.py` 导出,照着 `__all__` 写 `from wan.utils import HuggingfaceTokenizer` 直接 `ImportError`,一条过期没清理的声明。

还有 `import wan` 这一行的代价:它一次性拉起全部五条 pipeline 及各自依赖。只想跑 `t2v-A14B`,也会连带把 `speech2video.py` 里 TTS/CosyVoice 的重依赖拉进来。任何一个任务模块 import 出错,四个不相干的任务跟着挂。

## 一次生成会经过什么,又会在哪一步悄悄失败

以 `t2v-A14B`(MoE 双专家)为主线:`generate.py` 解析参数 → 选 pipeline 类 → 文本编码 → 扩散采样 → VAE 解码 → 存成 mp4。

![核心流程](https://img.leeguoo.com/media/949e06aa-5c0b-48c2-b8c5-d8f67104a551/wf-03-core-flow.png)

参数校验的粒度并不均匀。`i2v-A14B` 少传 `--image` 直接 `assert` 崩掉;`s2v-14B` 少传 `--audio`、`animate-14B` 少传 `video`/`pose`/`mask`,都没有对应断言,报错拖到运行期才暴露。文档写了 `frame_num` 要满足 `4n+1`,代码里没有这条 `assert`,传错帧数要等到 DiT/VAE 内部形状对不齐时才炸,报错位置离原因很远。

采样主循环里容易被误解的一点:CFG(条件 + 无条件各跑一次 DiT)叠加 MoE 双专家切换之后,每个 timestep 是"取一个专家,跑两次前向",两个专家从不在同一步登场。切换依据是 `t.item() >= boundary * num_train_timesteps`,拿调度器吐出的时间步数值直接比。这两边是独立维护的状态:换调度器,或者 `shift` 变了导致时间步数值范围漂移,专家切换点跟着偏,不报错,只能靠肉眼看质量下降。

再看落盘。`wan/utils/utils.py` 里 `save_video` 的收尾:

```python
        # write video
        writer = imageio.get_writer(
            cache_file, fps=fps, codec='libx264', quality=8)
        for frame in tensor.numpy():
            writer.append_data(frame)
        writer.close()
    except Exception as e:
        logging.info(f'save_video failed, error: {e}')
```

编码失败只打一行 `logging.info`,不 raise。主流程不知道保存失败了,继续往下跑 `merge_video_audio`,日志最后打出 `Finished`,磁盘上没有 mp4。`merge_video_audio` 里 ffmpeg 挂了同样被吞。整条链路前面全对,死在最后一步,还不吭声。

## 五份平行世界

五个任务类彼此不共享基类。`_configure_model` 这个公共骨架在五个文件里各抄了一份:`eval` + 冻结梯度 → 序列并行时给 attention 打猴子补丁 → `dist.barrier()` → FSDP 分片或搬 GPU。

![五大任务类实现对比](https://img.leeguoo.com/media/36276abd-0fb5-4450-9b42-3a8033d33d06/wf-04-task-classes.png)

抄五份,细节已经漂移。S2V 和 Animate 的版本多一行 `model.use_context_parallel = True`,另外三个没有,很难判断是刻意还是遗漏。CFG 默认开关也不统一:四个任务默认开,Animate 默认关(`guide_scale=1` 等于不跑 CFG)。对比调参时把"Animate 更快"当 bug 排查,白忙一场,那是刻意的省算力设计,docstring 里那句"仅用于表情控制,大多数情况不需要"解释了原因,但代码不会提醒你去看。

TI2V 本质是个分发器:传了图走内部 i2v 分支,没传走 t2v,共用一个模型。它有一处默认值陷阱:`WanTI2V.generate()` 签名里 `frame_num=81`,转发目标 `t2v()`/`i2v()` 的签名里却是 `frame_num=121`。直接把这个类当 Python 库用、不显式传帧数,跑出来 81 帧。CLI 用户没踩到,因为 `generate.py` 总会显式传配置里的 121,把这个默认值绕开了。

S2V 是唯一自回归分段的任务,按音频长度算出 `num_repeat`,循环生成片段,靠上一段结尾的 latent 衔接。它的 `get_gen_size` 留了"续写上一次生成结果"的接口(`pre_video_path` 参数),主流程调用时永远传 `None`。接口写好了,路没通,读代码时容易高估它的能力。

## 写了优雅降级,但没人用上

DiT 主干(`WanModel`)由重复的 Attention Block 堆叠:带 RoPE 的 Self-Attention、接文本 context 的 Cross-Attention、FFN,用 AdaLN 六路调制驱动。

![核心模型模块](https://img.leeguoo.com/media/ae45f413-59bf-4df6-849e-ead34fa31aab/wf-05-core-modules.png)

`wan/modules/attention.py` 里有一个带兜底的分发函数 `attention()`:装了 FlashAttention 就转发,没装就退到 PyTorch 的 `scaled_dot_product_attention`。但 `model.py` 第 9 行是这么导入的:

```python
from .attention import flash_attention
```

主干直接调最底层的 `flash_attention()`,而它内部没装 flash-attn 时是一句硬 `assert FLASH_ATTN_2_AVAILABLE`(attention.py:112),直接崩。兜底写了,主干没用。attention 内核还要求 `q.device.type == 'cuda'`,CPU 和 Mac 的 MPS 第一次调用就断言失败,没有提示。

VAE 的坑藏得深一层。Wan2.1 版(16 通道)和 Wan2.2 版(48 通道,多了 patchify 和双路径下采样)结构互不兼容,两套归一化 `scale` 维度不同。checkpoint 和 VAE 版本配错,报错只是 tensor shape mismatch,看不出根因。Wan2.2 VAE 的 `encode`/`decode` 还用 `try/except TypeError` 兜底非法输入,传错类型吞异常、打日志、返回 `None`,调用方在很远的地方才因为 `None` 炸;Wan2.1 版没有这层 try/except。同一个仓库,两版 VAE 两种容错脾气。

T5 这边,`umT5` 用逐层独立的相对位置编码,24 层各算一份 bias,配置里 `shared_pos=False`,不是 bug,是 umT5 的设计,但不看这行配置容易以为是共享的。风险更实际的一处:`t5.py` 和两版 VAE 加载 checkpoint 全用裸 `torch.load`,没加 `weights_only=True`,加载来路不明的权重文件时存在反序列化执行任意代码的风险。

## 显存和算力怎么分

多卡策略两条轴:FSDP 管显存(参数分片),Ulysses 序列并行管算力(激活值的序列维切给不同卡)。两者都吃 `torch.distributed` 的全局 world,没有独立的数据并行维度。一次启动的所有 GPU 要么同属一个并行组,要么单卡。

![多卡分布式推理策略](https://img.leeguoo.com/media/4a2fb674-a7d3-4cb4-8f5c-883d937d4e08/wf-06-distributed-inference.png)

Ulysses 的思路是两次转换:把 q/k/v 沿 heads 维 scatter、沿序列维 gather,每张卡拿完整序列算部分 heads,本地跑完 FlashAttention 再 all-to-all 换回来。里面埋了一个等长假设:`sp_dit_forward` 用 `torch.chunk` 切序列,`pad_freqs` 按"每卡切片等长"算位置编码。`torch.chunk` 不整除时最后一块更短。序列长度不是 `ulysses_size` 整数倍时,RoPE 频率和 token 对不上,静默出错,只表现为质量下降。

FSDP 的省显存红利也不是从头兑现的:模型先完整搬上 GPU,再交给 `shard_model` 分片,没走 meta device 初始化。分片前每张卡都得先放得下完整模型,显存紧的卡刚启动就 OOM。另外 `free_model` 摸了 FSDP 的私有属性 `_handle.flat_param.data`,torch 升级后这行随时可能断。

## shift 参数生效了两次,在两个地方

调度器(`FlowUniPCMultistepScheduler` / `FlowDPMSolverMultistepScheduler`)是把 diffusers 的调度器改成 flow-matching 版,文件头写着 "Copied from diffusers"。

![调度器与 Prompt 扩展](https://img.leeguoo.com/media/38393f5b-ab8b-479c-90f2-20673cd79709/wf-07-schedulers-and-prompt.png)

`text2video.py` 里构造 UniPC 的代码:

```python
sample_scheduler = FlowUniPCMultistepScheduler(
    num_train_timesteps=self.num_train_timesteps,
    shift=1,
    use_dynamic_shifting=False)
sample_scheduler.set_timesteps(
    sampling_steps, device=self.device, shift=shift)
```

构造函数里的 `shift=1` 是死值,真正的 `--sample_shift` 在 `set_timesteps` 里生效。DPM++ 那边路径又不一样:调用前用 `get_sampling_sigmas(steps, shift)` 手算 sigma 序列再塞进去。想调 shift 默认值或加第三种 solver,照抄一边容易漏另一边;望文生义去改构造函数参数,改了没效果。

Prompt 扩写有两个后端,DashScope 在线 API 和本地 Qwen,只在 rank 0 跑一次再广播,避免多卡重复调用。本地 Qwen 每次调用把整模型 CPU↔GPU 搬一个来回,给主模型腾显存的取舍,代价是每次扩写都有明显延迟。`DEFAULT_SYS_PROMPTS` 字典三个任务的嵌套结构互不相同,`decide_system_prompt` 靠字符串匹配硬分支,新增任务得手写新分支,嵌套写错只在运行时 `KeyError`。

## 三张分辨率表

配置层的头号命名坑:`t5_checkpoint`/`vae_checkpoint` 拼的是文件路径,`low_noise_checkpoint`/`high_noise_checkpoint` 却是传给 `from_pretrained` 的 `subfolder` 子目录名。同一个 `xxx_checkpoint` 命名,两种用法。手动整理模型目录、把子目录改名,报错是 transformers 层面"找不到 config.json",不会告诉你字段配错了。

![任务配置与模型下载](https://img.leeguoo.com/media/24e55e71-b72f-4f82-a657-cf0b0cdb0ad2/wf-08-configs-and-checkpoints.png)

分辨率白名单拆在 `SIZE_CONFIGS`、`MAX_AREA_CONFIGS`、`SUPPORTED_SIZES` 三张独立维护的表里。`ti2v-5B` 用 `704*1280`,其他任务常用 `720*1280`,两个数字长得太像,README 和社区帖子里经常混。传错了不报"分辨率不支持",报的是三张表查不到 key。新增分辨率要同时改三张表,漏一张没有任何静态检查提醒。

## 一个共同的模式

九段分析放在一起,模式浮出来了:这套代码的坑大多不是逻辑错误,是安全兜底写好了、主干没用上。`attention()` 的降级、`_validate_args` 参差不齐的断言、`frame_num` 文档要求和代码校验的错位、`shift` 的两套生效路径,全是一个模式的变体。文档、默认值、实际代码路径三者脱节,越往链路深处走,代价越隐蔽:CLI 报错,退化成运行时报错,再退化成不报错但质量悄悄变差。

接手这份代码做二次开发,一条经验管用:信代码里实际生效的路径,别信 docstring 和字段名的字面意思。同一个字段、同一个函数在不同任务里语义不同的地方,单独确认,别假设跨任务通用。
