多模态输入与 Token 切分原理

给大模型输入一句话时,文字会先被 tokenizer 切成 token,再转换成词表中的整数 ID。这个过程在 LLM 是怎么预测下一个 token 的 中已经讲过。

那么给模型发一张图片、一段录音或者一段视频时,它们也会被 tokenizer 切开吗?一张 448x448 的图片有多少 token?视频提高 FPS 后,token 数为什么会增加?

这些问题容易把几种不同的东西混在一起。本文先拆开图片、音频和视频的处理链路,再运行一个真实的官方 Processor,用实测结果回答它们。

媒体最终会变成文本 token 序列吗

先说结论:媒体内容不会像文字一样,被编码成一串各自携带内容的词表 ID。 文本和媒体最终确实会进入同一条序列,但真正汇合的是向量。input_ids 中虽然也会出现媒体特殊 ID,但它们只负责占位。

这句话要从你已经理解的文本 token 继续往下讲一步。

文本 ID 还要再变成向量

文本被 tokenizer 切开后,会得到一串整数 ID:

「一只猫」 -> [「一只」, 「猫」] -> [ID_A, ID_B]

但这些 ID 只是查表用的编号,大模型不会直接拿 ID_AID_B 做计算。模型内部还有一张 embedding 表,它会根据每个 ID 查出一串小数,也就是向量:

ID_A -> [0.12, -0.38, 0.57, ...]
ID_B -> [0.44,  0.09, -0.21, ...]

所以文本的完整路线其实是:

文字 -> 文本 token -> 词表 ID -> 文本向量

你之前理解的「ID 序列」完全没错,只是还差最后这次查表。Transformer 真正接收并计算的是一串向量,而不是一串整数 ID。

媒体绕过文本词表,直接变成向量

图片走的是另一条路线。它先被切成许多小块,每个小块叫做 patch,再交给视觉编码器,得到一串图片向量:

图片 -> 图片 patch -> 视觉编码器 -> [图片向量 1, 图片向量 2, ...]

音频也类似。它先被转换成频谱上的时间片段,再由音频编码器生成一串音频向量。视频可以先理解成「采样若干帧,再把每帧切成图片 patch」,最后生成一串视频向量。

这三条路线都没有经过文本 tokenizer,也没有把画面或声音强行翻译成词表里的文字。模态编码器会把媒体向量调整到和文本向量相同的宽度,于是它们就能拼进同一条序列:

[文本向量] [图片向量 1] [图片向量 2] [图片向量 3] [文本向量]

这串向量才是最终交给语言模型计算的输入。文本和图片虽然来源不同,但进入语言模型时都已经变成相同宽度的向量。

媒体 token 就是一个媒体向量占据的位置

现在可以解释 image token、audio token 和 video token 是什么了。不过这个词在不同资料中有两种常见用法:有时指 input_ids 中的媒体特殊 ID,有时指最终向量序列中的媒体位置。

为了避免混淆,本文把前者统一叫做 placeholder,把后者叫做媒体 token。

上面那条序列中,每个文本向量占一个位置,我们习惯把它叫做一个文本 token。图片向量同样要占据序列中的位置,所以本文也把每个图片向量位置叫做一个 image token。音频和视频也是同理。

本文所说的一个 image token,就是最终向量序列中由一个图片向量占据的位置。这个向量携带图片信息,不是一个文字片段。

placeholder 是等待填入媒体向量的空位

还有一个问题:文本和媒体走的是两条处理路线,程序怎么知道图片向量应该插到文本序列的什么位置?

Qwen2.5-Omni Processor 会先在 input_ids 中重复插入 <|IMAGE|> 这个特殊 ID,相当于在模板里预留一排空位:

Processor 排好的位置:
[文字 ID] [<|IMAGE|>] [<|IMAGE|>] [<|IMAGE|>] [文字 ID]

完整模型实际计算时:
[文本向量] [图片向量 1] [图片向量 2] [图片向量 3] [文本向量]

完整模型会找到所有 <|IMAGE|> 位置,用视觉编码器生成的图片向量替换这些位置原本的 embedding。<|AUDIO|><|VIDEO|> 也是同样的作用。

