Qwen3.8-27B 的图像怎么变成 token:从 16 像素的块到 5120 维的向量
Qwen3.8-27B 能吃图像和视频。这件事在 model card 和各平台的元数据里都写着,图像输入 和 视频输入 的调用格式本站也写过。
但从「传一张图进去」到「模型开始处理它」,中间发生了什么?图像是二维像素,语言模型吃的是一维 token 序列,这两者怎么接上的?
答案全都写在 config.json 的 vision_config 里。这篇按字段把这条路径走一遍。
这篇的依据
来源是 Hugging Face 上 Qwen/Qwen3.8-27B 的 config.json,以及 vLLM 主干仓库的 qwen3_5.py。采集时间 2026-08-24。
我们没有加载过模型、没有传过任何图片、没有跑过推理。 下面是配置字段的含义和它们之间的算术关系,不是运行观察。
关键一步:两个 5120 对上了
先看最要紧的一个字段。vision_config 里:
"out_hidden_size": 5120
而语言模型那边,text_config.hidden_size 也是 5120。
这不是巧合,这是设计要求。 视觉塔的输出维度必须等于语言模型的隐藏维度,图像特征才能和文本 token 的嵌入向量放在同一个序列里,一起送进那 64 层解码层。
注意 vision_config 自己的 hidden_size 是 1152,跟 5120 不是一回事。视觉塔内部按 1152 维工作,最后投影到 5120 维输出。内部维度和输出维度分开设置,这样视觉塔可以做得比语言模型「窄」,省下参数量,只在交接处对齐。
理解了这一点,多模态模型的基本结构就清楚了:视觉塔是一个翻译器,把图像翻译成语言模型能理解的向量格式。
图像是怎么被切开的
从像素到向量,中间有两次「合并」。
第一次是切块。 patch_size 是 16,意思是把图像切成 16×16 像素的小块,每一块变成一个初始单元。in_channels 是 3,也就是 RGB 三通道。
第二次是空间合并。 spatial_merge_size 是 2,意思是相邻的 2×2 也就是四个块合并成一个最终 token。
两步合起来,一个 token 覆盖的实际像素范围是 32×32(16 乘以 2)。
可以算一下:一张 224×224 的图,切成 16 像素的块是 14×14 = 196 块,再按 2×2 合并,得到 7×7 = 49 个 token。
这个算法解释了一件很实际的事:图像分辨率越高,占用的 token 越多,而且是按面积增长的。分辨率翻倍,token 数变四倍。这直接影响你的上下文预算——一张高清图可能比几千字文本还占地方。具体的尺寸上限约束在预处理配置里,见 两份 preprocessor 配置。
视频多一个维度
temporal_patch_size 是 2,这是视频专用的字段:时间上每两帧合并成一个单元。
所以视频的 token 数大致是「每帧的 token 数 × 帧数 ÷ 2」。这也解释了为什么视频输入对上下文的消耗特别可观——空间上已经压缩了四倍,时间上再压缩两倍,仍然架不住帧数多。
视觉塔本身
几个描述视觉塔结构的字段:
depth 是 27,即 27 层(这个数字本站单独写过,见 视觉塔 27 层)。num_heads 是 16,intermediate_size 是 4304,hidden_act 是 gelu_pytorch_tanh。
num_position_embeddings 是 2304,这是视觉塔自己的位置嵌入数量——它和语言模型那套 RoPE 是两回事,视觉塔用的是独立的位置表示。
2304 这个数也有意义:它约束了视觉塔一次能处理的块数量上限。
那个空数组
vision_config 里还有一个字段值得单独说:
"deepstack_visual_indexes": []
一个空数组。
vLLM 的实现是这么处理它的:
self.use_deepstack = hasattr(config.vision_config, "deepstack_visual_indexes")
self.deepstack_num_level = (
len(config.vision_config.deepstack_visual_indexes)
if self.use_deepstack
else 0
)
注意这里的判断是 hasattr——只看字段存不存在,不看它是不是空的。
结果就是一个略显别扭的状态:use_deepstack 为 True(因为字段确实存在),而 deepstack_num_level 为 0(因为数组长度是 0)。再往下 multiscale_dim 等于视觉维度乘以层级数,也是 0。
功能开关是开的,但没有任何层级可用。 实际效果等同于没启用,只是走的路径不太一样。这个观察呼应了 那个空的 deepstack 数组 里提到的疑问——从框架实现这边看,空数组是被安全处理了的,不会出错,但确实留下了一个「开着却是空的」的状态。
四个特殊 token 和一个空缺
图像要插进文本序列,就得有占位符。这个模型用了四个特殊 token:
| 字段 | ID |
|---|---|
vision_start_token_id | 248053 |
vision_end_token_id | 248054 |
image_token_id | 248056 |
video_token_id | 248057 |
前两个是视觉内容的起止标记,后两个分别代表图像和视频的占位。
注意 248055 这个编号,没有出现在这四个字段里。 四个 ID 里 248053、248054 连续,248056、248057 连续,中间隔了一个。
这个空缺 config.json 里没有解释。合理的推测是它被分配给了别的用途,但我们没有核对分词器的完整词表,所以不做断言——只是记录下这个编号在这几个字段中确实是缺席的。
这些 ID 都在 248000 出头,而词表大小是 248,320。特殊 token 集中排在词表末尾附近,这是常见做法:普通词汇占据前面的连续编号,特殊标记追加在后面,扩展时不打乱已有编号。
位置编码是两套,不是一套
有个容易混淆的点值得说清楚:视觉塔和语言模型用的是两套完全不同的位置编码。
视觉塔这边是 num_position_embeddings: 2304——学习式的位置嵌入表,一张查表用的表,有 2304 个条目。
语言模型那边是 RoPE,靠旋转注入位置信息,参数是 rope_theta、mrope_section 那一组,没有位置嵌入表(这套机制见 RoPE 维度 64 是怎么来的)。
两套机制的工作方式完全不同,一个是查表加法,一个是旋转变换。
它们各管一段:视觉塔用自己的位置嵌入处理「这个图块在图里的哪个位置」;图块变成 token 进入语言序列之后,MRoPE 再处理「这段视觉内容在整个对话序列里的哪个位置」。
理解这个分工,能避免一个常见误解:以为图像的空间位置信息是靠 MRoPE 表达的。实际上 MRoPE 处理的是视觉 token 在序列中的位置,而图块之间的空间关系,在进入语言模型之前就已经由视觉塔自己的位置嵌入编码进向量里了。
框架里怎么装配
vLLM 的多模态类构造时,用两个上下文管理器把两部分分别标记出来:
with self._mark_tower_model(vllm_config, {"image", "video"}):
self.visual = Qwen3_VisionTransformer(...)
with self._mark_language_model(vllm_config):
self.language_model = Qwen3_5ForCausalLM(...)
视觉塔被标记为处理 image 和 video 两种模态,语言模型单独标记。 这种显式区分让框架知道哪部分负责什么,进而可以对两部分做不同的处理——比如把视觉编码器放到不同的并行组里。
代码里确实有这样的配置项:mm_encoder_tp_mode 为 "data" 时启用数据并行方式处理多模态编码器。此外还有 video_pruning_rate 和 supports_multimodal_pruning = True,说明框架支持对视频输入做剪枝——考虑到视频的 token 消耗,这个功能很合理。
真正把两边合起来的是嵌入合并那一步:文本 token 走语言模型的嵌入表,图像位置上的占位 token 被替换成视觉塔输出的向量,两者拼成一条序列。这一步能成立的前提,就是开头说的那个 5120 对 5120。
小结
- 视觉塔
out_hidden_size5120 等于语言模型hidden_size,这是图文能拼进同一序列的前提 - 视觉塔内部按 1152 维工作,最后才投影到 5120
- 图像先按
patch_size16 切块,再按spatial_merge_size2 做 2×2 合并,一个 token 覆盖 32×32 像素 - token 数按图像面积增长,分辨率翻倍则 token 变四倍
- 视频还有
temporal_patch_size2,时间上每两帧合并 deepstack_visual_indexes是空数组,导致「开关为真但层级数为零」的状态- 四个视觉特殊 token 编号为 248053、248054、248056、248057,248055 在这几个字段中缺席
更多拆解在 Qwen3.8-27B 专题。