从零构建Qwen2.5VL的推理
从零构建Qwen2.5VL的推理
本文讲解如何仅用
torch.nn.Module从零搭建 Qwen2.5-VL-3B-Instruct 多模态大模型——
不调用任何现成的Qwen2_5_VLForConditionalGeneration/Qwen2_5_VLProcessor,而是手写
每一层:视觉编码器、语言模型、视觉-语言连接器、M-RoPE、KV cache、生成循环,再直接从
HuggingFace safetensors checkpoint 加载预训练权重,从而产出真实输出。完整项目代码在:https://github.com/huan-yin/Qwen2.5VL-from-scratch文中所有 shape 均以
example.png(1024×1024) + prompt"Describe this image."为例,
并通过实际运行真实模型(加载预训练权重、bfloat16、CUDA)验证,非纸面推算。
目录
- 整体架构:从零构建需要哪些部件
- 模型配置
- 部件一:图像预处理
- 部件二:视觉编码器
- 部件三:多模态位置编码 M-RoPE
- 部件四:语言模型
- 部件五:视觉-语言融合与生成
- 完整前向传播 Shape 追踪(已验证)
- 关键设计要点
1. 整体架构:从零构建需要哪些部件
Qwen2.5-VL 是一个视觉-语言多模态自回归模型。从零构建它,需要依次实现下面 6 个部件:
1 | |
构建顺序遵循数据流:先把图像变成视觉 token,再把图文拼成一条序列,最后让 LLM 自回归生成。
2. 模型配置
所有超参硬编码为常量,与 Qwen/Qwen2.5-VL-3B-Instruct 的 config.json 一致。
视觉 (V)
| 项 | 值 | 说明 |
|---|---|---|
| depth | 32 | ViT 层数 |
| hidden_size | 1280 | 视觉隐藏维度 |
| intermediate_size | 3420 | MLP 中间维度 |
| num_heads | 16 | head_dim = 1280/16 = 80 |
| in_chans | 3 | RGB |
| out_hidden_size | 2048 | 对齐 LLM 隐藏维度 |
| patch_size | 14 | 空间 patch 边长 |
| temporal_patch_size | 2 | 时间 patch 边长(静态图复制成 2 帧) |
| spatial_merge_size | 2 | 2×2 空间合并 |
| window_size | 112 | 窗口注意力窗口大小 |
| fullatt | (7,15,23,31) | 这几层全局注意力,其余窗口注意力 |
语言 (T)
| 项 | 值 | 说明 |
|---|---|---|
| hidden_size | 2048 | LLM 隐藏维度 |
| num_layers | 36 | 解码器层数 |
| num_heads | 16 | query 头数 |
| num_kv_heads | 2 | KV 头数(GQA 分组数 = 16/2 = 8) |
| intermediate_size | 11008 | MLP 中间维度 |
| vocab_size | 151936 | 词表大小 |
| rms_eps | 1e-6 | RMSNorm eps |
| rope_theta | 1,000,000 | RoPE base |
| mrope_section | (16,24,24) | M-RoPE 时间/高/宽 通道划分 |
派生常量:
1 | |
3. 部件一:图像预处理
目标:把一张 PIL 图变成模型输入 pixel_values,并把图文拼成 token 序列。
本文全程使用示例图 example.png(原图 1024×1024):

