17. 多模态大模型的结构:图像和语音怎样变成 token
上一篇拆完了主流开源模型的语言部分。到目前为止,模型的输入只有文字:分词器把句子切成 token,词嵌入查表得到向量,再穿过几十层注意力和 FFN。现在小林在资料助手里上传了一张报销单截图,问“这张能报多少?”;下班路上又对着手机问“年假怎么申请?”。语言模型本身并没有长出眼睛和耳朵,它仍然只处理一串向量。这一篇回答三个问题:一张图怎样变成一串和词向量同样形状的向量,一共多少个;这些向量从模型的哪里进去,“外挂编码器”和“原生多模态”在结构上到底差在哪;语音怎样进来、又怎样作为声音出去。读完以后,你应该能对着一个多模态模型的 config.json 算出一张图占多少 token,并判断它的视觉部分接在什么位置。
图文模型怎样训练(两阶段对齐、指令微调、表单数据)见训练系列的图文模型与多模态大模型,部署时语音链路的延迟和流式问题见推理系列的语音与多模态推理。这里只讲结构。
把图切成 patch:ViT 怎样得到第一串向量
语言模型要的输入是 [序列长度, hidden_size] 的矩阵,所以第一步是把图切成一个个小块,每块变成一个向量。这个做法来自 ViT(Vision Transformer,2020 年提出),切出来的小块叫 patch。
拿一张 448×448 像素的彩色图,按 14×14 像素切:
text
每边 448 / 14 = 32 块,共 32 × 32 = 1024 个 patch
每个 patch 有 14 × 14 × 3(RGB)= 588 个像素值
展平后乘一个 [588, d] 的矩阵,得到一个 d 维向量
形状:[1024, 588] → [1024, d]“乘一个矩阵”在源码里通常写成步长等于核大小的卷积,效果相同。Qwen2.5-VL 的 Qwen2_5_VisionPatchEmbed 用的是 Conv3d,核大小是 [2, 14, 14]:它把时间方向上相邻的 2 帧合成一个 patch,为的是图像和视频共用一套权重。单张图片会先复制成 2 帧,所以每个 patch 是 2×14×14×3 = 1176 个数,映射到视觉编码器的宽度 1280。
这 1024 个向量接着穿过一个 Transformer 编码器,结构和第 03 篇、第 05 篇讲的注意力加 FFN 一样,区别是没有因果掩码:每个 patch 可以看图里所有其他 patch,因为图像没有“先后”。Qwen2.5-VL-7B 的视觉编码器有 32 层,约 6.8 亿参数(它的 safetensors 总参数 8,292,166,656,减去语言模型部分 7,615,616,512,得 676,550,144)。
patch 的边长直接决定 token 数。Qwen3-VL 把 patch 从 14 改成 16,同一张 448×448 的图只切出 28×28 = 784 个 patch;patch 越大,每个 token 覆盖的像素越多,文字越小越容易糊在一个 patch 里。
合并与压缩:1024 个 patch 不必都送进语言模型
1024 个 patch 如果原样送进语言模型,一张图就占 1024 个位置,比小林的问题长几十倍。各家模型都会在视觉编码器之后压缩一次,常见三种做法:
- 相邻合并(Qwen2.5-VL、Qwen3-VL 的
PatchMerger):把 2×2 相邻的 4 个向量首尾拼接成一个 4 倍宽的向量,再用 MLP 压回语言模型的宽度。拼接本身不丢信息,压缩发生在 MLP 里。 - 像素重排(pixel shuffle,Llama 4 的
pixel_shuffle,pixel_shuffle_ratio0.5):把空间上的 2×2 挪到通道维,做法和拼接等价,336×336 的一块图从 576 个 patch 变成 144 个。 - 平均池化(Gemma 3、Gemma 4):Gemma 3 把每张图固定缩放到 896×896,切出 64×64 = 4096 个 patch,再按 4×4 取平均得到 256 个;Gemma 4 按 3×3 取平均。平均会把相邻 patch 的细节抹平。
下图沿着 Qwen2.5-VL-7B 走一遍形状变化,下方是同一张图在几种模型里最终占多少 token。
图里 Gemma 4 那一行值得注意:它的预处理会把图缩放到“刚好用满 token 预算”的尺寸,448×448 的小图反而被放大到 768×768。所以 Gemma 4 的 token 数主要由预算决定,Qwen 系列的 token 数主要由原图尺寸决定。
投影层:把视觉向量放进词向量的位置
压缩后的向量要变成和词嵌入一样宽,这一步叫投影层(projector)。它通常很小:LLaVA-1.5 是两层 MLP(1024 → 4096 → 4096),Llama 4 是一个不带偏置的线性层(4096 → 5120),Gemma 3(4B)是 RMSNorm 加一个 [1152, 2560] 的矩阵。
怎样把这些向量放进序列?处理器(processor)先把对话模板里的一个“图片”占位符展开成 N 个相同的特殊 token(Qwen2.5-VL 里是 image_token_id 151655),N 就是上面算出的图像 token 数。模型照常查词嵌入,然后把这 N 个位置的向量整体替换成投影层的输出:
python
# 对应 Qwen2_5_VLModel.forward 里的做法(简化)
embeds = embed_tokens(input_ids) # [L, 3584],图像位置先是占位符的嵌入
patches = vision_tower(pixel_values, grid_thw) # [1024, 1280]
img = merger(patches) # [256, 3584],2×2 合并 + MLP
mask = input_ids == image_token_id # 恰好 256 个 True
embeds[mask] = img # 原地替换
hidden = language_model(inputs_embeds=embeds, position_ids=mrope_ids)替换之后,语言模型看到的就是一串普通向量,前面讲的所有机制照常工作:图像 token 也要做注意力、也进 KV Cache、也占上下文长度。占位符数量和视觉输出数量对不上时,源码会直接报错,这是自己改预处理时最常见的问题。
外挂编码器还是原生多模态
“原生多模态”是近两年发布会上的高频词,但它说的往往是训练方式,而不是结构。看结构只要问一个问题:图像信息在哪里、以什么形式进入语言模型?按 Transformers 5.21 的源码和 2026 年 10 月的官方模型卡,可以分成三类。
外挂编码器,只在输入层接入。 LLaVA 是典型:视觉编码器是单独预训练好的 CLIP ViT-L/14,取倒数第二层的输出(vision_feature_layer -2),投影后放在问题文字前面。视觉编码器和语言模型各自先训练好,再用投影层“翻译”。好处是便宜,可以换任意语言模型;代价是视觉编码器在 336×336 下学到的特征决定了上限,它看不清的字,语言模型也补不回来。
原生训练,结构仍是编码器加投影。 Meta 在 2025 年 4 月 5 日发布 Llama 4 时说它“原生多模态、采用早期融合”,指的是从预训练开始就把文字、图像、视频 token 混在一起训练同一个主干;官方同时说明视觉编码器基于 MetaCLIP,是在冻结 Llama 的条件下单独训练、再接入的。对应到 modeling_llama4.py,结构仍是视觉编码器 → 像素重排 → 线性投影 → 替换输入层占位符。Gemma 3 用 SigLIP 编码器,结构也是这一类,不过它在语言模型里让同一张图的 token 互相双向注意(源码里按图像块构造掩码),而 Gemma 4 只在滑动窗口层保留这种双向,全局层恢复因果掩码。
改结构:多层注入或去掉编码器。 Qwen3-VL 的 DeepStack 把视觉编码器第 8、16、24 层的中间特征各自合并、投影,再加到语言模型第 1—3 层输出的图像位置上(Qwen3VLTextModel._deepstack_process),让浅层的细节特征也能直接进入语言模型。更彻底的是 2026 年 6 月发布的 Gemma 4 12B Unified:模型卡说明它没有视觉和音频编码器,源码 Gemma4UnifiedVisionEmbedder 把 48×48 像素的块(6912 个数)经几层归一化、线性层和二维位置嵌入投到 3840 维,中间没有任何注意力层,音频则是每 640 个采样点(16 kHz 下 40 毫秒)一个 token,全部模态从第一层起共用同一套解码器层。同系列的 31B 和 26B A4B 仍带约 5.5 亿参数的视觉编码器。
判断一个新模型属于哪一类,看源码里有没有 vision_tower / visual 这样的子模块、它的输出在哪一层被用到,比看宣传词可靠。结构差别会直接反映到部署上:带独立编码器的模型,推理引擎可以把编码器单独批处理甚至放到另一张卡上;无编码器的模型,图像 token 一进来就是语言模型的计算量。
动态分辨率:token 数跟着图片尺寸走
早期模型把所有图片缩放成固定的正方形,报销单这种细长的图会被压扁,小字糊掉。Qwen2-VL 开始的做法是动态分辨率:保留长宽比,只把边长取整到“patch × 合并尺寸”的倍数,再限制总像素在一个区间里。源码里的 smart_resize 规则是:
text
factor = patch_size × merge_size Qwen2.5-VL:14 × 2 = 28;Qwen3-VL:16 × 2 = 32
高、宽各自四舍五入到 factor 的倍数
若总像素 > max_pixels,等比缩小后向下取整;若 < min_pixels,等比放大后向上取整
图像 token 数 = (高 / factor) × (宽 / factor)Qwen2.5-VL-7B-Instruct 的 preprocessor_config.json 里 min_pixels 是 3136、max_pixels 是 12,845,056,也就是一张图最少 4 个、最多 16384 个 token。图越大 patch 越多,视觉编码器里的全局注意力会按 patch 数平方增长,所以 Qwen2.5-VL 的 ViT 大部分层只在 112×112 像素(8×8 个 patch)的窗口里做注意力,只有第 7、15、23、31 层是全图注意力(fullatt_block_indexes)。
动态分辨率解决了“压扁”,但把成本交给了图片尺寸:同样一句问题,配一张手机长截图和配一张缩略图,预填充的工作量能差十倍。下面“报销单的账”一节会具体算。
M-RoPE:图像 token 的位置怎样编号
第 07 篇讲过,RoPE 按 token 在序列里的编号旋转 Q 和 K。把一张图的 token 按行展开后接着编号,会丢掉“上下相邻”的关系:第 1 行最后一个和第 2 行第一个编号相邻,实际却隔了一整行。
Qwen2-VL 起使用 M-RoPE(多模态旋转位置编码):每个 token 有 t(时间)、h(行)、w(列)三个编号,每个注意力头的旋转频率分成三组,分别按 t、h、w 旋转。文字 token 的三个编号相同,于是退化成普通 RoPE;图像 token 的 t 相同,h、w 取它在合并后网格里的行列。下图是一个 2 行 3 列的小例子。
注意图后文字的编号:不是 3 + 6 = 9,而是 3 + max(1, 2, 3) = 6。位置编号按网格的最大边长前进,而不是按 token 个数。一张 63×44 网格、2772 个 token 的报销单截图,只让位置编号前进 63。好处是长图不会把位置编号推到训练没见过的范围;代价是位置编号和序列长度不再一致,推理引擎计算 RoPE 时必须拿到每张图的网格尺寸 image_grid_thw,不能只靠序号。
频率怎样分给三个方向,两代 Qwen 不同。Qwen2.5-VL 的 mrope_section 是 [16, 24, 24],按段切开:t 拿走最高频的 16 对,h、w 拿较低频的部分。Qwen3-VL 改成 [24, 20, 20] 并设置 mrope_interleaved,按 t、h、w 轮流分配,于是三个方向都覆盖从高到低的完整频率范围。
视频:多出来的时间维
视频就是一串图。Qwen2.5-VL 的 temporal_patch_size 为 2,相邻两帧在 Conv3d 里合成一个 patch。按每秒抽 2 帧算,一段 10 秒、448×448 的视频:
text
10 秒 × 2 帧/秒 = 20 帧 → 20 / 2 = 10 组
每组 256 个 token → 10 × 256 = 2560 个 token时间方向的位置编号,Qwen2.5-VL 用的是“绝对时间”:相邻两组的 t 编号间隔 = tokens_per_second × 每组的秒数,7B 模型的 tokens_per_second 是 2,每秒抽 2 帧时每组正好 1 秒,所以 t 编号每组加 2,和真实秒数成正比,抽帧率变了,编号仍对齐时间。Qwen3-VL 换了思路:处理器在每组帧前插入一段文字时间戳,例如 <1.0 seconds>,get_rope_index 把每组帧当作 t 只有 1 的独立网格。时间信息从位置编码挪到了文本里,模型读时间戳就像读其他文字一样。
视频的 token 数增长得非常快。Qwen3-VL 的视频处理器默认最多取 768 帧,按每帧几百个 token 算,一段长视频就能占满几十万的上下文。部署时通常要同时限制帧数和每帧像素。
语音怎样进来
声音进入语言模型的方式和图像类似:先变成一串特征向量,再压缩、投影、替换占位符。
Whisper 式音频编码器是最常见的做法。16 kHz 的波形先算成梅尔频谱(每 10 毫秒一帧、每帧 80 或 128 个频带的能量,相当于一张“时间 × 频率”的图),每秒 100 帧;再经卷积下采样和 Transformer 编码器。Whisper 的编码器步长为 2,每秒 50 个向量,30 秒的窗口正好是 1500 个位置。Qwen2.5-Omni 在它后面再做一次步长 2 的平均池化,每秒 25 个 token,每个代表 40 毫秒;Qwen3-Omni 的 AuT 编码器用三层步长 2 的卷积,下采样 8 倍,源码按每 100 帧输出 13 个算,约每秒 13 个 token。
小林说“年假怎么申请?”大约 3 秒,在 Qwen3-Omni 里是约 39 个音频 token,而同样的文字只有 5 个左右的文字 token。语音比文字“贵”,因为它还携带了语气、停顿和说话人这些文字里没有的信息,这些信息模型可以用,比如听出对方在着急。
语音要输出时,需要离散的语音 token。 语言模型擅长的是“从词表里挑下一个”,所以要先用一个语音编解码器(codec)把声音编码成整数:每一帧用多层码本(残差向量量化,第一层编码大致内容,后面每层编码前一层剩下的误差)各选一个编号。Qwen3-Omni 的 code2wav_config 里每层码本 2048 个条目,共 16 层;声码器 Code2Wav 把每帧放大 1920 倍,按模型卡示例的 24 kHz 输出,每秒正好 12.5 帧。
Thinker-Talker:一个模型想,一个模型说
把“听懂、思考”和“说出来”拆给两个模型,是 Qwen2.5-Omni 提出、Qwen3-Omni 沿用的 Thinker-Talker 结构。下图按 Qwen3-Omni-30B-A3B-Instruct 的 config.json 画出。
Thinker 是一个完整的多模态语言模型,Qwen3-Omni 的 Thinker 和 Qwen3-30B-A3B 同规格:48 层 MoE,128 个专家每次选 8 个,负责理解音频、图像、视频和文字,并输出文字回复。Talker 是另一个小一些的自回归模型(20 层 MoE、128 个专家选 6、宽 1024),它的“词表”是 3072 个语音 token(2048 个码本编号,加上开始、结束、说话人等特殊编号)。Talker 每步出一帧的第 1 层码本,再由一个 5 层的小模型(源码里叫 code predictor)补出这一帧其余 15 层的编号,Code2Wav 用因果卷积把它们变成波形,可以边生成边播放。
两代之间一个结构差别能在源码里直接看到。Qwen2.5-Omni 的 Talker 输入是 Thinker 最后一层隐藏状态加词嵌入;Qwen3-Omni 的 _get_talker_user_parts 只在图像、音频、视频位置取 Thinker 第 24 层(accept_hidden_layer)的隐藏状态,文字位置和回复部分只用词嵌入。也就是说,Qwen3-Omni 的 Talker 依据的是回复文字本身,而不是 Thinker 对它的内部表示。
这个结构的边界也很清楚。Talker 只负责“念”,Thinker 写错的数字它会照着念错;Thinker 如果输出表格、Markdown 列表,Talker 很难念得自然,Qwen3-Omni 模型卡专门给出了让 Thinker 少用复杂格式的系统提示。只要文字回复时,可以调用 disable_talker() 不加载 Talker,模型卡给出的节省约 10 GB 显存;截至模型卡说明,vLLM 的在线服务也只支持 Thinker。
资料助手读报销单截图的账
回到小林的报销单。设截图是 A4 纸按 150 dpi 扫描的 1240×1754 像素,用 Qwen2.5-VL-7B-Instruct 的默认预处理,按 smart_resize 和源码里的模型尺寸计算:
text
factor = 28:1754 / 28 = 62.6 → 63;1240 / 28 = 44.3 → 44
缩放后 1764 × 1232,未超过 max_pixels
patch:(1764/14) × (1232/14) = 126 × 88 = 11,088 个,送进 32 层视觉编码器
图像 token:63 × 44 = 2,772 个,前后再加视觉开始、结束 2 个再算语言模型这一侧(用第 18 篇的脚本从 config.json 得到每 token 的固定计算 14.14 GFLOPs 和 KV 每 token 57,344 字节):
| 项目 | 计算 | 结果(估算) |
|---|---|---|
| 视觉编码器前向 | 2 × 6.3 亿(32 层的矩阵参数)× 11,088 | 约 14 TFLOPs,不含注意力部分 |
| 语言模型预填充 | 14.14 G × 2,772 | 约 39 TFLOPs |
| 图像部分的 KV Cache | 2,772 × 57,344 字节 | 约 159 MB(约 152 MiB),BF16 |
| 位置编号前进 | max(63, 44) | 63 |
小林的问题本身只有十来个 token,这一轮几乎所有的计算和 KV 都花在图上。部署时常把 max_pixels 调成 1280 × 28 × 28 = 1,003,520,同一张图就缩到 1176 × 840、1260 个 token,成本降到一半以下。换到 Qwen3-VL(factor 32)默认设置是 2145 个 token;Gemma 4 在默认预算 280 下只给 266 个,缩放到 912 × 672。
缩到多小会出问题?截图里 12 像素高的金额,缩到 840 宽时还有约 8 像素,大致占半个 patch;按 Gemma 4 的 280 预算缩到 672 宽,只剩约 6.5 像素。模型看不清时不会说“看不清”,而会按语言习惯给出一个像样的数字。判断方法是拿自己的真实票据按不同的像素上限各跑一遍,比对抽取出的金额、日期和发票号;应用侧再用规则核对(明细之和是否等于合计),对不上就提高分辨率重试或转人工。用多少 token 是一个可调参数,它对应的是“看清”和“成本”之间的取舍,最好用实测数据来定,而不是默认值。
图像和语音进入语言模型的方式可以归结为同一条路:切块或分帧,编码成向量,合并压缩,投影到语言模型的宽度,替换占位符;差别在于用不用独立编码器、在几层注入、位置怎样编号、输出时是否再接一个说话的模型。这些都写在 config.json 的 vision_config、audio_config、rope_scaling 和对应的 modeling_*.py 里。下一篇读懂 config 与源码就把这件事系统化:逐字段读两个真实的配置,写一个只读 config.json 就能算参数量、计算量和 KV Cache 的小脚本,并给出拿到新模型时的阅读顺序。
