从零构建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)验证,非纸面推算。


目录

  1. 整体架构:从零构建需要哪些部件
  2. 模型配置
  3. 部件一:图像预处理
  4. 部件二:视觉编码器
  5. 部件三:多模态位置编码 M-RoPE
  6. 部件四:语言模型
  7. 部件五:视觉-语言融合与生成
  8. 完整前向传播 Shape 追踪(已验证)
  9. 关键设计要点

1. 整体架构:从零构建需要哪些部件

Qwen2.5-VL 是一个视觉-语言多模态自回归模型。从零构建它,需要依次实现下面 6 个部件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──────────────────────────────────────────────────────────────┐
│ 图像 ──▶ [1 预处理] ──▶ pixel_values │
│ │ │
│ ▼ │
[2 视觉编码器]
│ PatchEmbed → 32×(窗口/全局 Attn+MLP) │
│ → 2×2 Merger ──▶ 视觉 embedding (1369, 2048) │
│ │ │
│ 文本 prompt ──▶ tokenize + chat 模板 + image_pad 展开 │
│ │ │
│ ▼ │
[5 融合] masked_scatter 把视觉向量 │
│ 塞进 <|image_pad|> 占位 ──▶ inputs_embeds │
│ │ │
[3 M-RoPE] 构造 (3, L) 三维位置 │
│ │ │
│ ▼ │
[4 语言模型] 36×(GQA Attn+SwiGLU) │
│ │ │
[6 生成] prefill 建 KV cache → 逐 token 解码 │
└──────────────────────────────────────────────────────────────┘

构建顺序遵循数据流:先把图像变成视觉 token,再把图文拼成一条序列,最后让 LLM 自回归生成。


2. 模型配置

所有超参硬编码为常量,与 Qwen/Qwen2.5-VL-3B-Instructconfig.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
2
3
HEAD_DIM = T.hidden_size // T.num_heads   # 128  (语言模型头维度)
MERGE = V.spatial_merge_size # 2
SMU = MERGE * MERGE # 4 (每个合并块含 4 个 patch token)

3. 部件一:图像预处理

目标:把一张 PIL 图变成模型输入 pixel_values,并把图文拼成 token 序列。

本文全程使用示例图 example.png(原图 1024×1024):

example.png

示例图 example.png(1024×1024),用于下文所有 shape 追踪。

3.1 smart_resize:保持长宽比,两边整除 28

1
2
3
4
5
6
7
8
9
10
11
12
def smart_resize(h, w, factor=28, min_px=56*56, max_px=12845056):
h_bar = round(h / factor) * factor
w_bar = round(w / factor) * factor
if h_bar * w_bar > max_px: # 超像素上限:按比例缩
b = math.sqrt(h * w / max_px)
h_bar = max(factor, math.floor(h / b / factor) * factor)
w_bar = max(factor, math.floor(w / b / factor) * factor)
elif h_bar * w_bar < min_px: # 低于下限:按比例放
b = math.sqrt(min_px / (h * w))
h_bar = math.ceil(h * b / factor) * factor
w_bar = math.ceil(w * b / factor) * factor
return h_bar, w_bar

factor=28 = patch_size(14) × merge(2),保证 patch 网格能被 2×2 整除。

示例example.png 1024×1024 → smart_resize1036×1036grid_h = grid_w = 74

3.2 归一化 + patchify(按合并块排序)

1
2
3
4
5
6
7
8
x = pil_to_tensor(img.resize((W,H), BICUBIC)).float() / 255.0   # (3, 1036, 1036)
x = normalize(x, IMG_MEAN, IMG_STD) # CLIP 均值/方差
# 切 14×14 patch,再按 2×2 合并块重排,时间维复制成 2
x = x.view(3, 37, 2, 14, 37, 2, 14).permute(1,4,2,5,0,3,6) # (37, 37, 2, 2, 3, 14, 14)
x = x.reshape(74*74, 3, 14, 14) # (5476, 3, 14, 14)
x = x.unsqueeze(2).expand(-1, -1, 2, -1, -1) # (5476, 3, 2, 14, 14)
pixel_values = x.reshape(5476, 3*2*14*14) # (5476, 1176)
grid_thw = torch.tensor([[1, 74, 74]]) # (1, 3)

