Qwen3-Omni 多模态 Token 计算深度解析:从训练到推理
2026-03-05
深度学习
00

目录

Qwen3-Omni 多模态 Token 计算深度解析:从训练到推理
附:Qwen3-Omni 训练时真正可用的视觉参数完整列表
1. 样本介绍
1.1 训练数据格式
1.2 视频基本信息
1.3 训练环境变量
1.4 训练日志中的关键信息
2. Qwen3-Omni 的特殊 Token 体系
3. ms-swift 训练:视频 Token 计算
3.1 处理链路
3.2 Step 1:帧采样(smart_nframes)
3.3 Step 2:帧分辨率缩放(smart_resize)
3.4 Step 3:计算视频 Token
4. ms-swift 训练:音频 Token 计算
4.1 音频预处理
4.2 音频 Token 下采样公式
5. 视频与音频的交叉合并(Interleaving)
5.1 为什么需要交叉?
5.2 交叉合并算法
5.3 交叉示意
6. 最终 Token 汇总与验证
6.1 Media Token
6.2 完整样本 Token
6.3 Loss 掩码
7. vLLM 推理:Token 计算对比
7.1 vLLM 的视频处理链路
7.2 帧采样差异(关键区别)
7.3 Resize 逻辑——一致
7.4 音频 Token 计算——一致
7.5 完整对比表
8. qwen-omni-utils 版本差异(0.0.9)
8.1 帧采样逻辑——不变
8.2 新增默认常量——需注意
8.3 新增 VIDEOTOTALPIXELS 计算
9. 计算公式速查
视频 Token 公式
音频 Token 公式
快速估算
10. 实践建议
10.1 训练时控制 Token 数
10.2 推理时对齐训练
10.3 Token 预算估算表
附录:完整计算验证代码

Qwen3-Omni 多模态 Token 计算深度解析:从训练到推理

本文以一个真实的视频剪辑教学样本为例,深入拆解 Qwen3-Omni 模型在 ms-swift 训练和 vLLM 推理中,视频帧采样、图像 resize、视频 token、音频 token 的完整计算过程,并对比两者的差异。

附:Qwen3-Omni 训练时真正可用的视觉参数完整列表

环境变量默认值说明
IMAGE_MAX_TOKEN_NUM16384单张图最大 token 数(控显存核心参数)
IMAGE_MIN_TOKEN_NUM4单张图最小 token 数
VIDEO_MAX_TOKEN_NUM768视频每帧最大 token 数(控显存核心参数)
VIDEO_MIN_TOKEN_NUM128视频每帧最小 token 数
FPS2.0视频抽帧 FPS
FPS_MAX_FRAMES768最大抽帧数(控显存核心参数)
FPS_MIN_FRAMES4最小抽帧数
SPATIAL_MERGE_SIZE2空间合并大小
ENABLE_AUDIO_OUTPUTNone(用config)是否开启音频输出
USE_AUDIO_IN_VIDEOFalse视频中是否启用音频

这个 moe.sh 示例脚本确实有问题MAX_PIXELSVIDEO_MAX_PIXELS 对 Qwen3-Omni 是无效的"摆设",需要改成 IMAGE_MAX_TOKEN_NUMVIDEO_MAX_TOKEN_NUM

1. 样本介绍

1.1 训练数据格式

训练数据为标准的多轮对话 JSONL 格式,每行包含 messagesvideos 字段:

json
展开代码
{ "messages": [ { "role": "user", "content": "<video>\n你是专业的视频剪辑教程分析助手。\n\n【重要】请先判断这个视频是否为剪辑软件操作教学视频..." }, { "role": "assistant", "content": "四分屏音乐卡点制作教程\n\n1) 00:10.716 导入一段视频和背景音乐\n2) 00:14.663 选中音乐轨道..." } ], "videos": [ "/mnt/cpfs/datasets/.../Chenwu教剪辑_四分屏音乐卡点教程.mp4" ] }

1.2 视频基本信息

通过 ffprobe 获取的原始视频参数:

属性
分辨率720 x 1280(竖屏)
帧率30 fps
总帧数2449
视频时长81.633 秒
视频编码H.264
音频时长81.593 秒
音频采样率44100 Hz
音频编码AAC, 双声道
文件大小9.42 MB

1.3 训练环境变量

bash
展开代码
FPS=2 # 视频采样帧率 VIDEO_MAX_TOKEN_NUM=300 # 每帧最大 token 数 USE_AUDIO_IN_VIDEO=true # 提取视频中的音频并交叉编码 ENABLE_AUDIO_OUTPUT=0 # 不启用音频输出

1.4 训练日志中的关键信息

展开代码
[INFO:swift] [INPUT] <|im_start|>user <|vision_start|><|audio_start|>[151656 * 23417]<|audio_end|><|vision_end|> ...prompt文本... <|im_start|>assistant ...response文本...<|im_end|> [INFO:swift] Dataset Token Length: 24221.314381±1296.421172, min=20200, max=25000, size=299

训练日志显示:media 区域共 23417 个 token,整个样本(含文本)约 24221 个 token


2. Qwen3-Omni 的特殊 Token 体系

Qwen3-Omni 使用一套专用的特殊 token 来标记多模态内容:

Token IDToken 名称用途
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

USE_AUDIO_IN_VIDEO=true 时,视频和音频 token 会交叉合并在同一个区域内:

展开代码
<|vision_start|><|audio_start|> [video_pad 和 audio_pad 交叉混排] <|audio_end|><|vision_end|>

日志中的 [151656 * 23417] 是简写显示,实际上这 23417 个 token 中同时包含了 video_pad (151656) 和 audio_pad (151675)


3. ms-swift 训练:视频 Token 计算

3.1 处理链路

ms-swift 中 Qwen3-Omni 的模板继承链:

展开代码
Qwen3OmniTemplate → Qwen2_5OmniTemplate → Qwen2_5VLTemplate → Qwen2VLTemplate → Template

视频处理的关键调用链:

展开代码
1. replace_tag() → 调用 qwen_omni_utils.fetch_video() 读取视频帧 2. qwen_omni_utils 内部 → smart_nframes() 计算采样帧数 → smart_resize() 缩放帧分辨率 3. _encode() → HF Processor 计算 grid_thw 和 token 展开 4. _get_new_tokens_use_audio_in_video() → 交叉合并视频和音频 token

3.2 Step 1:帧采样(smart_nframes)

qwen_omni_utilssmart_nframes() 函数决定从视频中抽取多少帧:

python
展开代码
# qwen_omni_utils/v2_5/vision_process.py def smart_nframes(ele, total_frames, video_fps): fps = ele.get("fps", FPS) # FPS=2(环境变量) min_frames = ceil_by_factor(FPS_MIN_FRAMES, FRAME_FACTOR) # 4 max_frames = floor_by_factor(min(FPS_MAX_FRAMES, total_frames), FRAME_FACTOR) # 768 nframes = total_frames / video_fps * fps # 2449 / 30 * 2 = 163.267 nframes = min(min(max(nframes, min_frames), max_frames), total_frames) # 163.267 nframes = floor_by_factor(nframes, FRAME_FACTOR) # floor(163.267 / 2) * 2 = 162 return nframes

其中 FRAME_FACTOR = 2 对应模型的 temporal_patch_size = 2

计算过程:

展开代码
原始帧数 / 原始帧率 × 采样帧率 = 2449 / 30 × 2 = 163.267 ↓ floor_by_factor(163.267, 2) = floor(163.267 / 2) × 2 = 81 × 2 = 162

结果:采样 162 帧。

这 162 帧通过 torch.linspace(0, 2448, 162) 从原始 2449 帧中均匀抽取。

3.3 Step 2:帧分辨率缩放(smart_resize)

每帧图像需要 resize 到模型能处理的尺寸,受 VIDEO_MAX_TOKEN_NUM 控制:

python
展开代码
# 模型参数 patch_size = 16 spatial_merge_size = 2 image_factor = patch_size × spatial_merge_size = 32 # 像素限制(由环境变量 VIDEO_MAX_TOKEN_NUM=300 推导) VIDEO_MAX_PIXELS = VIDEO_MAX_TOKEN_NUM × image_factor² = 300 × 1024 = 307,200
python
展开代码
def smart_resize(height, width, factor=32, min_pixels=3136, max_pixels=307200): h_bar = max(factor, round(height / factor) * factor) w_bar = max(factor, round(width / factor) * factor) if h_bar * w_bar > max_pixels: beta = sqrt((height * width) / max_pixels) h_bar = floor(height / beta / factor) * factor w_bar = floor(width / beta / factor) * factor return h_bar, w_bar

计算过程:

展开代码
原始:1280 × 720 = 921,600 像素 > max_pixels 307,200 ↓ 需要缩小 beta = sqrt(921600 / 307200) = sqrt(3.0) = 1.732 h_bar = floor(1280 / 1.732 / 32) × 32 = floor(23.1) × 32 = 23 × 32 = 736 w_bar = floor(720 / 1.732 / 32) × 32 = floor(12.99) × 32 = 12 × 32 = 384

结果:每帧 resize 为 736 × 384。

3.4 Step 3:计算视频 Token

展开代码
grid_t = nframes / temporal_patch_size = 162 / 2 = 81 tokens_per_frame = (H / image_factor) × (W / image_factor) = (736/32) × (384/32) = 23 × 12 = 276 视频 token = grid_t × tokens_per_frame = 81 × 276 = 22,356
参数说明
采样帧数162floor_by_factor 保证偶数
grid_t81时间维度 = 帧数/2
帧分辨率736 × 384smart_resize 结果
每帧 token23 × 12 = 276空间维度 token 数
视频 token 总计22,356

4. ms-swift 训练:音频 Token 计算

4.1 音频预处理

Qwen3-Omni 使用 Whisper 架构处理音频:

展开代码
原始音频:44100 Hz → 重采样到 16000 Hz (Whisper 标准) 特征提取:hop_length = 160 特征帧数 = 采样点数 / hop_length ≈ 音频时长 × 100

计算过程:

展开代码
音频时长 = 81.593 秒 特征帧数 = int(81.593 × 100) = 8159

4.2 音频 Token 下采样公式

Qwen3-Omni 对 Whisper 特征做三层下采样,将 ~100 帧/秒压缩到 ~13 token/秒:

python
展开代码
def _get_feat_extract_output_lengths(input_lengths): # 将输入分成完整的 100 帧块和余数 input_lengths_leave = input_lengths % 100 # 余数帧 # 余数部分经过三层下采样 feat_lengths = (input_lengths_leave - 1) // 2 + 1 # 第1层:÷2 output_lengths = ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 # 第2、3层 # 完整块部分:每100帧 → 13 token output_lengths += (input_lengths // 100) * 13 return output_lengths

计算过程:

展开代码
input_lengths = 8159 完整块:8159 // 100 = 81 块 → 81 × 13 = 1053 token 余数: 8159 % 100 = 59 帧 第1层下采样:(59 - 1) // 2 + 1 = 30 第2层下采样:(30 - 1) // 2 + 1 = 15 第3层下采样:(15 - 1) // 2 + 1 = 8 音频 token = 1053 + 8 = 1061

约 13 token/秒 (1061 / 81.593 ≈ 13.0)

参数
音频时长81.593 秒
Whisper 特征帧数8159
完整 100 帧块81 块 → 1053 token
余数帧(59帧)→ 8 token
音频 token 总计1061

5. 视频与音频的交叉合并(Interleaving)

5.1 为什么需要交叉?

USE_AUDIO_IN_VIDEO=true 时,模型需要同时理解视频画面和对应时间点的音频。为了让 M-RoPE 位置编码正确表示时间关系,视频和音频 token 按时间位置交叉排列。

5.2 交叉合并算法