所以 <|IMAGE|> 只是 placeholder,也就是占位标记。几十个位置可以全部重复同一个特殊 ID,因为图片内容根本不在这个 ID 里,而在另一条输入的像素张量和视觉编码器生成的向量里。

把这几个词压缩成四句话就是:

文本 token:文字切片,先变成词表 ID,再查表得到文本向量
媒体 token:媒体编码器生成的一个向量,占据序列中的一个位置
placeholder:告诉模型「把媒体向量填到这里」的特殊 ID
计费 token:服务商计算上下文用量和费用的计数单位

最后一项属于 API 的对外计量规则。例如 Gemini 的官方 token 文档 会分别说明图片、音频和视频如何计数。它和模型内部有多少媒体向量可能相关,但不是文本词表 ID,也不能用某个 tensor 的维度直接代替。

图片:把二维像素排成一维序列

从像素到 patch 网格

Vision Transformer 论文 提出了一种很直观的办法:把图片切成固定大小的小方块,也就是 patch,再把二维网格排成一维序列。

假设 patch 的边长是 14,一张 224x224 的图片会得到:

纵向 patch 数 = 224 / 14 = 16
横向 patch 数 = 224 / 14 = 16
patch 总数     = 16 * 16 = 256

每个 patch 都包含一小块 RGB 像素。程序会把它展平并做线性变换,得到一个固定长度的向量。这样一来,模型面对的不再是一张二维图片,而是一串沿空间排列的向量。

为什么还要做 patch merge

真实模型通常还会继续压缩。Qwen2.5-Omni 的视觉编码器会用 2x2 的 PatchMerger 压缩空间特征,Processor 则提前按照压缩后的长度,在 input_ids 中预留媒体位置。所以 256 个 patch 对应 64 个图片 placeholder:

placeholder 数 = 1 * 16 * 16 / (2 * 2) = 64

公式开头的 1grid_thw 的时间维。单张图片只有一个时间组,所以结果是 [1, 16, 16]

Processor 本身并没有运行 PatchMerger,它返回的 pixel_values 仍然保留 256 行 merge 前的 patch 输入。真正的特征合并要等完整视觉编码器运行时才会发生。

下图是程序生成的三张相同图案。细线表示 14x14 patch,粗线表示视觉编码器随后要合并的 2x2 区域。分辨率越高,同一幅画面被切出的网格越密。

分辨率提高会发生什么

实验只改变图片分辨率,得到以下结果:

分辨率image_grid_thw图片 placeholderpixel_values shape
224x224[1, 16, 16]64[256, 1176]
448x448[1, 32, 32]256[1024, 1176]
672x672[1, 48, 48]576[2304, 1176]

边长从 224 变成 448,横向和纵向的 patch 都翻倍,所以 placeholder 数变成 4 倍。边长变成 672,也就是原来的 3 倍时,placeholder 数变成 9 倍。

这就是高分辨率图片容易占用更多上下文和计算量的原因。不过具体模型可能先缩放图片、采用动态分辨率,或者使用不同的 merge 策略,所以不能只拿原图宽高套一个通用公式。

表中的 [256, 1176] 也很好地说明了边界。第一维 256 是 merge 前的 patch 网格数量;这个配置的 temporal_patch_size=2,单张图片会复制最后一帧来补足时间维,所以第二维是 3 * 2 * 14 * 14 = 1176。它表示一个时空 patch 输入展平后的数值宽度,不是本文所说的 64 个 Processor 输入位置。

音频:模型处理的不是一个个 PCM 采样点

从波形到时间窗口

一秒 16 kHz 的音频包含 16000 个 PCM 采样点。如果每个采样点都占一个序列位置,一小段录音就会非常长,而且单个瞬时振幅也很难表达音高和音色。

Whisper 为代表的音频处理链路,会先把一小段时间窗口变换到频域,再映射成 log-Mel 频谱。频谱的横轴是时间,纵轴是频率区间,颜色表示能量强弱。

本文生成的音频包含两个固定音调和一条频率逐渐升高的扫频。不同用例共享同一个确定性的波形生成公式,较长音频只是继续保留后面的时间片段:

Qwen2.5-Omni 的音频特征提取器以 16 kHz 读取波形,hop length 是 160 个采样点,因此一秒音频有 100 个有效频谱时间帧。Processor 再按自己的长度公式把这些有效帧换算成 placeholder:

时长有效频谱帧音频 placeholderinput_features shape
1 秒10025[1, 128, 30000]
2 秒20050[1, 128, 30000]
4 秒400100[1, 128, 30000]

时长翻倍,有效时间帧和 placeholder 都跟着翻倍。这里的一百个频谱帧也不是一百个原始采样点,而是一百个覆盖短时间窗口的声学特征。

为什么音频 tensor 看起来一样长

表中还有一个容易误判的现象:三段音频的 input_features shape 都是 [1, 128, 30000]。如果直接把最后一维当作 audio token 数,就会得出三段音频完全一样长的错误结论。

原因是 Processor 把特征放进固定大小的 padding 容器。真正有效的部分由 feature_attention_mask 标记,1、2、4 秒分别只有 100、200、400 个有效时间帧;后面由静音 padding 产生的填充帧不应该参与长度计算。原始波形虽然用 0 填充,但经过频谱变换和归一化后,input_features 的填充区域本身不再是数值 0。

所以看 tensor shape 时必须先问清楚:这一维表示容量、有效长度,还是特征宽度?没有 mask、grid 和处理器源码作为上下文,一个孤立的数字说明不了 token 数量。

视频:空间网格再乘上一条时间轴

视频可以先理解成一串图片

视频多了一条时间轴。最直观的处理过程是先按 FPS 采样若干帧,再把每帧切成空间 patch,最后沿时间维做合并和编码:

视频 -> 采样帧 -> 每帧的空间 patch -> 时间合并 -> 视频特征序列

因此视频长度同时受时长、FPS 和分辨率影响。如果实验时三个变量一起变,最后只能看到数字变化,却不知道是哪一个因素造成的。

本文把时间和空间拆成两组实验,每次只改变一个变量。

只提高 FPS 会发生什么

第一组固定分辨率 336x336、时长 2 秒,只把 FPS 从 1 调到 2 和 4:

FPS实际帧数video_grid_thw视频 placeholder
12[1, 24, 24]144
24[2, 24, 24]288
48[4, 24, 24]576

这个 Processor 每两个相邻帧形成一个时间网格,所以 2、4、8 帧对应的 T 分别是 1、2、4。空间网格始终是 24x24,FPS 翻倍时,placeholder 数也线性翻倍。

只提高分辨率会发生什么

第二组固定 2 FPS、2 秒,也就是始终使用 4 帧,只改变每帧分辨率:

分辨率实际帧数video_grid_thw视频 placeholder
336x3364[2, 24, 24]288
448x4484[2, 32, 32]512
672x6724[2, 48, 48]1152

时间维一直是 2,变化来自空间网格。每一维的分辨率翻倍时,空间位置会接近 4 倍,这和图片实验的规律相同。

现在也能看出视频为什么特别容易变长:大致可以把它理解为「时间位置数 * 每个时间位置的空间位置数」。真实模型还会限制最大像素数、最大帧数或采样策略,但时间和空间相乘的直觉仍然有用。

用官方 Processor 亲眼看一次完整过程

为什么选择 Qwen2.5-Omni

这里选择 Qwen2.5-Omni,不是因为它代表所有模型,也不是因为它是当前最新的多模态模型。写作本文时,官方已经发布了 Qwen3-Omni,如果目标是比较模型能力,当然应该重新按任务评测候选型号。

但本文的目标是搞清楚输入原理,Qwen2.5-Omni 是目前最简单、成本最低的观察样本之一:

  • 一套公开的官方 Qwen2_5OmniProcessor 同时处理图片、音频和视频,不需要为三种模态拼三个模型。
  • Processor、图片预处理和音频特征提取源码都能固定到 transformers==4.52.3,每一个结果都可以顺着代码核对。
  • 实验从 Qwen 模型仓库只下载 6 个 processor/tokenizer 文件,总计 4,457,233 字节,不下载任何模型权重。首次运行仍需要安装项目声明的普通 Python 依赖。
  • Processor 已经能给出真实 input_ids、媒体 placeholder、grid、mask 和输入张量,足够回答本文的问题。