每行是一个 patch 的展平输入:3 通道 × 2 时间 × 14 × 14 = 1176行顺序是合并块顺序
(先排 37×37 个 2×2 块,块内 4 个 patch 连续),这样后续 Merger 能直接 view(-1, 5120)
把 4 个 patch 拼成一个向量。

3.3 chat 模板 + image_pad 展开

1
2
3
4
5
6
7
8
9
10
11
text = ("<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
"<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
"Describe this image.<|im_end|>\n<|im_start|>assistant\n")
ids = tokenizer(text).input_ids # 26 个 token(含 1 个 image_pad 占位)
n_img = (74//2) * (74//2) # 1369(2×2 合并后视觉 token 数)
# 把单个 image_pad 展开成 1369 个 IMAGE_TOKEN_ID
new = []
for t in ids:
new.extend([IMAGE_TOKEN_ID] * n_img if t == IMAGE_TOKEN_ID else [t])
input_ids = torch.tensor([new], dtype=torch.long) # (1, 1394) = 25 文本 + 1369 图像
mm_type = (input_ids == IMAGE_TOKEN_ID).int() # (1, 1394):0=文本, 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
2
3
4
5
6
7
class PatchEmbed(nn.Module):
def __init__(self):
k = (temporal_patch_size, patch_size, patch_size) # (2,14,14)
self.proj = nn.Conv3d(3, 1280, k, stride=k, bias=False)
def forward(self, x): # x: (5476, 1176)
x = x.view(-1, 3, 2, 14, 14) # (5476, 3, 2, 14, 14)
return self.proj(x).view(-1, 1280) # (5476, 1280)

验证patch_embed out = (5476, 1280)

4.2 二维 RoPE 频率表

视觉用 2D RoPE:每个 token 带一个 (row, col) 位置,同一 2×2 合并块内的 4 个 token 共享位置。

1
2
3
4
5
6
7
8
9
10
11
12
def vision_position_ids(grid_thw, merge):
"""(total_tokens, 2) -> (row, col) position per token; tokens in the same
2x2 merge block share a position."""
dev = grid_thw.device
out = []
for t, h, w in grid_thw.tolist(): # t=1, h=74, w=74
hp = torch.arange(h, device=dev).unsqueeze(1).expand(-1, w) # (74, 74) 行坐标
hp = hp.reshape(h // merge, merge, w // merge, merge).transpose(1, 2).flatten() # (5476,)
wp = torch.arange(w, device=dev).unsqueeze(0).expand(h, -1) # (74, 74) 列坐标
wp = wp.reshape(h // merge, merge, w // merge, merge).transpose(1, 2).flatten() # (5476,)
out.append(torch.stack([hp, wp], -1).repeat(t, 1)) # (5476, 2)
return torch.cat(out, 0) # (5476, 2)

关键:reshape(37, 2, 37, 2).transpose(1, 2).flatten() 让同一 2×2 合并块内的 4 个 token
拿到相同的 (row, col) – 位置以合并块为粒度,与后续 4→1 合并对齐。

1
2
3
4
5
6
7
8
9
class VisionRotary(nn.Module):
def __init__(self, dim): # dim = head_dim // 2 = 40
super().__init__()
self.dim = dim

def forward(self, pos): # pos: (L, 2)
inv_freq = 1.0 / (10000.0 ** (torch.arange(0, self.dim, 2, device=pos.device, dtype=torch.float) / self.dim)) # (20,)
return (pos.unsqueeze(-1) * inv_freq).flatten(1) # (L, 2, 20) -> (L, 40)
# 调用方后续: emb = torch.cat([rot, rot], -1) -> (L, 80) 对应 head_dim=80

验证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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def window_index(grid_thw, merge, window_size, patch_size):
"""Reorder indices so tokens of each attention window are contiguous."""
dev = grid_thw.device
idx, cu = [], [0]
wid = 0
mws = window_size // merge // patch_size # 112/2/14 = 4:每窗口 4×4 合并块
for t, h, w in grid_thw.tolist(): # t=1, h=74, w=74
lh, lw = h // merge, w // merge # 37, 37 (合并块网格)
index = torch.arange(t * lh * lw, device=dev).reshape(t, lh, lw) # (1, 37, 37) 块编号
ph = mws - lh % mws # 4 - 37%4 = 3 (行方向 pad)
pw = mws - lw % mws # 3
nwh, nww = (lh + ph) // mws, (lw + pw) // mws # 10, 10 (窗口行列数)
ip = F.pad(index, (0, pw, 0, ph), "constant", -100) # (1, 40, 40) pad 到整除
ip = ip.reshape(t, nwh, mws, nww, mws).permute(0, 1, 3, 2, 4).reshape(t, nwh * nww, mws, mws) # (1, 100, 4, 4)
seqlens = (ip != -100).sum([2, 3]).reshape(-1) # (100,) 每窗口有效合并块数
inew = ip.reshape(-1)
inew = inew[inew != -100] # 剔除 pad,保留有效块编号
idx.append(inew + wid) # 加全局偏移
cu.extend((seqlens.cumsum(0) * SMU + cu[-1]).tolist()) # 每窗口 token 数 = 块数×4(SMU)
wid += t * lh * lw
return torch.cat(idx, 0), torch.unique_consecutive(torch.tensor(cu, dtype=torch.int32, device=dev))
# win: (1369,) 重排后的合并块编号顺序
# cuw: (101,) 100 个窗口的累计 token 长度(满窗 4×4×4=64)

注意 win 长度是 1369(合并块数),不是 5476——它重排的是合并块,每个块含 4 个 token。

验证win = (1369,)cuw = (101,)(100 窗口 + 起始 0),满窗 seg_max = 64

注意力本身:

1
2
3
4
5
6
7
8
9
10
class VisionAttn(nn.Module):
def forward(self, x, cu, cos, sin): # x: (L, 1280)
q, k, v = self.qkv(x).reshape(L, 3, 16, 80).permute(1,0,2,3).unbind(0) # 各 (L,16,80)
q, k = self._rope(q, k, cos, sin) # 2D RoPE
q, k, v = [t.transpose(0,1).unsqueeze(0) for t in (q,k,v)] # 各 (1,16,L,80)
lens = (cu[1:] - cu[:-1]).tolist() # 各段长度
qs, ks, vs = torch.split(q, lens, 2), torch.split(k, lens, 2), torch.split(v, lens, 2) # 按段切开
out = torch.cat([F.scaled_dot_product_attention(qi,ki,vi, scale=scale)
for qi,ki,vi in zip(qs,ks,vs)], dim=2) # 每段独立注意力
return self.proj(out.transpose(1,2).reshape(L, 1280))
  • 窗口层(如第 0 层):cu=cuw,分 100 段,每段最多 64 token,段内互不注意力。
  • 全局层(如第 7 层):cu=[0, 5476],1 段,全部 5476 token 互注意力。

验证

1
2
3
block0  WINDOW : q,k,v = (1,16,5476,80) ×3 | 100 段 | seg_max=64
block7 FULLATT: q,k,v = (1,16,5476,80) ×3 | 1 段 | seg_max=5476
每层输出 block out = (5476, 1280) ✓

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
2
3
4
5
6
7
class Merger(nn.Module):
def __init__(self):
self.hs = 1280 * 4 # 5120
self.ln_q = RMSNorm(1280)
self.mlp = nn.Sequential(Linear(5120,5120), GELU(), Linear(5120, 2048))
def forward(self, x): # x: (5476, 1280)
return self.mlp(self.ln_q(x).view(-1, 5120)) # (1369, 2048)

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)
    • 高度 pharange(37) repeat
    • 宽度 pwarange(37) repeat
    • 该段位置跨度 = max(h,w)//MERGE = 37
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def rope_index(input_ids, mm_type, grid_thw):
"""3D mrope position ids for a single (batch=1, no padding) sequence."""
dev = input_ids.device
ids = input_ids[0]
L = ids.shape[0] # 1394
mt = mm_type[0].tolist()
groups = []
for k, g in itertools.groupby(enumerate(mt), lambda x: x[1]):
g = list(g)
groups.append((k, g[0][0], g[-1][0] + 1)) # (mod, start, end) 每段连续 run
cur = 0
parts = []
gi = iter(grid_thw.tolist())
for mod, s, e in groups:
if mod == 0: # 文本段 -> 1D 位置复制到 3 轴
n = e - s
parts.append(torch.arange(n, device=dev).view(1, -1).expand(3, -1) + cur) # (3, n)
cur += n
else: # 图像段 -> 3D 位置 (time_interval=1)
t, h, w = next(gi) # 1, 74, 74
lt, lh, lw = t, h // MERGE, w // MERGE # 1, 37, 37
pt = torch.arange(lt, device=dev).repeat_interleave(lh * lw) + cur # (1369,) 时间
ph = (torch.arange(lh, device=dev) + cur).repeat_interleave(lw).repeat(lt) # (1369,) 高
pw = (torch.arange(lw, device=dev) + cur).repeat(lh * lt) # (1369,) 宽
parts.append(torch.stack([pt, ph, pw], 0)) # (3, 1369)
cur += max(h, w) // MERGE # 跨度 37(不是 1369!)
positions = torch.cat(parts, 1) # (3, L=1394)
pos = torch.zeros(3, 1, L, dtype=torch.long, device=dev)
pos[:, 0] = positions
return pos, (positions.max() + 1 - L).item() # pos (3,1,1394), delta

验证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
2
3
4
5
6
7
8
9
10
def rope_cos_sin(self, pos):  # pos: (3, b, s) long
inv_freq = 1.0 / (T.rope_theta ** (torch.arange(0, HEAD_DIM, 2, device=pos.device, dtype=torch.float) / HEAD_DIM)) # (64,)
freqs = pos.float().unsqueeze(-1) * inv_freq # (3, b, s, 64)
emb = torch.cat([freqs, freqs], -1) # (3, b, s, 128)
cos, sin = emb.cos(), emb.sin() # 各 (3, b, s, 128)
cs, ss = cos.split(self.sec, -1), sin.split(self.sec, -1) # sec=[32,48,48] 拆 T/H/W 三段
cos = torch.cat([cs[i][i] for i in range(3)], -1).unsqueeze(1) # 第 i 段取第 i 轴 -> (b, 1, s, 128)
sin = torch.cat([ss[i][i] for i in range(3)], -1).unsqueeze(1)
dt = self.lm_head.weight.dtype
return cos.to(dt), sin.to(dt) # (b, 1, s, 128)

即: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
2
3
idx  0 .. 14        : 15 个文本 token  (system + user 前缀,到 <|vision_start|>)
idx 15 .. 1383 : 1369 个图像 token (37×37 网格)
idx 1384 .. 1393 : 10 个文本 token (<|vision_end|>Describe this image.<|im_end|>...assistant)

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) 三轴相等

读法:

  • 文本 tokent = 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
2
3
4
5
6
        w=15  16  17 ... 51
h=15 [ 15 16 17 ... 51 ] <- idx 15..51 (第 0 行)
h=16 [ 15 16 17 ... 51 ] <- idx 52..88 (第 1 行)
h=17 [ 15 16 17 ... 51 ] <- idx 89..125 (第 2 行)
...
h=51 [ 15 16 17 ... 51 ] <- idx 1347..1383(第 36 行)

这样模型对图像有了真正的二维空间感知:空间相邻的 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
2
3
4
5
6
7
8
9
10
11
12
13
class LLMAttn(nn.Module):
def forward(self, x, cos, sin, cache): # x: (b, L, 2048)
q = self.q_proj(x).view(b, L, 16, 128).transpose(1,2) # (b, 16, L, 128)
k = self.k_proj(x).view(b, L, 2, 128).transpose(1,2) # (b, 2, L, 128) GQA
v = self.v_proj(x).view(b, L, 2, 128).transpose(1,2)
q, k = apply_mrope(q, k, cos, sin) # M-RoPE 旋转
if cache is not None:
k = torch.cat([cache[0], k], 2) # 拼历史 K
v = torch.cat([cache[1], v], 2) # 拼历史 V
k = repeat_kv(k, 8) # 2 头复制成 16 头
v = repeat_kv(v, 8)
a = F.scaled_dot_product_attention(q, k, v, is_causal=(L>1), scale=128**-0.5)
return self.o_proj(a.transpose(1,2).reshape(b, L, 2048)), (k_raw, v_raw)
  • prefillL=1394>1):is_causal=True,整条序列一次算完,建立 KV cache。
  • 解码L=1):is_causal=False,新 token 的 k/v 拼到 cache 末尾,只算 1 个 query 对全部历史。

