郭立 (leeguoo)

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

从五大任务矩阵、分层架构到 DiT/VAE/T5/分布式/调度器,逐层拆解 Wan2.2 源码里那些文档和代码脱节的真实坑点。

2026年7月16日 · 文章 · 公开

本页目录

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

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

这篇讲的是工程坑。想先弄懂视频生成本身的原理(VAE 压缩、flow matching、CFG、MoE 双专家的数学和数字),看姊妹篇:跟着 Wan2.2 源码学视频生成,两篇配着读。

Wan2.2 全貌

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

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

业务理解

先看一段 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-14Bi2v-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.pyimage2video.py 这些 pipeline 类。

架构总览

这一层留了几处历史包袱。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。

核心流程

参数校验的粒度并不均匀。i2v-A14B 少传 --image 直接 assert 崩掉;s2v-14B 少传 --audioanimate-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.pysave_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。

五大任务类实现对比

抄五份,细节已经漂移。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 六路调制驱动。

核心模型模块

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 要么同属一个并行组,要么单卡。

多卡分布式推理策略

Ulysses 的思路是两次转换:把 q/k/v 沿 heads 维 scatter、沿序列维 gather,每张卡拿完整序列算部分 heads,本地跑完 FlashAttention 再 all-to-all 换回来。里面埋了一个等长假设:sp_dit_forwardtorch.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 扩展

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_shiftset_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_pretrainedsubfolder 子目录名。同一个 xxx_checkpoint 命名,两种用法。手动整理模型目录、把子目录改名,报错是 transformers 层面"找不到 config.json",不会告诉你字段配错了。

任务配置与模型下载

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

一个共同的模式

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

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

← 上一篇
跟着 Wan2.2 源码学视频生成:从一句 prompt 到 81 帧 720p
下一篇 →
如何在 Claude Code 里用上 Codex 的 Computer Use

评论

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

最多 1000 字。