python
展开代码
# swift/template/templates/qwen.py, _get_new_tokens_use_audio_in_video() # 计算每个视频 token 的时间位置 video_second_per_grid = temporal_patch_size / sample_fps # 2 / ~2.0 ≈ 1.0 秒 position_id_per_seconds = 13.0 # 每秒对应 13 个位置单位 # 视频 token 位置:每个 grid_t 对应一个时间点,展开为 (grid_t × H × W) 个 token video_token_indices = [i * video_second_per_grid * position_id_per_seconds for i in range(grid_t)] # 每个 grid_t 重复 H×W 次 # 音频 token 位置:从 0 开始递增 audio_token_indices = [0, 1, 2, ..., 1060] # 双指针合并(类似归并排序) result = [] v_ptr, a_ptr = 0, 0 while v_ptr < len(video) and a_ptr < len(audio): if video_position[v_ptr] <= audio_position[a_ptr]: result.append(video_pad_token) # 151656 else: result.append(audio_pad_token) # 151675 # 追加剩余的 token

5.3 交叉示意

以前几个 grid_t 为例:

展开代码
时间位置: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 ... |-------- grid_t=0 --------| |-------- grid_t=1 --------| 视频: V V V ... V (276个) V V V ... V (276个) 音频: A A A A A A A A A A A A A A A A A A A A A A A A A A

实际排列结果(简化):

展开代码
[V×276] [A×13] [V×276] [A×13] [V×276] [A×13] ...

每 1 秒的时间窗口内,先放约 276 个视频 token,再放约 13 个音频 token,形成时间对齐的交叉序列。


6. 最终 Token 汇总与验证

6.1 Media Token

展开代码
视频 token: 22,356 (95.5%) 音频 token: 1,061 ( 4.5%) ───────────────────── media 合计: 23,417 ← 与训练日志 [151656 * 23417] 完全匹配 ✓

6.2 完整样本 Token

展开代码
<|im_start|>user\n ≈ 3 token <|vision_start|><|audio_start|> = 2 token [media token 交叉混排] = 23,417 token <|audio_end|><|vision_end|>\n = 3 token ...prompt 文本... ≈ 520 token <|im_end|>\n = 2 token <|im_start|>assistant\n = 3 token ...response 文本... ≈ 269 token <|im_end|> = 1 token ───────────────────────────────────────────── 总计: ≈ 24,220 token ← 与日志均值 24221.31 吻合 ✓

6.3 Loss 掩码

训练日志中 [LABELS] 显示:

展开代码
[-100 * 24147] 四分屏音乐卡点制作教程...

前 24147 个 token(system + user + media + prompt)的 label 为 -100(不计算 loss),只有 assistant 的回复部分参与 loss 计算。


7. vLLM 推理:Token 计算对比

7.1 vLLM 的视频处理链路

vLLM 不使用 qwen_omni_utils,而是使用自己的 OpenCVVideoBackend

展开代码
1. VideoMediaIO → 接收 --media-io-kwargs 参数 2. OpenCVVideoBackend → 用 OpenCV 读取视频帧 3. HF Qwen2VLVideoProcessor → 用 transformers 的 smart_resize 缩放 4. Qwen3OmniMoeProcessor → 计算 grid_thw 和 token 展开

7.2 帧采样差异(关键区别)

vLLM 的帧采样公式:

python
展开代码
# vllm/multimodal/video.py, OpenCVVideoBackend.load_bytes() num_frames_to_sample = math.floor(duration * fps) # = math.floor(81.633 * 2) = math.floor(163.267) = 163

ms-swift/qwen_omni_utils 的帧采样公式:

python
展开代码
# qwen_omni_utils/v2_5/vision_process.py, smart_nframes() nframes = total_frames / video_fps * fps # 2449/30*2 = 163.267 nframes = floor_by_factor(nframes, 2) # floor(163.267/2)*2 = 162

核心区别:

ms-swift(训练)vLLM(推理)
帧采样函数floor_by_factor(n, 2)math.floor(duration * fps)
保证偶数
采样帧数162163(奇数)
padding无需 padding163 → pad 到 164

vLLM 使用的是 math.floor(时长 × fps),不保证结果是 temporal_patch_size=2 的倍数。当结果为奇数时,HF Processor 会 pad 1 帧,导致多出 1 个 grid_t。