验证(layer 0,prefill):

1
2
3
4
5
q          = (1, 16, 1394, 128)   # 16 query 头
k, v = (1, 2, 1394, 128) # 2 KV 头
repeat_kv = (1, 16, 1394, 128) # 复制成 16 头与 q 对齐
prefill logits = (1, 1394, 151936)
cache 每层 = (1, 2, 1394, 128) ×2 (k,v),共 36 层 ✓

6.2 SwiGLU MLP + RMSNorm

无 bias SwiGLU:down(silu(gate(x)) * up(x)),中间维度 11008。每层两个 RMSNorm(input / post-attention)。


7. 部件五:视觉-语言融合与生成

7.1 融合:masked_scatter 注入

1
2
3
4
ie = visual(pixel_values, grid_thw)              # (1369, 2048) 视觉 embedding
embeds = embed_tokens(input_ids) # (1, 1394, 2048)
mask = (input_ids == IMAGE_TOKEN_ID).unsqueeze(-1) # (1, 1394, 1),1369 处 True
embeds = embeds.masked_scatter(mask, ie.reshape(-1)) # 把视觉向量塞进 image_pad 位置

验证mask = (1, 1394, 1)num_true = 1369,注入后 embeds = (1, 1394, 2048)

7.2 生成循环

  1. 视觉编码ie (1369, 2048)
  2. 注入embeds (1, 1394, 2048)
  3. 位置pos (3, 1, 1394) + delta
  4. Prefillforward(embeds, pos) 一次跑完整条序列,建 KV cache,取末位 logits
  5. 解码循环
    • 对已出现 token 做重复惩罚:负 logit ×1.05、正 Logit ÷1.05
    • argmax 取下一个 token,命中 EOS (151645/151643) 则停
    • 新 token embedding (1,1,2048) + 新位置 npos (3,1,1)forward(..., cache) 增量解码