换句话说,我们把它当成一台结构透明的显微镜,而不是一次模型选型推荐。这样既能观察真实模型使用的输入接口,也不会为了看预处理过程先付出下载数 GB 权重和运行完整推理的成本。

运行多文件 Playground

完整实验已经整理成下面这个多文件项目:

本机需要 Python 3.10 到 3.12、uv 和 FFmpeg。在项目目录运行:

./run.sh

程序会确定性生成 3 张图片、3 段 WAV 音频和 5 段 MP4 视频,然后处理 12 个实验用例。两组视频实验会复用同一个基线视频,所以素材总数是 11 个。

依次生成素材、调用官方 Processor、绘制四张可视化结果并执行断言。素材生成逻辑集中在 中,每组用例只改变分辨率、时长或 FPS 中的一个变量。

处理单个用例的核心在 。它先调用 Processor,再完整保存 input_ids,找出媒体特殊 ID 出现的位置,同时记录 grid、mask 和媒体 tensor shape。

例如 224x224 图片的关键输出是:

input_ids.shape                 = [1, 118]
non_media_input_id_positions   = 54
media_placeholder_token        = <|IMAGE|>
media_placeholder_token_id     = 151655
media_placeholder_positions    = [44, 45, ..., 107]
media_placeholder_count        = 64
image_grid_thw                 = [1, 16, 16]
pixel_values.shape             = [256, 1176]

64 个媒体位置全部是同一个整数 151655。这说明 input_ids 里的重复数字只是图片内容将要占据的位置,并不是 64 个不同的「视觉单词」。图片像素经过预处理后保存在独立的 pixel_values 浮点张量中。

12 个用例使用相同的文字指令;在这个固定版本的 chat template 中,三种模态的包装 token 数也恰好相同,因此非媒体位置始终是 54 个。总 input_ids 长度的变化只来自 Processor 展开的媒体 placeholder,这也让对照结果更容易解释。

项目末尾的 会检查变量是否严格递增、placeholder 是否连续、grid 公式是否成立、两组视频基线是否完全一致,并确认下载目录中不存在模型权重文件。完整 ID 和所有 shape 则保存在项目的 results/ 目录中。

顺着源码找到关键证据

实验固定使用 Transformers 4.52.3 的 Qwen2.5-Omni Processor 源码。其中图片和视频 placeholder 的数量来自 grid_thw 的乘积,再除以 merge_size**2;音频 placeholder 则根据 feature_attention_mask 中的有效长度和下采样公式计算。

图片如何生成 patch 和 grid,可以继续看固定版本的 Qwen2VLImageProcessor 源码,视频则由独立的 Qwen2VLVideoProcessor 处理。完整模型中的 2x2 特征合并实现在 Qwen2_5OmniPatchMerger。音频如何从波形得到 Mel 特征,可以对照 WhisperFeatureExtractor 源码

这些证据足以证明 Processor 如何准备模型输入,但还没有运行视觉或音频编码器。因此本文不会把 placeholder 数写成「最终 encoder 特征数」,也不会把输入 tensor 的某个维度写成「最终 LLM token 数」。如果要观察编码器每一层的隐藏状态,需要下载完整权重并增加 hook,那已经是另一个实验问题。

回到最初的问题:媒体到底有没有变成 token

可以说有,也可以说没有,关键在于这里的 token 指什么。

图片、音频和视频没有像文本一样,被统一切成词表中的一串不同整数。它们先被处理为连续张量,再由专用编码器变成连续特征序列。从「有限词表 ID」这个严格含义看,媒体内容本身并不是文本 token。

但进入语言模型时,这些连续特征确实要占据序列位置。工程上把每个位置叫 image token、audio token 或 video token,是合理的简称。Qwen2.5-Omni Processor 还会在 input_ids 中插入重复的媒体特殊 ID,为这些位置建立对齐关系。

最后,供应商 API 返回的输入 token 或计费 token 是另一层口径。它可能和内部序列长度有关,却不能由本文的 placeholder 数或 tensor shape 直接推导。讨论原理时看模型和 Processor,讨论上下文限制与费用时看当前供应商文档和实际 API 返回值,这两条边界分清楚,就不会再被「一张图片到底等于多少 token」绕晕了。