7.3 Resize 逻辑——一致

vLLM 通过 --mm-processor-kwargs 传递 resize 参数,最终调用 transformers 中的 smart_resize,和 qwen_omni_utils 的实现逻辑相同:

python
展开代码
# 两者都是: smart_resize(1280, 720, factor=32, max_pixels=307200) # → 736 × 384

Resize 结果完全一致。

7.4 音频 Token 计算——一致

vLLM 中音频 token 的计算在 qwen3_omni_moe_thinker.py 中:

python
展开代码
# vLLM 音频帧数计算 num_frame = audio_length // hop_length # (细节上略有不同但结果相近) # 下采样公式与训练完全相同 def _get_feat_extract_output_lengths(input_lengths): 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 return output_lengths

音频 token 结果一致:1061。

7.5 完整对比表

项目ms-swift(训练)vLLM(推理)差异
帧采样公式floor_by_factor(n, 2)math.floor(dur*fps)不同
采样帧数162163 → pad 164+2 帧
grid_t8182+1
帧 resize736 × 384736 × 384相同
每帧 token276276相同
视频 token22,35622,632+276
音频 token1,0611,061相同
media token 合计23,41723,693+276
token 偏差率+1.2%

结论:vLLM 推理比 ms-swift 训练多 276 个 token(1 帧的 token 量),偏差约 1.2%。


8. qwen-omni-utils 版本差异(0.0.9)

8.1 帧采样逻辑——不变

smart_nframes() 的核心公式在 0.0.9 中完全不变:

python
展开代码
nframes = floor_by_factor(nframes, FRAME_FACTOR) # 依然是 floor 取偶

新增了一条 warning 日志(当 nframes > total_frames 时),不影响计算。

8.2 新增默认常量——需注意

常量旧版0.0.9说明
VIDEO_MAX_TOKEN_NUM无明确默认768每帧最大 token 数
VIDEO_MIN_TOKEN_NUM无明确默认128每帧最小 token 数
MODEL_SEQ_LEN128000新增,控制总像素预算

重要影响: 如果不设置 VIDEO_MAX_TOKEN_NUM 环境变量:

展开代码
旧版:每帧 ~300 token → resize 736×384 → 总 23,417 token 0.0.9:每帧最大 768 token → resize 1152×640 → 总 59,381 token(2.5倍!)

升级后必须确保环境变量 VIDEO_MAX_TOKEN_NUM=300 仍然设置,否则 token 数会暴涨,超出 max_length 限制。

8.3 新增 VIDEO_TOTAL_PIXELS 计算

0.0.9 中 fetch_video() 新增了基于 MODEL_SEQ_LEN 的总像素预算:

python
展开代码
total_pixels = MODEL_SEQ_LEN * image_factor² * 0.9 # = 128000 * 1024 * 0.9 = 117,964,800 max_pixels_per_frame = max( min(VIDEO_FRAME_MAX_PIXELS, total_pixels / nframes * FRAME_FACTOR), int(min_pixels * 1.05) )

这意味着:长视频的帧分辨率会被自动降低,以保证总 token 数不超出模型序列长度。


9. 计算公式速查

视频 Token 公式

展开代码
采样帧数 = floor_by_factor(总帧数 / 原始fps × 采样fps, 2) grid_t = 采样帧数 / temporal_patch_size # temporal_patch_size = 2 image_factor = patch_size × spatial_merge_size # 16 × 2 = 32 (resized_H, resized_W) = smart_resize(H, W, factor=32, max_pixels=VIDEO_MAX_TOKEN_NUM × 32²) tokens_per_frame = (resized_H / 32) × (resized_W / 32) 视频 token = grid_t × tokens_per_frame

音频 Token 公式

展开代码
feature_frames = int(音频时长 × 100) # 16kHz / hop_length(160) 完整块 token = (feature_frames // 100) × 13 # 每秒约 13 token 余数帧 = feature_frames % 100 余数 token = 三层下采样(余数帧) # 每层 (n-1)//2+1 音频 token = 完整块 token + 余数 token

快速估算

