基于 Qwen3-Omni-30B-A3B-Instruct 模型,ms-swift 4.1.0.dev0 训练框架分析。
Qwen3-Omni 处理视频+音频输入时,token 序列的整体结构为:
展开代码<|im_start|>user <|vision_start|><|audio_start|> [视频token与音频token交叉排列] <|audio_end|><|vision_end|> 用户的文本 prompt... <|im_end|> <|im_start|>assistant 模型回复... <|im_end|>
当 use_audio_in_video=true 时,视频和音频 token 被交叉合并在同一个 <|vision_start|><|audio_start|>...<|audio_end|><|vision_end|> 区域内。
| Token ID | Token 名称 | 说明 |
|---|---|---|
| 151644 | <|im_start|> | 对话轮次开始 |
| 151645 | <|im_end|> | 对话轮次结束 |
| 151652 | <|vision_start|> | 视觉区域开始 |
| 151653 | <|vision_end|> | 视觉区域结束 |
| 151655 | <|image_pad|> | 图片填充 token |
| 151656 | <|video_pad|> | 视频填充 token |
| 151669 | <|audio_start|> | 音频区域开始 |
| 151670 | <|audio_end|> | 音频区域结束 |
| 151675 | <|audio_pad|> | 音频填充 token |
temporal_patch_size=2 的含义是:将相邻 2 帧视频在时间维度上合并为 1 组,通过一个可学习的 3D 卷积提取时空特征。这不是简单的平均或拼接,而是一个有 170 万+ 参数的 Conv3d 操作。
来自 Qwen3-Omni-30B-A3B-Instruct/config.json 中的 thinker_config.vision_config:
| 参数 | 值 | 说明 |
|---|---|---|
temporal_patch_size | 2 | 每 2 帧合并为 1 个时间组 |
patch_size (spatial) | 16 | 每 16×16 像素为 1 个空间 patch |
spatial_merge_size | 2 | ViT 输出后,2×2 = 4 个相邻 token 合并为 1 个 |
hidden_size | 1152 | ViT 内部 embedding 维度 |
out_hidden_size | 2048 | 输出维度(对齐 LLM hidden_size) |
depth | 27 | ViT transformer 层数 |
in_channels | 3 | RGB 三通道 |
以一个 1280×720、30fps、81.6 秒的视频为例(FPS=2 采样后 162 帧,resize 后 384×736):
展开代码grid_t = 采样帧数 / temporal_patch_size = 162 / 2 = 81(时间格数) grid_h = 高度 / patch_size = 384 / 16 = 24(空间高度格数) grid_w = 宽度 / patch_size = 736 / 16 = 46(空间宽度格数)
将视频帧重组为 patch 向量,每个向量包含 2 帧同一空间位置的像素:
展开代码[162帧, 3通道, 384高, 736宽] ↓ 重组 [89424 个体素, 1536 维] 其中:89424 = 81 × 24 × 46(grid_t × grid_h × grid_w) 1536 = 3 × 2 × 16 × 16(通道 × 时间patch × 空间patch × 空间patch)
每个体素物理含义如下图:
展开代码帧 0(第 1 帧) 帧 1(第 2 帧) ┌──────────────┐ ┌──────────────┐ │ ┌────────┐ │ │ ┌────────┐ │ │ │ 16×16 │ │ │ │ 16×16 │ │ │ │ RGB │ │ │ │ RGB │ │ │ │ 像素块 │ │ │ │ 像素块 │ │ │ └────────┘ │ │ └────────┘ │ │ 384×736 │ │ 384×736 │ └──────────────┘ └──────────────┘ │ │ └───────────┬─────────────────┘ ↓ 1 个 3D 体素: [3通道, 2帧, 16像素, 16像素] = 1536 个数值
python展开代码Conv3d(
in_channels=3, # RGB
out_channels=1152, # ViT hidden dim
kernel_size=[2, 16, 16],# 覆盖 2帧 × 16px × 16px
stride=[2, 16, 16], # 步长=核大小,不重叠
)
展开代码[89424, 3, 2, 16, 16] → Conv3d → [89424, 1152, 1, 1, 1] → 展平 → [89424, 1152]
nn.Linear(1536, 1152)展开代码[89424, 1152] → 加位置编码 → 27 层 Transformer → [89424, 1152]
注意:ViT 的自注意力是帧内计算的。每帧 1104 个 token(24×46)互相 attend,不跨帧。跨帧信息已在 Conv3d 中融合。
spatial_merge_size=2:相邻 2×2 = 4 个空间 token 拼接后通过 MLP 降维:
展开代码[89424, 1152] → 每4个token拼接 → [22356, 4608] → MLP → [22356, 2048]
示意图:
展开代码ViT 输出(某个 grid_t 的 24×46 = 1104 个 token): ┌────┬────┬────┬────┐ │ t1 │ t2 │ t3 │ t4 │ ├────┼────┼────┼────┤ │ t5 │ t6 │ t7 │ t8 │ └────┴────┴────┴────┘ 空间合并后(12×23 = 276 个 token): ┌──────────┬──────────┐ │concat │concat │ │(t1,t2, │(t3,t4, │ │ t5,t6) │ t7,t8) │ │→MLP→2048 │→MLP→2048 │ └──────────┴──────────┘
| 步骤 | 操作 | Tensor Shape | 说明 |
|---|---|---|---|
| 0 | 原始输入 | [162, 3, 384, 736] | 162 帧 RGB |
| 1 | 帧数对齐 | [162, 3, 384, 736] | 已整除 2,无需 pad |
| 2 | 重组为体素 | [89424, 1536] | 89424 = 81×24×46 |
| 3 | Conv3d | [89424, 1152] | 2 帧融合为 1 个 token |
| 4 | 位置编码 | [89424, 1152] | 加绝对位置 + RoPE |
| 5 | 27 层 ViT | [89424, 1152] | 帧内自注意力 |
| 6 | 空间合并 | [22356, 2048] | 4 个 token 合并为 1 个 |
| 操作 | 缩减维度 | 缩减倍数 |
|---|---|---|
| temporal_patch_size=2(Conv3d) | 时间 | ÷ 2 |
| patch_size=16(Conv3d) | 空间 | ÷ 16 × ÷ 16 = ÷ 256 |
| spatial_merge_size=2(MLP) | 空间 token 数 | ÷ 4 |
| 总缩减 | — | 162帧×384×736像素 → 22356 个 token |
展开代码原始音频(44100Hz 等) ↓ 重采样 16000 Hz(Whisper 采样率) ↓ 特征提取(hop_length=160) feature_frames ≈ 音频秒数 × 100 ↓ 三层下采样编码 audio_tokens ≈ 音频秒数 × 13
python展开代码def get_audio_output_lengths(input_lengths):
"""Qwen3-Omni 音频 token 数计算"""
input_lengths_leave = input_lengths % 100 # 余数部分
feat_lengths = (input_lengths_leave - 1) // 2 + 1
output_lengths = (
((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 # 余数经三层下采样
+ (input_lengths // 100) * 13 # 完整秒部分,每秒 13 token
)
return output_lengths
以 81.593 秒音频为例:
展开代码feature_frames = int(81.593 × 100) = 8159 完整秒部分: 8159 // 100 = 81 → 81 × 13 = 1053 token 余数部分: 8159 % 100 = 59 → 经三层下采样 → 8 token 音频 token 总计: 1053 + 8 = 1061
近似规则:每秒音频 ≈ 13 个 token。
当 use_audio_in_video=true 时,视频和音频 token 通过 merge-sort(归并排序) 方式交叉排列。
核心逻辑(三个代码库 HF Transformers / ms-swift / vLLM 完全一致):
python展开代码# 1. 视频 position: 同一 grid_t 的所有 token 共享相同 position
video_positions = []
for t in range(grid_t): # t = 0, 1, 2, ..., 80
for _ in range(height * width): # 276 个空间 token
video_positions.append(t * video_second_per_grid * position_id_per_seconds)
# 2. 音频 position: 简单递增整数
audio_positions = [0, 1, 2, 3, ..., audio_len-1]
# 3. 归并:position 小的先排,相等时视频优先(<=)
v_idx, a_idx = 0, 0
while v_idx < len(video) and a_idx < len(audio):
if video_positions[v_idx] <= audio_positions[a_idx]:
输出 video_pad token; v_idx++
else:
输出 audio_pad token; a_idx++
# 输出剩余 token
在 FPS=2、temporal_patch_size=2 的配置下:
展开代码video_second_per_grid = temporal_patch_size / FPS = 2 / 2 = 1.0 秒 position_id_per_seconds = 13.0(模型固定值) 视频 position 值: grid_t=0 的 276 个 token → position = 0 × 1.0 × 13.0 = 0.0 grid_t=1 的 276 个 token → position = 1 × 1.0 × 13.0 = 13.0 grid_t=2 的 276 个 token → position = 2 × 1.0 × 13.0 = 26.0 ... 音频 position 值: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, ...]
| 比较 | video_pos | audio_pos | 结果 | 输出 |
|---|---|---|---|---|
| 1~276 | 0.0 | 0 | 0.0 <= 0 ✓ | 276 个 video_pad |
| 277 | 13.0(grid_t=1) | 0 | 13.0 <= 0 ✗ | audio_pad |
| 278 | 13.0 | 1 | 13.0 <= 1 ✗ | audio_pad |
| ... | 13.0 | 2~11 | 13.0 <= n ✗ | audio_pad |
| 289 | 13.0 | 12 | 13.0 <= 12 ✗ | audio_pad |
| 共 13 个 audio_pad |
| 比较 | video_pos | audio_pos | 结果 | 输出 |
|---|---|---|---|---|
| 290 | 13.0 | 13 | 13.0 <= 13 ✓ | video_pad |
| 291~565 | 13.0 | 13 | 13.0 <= 13 ✓ | 276 个 video_pad |
| 566 | 26.0(grid_t=2) | 13 | 26.0 <= 13 ✗ | audio_pad |
| ... | 26.0 | 14~25 | 26.0 <= n ✗ | 13 个 audio_pad |
| 比较 | video_pos | audio_pos | 结果 | 输出 |
|---|---|---|---|---|
| 26.0 | 26 | 26.0 <= 26 ✓ | 276 个 video_pad | |
| 39.0 | 26~38 | 39.0 <= n ✗ | 13 个 audio_pad |
每秒内:先排完所有视频 token,再排所有音频 token。
展开代码<|vision_start|><|audio_start|> 第 0 秒: [video_pad × 276] [audio_pad × 13] ← grid_t=0 第 1 秒: [video_pad × 276] [audio_pad × 13] ← grid_t=1 第 2 秒: [video_pad × 276] [audio_pad × 13] ← grid_t=2 ... 第80秒: [video_pad × 276] [audio_pad × 剩余] ← grid_t=80 <|audio_end|><|vision_end|>
为什么是这个顺序:
<= 让视频在平局时优先展开代码每秒视频 token: 276 每秒音频 token: ~13 每秒总计: ~289 视频占比: 276 / 289 ≈ 95.5% 音频占比: 13 / 289 ≈ 4.5%
python展开代码# qwen_omni_utils 的 smart_nframes 函数
nframes = total_frames / video_fps * sample_fps
nframes = floor_by_factor(nframes, temporal_patch_size) # 向下取 2 的倍数
python展开代码# smart_resize: 保持宽高比,H 和 W 对齐到 factor=32 的倍数
image_factor = patch_size × spatial_merge_size = 16 × 2 = 32
max_pixels = VIDEO_MAX_TOKEN_NUM × image_factor² = 300 × 1024 = 307200
resized_h, resized_w = smart_resize(
height, width,
factor=32,
min_pixels=3136,
max_pixels=307200
)
展开代码video_tokens = (nframes / temporal_patch_size) × (resized_h / patch_size) × (resized_w / patch_size) / spatial_merge_size²
简化为:
展开代码video_tokens = grid_t × grid_h × grid_w / 4 = grid_t × (resized_h / 32) × (resized_w / 32)
展开代码total_media_tokens = video_tokens + audio_tokens
视频:1280×720,30fps,2449 帧,81.6 秒。环境变量 FPS=2, VIDEO_MAX_TOKEN_NUM=300。
展开代码帧采样: 2449/30×2 = 163.27 → floor_by_factor(2) → 162 帧 grid_t: 162 / 2 = 81 Resize: 1280×720 → smart_resize(factor=32, max=307200) → 736×384 每帧 token: (736/32) × (384/32) = 23 × 12 = 276 视频 token: 81 × 276 = 22356 音频 token: get_audio_output_lengths(8159) = 1061 总计: 22356 + 1061 = 23417 ← 与训练日志完全匹配 ✓
| ms-swift(训练) | vLLM(推理) | |
|---|---|---|
| 采样公式 | floor_by_factor(total/fps×2, 2) | math.floor(duration×fps) |
| 采样帧数 | 162 | 163 |
| pad 后 | 162(已整除 2) | 164(pad 到偶数) |
| grid_t | 81 | 82 |
| 视频 token | 22356 | 22632 |
| 总 media token | 23417 | 23693 |
| 差异 | — | +276(多 1 个 grid_t) |
三个代码库(HF Transformers / ms-swift / vLLM)的归并排序算法完全一致。
两边的 smart_resize 实现一致(相同 factor 和 pixel 限制下结果相同)。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
FPS | 2.0 | 视频采样帧率 |
FPS_MAX_FRAMES | 768 | 最大采样帧数 |
FPS_MIN_FRAMES | 4 | 最小采样帧数 |
VIDEO_MAX_TOKEN_NUM | — | 每帧最大 token 数,换算为 max_pixels = N × 32² |
VIDEO_TOTAL_PIXELS | 128000×28×28×0.9 | 所有帧的总像素上限 |
USE_AUDIO_IN_VIDEO | false | 是否提取视频中的音频并交叉排列 |
ENABLE_AUDIO_OUTPUT | — | 控制音频输出 |


本文作者:Dong
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC。本作品采用《知识共享署名-非商业性使用 4.0 国际许可协议》进行许可。您可以在非商业用途下自由转载和修改,但必须注明出处并提供原作者链接。 许可协议。转载请注明出处!