示例图 example.png(1024×1024),用于下文所有 shape 追踪。
3.1 smart_resize:保持长宽比,两边整除 28
1 | |
factor=28 = patch_size(14) × merge(2),保证 patch 网格能被 2×2 整除。
示例:example.png 1024×1024 → smart_resize → 1036×1036,grid_h = grid_w = 74。
3.2 归一化 + patchify(按合并块排序)
1 | |
每行是一个 patch 的展平输入:
3 通道 × 2 时间 × 14 × 14 = 1176。行顺序是合并块顺序
(先排 37×37 个 2×2 块,块内 4 个 patch 连续),这样后续Merger能直接view(-1, 5120)
把 4 个 patch 拼成一个向量。
3.3 chat 模板 + image_pad 展开
1 | |
验证结果:
| 输出 | shape | 值 |
|---|---|---|
input_ids |
(1, 1394) |
25 文本 + 1369 图像 |
mm_type |
(1, 1394) |
0/1 标记 |
grid_thw |
(1, 3) |
[[1, 74, 74]] |
pixel_values |
(5476, 1176) |
合并块顺序 |
4. 部件二:视觉编码器
目标:(5476, 1176) 像素 → (1369, 2048) 视觉 embedding。
4.1 PatchEmbed:3D Conv 一次性嵌入
用 3D 卷积(kernel = stride = (2,14,14))把每个 patch 的 3×2×14×14 输入压成 1280 维向量,
时间维 2 被卷掉,等效于聚合"复制出的两帧":
1 | |
验证:patch_embed out = (5476, 1280) ✓
4.2 二维 RoPE 频率表
视觉用 2D RoPE:每个 token 带一个 (row, col) 位置,同一 2×2 合并块内的 4 个 token 共享位置。
1 | |
关键:
reshape(37, 2, 37, 2).transpose(1, 2).flatten()让同一 2×2 合并块内的 4 个 token
拿到相同的 (row, col) – 位置以合并块为粒度,与后续 4→1 合并对齐。
1 | |
验证:pos = (5476, 2),rotary emb = (5476, 80),cos/sin = (5476, 80) ✓
4.3 窗口注意力:一维打包 + 分段 SDPA
这是视觉编码器最巧妙的部分。Qwen2.5-VL 的 ViT 用窗口注意力(窗口 112 像素 = 4×4 合并块),
只在第 7/15/23/31 层做全局注意力。实现方式不是 mask,而是重排 token 顺序 + 分段 SDPA:
1 | |
注意
win长度是 1369(合并块数),不是 5476——它重排的是合并块,每个块含 4 个 token。
验证:win = (1369,),cuw = (101,)(100 窗口 + 起始 0),满窗 seg_max = 64 ✓
注意力本身:
1 | |
- 窗口层(如第 0 层):
cu=cuw,分 100 段,每段最多 64 token,段内互不注意力。 - 全局层(如第 7 层):
cu=[0, 5476],1 段,全部 5476 token 互注意力。
验证:
1 | |
4.4 视觉块与 Merger
每层是标准 pre-norm 残差:x = x + attn(norm1(x)),x = x + mlp(norm2(x)),MLP 为带 bias 的 SwiGLU。
32 层后,Merger 把每个 2×2 合并块的 4 个 patch token(4×1280=5120)拼成一个 2048 维向量:
1 | |
Merger 之后用 argsort(win) 的逆重排恢复原始合并块顺序。
验证:merger out = (1369, 2048) ✓ — 1369 个视觉 token,维度对齐 LLM。
5. 部件三:多模态位置编码 M-RoPE
普通 RoPE 只有 1D 位置。Qwen2.5-VL 用 M-RoPE:把 head_dim=128 拆成
时间(32) + 高(48) + 宽(48) 三段,每段用对应维度的位置独立旋转,让模型感知图像的 2D+T 结构。
5.1 构造 3D 位置:rope_index
对单条(batch=1,无 padding)序列,按 mm_type 的连续 run 分组:
- 文本段 (mod=0):1D 位置
arange(n),复制到 3 个轴。 - 图像段 (mod=1):取对应
grid_thw,生成 3D 位置- 时间
pt:全 0(静态图,time_interval=1) - 高度
ph:arange(37)repeat - 宽度
pw:arange(37)repeat - 该段位置跨度 =
max(h,w)//MERGE = 37
- 时间
1 | |
验证:pos = (3, 1, 1394),delta = -1332 ✓
delta 为负的原因:图像 1369 个 token 的 3D 位置只跨 37(高/宽各 37),而它在序列里占
1369 个位置。可精确推导:
- 文本 token 共 25 个(图前 n1 + 图后 n2,n1+n2=25),图像位置从 n1 起算,跨度 37。
positions.max() = n1 + 37 + n2 - 1 = 25 + 36 = 61,而L = 1394。delta = 61 + 1 - 1394 = -1332。解码时新 token 位置 =
cur + delta,等价于从positions.max()+1 = 62继续递增——
见 §8 第 5 步实测npos = 62,完美吻合。这保证生成 token 的 M-RoPE 位置紧接 prompt 末尾。
5.2 旋转:rope_cos_sin
1 | |
即:head_dim 前 32 维用时间位置旋转,中 48 维用高度位置,后 48 维用宽度位置。
验证:mrope cos/sin = (1, 1, 1394, 128) ✓
5.3 M-RoPE 坐标举例(以示例序列实测)
光看公式不直观,下面用示例序列(example.png + "Describe this image.")实测每个 token
的 (t, h, w) 三轴位置。该序列共 1394 个 token,结构为:
1 | |
记
n1 = 15(图像前的文本数)。图像段的cur = 15,所以图像的 2D 网格位置整体偏移 15,
即h, w ∈ [15, 51](37 个值)。
| 序列 idx | token 类型 | (t, h, w) | 说明 |
|---|---|---|---|
| 0 | 文本(首个) | (0, 0, 0) | 三轴相等 |
| 14 | 文本(图前最后) | (14, 14, 14) | 三轴相等 |
| 15 | 图像 [0,0] 左上 | (15, 15, 15) | t 恒定;h=行0、w=列0 |
| 16 | 图像 [0,1] | (15, 15, 16) | 同行,w+1 |
| 51 | 图像 [0,36] 行末 | (15, 15, 51) | 行 0 最后一个,w=15+36 |
| 52 | 图像 [1,0] 换行 | (15, 16, 15) | 新行,h+1、w 重置 |
| 53 | 图像 [1,1] | (15, 16, 16) | |
| 89 | 图像 [2,0] | (15, 17, 15) | 第 2 行起点 |
| 1383 | 图像 [36,36] 右下 | (15, 51, 51) | 网格最后一个 |
| 1384 | 文本(图后首个) | (52, 52, 52) | 三轴相等;cur=15+37=52 |
| 1393 | 文本(末尾 assistant) | (61, 61, 61) | 三轴相等 |
读法:
- 文本 token:
t = h = w,三轴完全相同–就是把 1D 位置复制到 3 个轴,退化为普通 RoPE。 - 图像 token:
t恒为 15(静态图,时间维不增长);h = 15 + 行号,每 37 个 token 换一行(行内 h 不变);w = 15 + 列号,每行内从 15 递增到 51(行末重置回 15)。
把图像 1369 个 token 的 (h, w) 画出来,就是一张 37×37 的 2D 网格:
1 | |
这样模型对图像有了真正的二维空间感知:空间相邻的 patch(如 [0,0] 与 [0,1])在 h/w
位置上也相邻,旋转后的内积更大、注意力更强;而 [0,36] 与 [1,0] 虽然序列上相邻(idx 51、52),
但 h/w 位置差异大,注意力反而弱–这正是 M-RoPE 相比 1D RoPE 的核心收益。
回到 delta:图像 1369 个 token 在序列里占了 1369 个位置,但其 (h, w) 只跨 37(15→51),
positions.max() 由末尾文本 token 决定 = 61(见 idx 1393),远小于 L = 1394,故
delta = 62 − 1394 = −1332。解码时新 token 用 cur + delta 从 62 继续递增,与 idx 1393 的 61
严格衔接。
6. 部件四:语言模型
标准 pre-norm 解码器,36 层,每层 = GQA 注意力 + SwiGLU MLP。
6.1 GQA 注意力(含 KV cache)
1 | |
- prefill(
L=1394>1):is_causal=True,整条序列一次算完,建立 KV cache。 - 解码(
L=1):is_causal=False,新 token 的 k/v 拼到 cache 末尾,只算 1 个 query 对全部历史。
验证(layer 0,prefill):
1 | |
6.2 SwiGLU MLP + RMSNorm
无 bias SwiGLU:down(silu(gate(x)) * up(x)),中间维度 11008。每层两个 RMSNorm(input / post-attention)。
7. 部件五:视觉-语言融合与生成
7.1 融合:masked_scatter 注入
1 | |
验证:mask = (1, 1394, 1),num_true = 1369,注入后 embeds = (1, 1394, 2048) ✓
7.2 生成循环
- 视觉编码 →
ie (1369, 2048) - 注入 →
embeds (1, 1394, 2048) - 位置 →
pos (3, 1, 1394)+delta - Prefill:
forward(embeds, pos)一次跑完整条序列,建 KV cache,取末位 logits - 解码循环:
- 对已出现 token 做重复惩罚:负 logit ×1.05、正 Logit ÷1.05
argmax取下一个 token,命中 EOS (151645/151643) 则停- 新 token embedding
(1,1,2048)+ 新位置npos (3,1,1)调forward(..., cache)增量解码
1 | |
验证(第一步解码):
1 | |
7.3 权重加载
直接读 safetensors shard,strict=False 加载,并 tying 输出权重:
1 | |
7.4 端到端实测输出
对 example.png 实际运行(max_new=60,bfloat16,CUDA)生成:
“The image shows a person with long, dark hair wearing a white top. They are holding their hands
near their face, with fingers gently touching the sides of their cheeks. The background appears to
be an outdoor setting with greenery and some fallen leaves on the ground.”
8. 完整前向传播 Shape 追踪(已验证)
输入:example.png (1024×1024) + "Describe this image."。以下 shape 全部经实际运行验证。
8.1 预处理
| 步骤 | 张量 | shape | 实测值 |
|---|---|---|---|
| 原图 | img |
(W, H) | (1024, 1024) |
| smart_resize | H, W |
— | 1036, 1036 |
| grid | grid_h, grid_w |
— | 74, 74 |
| patchify | pixel_values |
(5476, 1176) | ✓ |
| grid_thw | (1, 3) | [[1,74,74]] |
|
| tokenize | ids |
(26,) | 含 1 image_pad |
| 展开 | input_ids |
(1, 1394) | 25 文本 + 1369 图像 |
| 类型标记 | mm_type |
(1, 1394) | 0/1 |
8.2 视觉编码
| 步骤 | 张量 | shape | 实测值 |
|---|---|---|---|
| 2D 位置 | pos |
(5476, 2) | (row,col) |
| 全局 cu | cu |
(2,) | [0, 5476] |
| 窗口重排 | win |
(1369,) | 合并块粒度 |
| 窗口 cu | cuw |
(101,) | 100 窗口 |
| PatchEmbed | x |
(5476, 1280) | Conv3d |
| RoPE emb | cos/sin |
(5476, 80) | head_dim=80 |
| 窗口层 q,k,v | (1,16,5476,80) ×3 | 100 段,seg_max=64 | |
| 全局层 q,k,v | (1,16,5476,80) ×3 | 1 段,seg_max=5476 | |
| 每层输出 | (5476, 1280) | ||
| Merger | ie |
(1369, 2048) | 4→1 合并 |
8.3 融合与位置
| 步骤 | 张量 | shape | 实测值 |
|---|---|---|---|
| 视觉 embedding | ie |
(1369, 2048) | |
| token embedding | embeds |
(1, 1394, 2048) | |
| 注入 mask | mask |
(1, 1394, 1) | 1369 True |
| 注入后 | embeds |
(1, 1394, 2048) | |
| M-RoPE 位置 | pos |
(3, 1, 1394) | delta = -1332 |
8.4 LLM Prefill
| 步骤 | 张量 | shape | 实测值 |
|---|---|---|---|
| M-RoPE cos/sin | (1, 1, 1394, 128) | T32+H48+W48 | |
| query | q |
(1, 16, 1394, 128) | 16 头 |
| key/value | k, v |
(1, 2, 1394, 128) | 2 KV 头 |
| repeat_kv 后 | k, v |
(1, 16, 1394, 128) | 对齐 q |
| 输出 logits | (1, 1394, 151936) | ||
| KV cache(每层) | (1, 2, 1394, 128) ×2 | 共 36 层 |
8.5 解码(每生成 1 token)
| 步骤 | 张量 | shape | 实测值 |
|---|---|---|---|
| 取末位 logits | lg |
(1, 151936) | argmax → 785 “The” |
| 新 token embedding | nem |
(1, 1, 2048) | |
| 新位置 | npos |
(3, 1, 1) | 值 = 62 |
| 拼接 cache | k, v |
(1, 2, 1395, 128) | 长度 +1 |
| 输出 logits | (1, 1, 151936) |
8.6 端到端 Shape 流转图
1 | |
9. 关键设计要点
9.1 视觉 token 4× 压缩
patch_size=14 把 1036×1036 切成 74×74=5476 patch;spatial_merge_size=2 让 Merger 把每个
2×2 块的 4 个 patch 合成 1 个 token,5476/4 = 1369。这是压缩视觉序列长度、降低 LLM 端注意力
开销的关键。
9.2 窗口注意力的一维打包实现
不用 mask,而是 window_index 把 token 按 4×4 合并块窗口重排(win 长度 1369),让同窗口
token 连续;cuw 给出每窗口累计长度;VisionAttn 用 torch.split 按 cuw 切段,对每段独立
调 SDPA 再 cat。第 7/15/23/31 层换全局 cu(整图一段)传递长距离信息。实测窗口层 100 段、
seg_max=64;全局层 1 段、seg_max=5476。
9.3 M-RoPE 与普通 RoPE 的区别
普通 RoPE 只有 1D 位置;M-RoPE 把 head_dim 拆成 T/H/W 三段(32+48+48),分别用三维位置旋转。
文本 token 三轴位置相同(1D 复制);图像 token 三轴分别是时间/高/宽——模型因此能感知图像
二维空间结构。
9.4 delta 补偿:3D 位置与序列长度不一致
图像 1369 个 token 的 3D 位置只跨 37,导致 positions.max()=61 远小于 L=1394,delta=-1332。
解码时 npos = cur + delta 把"绝对 token 计数"映射回"逻辑位置空间",实测第一步 npos=62,
正好是 positions.max()+1,保证生成 token 的 M-RoPE 位置紧接 prompt 末尾。
9.5 GQA + KV cache
每层缓存 k, v 各 (1, 2, L, 128)(2 个 KV 头)。解码时新 token 的 k/v 拼到 cache 末尾,
repeat_kv 把 2 头复制成 16 头与 q 对齐。Prefill 用 is_causal=True,单步解码 is_causal=False。
实测 cache 从 (1,2,1394,128) 增长到 (1,2,1395,128)。
9.6 重复惩罚
greedy 解码下对所有已出现 token 的 logit:负值 ×1.05、正值 ÷1.05,降低重复概率。无 beam search /
sampling / top-k,适合确定性单答。
9.7 权重 tying
lm_head 不单独存权重,直接共享 embed_tokens.weight,节省 151936×2048 参数。
9.8 局限
- 仅单图单轮、batch=1
- 仅 greedy + 重复惩罚
- 仅 bfloat16 / CUDA(8GB 显卡需
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True减少碎片) - 不使用 FlashAttention / fused kernel,为可读性牺牲吞吐