展开代码
视频 token ≈ 视频时长(秒) × tokens_per_frame # 每秒 1 个 grid_t 音频 token ≈ 音频时长(秒) × 13 总 media token ≈ 时长 × (tokens_per_frame + 13)

以本样本为例:81.6 × (276 + 13) = 81.6 × 289 ≈ 23,582,与实际值 23,417 的误差约 0.7%。


10. 实践建议

10.1 训练时控制 Token 数

  • 通过 VIDEO_MAX_TOKEN_NUM 控制每帧 token 数(降低 = 每帧分辨率更低但 token 更少)
  • 通过 FPS 控制采样帧率(降低 = 帧数更少但时间分辨率更低)
  • max_length 应设置为 预估最大 media token + 最大文本 token 的上限
  • 升级 qwen-omni-utils 后务必检查环境变量是否仍然生效

10.2 推理时对齐训练

训练和推理存在 1 帧的系统性偏差(162 vs 163 帧),原因是帧采样公式不同。对于大多数视频,差异在 1%~2% 以内。

如需严格对齐,可以考虑:

  • 在 vLLM 侧指定固定 num_frames(但需要逐视频计算)
  • 或修改 vLLM 的 OpenCVVideoBackend 加入 floor_by_factor 逻辑

10.3 Token 预算估算表

视频时长FPS=2, 每帧276 tokenFPS=2, 每帧300 token音频 token合计
30 秒8,2809,000390~9,390
60 秒16,56018,000780~18,780
80 秒22,08024,0001,040~25,040
120 秒33,12036,0001,560~37,560

附录:完整计算验证代码

python
展开代码
import math def floor_by_factor(n, f): return math.floor(n / f) * f def smart_resize(height, width, factor=32, min_pixels=3136, max_pixels=307200): h_bar = max(factor, round(height / factor) * factor) w_bar = max(factor, round(width / factor) * factor) if h_bar * w_bar > max_pixels: beta = math.sqrt((height * width) / max_pixels) h_bar = math.floor(height / beta / factor) * factor w_bar = math.floor(width / beta / factor) * factor elif h_bar * w_bar < min_pixels: beta = math.sqrt(min_pixels / (height * width)) h_bar = math.ceil(height * beta / factor) * factor w_bar = math.ceil(width * beta / factor) * factor return h_bar, w_bar def get_audio_output_lengths(input_lengths): input_lengths_leave = input_lengths % 100 feat_lengths = (input_lengths_leave - 1) // 2 + 1 output_lengths = ((feat_lengths - 1) // 2 + 1 - 1) // 2 + 1 output_lengths += (input_lengths // 100) * 13 return output_lengths # ============ 参数 ============ video_h, video_w = 1280, 720 video_fps, total_frames = 30, 2449 audio_duration = 81.593 sample_fps = 2 VIDEO_MAX_TOKEN_NUM = 300 image_factor = 32 temporal_patch_size = 2 # ============ 视频 token ============ nframes = total_frames / video_fps * sample_fps # 163.267 nframes = floor_by_factor(nframes, 2) # 162 VIDEO_MAX_PIXELS = VIDEO_MAX_TOKEN_NUM * image_factor ** 2 # 307200 rh, rw = smart_resize(video_h, video_w, image_factor, 3136, VIDEO_MAX_PIXELS) grid_t = nframes // temporal_patch_size # 81 tokens_per_frame = (rh // image_factor) * (rw // image_factor) # 23×12=276 video_tokens = grid_t * tokens_per_frame # 22356 # ============ 音频 token ============ feature_frames = int(audio_duration * 100) # 8159 audio_tokens = get_audio_output_lengths(feature_frames) # 1061 # ============ 结果 ============ total = video_tokens + audio_tokens # 23417 print(f"采样帧数: {nframes}, grid_t: {grid_t}") print(f"帧分辨率: {rh}×{rw}, 每帧token: {tokens_per_frame}") print(f"视频token: {video_tokens}, 音频token: {audio_tokens}") print(f"总计: {total}") # 23417 ✓
如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:Dong

本文链接:

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