1
2
3
npos = torch.full((3, 1, 1), cur + delta, dtype=torch.long, device=dev)  # cur 从 L=1394 起,+delta 回到逻辑位置空间
nem = embed_tokens(torch.tensor([[nxt]], device=dev)) # (1, 1, 2048)
logits, cache = forward(nem, npos, cache) # 用 cache 增量

验证(第一步解码):

1
2
3
4
5
next token id = 785  ->  "The"
nem = (1, 1, 2048)
npos = (3, 1, 1),值 = 62 # = 1394 + (-1332),与 positions.max()+1 吻合
decode logits = (1, 1, 151936)
cache after +1 = (1, 2, 1395, 128) # 序列长度 +1

7.3 权重加载

直接读 safetensors shard,strict=False 加载,并 tying 输出权重:

1
2
3
4
5
sd = {}
for f in sorted(glob(path + "/model-*.safetensors")):
sd.update(load_file(f))
model.load_state_dict(sd, strict=False)
model.lm_head.weight = model.model.embed_tokens.weight # tied embeddings

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
图像 (1024,1024,3)
│ smart_resize + normalize + patchify

pixel_values (5476, 1176) ──grid_thw (1,3)──┐
│ PatchEmbed (Conv3d)
▼ │
(5476, 1280)
│ 32×[窗口/全局 Attn + SwiGLU] │
▼ │
(5476, 1280)
│ Merger (41)

