本文以一个真实的视频剪辑教学样本为例,深入拆解 Qwen3-Omni 模型在 ms-swift 训练和 vLLM 推理中,视频帧采样、图像 resize、视频 token、音频 token 的完整计算过程,并对比两者的差异。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
IMAGE_MAX_TOKEN_NUM | 16384 | 单张图最大 token 数(控显存核心参数) |
IMAGE_MIN_TOKEN_NUM | 4 | 单张图最小 token 数 |
VIDEO_MAX_TOKEN_NUM | 768 | 视频每帧最大 token 数(控显存核心参数) |
VIDEO_MIN_TOKEN_NUM | 128 | 视频每帧最小 token 数 |
FPS | 2.0 | 视频抽帧 FPS |
FPS_MAX_FRAMES | 768 | 最大抽帧数(控显存核心参数) |
FPS_MIN_FRAMES | 4 | 最小抽帧数 |
SPATIAL_MERGE_SIZE | 2 | 空间合并大小 |
ENABLE_AUDIO_OUTPUT | None(用config) | 是否开启音频输出 |
USE_AUDIO_IN_VIDEO | False | 视频中是否启用音频 |
这个 moe.sh 示例脚本确实有问题 — MAX_PIXELS 和 VIDEO_MAX_PIXELS 对 Qwen3-Omni 是无效的"摆设",需要改成 IMAGE_MAX_TOKEN_NUM 和 VIDEO_MAX_TOKEN_NUM。
训练数据为标准的多轮对话 JSONL 格式,每行包含 messages 和 videos 字段:
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"
]
}
通过 ffprobe 获取的原始视频参数:
| 属性 | 值 |
|---|---|
| 分辨率 | 720 x 1280(竖屏) |
| 帧率 | 30 fps |
| 总帧数 | 2449 |
| 视频时长 | 81.633 秒 |
| 视频编码 | H.264 |
| 音频时长 | 81.593 秒 |
| 音频采样率 | 44100 Hz |
| 音频编码 | AAC, 双声道 |
| 文件大小 | 9.42 MB |
bash展开代码FPS=2 # 视频采样帧率
VIDEO_MAX_TOKEN_NUM=300 # 每帧最大 token 数
USE_AUDIO_IN_VIDEO=true # 提取视频中的音频并交叉编码
ENABLE_AUDIO_OUTPUT=0 # 不启用音频输出
展开代码[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。
Qwen3-Omni 使用一套专用的特殊 token 来标记多模态内容:
| 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 |
当 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)。
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
qwen_omni_utils 的 smart_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 帧中均匀抽取。
每帧图像需要 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。
展开代码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
| 参数 | 值 | 说明 |
|---|---|---|
| 采样帧数 | 162 | floor_by_factor 保证偶数 |
| grid_t | 81 | 时间维度 = 帧数/2 |
| 帧分辨率 | 736 × 384 | smart_resize 结果 |
| 每帧 token | 23 × 12 = 276 | 空间维度 token 数 |
| 视频 token 总计 | 22,356 |
Qwen3-Omni 使用 Whisper 架构处理音频:
展开代码原始音频:44100 Hz → 重采样到 16000 Hz (Whisper 标准) 特征提取:hop_length = 160 特征帧数 = 采样点数 / hop_length ≈ 音频时长 × 100
计算过程:
展开代码音频时长 = 81.593 秒 特征帧数 = int(81.593 × 100) = 8159
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 |
当 USE_AUDIO_IN_VIDEO=true 时,模型需要同时理解视频画面和对应时间点的音频。为了让 M-RoPE 位置编码正确表示时间关系,视频和音频 token 按时间位置交叉排列。
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
以前几个 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,形成时间对齐的交叉序列。
展开代码视频 token: 22,356 (95.5%) 音频 token: 1,061 ( 4.5%) ───────────────────── media 合计: 23,417 ← 与训练日志 [151656 * 23417] 完全匹配 ✓
展开代码<|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 吻合 ✓
训练日志中 [LABELS] 显示:
展开代码[-100 * 24147] 四分屏音乐卡点制作教程...
前 24147 个 token(system + user + media + prompt)的 label 为 -100(不计算 loss),只有 assistant 的回复部分参与 loss 计算。
vLLM 不使用 qwen_omni_utils,而是使用自己的 OpenCVVideoBackend:
展开代码1. VideoMediaIO → 接收 --media-io-kwargs 参数 2. OpenCVVideoBackend → 用 OpenCV 读取视频帧 3. HF Qwen2VLVideoProcessor → 用 transformers 的 smart_resize 缩放 4. Qwen3OmniMoeProcessor → 计算 grid_thw 和 token 展开
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) |
| 保证偶数 | 是 | 否 |
| 采样帧数 | 162 | 163(奇数) |
| padding | 无需 padding | 163 → pad 到 164 |
vLLM 使用的是 math.floor(时长 × fps),不保证结果是 temporal_patch_size=2 的倍数。当结果为奇数时,HF Processor 会 pad 1 帧,导致多出 1 个 grid_t。
vLLM 通过 --mm-processor-kwargs 传递 resize 参数,最终调用 transformers 中的 smart_resize,和 qwen_omni_utils 的实现逻辑相同:
python展开代码# 两者都是:
smart_resize(1280, 720, factor=32, max_pixels=307200)
# → 736 × 384
Resize 结果完全一致。
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。
| 项目 | ms-swift(训练) | vLLM(推理) | 差异 |
|---|---|---|---|
| 帧采样公式 | floor_by_factor(n, 2) | math.floor(dur*fps) | 不同 |
| 采样帧数 | 162 | 163 → pad 164 | +2 帧 |
| grid_t | 81 | 82 | +1 |
| 帧 resize | 736 × 384 | 736 × 384 | 相同 |
| 每帧 token | 276 | 276 | 相同 |
| 视频 token | 22,356 | 22,632 | +276 |
| 音频 token | 1,061 | 1,061 | 相同 |
| media token 合计 | 23,417 | 23,693 | +276 |
| token 偏差率 | — | — | +1.2% |
结论:vLLM 推理比 ms-swift 训练多 276 个 token(1 帧的 token 量),偏差约 1.2%。
smart_nframes() 的核心公式在 0.0.9 中完全不变:
python展开代码nframes = floor_by_factor(nframes, FRAME_FACTOR) # 依然是 floor 取偶
新增了一条 warning 日志(当 nframes > total_frames 时),不影响计算。
| 常量 | 旧版 | 0.0.9 | 说明 |
|---|---|---|---|
VIDEO_MAX_TOKEN_NUM | 无明确默认 | 768 | 每帧最大 token 数 |
VIDEO_MIN_TOKEN_NUM | 无明确默认 | 128 | 每帧最小 token 数 |
MODEL_SEQ_LEN | 无 | 128000 | 新增,控制总像素预算 |
重要影响: 如果不设置 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 限制。
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 数不超出模型序列长度。
展开代码采样帧数 = 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
展开代码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%。
VIDEO_MAX_TOKEN_NUM 控制每帧 token 数(降低 = 每帧分辨率更低但 token 更少)FPS 控制采样帧率(降低 = 帧数更少但时间分辨率更低)max_length 应设置为 预估最大 media token + 最大文本 token 的上限qwen-omni-utils 后务必检查环境变量是否仍然生效训练和推理存在 1 帧的系统性偏差(162 vs 163 帧),原因是帧采样公式不同。对于大多数视频,差异在 1%~2% 以内。
如需严格对齐,可以考虑:
num_frames(但需要逐视频计算)OpenCVVideoBackend 加入 floor_by_factor 逻辑| 视频时长 | FPS=2, 每帧276 token | FPS=2, 每帧300 token | 音频 token | 合计 |
|---|---|---|---|---|
| 30 秒 | 8,280 | 9,000 | 390 | ~9,390 |
| 60 秒 | 16,560 | 18,000 | 780 | ~18,780 |
| 80 秒 | 22,080 | 24,000 | 1,040 | ~25,040 |
| 120 秒 | 33,120 | 36,000 | 1,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 ✓


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