视觉 embedding (1369, 2048)
masked_scatterinput_ids embedding

embeds (1, 1394, 2048) ←── pos (3,1,1394) M-RoPE [delta=-1332]
│ 36×[GQA Attn + SwiGLU] (建 KV cache)

logits (1, 1394, 151936) ─取末位─▶ argmax ──▶ "The"(785)

┌─── 循环 ──────────┘

nem (1,1,2048) + npos (3,1,1)=62 + cache
│ 36 层(每层 k,v 长度 +1 → 1395)

logits (1,1,151936) ─▶ argmax ─▶ ... 直到 EOS

9. 关键设计要点

9.1 视觉 token 4× 压缩

patch_size=14 把 1036×1036 切成 74×74=5476 patch;spatial_merge_size=2Merger 把每个
2×2 块的 4 个 patch 合成 1 个 token,5476/4 = 1369。这是压缩视觉序列长度、降低 LLM 端注意力
开销的关键。

9.2 窗口注意力的一维打包实现

不用 mask,而是 window_index 把 token 按 4×4 合并块窗口重排(win 长度 1369),让同窗口
token 连续;cuw 给出每窗口累计长度;VisionAttntorch.splitcuw 切段,对每段独立
调 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=1394delta=-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,为可读性牺牲吞吐


从零构建Qwen2.5VL的推理
https://huan-yin.github.io/2026/07/23/从零构建Qwen2-5VL的推理/
作者
李相越
发布于
2026年7月23日
许可协议