RussellLuo

让思想在文字间徜徉

GPT-2 中 Self-Attention 的位置

在上一篇《Hidden State 如何流过 GPT-2 Block?》中,我们只跟踪了 Self-Attention 在 Block 中的输入和输出:LayerNorm 之后的 Hidden State 进入 Self-Attention,得到的 Attention 更新再通过 Residual Connection 加回原来的 Hidden State。本篇进一步展开内部计算,观察它如何读取可见上下文。

GPT-2 同时使用多个 Attention Head(注意力头,以下简称 Head)。本文先说明单个 Head 与整体 Self-Attention 的关系,再以 How are 中的 " are" 为例,完整跟踪 Q、K、V、匹配分数、Causal Mask(因果掩码)、Softmax 和 Value 加权求和。单头视角不是额外的执行阶段,而是从多头并行计算中抽出的一条分支。

为什么需要 Self-Attention

先看两个结构相同的句子:

1
2
这种水果叫苹果,它很甜。
这家公司叫苹果,它生产手机。

两句话都有“苹果”和“它”,但上下文不同。“水果”和“公司”会影响“苹果”在当前句子中的表示;处理“它”时,也需要结合此前位置的信息。也就是说,一个位置的表示不能只包含当前 Token,还需要聚合可见上下文。

回到本文使用的 "How are",在第一个 GPT-2 Block 之前,每个位置的 Initial Hidden State 由自己的 Token Embedding 与 Position Embedding 相加得到:

1
2
"How"   → x_(0,0) [D]
" are" → x_(0,1) [D]

x_(0,0)x_(0,1) 已经分别包含“当前是什么 Token”和“当前位于哪里”,但 " are" 还没有通过 Attention 聚合 "How" 的信息。

Self-Attention 将上下文读取拆成三个部分:

  1. Causal Mask 决定当前位置可以读取哪些位置。
  2. Q、K 的匹配分数决定各个可见位置应该获得多大权重。
  3. 模型使用这些权重对 V 加权求和,得到包含上下文信息的新向量。

如果始终以相同权重汇总所有可见位置的 V,模型就无法根据当前输入决定更侧重哪些位置。Self-Attention 则会根据当前 Hidden State 动态计算 Attention 权重,从可见位置中有选择地汇总信息。

为什么 Transformer 使用 Self-Attention?

GPT-2 采用基于 Transformer 的架构。与其他序列信息混合方式相比:

  • RNN 需要沿序列逐步传递状态,位置之间的计算具有顺序依赖。
  • 常规局部 CNN 可以并行计算,但远距离位置通常需要经过多层才能建立联系。
  • Self-Attention 可以在一层内连接任意两个允许读取的位置,并对一段已经给定的序列并行计算各位置。

这里的并行是指一次前向计算内部的位置计算;GPT-2 的自回归生成仍然需要逐个生成 Token。

Self-Attention 与单个 Head

上一篇从 Block 的整体视角,把 Self-Attention 写成一个 [T,D] → [T,D] 的子层。将 LayerNorm 的输出记为 Z_l,可以写成:

1
2
Z_l = LN_attn_l(X_l)       [T,D]
A_l = Attention_l(Z_l) [T,D]

其中,X_l [T,D] 是第 l 个 Block 的输入,A_l [T,D] 是完整的 Attention 更新。展开中间过程,可以看到多个 Head 与整体 Self-Attention 的关系:

GPT-2 Small 中多个 Attention Head 与单个 Head 的关系

GPT-2 Small 的 Hidden size 为 D = 768,包含 12 个 Head,因此每个 Head 的维度为:

1
D_h = D / 12 = 64

这里的 D / 12 只用于确定每个 Head 的特征宽度,不是把输入 Z_l 预先切成 12 份。12 个 Head 使用各自的投影参数,并行读取同一个 Z_l [T,768];每个 Head 都从完整的 Z_l 产生一个 O_h [T,64]

12 个 Head 的输出随后沿特征维合并回 [T,768];Token 数量 T 不变,也不是逐元素相加。本文只跟踪到 O_h

上下文读取的完整流程

下面只观察第 h 个 Head,并以 " are" 位置的 Query 为例,沿图中箭头从上向下跟踪一次上下文读取:

Z_l 中“ are”位置向量通过一个 Attention Head 读取可见上下文

当前 Attention 的输入是 Z_l [T,D]。在这个例子中,它包含 "How"" are" 两个 Token 位置;真正进入当前 Head 的分别是位置向量 z_(l,0) [D]z_(l,1) [D]

为了与图中的符号对应,下面用下标 01 表示 Token 位置,用上标 (h) 表示当前观察的第 h 个 Head。

" are" 位置的读取过程可以分成四步:

  1. 计算 Q、K、V:当前 Head 使用三组线性变换,把 z_(l,0)z_(l,1) 分别投影为各自的 Query、Key 与 Value;其中 " are" 的 Query 记为 q_1^(h),后续沿它展开。
  2. 计算匹配分数q_1^(h) 分别与两个输入位置的 Key(k_0^(h)k_1^(h))做点积并缩放。
  3. 得到 Attention 权重:Causal Mask 先排除不允许读取的位置,Softmax 再把保留的分数转换成权重 p_(1,0)p_(1,1),分别对应 "How"" are"
  4. 读取 Value:将两个 Value(v_0^(h)v_1^(h))分别乘以对应的权重,再相加得到当前 Head 在 " are" 位置的输出 o_1^(h) [D_h]

实际的 Self-Attention 会在一次前向计算中为序列中的各个位置计算读取结果。“Self”表示 Q、K、V 来自同一段输入序列;“Causal”表示每个位置只能读取自身及此前位置。

Q、K、V

Q、K、V 都来自同一个 Z_l,但使用不同的投影参数,因此承担不同作用。可以先这样理解三者的分工:

  • Query:当前位置拿什么条件去查找。
  • Key:每个位置拿什么特征参与匹配。
  • Value:匹配到某个位置后,实际汇总什么向量。

对第 h 个 Head,Q、K、V 的计算可以写成:

1
2
3
Q_h = Z_l W_Q^(h) + b_Q^(h)
K_h = Z_l W_K^(h) + b_K^(h)
V_h = Z_l W_V^(h) + b_V^(h)

Z_l W_Q^(h) 表示矩阵乘法;后文相邻书写的矩阵也表示矩阵乘法。

当前 Head 使用三组训练得到的投影参数:权重矩阵 W_Q^(h)W_K^(h)W_V^(h),以及偏置向量 b_Q^(h)b_K^(h)b_V^(h)。这些参数由当前 Head 的所有 Token 位置共用;Q、K、V 则由各位置的当前输入计算,会随输入变化。

三组投影参数及其输出的 shape 是:

1
2
3
W_Q^(h) / W_K^(h) / W_V^(h)  [D, D_h]
b_Q^(h) / b_K^(h) / b_V^(h) [D_h]
Q_h / K_h / V_h [T, D_h]

小写的 q_i^(h)k_i^(h)v_i^(h) 表示第 i 个 Token 位置在当前 Head 中的向量;大写的 Q_hK_hV_h 则把所有 T 个位置的向量按行放在一起。因此:

1
2
3
q_i^(h) = Q_h[i, :]
k_i^(h) = K_h[i, :]
v_i^(h) = V_h[i, :]

对于 "How are",图中跟踪的 q_1^(h) 就是 Q_h 中索引为 1 的行,也就是第二行。

Scaled Dot-Product Attention

Q、K、V 准备好后,当前 Head 会依次计算匹配分数、Attention 权重和 Value 加权结果。这一过程就是 Scaled Dot-Product Attention(缩放点积注意力)。

下面先沿 q_1^(h) 的路径逐步展开,再扩展到整个 Head 的矩阵形式。

原始匹配分数

一个 Query 与一个 Key 都是 D_h 维向量,二者的点积得到一个匹配分数:

1
score_h(i, j) = q_i^(h) · k_j^(h)

对于图中 " are" 位置的 q_1^(h)

1
2
score_h(1, 0) = q_1^(h) · k_0^(h)
score_h(1, 1) = q_1^(h) · k_1^(h)

分数越大,表示 Query i 与 Key j 在模型学到的特征空间中越匹配,但它还不是 Attention 权重。

从分数到 Attention 权重

原始分数要依次经过三个步骤:缩放 → Causal Mask → Softmax

第一步:缩放。D_h 较大时,点积的数值幅度也容易增大,使 Softmax 过早集中到少数位置。除以 sqrt(D_h) 可以控制分数的典型尺度:

1
scaled_score_h(i, j) = score_h(i, j) / sqrt(D_h)

GPT-2 Small 的单个 Head 为 D_h = 64,所以缩放因子是:

1
1 / sqrt(64) = 1 / 8

第二步:加入 Causal Mask。 GPT-2 是 Decoder-only 模型,使用自回归方式预测下一个 Token。计算位置 i 的表示时,只允许读取位置 0i,不能读取未来位置 j > i

对于当前的 "How are",Causal Mask 可以写成:

1
2
3
4
                    Key
"How" " are"
Query "How" 0 -∞
" are" 0 0

Causal Mask 会加到缩放后的分数上:允许读取的位置加 0,不改变分数;未来位置加 -∞,经过 Softmax 后权重变成 0。因此,q_0^(h) 只能读取 "How"q_1^(h) 可以读取 "How"" are"

扩展到更长序列时,Causal Mask 会形成同样的下三角结构:即使一次输入整段文本,每个位置也只能使用当时可见的信息。

Causal Mask 是否已经包含全部位置信息?

不是。Causal Mask 只规定“哪些位置可见”,不能独自告诉模型当前位置是第几个 Token。GPT-2 的输入已经加入 Position Embedding;它通过训练学习得到,并使用绝对位置编号。Self-Attention 再使用 Causal Mask 限制信息流向。去掉位置输入后,普通 Self-Attention 本身无法区分不同排列中的绝对位置。

第三步:Softmax。 加入 Causal Mask 后,Softmax 沿每个 Query 对应的那一行计算,把可见位置的分数转换成总和为 1 的非负权重。对于 q_1^(h),得到的两个权重是:

1
[p_(1,0), p_(1,1)]

p_(1,0) 表示 " are""How" 读取信息的权重,p_(1,1) 表示它从自身读取信息的权重。

用 Attention 权重读取 Value

得到 Attention 权重后,当前 Query 使用这些权重对所有可见位置的 Value 做加权求和:

1
o_i^(h) = Σ_j p_(i,j) v_j^(h)

对于图中的 " are"

1
o_1^(h) = p_(1,0) v_0^(h) + p_(1,1) v_1^(h)

权重作用在 Value 上,而不是直接复制某个 Token,也不是对 Key 做加权求和。计算结果 o_1^(h) [D_h] 就是当前 Head 在 " are" 位置的输出。

整个 Head 的矩阵形式

上面只跟踪了 q_1^(h) 的一条路径。实际计算会同时处理所有 Query 位置:

1
2
3
S_h = Q_h K_h^T / sqrt(D_h) + M
P_h = softmax(S_h)
O_h = P_h V_h

K_h^T 表示 K_h 的转置。K_h [T,D_h] 转置后变为 K_h^T [D_h,T],因此 Q_h [T,D_h]K_h^T [D_h,T] 相乘会得到 [T,T] 的分数矩阵。

其中:

  • M [T,T] 是前面 Causal Mask 表格的矩阵形式。
  • S_h [T,T] 是原始匹配分数经过缩放并加上 M 后的分数矩阵。
  • P_h [T,T] 是 Attention 权重,Softmax 沿每一行计算。
  • O_h [T,D_h] 是当前 Head 的输出。

位置级符号与矩阵中的元素一一对应:

1
2
3
4
5
(Q_h K_h^T)[i,j]  = score_h(i, j) = q_i^(h) · k_j^(h)
S_h[i,j] = score_h(i, j) / sqrt(D_h) + M[i,j]
P_h[i,:] = softmax(S_h[i,:])
P_h[i,j] = p_(i,j)
O_h[i,:] = o_i^(h)

因此,当前 q_1^(h) 对应 P_h 中索引为 1 的第二行,输出 o_1^(h) 对应 O_h 中索引为 1 的第二行。

合在一起,就是 Scaled Dot-Product Attention 的完整公式:

1
O_h = softmax(Q_h K_h^T / sqrt(D_h) + M) V_h

最小数值例子

下面继续使用 "How are" 的两个位置(T = 2),并将 Head 维度简化为 D_h = 4,把分数、Causal Mask、Softmax 和 Value 加权连起来。为便于手算,这里只观察一个 Head,省略下标 h,并取 Q = K。GPT-2 Small 的实际 D_h64,示例数值不代表真实运行结果。

QV 的两行以及 K^T 的两列,都依次对应 "How"" are"。设:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Q = [
[1, 0, 1, 1],
[1, 1, 0, 1]
]

K^T = [
[1, 1],
[0, 1],
[1, 0],
[1, 1]
]

V = [
[1, 0, 1, 0],
[0, 1, 0, 1]
]

Q 的每一行与 K^T 的每一列做点积,得到原始匹配分数:

1
2
3
4
Q K^T = [
[3, 2],
[2, 3]
]

再除以 sqrt(D_h) = sqrt(4) = 2,得到缩放后的分数:

1
2
3
4
Q K^T / sqrt(4) = [
[1.500, 1.000],
[1.000, 1.500]
]

Query 行和 Key 列均按 "How"" are" 的顺序排列,Causal Mask 为:

1
2
3
4
M = [
[0, -∞],
[0, 0]
]

M 加到缩放后的分数上,未来位置才会变成 -∞

1
2
3
4
S = Q K^T / sqrt(4) + M = [
[1.500, -∞],
[1.000, 1.500]
]

接着逐行计算 P = softmax(S)" are" 对应的第二行计算结果是:

1
softmax([1.000, 1.500]) ≈ [0.378, 0.622]

得到完整的 Attention 权重:

1
2
3
4
P = [
[1.000, 0.000],
[0.378, 0.622]
]

最后用这些权重对 V 加权求和," are" 位置的输出是:

1
2
O_1 ≈ 0.378 [1, 0, 1, 0] + 0.622 [0, 1, 0, 1]
= [0.378, 0.622, 0.378, 0.622]

完整结果为:

1
2
3
4
O ≈ [
[1.000, 0.000, 1.000, 0.000],
[0.378, 0.622, 0.378, 0.622]
]

"How" 位置只能读取自己的 V_0" are" 位置则从 V_0V_1 读取信息。这就是一个 Head 中完整的 Causal Self-Attention 计算。

完整 shape 主线

训练完成后,W_Q^(h)W_K^(h)W_V^(h) 及其偏置作为模型参数,在推理时保持不变。运行时,当前 Z_l 依次产生 Q_hK_hV_h、匹配分数、Attention 权重和 O_h,这些 Tensor 都会随输入、Block 或 Head 发生变化。因此,“Attention 权重”不是保存在模型文件中的固定参数。

Causal Mask 稍有不同:它的因果规则固定,但具体 Tensor 由当前序列长度和位置关系确定。

前文的 W_Q^(h)W_K^(h)W_V^(h) 及其偏置,是按单个 Head 展开的逻辑写法;GPT-2 如何组织多个 Head 的参数留到下一篇。

这条单头计算路径的逻辑 shape 如下:

阶段 逻辑 shape GPT-2 Small(T=2
Attention 输入 Z_l [T,D] [2,768]
Q_h / K_h / V_h 各自 [T,D_h] 各自 [2,64]
Attention 分数 S_h / 权重 P_h 各自 [T,T] 各自 [2,2]
Head 输出 O_h [T,D_h] [2,64]

O_h 对应整体视图中的一条 Head 分支,还不是最终的 Attention 更新 A_l [T,D]

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从真实运行中选出 Block 0 / Head 0,验证单头公式。基础环境和模型准备见《LLM 如何逐个生成 Token?》gguf-dump 的安装方式见《文本如何变成 Token ID?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的调试程序:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

确认单个 Head 的逻辑维度

先从 GGUF 元数据读取 Hidden size 和 Head 数,并计算单个 Head 的维度:

1
2
3
4
5
6
7
8
9
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '
(.metadata["gpt2.embedding_length"].value) as $d |
(.metadata["gpt2.attention.head_count"].value) as $h |
{
hidden_size: $d,
head_count: $h,
head_dim: ($d / $h)
}'

当前模型的输出为:

1
2
3
4
5
{
"hidden_size": 768,
"head_count": 12,
"head_dim": 64
}

Hidden size 是 768,共分为 12 个 Head,因此每个 Head 的维度是 768 / 12 = 64。本文只选择 Block 0 / Head 0,因此后面的 Q、K、V 和输出都按单头逻辑 shape [T,64] 阅读,不比较不同 Head。

查看运行时 shape

How are 为输入,筛选 Block 0 的 Attention 中间 Tensor:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
rg 'common_debug_cb_eval: +(Qcur-0|Kcur-0|Vcur-0|kq-0|kq_soft_max-0|kqv-0) ='

本次实验使用 --flash-attn off 保留 kqkq_soft_maxkqv 三个中间 Tensor,便于逐步检查单头计算过程。

经过裁剪的真实输出如下:

1
2
3
4
5
6
Qcur-0         = {64, 12, 2, 1}
Kcur-0 = {64, 12, 2, 1}
Vcur-0 = {64, 12, 2, 1}
kq-0 = {256, 2, 12, 1}
kq_soft_max-0 = {256, 2, 12, 1}
kqv-0 = {64, 2, 12, 1}

节点名末尾的 -0 表示 Block 0,不表示 Head 0。对于 Q、K、V,{64,12,2,1} 的前三个维度依次表示每个 Head 的特征数、Head 数和 Token 数,即 {head_dim, head_count, T, 1}。Head 0 就是在 Head 数所在的第二个维度取索引 0

分数 Tensor 的维度顺序不同:在 {256,2,12,1} 中,第三个维度 12 才是 Head 数,因此 Head 0 是该维度中索引为 0 的切片。后面的数值实验都只读取这个切片。

运行时输出采用 GGML 的维度顺序,与前文使用的逻辑 shape 写法不同。只取 Head 0 后,可以按计算顺序理解为:

运行时 Tensor 单头逻辑含义 单头逻辑 shape
Qcur-0 / Kcur-0 / Vcur-0 Q_h / K_h / V_h 各自 [2,64]
kq-0 Q_h K_h^T 的原始分数 有效区域 [2,2]
kq_soft_max-0 缩放、Causal Mask、Softmax 后的 P_h 有效区域 [2,2]
kqv-0 P_h V_h [2,64]

llama.cpp 会先把本轮 Kcur-0Vcur-0 写入 KV Cache,再让 Attention 读取 KV Cache 中的 K、V 视图。当前实验从空 KV Cache 开始处理 How are,因此前两个有效位置就是本轮产生的 K、V,对应这里的 K_hV_h

kq-0 中的 256 来自当前 KV Cache 的对齐视图容量。本轮只有 How are 两个位置有效,其余槽位会被 Causal Mask 排除;它不表示输入变成了 256 个 Token。

验证单头 Attention 计算

分数与 Attention 权重。 保留 kq-0kq_soft_max-0 的 Tensor 数值:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
sed -n '/kq-0 =/,/sum =/p; /kq_soft_max-0 =/,/sum =/p'

从输出中取出 Head 0 的两个 Query,并为每个 Query 只保留前两个 Key 位置:

Query kq 原始分数 kq_soft_max 权重
Query 0 / How [3.7358, -13.7144] [1.0000, 0.0000]
Query 1 / are [-1.3936, -25.5417] [0.9534, 0.0466]

kq 是尚未缩放的 Q/K 点积分数。GPT-2 Small 的 kq_scale = 1/8,所以 " are" 的两个可见分数先变成:

1
2
[-1.3936 / 8, -25.5417 / 8]
≈ [-0.1742, -3.1927]

两个位置对 Query 1 都可见,Softmax 后得到:

1
[0.9534, 0.0466]

Query 0 虽然在 kq 中也有两个原始分数,但 " are" 对它来说是未来位置,Causal Mask 会把第二项排除,因此最终权重为 [1.0000, 0.0000]。这验证了两个不同职责:Q/K 点积决定匹配分数,Causal Mask 决定哪些分数允许参与 Softmax。

Value 加权。 继续查看 Vcur-0kqv-0

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
sed -n '/Vcur-0 =/,/sum =/p; /kqv-0 =/,/sum =/p'

将 Head 0 的两个 Value 各保留前 3 维,并列出 Query 1 的两个权重:

1
2
3
4
5
v_0^(0) = [-0.1252,  0.0577, 0.0428, ...]   # "How"
v_1^(0) = [ 0.2463, -0.0901, 0.2779, ...] # " are"

p_(1,0) = 0.9534
p_(1,1) = 0.0466

按照单头公式:

1
2
3
o_1^(0)
= 0.9534 × v_0^(0) + 0.0466 × v_1^(0)
≈ [-0.1079, 0.0508, 0.0538, ...]

kqv-0 中 Query 1 对应位置的前 3 维是:

1
[-0.1080, 0.0509, 0.0538, ...]

正文只保留了 4 位小数,因此手算结果与回调存在约 0.0001 的舍入误差。这仍验证了 kq_soft_max 中的 Attention 权重确实作用在 Value 上,并产生 Head 0 的输出 O_h [2,64]

改变输入。 最后把输入扩展为三个 Token How are you,再次查看 kq_soft_max-0

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are you' \
--flash-attn off \
2>&1 |
sed -n '/kq_soft_max-0 =/,/sum =/p'

输出的第一个 Head 切片包含三个 Query;只保留前三个 Key 位置:

1
2
3
Query 0 / "How"  = [1.0000, 0.0000, 0.0000]
Query 1 / " are" = [0.9534, 0.0466, 0.0000]
Query 2 / " you" = [0.8849, 0.0563, 0.0588]

Query 0 只能读取位置 0,Query 1 只能读取位置 0~1,Query 2 才能读取全部三个位置。前两个 Query 的权重与 How are 实验相同,说明在同一次前向计算中,新增的未来 Token 不会影响此前位置。

对照源码

GPT-2 在 llama_model_gpt2::graph::graph() 中先调用 build_qkv(),再把 Q、K、V 和 1/sqrt(D_h) 传入 build_attn()

1
2
3
4
5
6
7
auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur,
n_embd_head, n_head, n_head_kv, il);

cur = build_attn(inp_attn,
model.layers[il].wo, model.layers[il].wo_b, model.layers[il].wo_s,
Qcur, Kcur, Vcur, nullptr, nullptr, nullptr,
1.0f/sqrtf(float(n_embd_head)), il);

build_qkv() 产生包含全部 Head 的 QcurKcurVcur,随后由 build_attn() 继续处理。本文只从这些 Tensor 中选择 Head 0,把它们当作单头计算的输入;GPT-2 如何一次产生并组织所有 Head 的 Q、K、V 留到下一篇。

build_attn() 再通过 cpy_k()cpy_v() 把当前 K、V 写入 KV Cache,并用 get_k()get_v() 取得 KV Cache 视图,交给 build_attn_mha()

关闭 Flash Attention 后,build_attn_mha() 中与单头公式对应的核心操作是:

1
2
3
4
5
6
ggml_tensor * kq = ggml_mul_mat(ctx0, k, q);

kq = ggml_soft_max_ext(ctx0, kq, kq_mask, kq_scale,
hparams.f_max_alibi_bias);

ggml_tensor * kqv = ggml_mul_mat(ctx0, v, kq);

逐项对应如下:

数学计算 llama.cpp / GGML
Q_h K_h^T ggml_mul_mat(k, q)kq
缩放 + Causal Mask + Softmax ggml_soft_max_ext(..., kq_mask, kq_scale, ...)kq_soft_max
P_h V_h ggml_mul_mat(v, kq)kqv

虽然函数名是 build_attn_mha(),Q、K、V Tensor 也保留了 12 个 Head,但上面三步会沿 Head 维分别计算;只取 Head 0,就得到本文推导的单头公式。

源码没有为缩放、Causal Mask 和 Softmax 分别创建三个节点。ggml_soft_max_ext() 同时接收 kq_scalekq_mask,在一个操作中完成这三步。上述调用只是在构建 GGML 计算图,真正的数值由后端执行。

小结

本文以 "How are" 中的 " are" 为例,从单个 Head 的视角说明了 Self-Attention 如何读取可见上下文:

  • 一个 Attention Head 用 Query 与 Key 计算分数,经缩放和 Causal Mask 排除未来位置,再由 Softmax 得到 Attention 权重,最后汇总 Value。
  • Q、K、V、Attention 权重和 Head 输出都由当前输入动态产生;训练得到并保存在模型文件中的是投影参数,而不是固定的 Attention 结果。
  • GPT-2 Small 并行计算 12 个 Head;单个 Head 输出 O_h [T,64],还不是完整的 Attention 更新 A_l [T,768]

GPT-2 中 12 个 Transformer Block 的位置

在上一篇《Token ID 如何变成向量?》中,How are 对应的 Token Embedding 与 Position Embedding 相加,得到 shape 为 [2,768] 的 Initial Hidden State(初始隐藏状态)。它是第一个 GPT-2 Block 的输入,但还不是模型最终用于预测下一个 Token 的表示。

接下来,Hidden State 会依次流过 12 个 GPT-2 Block。本文先把 Self-Attention 和 MLP 当作两个内部黑盒,重点理解每个 Block 的共同骨架:Pre-Norm、Residual Connection,以及一个 Block 的输出如何成为下一个 Block 的输入;最后再用 llama.cpp 的实际代码和运行结果验证这条数据流。

GPT-2 Block

Embedding 只完成了输入表示的初始化。真正结合上下文、逐层更新每个 Token 表示的工作,主要发生在这 12 个 Block 中。

一个 GPT-2 Block 包含两个子层:

  1. Self-Attention(自注意力),结合可见的上下文更新每个位置的表示。
  2. MLP(多层感知机),分别更新每个位置的表示。

GPT-2 使用 Causal Self-Attention(因果自注意力)

这里的 Causal 表示每个位置只能读取自身和此前的位置,不能读取后续位置。

Self-Attention 和 MLP 都采用 Pre-Norm 和 Residual Connection(残差连接),完整数据流如下:

Hidden State 流过一个 GPT-2 Block 的数据流

图中,黑色主路径经过 LayerNorm 和子层,分别产生 Attention 更新 A_l 和 MLP 更新 M_l;蓝色 Residual Connection 旁路则保留各子层进入 LayerNorm 前的状态。在每个阶段,两条路径都在 + 处逐元素相加。

一个 Block 的完整数据流可以分为两个依次执行、结构相同的阶段:

  1. Attention 阶段:以 X_l 为输入,经过 LayerNorm 和 Self-Attention 得到更新 A_l;再将 A_l 与原始 X_l 相加,得到 H_l
  2. MLP 阶段:以 H_l 为输入,经过 LayerNorm 和 MLP 得到更新 M_l;再将 M_l 与原始 H_l 相加,得到 X_(l + 1)

如果第 l 个 Block 的输入是 X_l,一层数据流可以写成:

1
2
3
4
5
A_l       = Attention_l(LN_attn_l(X_l))
H_l = X_l + A_l

M_l = MLP_l(LN_ffn_l(H_l))
X_(l + 1) = H_l + M_l

Hidden State

Hidden State 可以理解为模型在当前层对整段 Token 序列的内部表示。对于包含 T 个 Token、Hidden size 为 D 的输入,它的 shape 是:

1
[T, D]

每个 Token 位置对应一个 D 维向量。GPT-2 Small 的 D = 768,因此 How are 在原理视角下对应:

1
2
3
"How"  的表示 ─┐
├─ Hidden State [2, 768]
" are" 的表示 ─┘

这里的 Hidden State 不是另一张可以直接翻译回文本的词表,也不是给每个 Token 固定不变的向量。它有三个重要特点:

  • 同一位置的向量会随着 Block 的计算不断更新。
  • 向量逐渐包含当前位置及其可见上下文的信息。
  • Block 输入和输出的 Hidden size 都是 768,因此多个 Block 可以首尾相接。

x_(l,i) 表示第 l 个 Block 输入中第 i 个 Token 位置的 Hidden 向量。它不是一个数,而是包含 D 个数的一维向量:

1
x_(l,i) [D] = [x_(l,i,0), x_(l,i,1), ..., x_(l,i,D-1)]

T 个位置的向量按照 Token 顺序纵向排列,就得到整个 Hidden State X_l

1
2
3
4
5
6
X_l [T,D] = [
x_(l,0) ← 第 0 个 Token 位置的 D 维向量
x_(l,1) ← 第 1 个 Token 位置的 D 维向量
...
x_(l,T-1) ← 第 T - 1 个 Token 位置的 D 维向量
]

其中,l 表示 Block 层编号,i 表示 Token 在序列中的位置,最后一个下标表示向量内部的维度编号。对于 GPT-2 Small,D = 768

Self-Attention 会在遵守因果约束的前提下,让不同位置之间交换信息;MLP 则对每个位置分别做进一步变换。虽然内部计算不同,但两个子层最终都会回到 [T,D],才能与 Residual Connection 分支逐元素相加。

Pre-Norm

LayerNorm(层归一化)先对每个 Token 位置的 768 维向量单独归一化,再使用训练得到的 weight 和 bias 做缩放与平移。对一个 Hidden 向量 x,可以简写为:

1
LN(x) = γ ⊙ (x - μ) / sqrt(σ² + ε) + β

其中,μσ² 由当前 Hidden 向量的 768 个值计算得到;γβ 分别是训练得到的 weight 和 bias;ε 是一个固定的很小正数,用于避免方差过小时分母接近 0。LayerNorm 不会改变 Token 数量或 Hidden size:

1
2
输入  [T, D]
输出 [T, D]

为什么需要 LayerNorm?

Hidden State 经过不同 Block 后,数值的整体偏移和尺度可能发生变化。LayerNorm 的作用不是增加新的上下文信息,而是让 Attention 和 MLP 获得数值尺度相对可控的输入。

GPT-2 使用 Pre-Norm:LayerNorm 位于 Attention 或 MLP 之前。GPT-2 原论文将其描述为把 LayerNorm 移到每个子 Block 的输入。对一个 Block 来说,顺序是:

1
2
X_l → LayerNorm → Attention → Residual Add
H_l → LayerNorm → MLP → Residual Add

Pre-Norm 可以写成 x + Sublayer(LN(x)):原始状态 x 沿 Residual Connection 主路径直接保留,归一化后的另一份状态则交给子层计算更新。相比将相加结果再归一化的 Post-Norm LN(x + Sublayer(x)),Pre-Norm 保留了不经过 LayerNorm 的直接路径,也为训练时的梯度传播提供了更直接的通道。

Pre-Norm 为什么更容易训练?

Xiong 等人的研究表明,在其理论与实验设置下,Pre-Norm Transformer 初始化时的梯度比 Post-Norm 更稳定,对 Learning Rate Warm-up 的依赖也更小。这解释了 Pre-Norm 为什么通常更容易训练,但不表示它在所有场景下都一定优于 Post-Norm。

这些考量主要发生在训练阶段。本文讨论的推理过程只会使用已经训练好的参数完成前向计算,不会继续训练或更新它们。每个 Block 有两组 LayerNorm 参数,分别服务于 Attention 和 MLP。

Residual Connection

Residual Connection 不直接用子层输出替换原来的 Hidden State,而是保留原状态,再加入子层算出的更新:

1
2
新表示 = 原状态 + 子层更新
x_new = x + F(x)

这里的 F(x) 表示 LayerNorm 和子层共同算出的更新。在前面的 Block 数据流中,A_lM_l 分别是两个子层产生的 F(x),而 X_lH_l 则沿对应的 Residual Connection 旁路直接保留。

不使用 Residual Connection 时,下一层只能接收到子层输出;使用 Residual Connection 后,原状态 x 会沿旁路直接传到输出:

1
2
无 Residual Connection:x_new = F(x)
有 Residual Connection:x_new = x + F(x)

两者的区别在“保持原状态不变”时最容易看出来:

  • 无 Residual Connection:如果模型希望得到 x_new ≈ x,就必须让整个子层学会 F(x) ≈ x,也就是由子层重新产生一份接近输入的表示。
  • 有 Residual Connection:原状态 x 已经通过旁路直接到达输出。子层不需要重新生成 x,只需让更新 F(x) ≈ 0,就可以得到 x_new ≈ x

用一个简单的线性变换说明:如果 F(x) = Wx,没有 Residual Connection 时,需要学到 W ≈ II 表示单位矩阵)才能保持输入;使用 Residual Connection 后,只需让 W ≈ 0,旁路就会自动保留 x

这并不表示 F(x) 必须始终很小。当子层确实需要改变当前表示时,F(x) 可以产生相应大小的更新;F(x) ≈ 0 只是在“不需要修改原状态”时的一种特殊情况。

Residual Connection 为什么有利于深层训练?

He 等人在图像识别网络的实验中发现,对于不使用 Residual Connection、只把上一层输出交给下一层的网络,增加深度后训练误差可能反而升高;加入 Residual Connection 后,较深的网络获得了更低的训练误差。这项实验结果支持了前面的设计直觉:让恒等路径直接存在、由子层学习额外变化,有利于深层网络的训练。

无论子层最终加入什么变化,它的输出都必须与原状态具有相同的 shape,才能进行逐元素相加:

阶段 原理 shape
输入 Hidden State X_l [T,D]
Attention LayerNorm 输出 [T,D]
Attention 更新 A_l [T,D]
第一次 Residual Add 结果 H_l [T,D]
MLP LayerNorm 输出 [T,D]
MLP 更新 M_l [T,D]
输出 Hidden State X_(l + 1) [T,D]

12 个 Block

GPT-2 Small 包含 12 个串联的 Transformer Block。本文沿用 llama.cpp 从 0 开始的编号方式,将它们依次记作 Block 0Block 11

12 个 GPT-2 Block 的层间数据流

为什么要堆叠多个 Block?

多个 Block 串联后,Hidden State 可以依次经过多轮上下文信息交互和非线性变换。这样既增加了模型的计算深度,也让前面形成的表示能够被后续 Block 继续组合和更新,为模型表达更复杂的关系提供更大的能力。

不过,更深并不意味着效果一定更好。GPT-2 论文比较了 12、24、36 和 48 层的四种模型配置,并观察到模型整体容量增加时,多项任务的表现随之提升。但这些配置同时增加了层数、Hidden State 的维度和总参数量,因此不能把提升单独归因于 Block 数量。

后续的 Scaling Laws 研究进一步指出,模型的损失主要随模型规模、训练数据量和训练计算量变化;在研究覆盖的较大范围内,宽度与深度等具体形状的影响相对较小。因此,Block 数量应被理解为训练前结合实验效果与计算预算确定的架构超参数,而不是越多越好。

这 12 个 Block 的计算骨架相同,但并不共享参数。每一层都有自己训练得到的 LayerNorm、Attention 和 MLP 参数,因此 Block 0 学到的变换与 Block 11 并不相同。

因此,12 层串联可以概括为:

1
X_(l + 1) = Block_l(X_l),  l = 0, 1, ..., 11

每一层接收上一层的输出,再生成新的 Hidden State。最后一个 Block 的输出还要经过 Final LayerNorm 和 LM Head,才会变成用于预测下一个 Token 的 logits。

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从 GGUF 参数、GPT-2 建图源码和运行时 Tensor 三个角度验证 Block 数据流。基础环境和模型准备见《LLM 如何逐个生成 Token?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的调试程序:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

验证 12 个 Block 的独立参数

先用 gguf-dump 查看模型的 Block 数量和每层的 Tensor 数量,再将 Block 0 和 Block 11 的 Tensor 按参数组归类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '
.metadata["gpt2.block_count"].value as $block_count |
def block_tensors($i):
[.tensors | keys[] | select(startswith("blk.\($i)."))];
def parameter_groups($i):
[block_tensors($i)[] | sub("\\.(weight|bias)$"; "")] | unique;
{
block_count: $block_count,
tensor_count_per_block: [
range(0; $block_count) as $i |
block_tensors($i) | length
],
block_0_groups: parameter_groups(0),
block_11_groups: parameter_groups(11)
}'

当前模型的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"block_count": 12,
"tensor_count_per_block": [
12, 12, 12, 12, 12, 12,
12, 12, 12, 12, 12, 12
],
"block_0_groups": [
"blk.0.attn_norm",
"blk.0.attn_output",
"blk.0.attn_qkv",
"blk.0.ffn_down",
"blk.0.ffn_norm",
"blk.0.ffn_up"
],
"block_11_groups": [
"blk.11.attn_norm",
"blk.11.attn_output",
"blk.11.attn_qkv",
"blk.11.ffn_down",
"blk.11.ffn_norm",
"blk.11.ffn_up"
]
}

block_count 验证了当前模型包含 12 个 Block,tensor_count_per_block 中从第 0 项到第 11 项都为 12,说明每层都有 12 个参数 Tensor。命令去掉 Tensor 名称末尾的 .weight.bias 后,得到每层的 6 个参数组:

GGUF 参数组 Block 中的概念
attn_norm Attention 前的 LayerNorm
attn_qkvattn_output Attention
ffn_norm MLP 前的 LayerNorm
ffn_upffn_down MLP

每个参数组都包含 weight 和 bias,因此 6 个参数组对应 12 个 Tensor。Block 0 和 Block 11 具有相同的参数组,但完整名称分别以 blk.0.blk.11. 开头,说明两层的计算骨架相同,却读取各自独立的参数。

Residual Add 没有训练参数,因此不会出现在 GGUF 参数列表中;下一节将从源码中的 ggml_add() 确认它在 Block 数据流中的位置。

从源码确认 Block 的计算路径

GPT-2 的建图入口是 llama_model_gpt2::graph::graph()。其中,Block 层循环的主路径如下:

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
for (int il = 0; il < n_layer; ++il) {
cur = build_norm(inpL,
model.layers[il].attn_norm,
model.layers[il].attn_norm_b,
LLM_NORM, il);

// self-attention
{
cur = build_attn(/* ... */);
}

ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpL);

cur = build_norm(ffn_inp,
model.layers[il].ffn_norm,
model.layers[il].ffn_norm_b,
LLM_NORM, il);

cur = build_ffn(cur, /* ... */);

cur = ggml_add(ctx0, cur, ffn_inp);

// input for next layer
inpL = cur;
}

这些代码位置可以与前面的 Block 数据流逐项对应。表中的运行时节点将在下一节用于检查实际计算结果:

原理中的表示 llama.cpp 代码 运行时节点
输入 Hidden State X_l 循环开始时的 inpL inpL,或上一层的 l_out-(l-1)
LN(X_l) cur = build_norm(inpL, ...) attn_norm-l
Attention 更新 A_l cur = build_attn(...) —(无独立节点)
H_l = X_l + A_l ffn_inp = ggml_add(ctx0, cur, inpL) ffn_inp-l
LN(H_l) cur = build_norm(ffn_inp, ...) ffn_norm-l
MLP 更新 M_l cur = build_ffn(cur, ...) ffn_out-l
X_(l + 1) = H_l + M_l 第二次 ggml_add(),随后执行 inpL = cur l_out-l

两次 build_norm(..., LLM_NORM, ...) 都位于对应子层之前,验证了 GPT-2 的 Pre-Norm 结构。两次 ggml_add() 则分别对应 Attention 和 MLP 之后的 Residual Add。

源码中的 cur 只是建图时反复复用的临时变量,它在不同位置代表不同 Tensor;理解代码时应结合它刚刚经过的操作,而不能把 cur 当作一个含义固定的模型概念。

用运行时输出验证 shape 与层间连接

使用 llama-eval-callback 运行 How are,只保留 Block 0 和 Block 1 的关键节点:

1
2
3
4
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg 'common_debug_cb_eval: +(attn_norm|ffn_inp|ffn_norm|ffn_out|l_out)-(0|1) ='

为了突出数据流,下面只保留了节点名称、输入关系和 shape:

1
2
3
4
5
6
7
8
9
10
11
attn_norm-0                                   = {768, 2}
ffn_inp-0 = ADD(...{768, 2}, inpL{768, 2}) = {768, 2}
ffn_norm-0 = {768, 2}
ffn_out-0 = {768, 2}
l_out-0 = ADD(ffn_out-0,
ffn_inp-0) = {768, 2}

attn_norm-1 = {768, 2}
ffn_inp-1 = ADD(...{768, 2}, l_out-0{768, 2}) = {768, 2}
...
l_out-1 = {768, 2}

这些节点是 llama-eval-callback 输出的运行时中间 Tensor,不是前面 blk.0.attn_norm.weightblk.0.attn_norm.bias 这类模型参数。节点名中的 -0-1 分别表示 Block 0 和 Block 1,它们与原理概念的对应关系见上一节表格。输出中的 ... 表示没有独立回调节点名的 Attention 更新,这里只保留其 shape。

GGML 将 Hidden 维度放在前面,因此 {768,2} 对应原理部分的 [2,768]ffn_inp-0l_out-0 分别是两次 Residual Add 的实际结果,参与相加的 Tensor 与结果都保持相同 shape。Block 0 的 l_out-0 又出现在 Block 1 第一次 Residual Add 的输入中,直接验证了“当前层输出成为下一层输入”。

小结

本文从 Block 父视角说明了 Hidden State 如何逐层更新:

  • Hidden State 以 [T,D] 的 shape 流过 Block;Attention 和 MLP 会更新其中的表示,但不会改变 Token 数量和 Hidden size。
  • Attention 和 MLP 都采用 Pre-Norm 和 Residual Connection:先对输入进行 LayerNorm,再由子层计算更新,最后与原状态逐元素相加。
  • GPT-2 Small 串联了 12 个计算骨架相同、参数各自独立的 Block;每个 Block 的输出都会成为下一个 Block 的输入。

在上一篇《文本如何变成 Token ID?》中,How are 经过分词后得到 Token ID 序列 [2437, 389]。但 Token ID 只是词表中的整数编号,不能直接参与 GPT-2 的向量和矩阵计算。

本文先打开 GPT-2 的模型黑盒,了解 Embedding(嵌入)在完整前向过程中的位置;再说明 Token Embedding 和 Position Embedding 如何得到 Initial Hidden State(初始隐藏状态);最后用 llama.cpp 查看 GGUF 中的真实参数和运行时结果。

GPT-2 内部的整体流程

GPT-2 接收 Token ID 序列,依次经过 Embedding、12 个 Transformer Block 和 LM Head,最终得到 logits:

GPT-2 从 Token ID 到 logits 的内部流程

图中蓝色的 Embedding 是本文聚焦的位置;后续文章将依次展开 Transformer Block 的整体骨架、Self-Attention、MLP,以及输出阶段的 Final LayerNorm 与 LM Head。为保持母图简洁,Final LayerNorm 没有单独画出。采样位于 GPT-2 模型边界之外,将在模型内部流程讲完后单独展开。

从 Embedding 到 Initial Hidden State

本文从 [2437, 389] 出发,展开整体流程图中的 Embedding。具体来说,GPT-2 分别根据 Token ID 和 Position ID 查询两张 Embedding Table,再将两个查表结果逐元素相加,得到 Initial Hidden State,即第一个 GPT-2 Block 的输入。整个过程如下:

Token Embedding 与 Position Embedding 相加得到 Initial Hidden State

图中的符号含义如下:

符号 含义 GPT-2 Small / 本文示例
V 词表大小 50257
D Hidden / Embedding size 768
C 最大位置数 1024
T 当前输入的 Token 数量 2

Token Embedding

Token ID 是 Token 在词表中的整数编号。例如:

1
2
"How"  → 2437
" are" → 389

数字 2437 的大小不表示 "How" 的语义,也不能把相邻的 Token ID 理解为语义相近。Token ID 在模型中的作用更像数组下标:模型用它从 Token Embedding Table 中取出一个向量。

Embedding Table 与查表

Token Embedding Table 可以理解为包含 V 行、每行 D 个数的参数表:

1
2
3
Token IDs [T]
→ 在 Token Embedding Table [V, D] 中查表
→ Token Embedding [T, D]

设 Token Embedding Table 为 E_token,第 i 个 Token ID 为 id_i,查表过程可以写成:

1
X_token[i] = E_token[id_i]

[2437, 389] 来说:

1
2
E_token[2437] → "How"  对应的 768 维向量
E_token[389] → " are" 对应的 768 维向量

两个向量按照输入顺序排列,得到:

1
2
Token IDs         [2]
→ Token Embedding [2, 768]

这个过程叫做 Embedding Lookup(Embedding 查表)。它不是把 2437389 当作普通数字代入某个公式,而是根据它们选择参数表中的两个向量。

两种 shape 表示

shape(形状)表示 Tensor(张量)各个维度的大小。本文在原理部分使用更符合阅读直觉的 shape 顺序:

1
[Token 数量, Hidden 维度] = [T, D]

在后文使用的 llama.cpp / GGML 中,同一个 Tensor 通常显示为:

1
{Hidden 维度, Token 数量} = {D, T}

因此,下面两种写法描述的是同一个 Token Embedding:

1
2
数学视角:[2, 768]
GGML 视角:{768, 2}

维度顺序不同,不代表模型结构不同。

Position Embedding

Token Embedding 只由 Token ID 决定。同一个 Token 无论出现在第几个位置,都会查到同一个 Token Embedding 向量。

但文本顺序会影响含义。例如:

1
2
dogs chase cats
cats chase dogs

两个句子包含相同的单词,但顺序不同,追逐者和被追逐者也随之交换。

为了让模型知道每个 Token 位于序列中的什么位置,GPT-2 还会查询 Position Embedding Table。对于同一个 Token ID id,它出现在位置 0 和位置 2 时,Token Embedding 相同,但加入的 Position Embedding 不同:

1
2
E_token[id] + E_position[0]
E_token[id] + E_position[2]

因此,加入 Position Embedding 后,同一个 Token 出现在不同位置时会得到不同的 Initial Hidden State。Position Embedding 本身不直接表示具体语义,它只是把位置信息加入模型输入;后续 Transformer Block 再结合上下文计算每个位置的表示。

GPT-2 Small 的 Position Embedding Table 包含 1024 个位置,每个位置也是一个 768 维向量:

1
2
3
Position IDs [T]
→ 在 Position Embedding Table [C, D] 中查表
→ Position Embedding [T, D]

How are 是一段没有历史上下文的新输入文本,因此两个 Token 的位置编号依次是 01

输入位置 Token ID Token Position ID
0 2437 How 0
1 389 are 1

Position ID 和 Token ID 是两套不同的编号:

  • Token ID 用来查询 Token Embedding Table,表示“当前是什么 Token”。
  • Position ID 用来查询 Position Embedding Table,表示“当前位于哪里”。

GPT-2 的 Position Embedding 通过训练学习得到,并使用绝对位置编号。这里的“绝对”表示每个位置使用自己的编号,例如 0、1、2

Initial Hidden State

Token Embedding 和 Position Embedding 的 shape 相同,GPT-2 将它们逐元素相加:

1
2
3
Token Embedding        [T, D]
+ Position Embedding [T, D]
= Initial Hidden State [T, D]

对序列中的第 i 个位置:

1
X_0[i] = E_token[id_i] + E_position[pos_i]

下面用 3 维向量演示相加过程。数值仅用于说明,不是 GPT-2 的真实参数:

1
2
3
4
Token Embedding       [ 0.20, -0.10,  0.50]
Position Embedding [ 0.01, 0.02, -0.03]
---------------------
Initial Hidden State [ 0.21, -0.08, 0.47]

GPT-2 Small 实际使用 768 维向量。对于 How are

1
2
3
Token Embedding        [2, 768]
+ Position Embedding [2, 768]
= Initial Hidden State [2, 768]

为什么选择相加?

这是 GPT-2 延续 Transformer 的架构选择。相加可以在不改变 Hidden size 的情况下,将位置信息加入 Token 表示,使结果直接进入后续 Transformer Block。它不是唯一方案,而是一种保持模型主干维度统一的简洁设计。

Embedding 参数从哪里来

Token Embedding Table 和 Position Embedding Table 都是 GPT-2 在训练过程中学习得到的模型参数。推理时,推理框架不会重新训练或随机生成它们,而是从模型文件中加载训练好的参数。

从训练到查表的过程可以概括为:

1
2
3
4
5
6
7
训练阶段:得到两张 Embedding Table
↓ 导出并保存
模型文件:保存训练得到的参数
↓ 推理框架加载
推理运行时:加载为两个参数 Tensor
↓ 分别根据 Token ID / Position ID 查表
查表结果:Token Embedding / Position Embedding

本文实战使用的模型文件格式是 GGUF。转换为 GGUF 后,GPT-2 中的两个 Embedding 参数对应为:

1
2
transformer.wte → token_embd.weight
transformer.wpe → position_embd.weight

GGUF 使用元数据保存模型架构和相关配置,用 Tensor 保存 Embedding Table 等训练参数。加载模型时,llama.cpp 会读取这些 Tensor,供后续查表使用。

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,查看 GGUF 中的 Embedding 参数、运行时操作与 shape,以及实际 Tensor 数值。基础环境准备见《LLM 如何逐个生成 Token?》gguf-dump 的安装方式见《文本如何变成 Token ID?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的工具:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

查看 GGUF 中的 Embedding 参数

模型文件名中的 Q8_0 表示部分参数采用 Q8_0 量化格式保存,具体 Tensor 的类型仍需查看 GGUF。

使用 gguf-dump 读取模型元数据和两个 Embedding Tensor,再用 jq 只保留本篇需要的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '{
model: {
architecture: .metadata["general.architecture"].value,
blocks: .metadata["gpt2.block_count"].value,
context: .metadata["gpt2.context_length"].value,
embedding: .metadata["gpt2.embedding_length"].value
},
tensors: {
token_embedding: (
.tensors["token_embd.weight"] | {shape, type}
),
position_embedding: (
.tensors["position_embd.weight"] | {shape, type}
)
}
}'

整理后的输出如下(只调整了 JSON 的换行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"model": {
"architecture": "gpt2",
"blocks": 12,
"context": 1024,
"embedding": 768
},
"tensors": {
"token_embedding": {
"shape": [768, 50257],
"type": "Q8_0"
},
"position_embedding": {
"shape": [768, 1024],
"type": "F32"
}
}
}

这份静态 GGUF 数据验证了前面的模型配置:

  • token_embd.weight 的 GGML shape 是 {768,50257},数学语义是 [50257,768]
  • position_embd.weight 的 GGML shape 是 {768,1024},数学语义是 [1024,768]
  • Token Embedding Table 使用 Q8_0,Position Embedding Table 使用 F32,说明同一 GGUF 文件中的 Tensor 可以使用不同类型。

查看 Embedding 的计算过程与 shape

gguf-dump 只能查看模型文件中的静态参数。要观察 How are 实际进入计算图后的 Tensor,可以使用 llama-eval-callback

这个调试程序默认会打印大量中间 Tensor。下面只筛选 Embedding 阶段的三个节点:

1
2
3
4
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg 'common_debug_cb_eval: +(embd|pos_embd|inpL)'

整理后的关键输出如下:

1
2
3
4
5
6
7
8
9
common_debug_cb_eval: embd = (f32) GET_ROWS(
token_embd.weight{768, 50257, 1, 1},
inp_tokens{2, 1, 1, 1}) = {768, 2, 1, 1}
common_debug_cb_eval: pos_embd = (f32) GET_ROWS(
position_embd.weight{768, 1024, 1, 1},
leaf_5{2, 1, 1, 1}) = {768, 2, 1, 1}
common_debug_cb_eval: inpL = (f32) ADD(
embd{768, 2, 1, 1},
pos_embd{768, 2, 1, 1}) = {768, 2, 1, 1}

其中 leaf_5 对应 Position ID 输入。

这三行分别对应:

  1. GET_ROWS(token_embd.weight, inp_tokens) 根据 [2437, 389] 查询 Token Embedding。
  2. GET_ROWS(position_embd.weight, leaf_5) 根据 [0, 1] 查询 Position Embedding。
  3. ADD(embd, pos_embd) 把两个 {768,2} Tensor 相加,得到 {768,2}inpL

inpL 就是进入第一个 GPT-2 Block 的 Initial Hidden State。日志还显示,查表结果 embd 是参与后续计算的 F32 Tensor。

查看 Embedding 与 Initial Hidden State 的数值

llama-eval-callback 还会在节点信息后打印 Tensor 数值。下面使用 -A 6 保留每个目标节点后面的六行:

1
2
3
4
5
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg -A 6 --context-separator '' \
'common_debug_cb_eval: +(embd|pos_embd|inpL) ='

整理后的数值部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
embd:
[
[-0.0483, 0.0604, 0.0587, ..., 0.1476, 0.0712, 0.0738],
[ 0.0760, 0.0564, 0.0491, ..., 0.0599, -0.0559, 0.0839]
]

pos_embd:
[
[-0.0188, -0.1974, 0.0040, ..., -0.0430, 0.0283, 0.0545],
[ 0.0240, -0.0538, -0.0948, ..., 0.0342, 0.0102, -0.0002]
]

inpL:
[
[-0.0671, -0.1370, 0.0627, ..., 0.1045, 0.0995, 0.1283],
[ 0.1000, 0.0026, -0.0458, ..., 0.0941, -0.0457, 0.0837]
]

每个 Tensor 都有两行,分别对应序列位置 0"How" 和位置 1" are"。每行实际包含 768 个值,调试输出只展示前 3 个和后 3 个。

不同计算后端的最后几位可能略有差异,但不影响这里的逐元素相加关系。

对照 Embedding 源码

GPT-2 在 load_arch_tensors() 中声明两个参数 Tensor:

1
2
3
4
5
6
7
tok_embd = create_tensor(
tn(LLM_TENSOR_TOKEN_EMBD, "weight"),
{n_embd, n_vocab}, 0);

pos_embd = create_tensor(
tn(LLM_TENSOR_POS_EMBD, "weight"),
{n_embd, n_ctx_train}, 0);

代入当前 GPT-2 Small 的配置:

1
2
tok_embd → {768, 50257}
pos_embd → {768, 1024}

llama_model_gpt2::graph::graph() 中,Embedding 主路径是:

1
2
3
4
5
6
7
inpL = build_inp_embd(model.tok_embd);

ggml_tensor * inp_pos = build_inp_pos();

pos = ggml_get_rows(ctx0, model.pos_embd, inp_pos);

inpL = ggml_add(ctx0, inpL, pos);

其中,通用的 build_inp_embd() 为 Token ID 创建输入 Tensor,再构造查表节点:

1
2
3
4
inp->tokens = ggml_new_tensor_1d(
ctx0, GGML_TYPE_I32, ubatch.n_tokens);

cur = ggml_get_rows(ctx0, tok_embd, inp->tokens);

因此,从源码到运行结果可以连成同一条路径:

1
2
3
4
5
6
7
8
9
10
inp_tokens {2}
→ ggml_get_rows(token_embd.weight)
→ embd {768,2}

positions {2}
→ ggml_get_rows(position_embd.weight)
→ pos_embd {768,2}

embd {768,2} + pos_embd {768,2}
→ inpL {768,2}

这里的 ggml_get_rows()ggml_add() 首先构造计算图节点;真正的数值计算在实际执行阶段完成。llama-eval-callback 展示的是这些节点执行后的 Tensor 类型、shape 和部分数值,计算图与实际执行的完整边界将在后续文章中单独介绍。

小结

本文以 [2437, 389] 为例,说明了 Token ID 如何变成 GPT-2 的输入向量:

  • Token ID 只是词表中的整数编号;GPT-2 将它作为下标,查询训练得到的 Token Embedding Table。
  • GPT-2 同时根据 Position ID 查询 Position Embedding,为每个 Token 加入位置信息。
  • Token Embedding 与 Position Embedding 逐元素相加,得到 Initial Hidden State。

得到的 inpL 会进入第一个 GPT-2 Block,成为后续 12 层计算的起点。

在上一篇《LLM 如何逐个生成 Token?》中,How are 经过分词后得到 Token ID 序列 [2437, 389]。那么,Tokenizer(分词器)是如何完成这一步的?

本文先介绍 Token、Token ID 和词表,再说明文本与 Token ID 如何相互转换,最后用 llama.cpp 查看真实的 Token、预切分规则、词表和合并规则。

Token、Token ID 与词表

GPT-2 模型本身不直接处理字符串。文本进入模型计算之前,Tokenizer 会依据固定规则完成下面的转换:

How are 转换为 Token ID 2437 和 389

这里的 " are" 包含一个前导空格。这个空格不会被丢弃,而是输入文本的一部分。

Token 不等于单词

Token 是模型处理文本时使用的基本单位。在 GPT-2 的词表中,它可能是完整单词,也可能只是单词的一部分:

1
2
3
4
5
6
7
unbelievable!

"un" + "bel" + "iev" + "able" + "!"

"你好"

[19526, 254, 25001, 121]

它还可能包含前导空格、标点或换行。你好 只有两个 Unicode 字符,在这个 GPT-2 Tokenizer 中却对应四个 Token ID。对于中文等非 ASCII 文本,一个 Token 甚至可能只对应某个 UTF-8 字节序列的一部分,单独显示时不一定是完整字符。

因此,Token 不能简单等同于“单词”或“字符”,而是 Tokenizer 根据固定规则得到的文本片段。

词表保存 Token 与 ID 的对应关系

词表(Vocabulary)可以看作一张固定的映射表,其中每个 Token 都有一个整数编号,也就是 Token ID。

按照文本中的先后顺序,How are you doing? 对应下面的 Token ID 序列:

1
2
"How are you doing?"
→ [2437, 389, 345, 1804, 30]

但在词表中,这些 Token 按照 Token ID 排列在不同位置:

Token ID Token
0 !
30 ?
345 Ġyou
389 Ġare
1804 Ġdoing
2437 How
50256 <|endoftext|>

省略号表示未展示的词表项;Token 在句子中的顺序与它在词表中的位置无关。表中的 Ġ 对应前导空格,这种表示方式将在后面的“字节级映射”中说明。

GPT-2 的 Byte-level BPE

BPE 是 Byte Pair Encoding(字节对编码)的缩写。GPT-2 使用 Byte-level BPE(字节级 BPE)将文本转换成 Token ID。一次编码可以概括为:

How are 的 Byte-level BPE 编码过程

预切分

Tokenizer 先用一条固定的文本匹配规则(正则表达式)扫描输入,将内容大致划分为:

  • 连续的字母,包括汉字等 Unicode 字母
  • 连续的数字
  • 连续的标点或其他符号
  • 连续的空白
  • 常见的英文缩写后缀,如 's't're

其中,字母、数字或符号片段可以带上紧邻其前的一个普通空格。例如:

1
2
3
How are

"How" + " are"

这也是 "are"" are" 可能得到不同 Token ID 的原因。在当前词表中,不带空格的 "are" 是 Token ID 533,带空格的 " are" 则是 Token ID 389

预切分只负责划分片段,并不直接决定最终 Token。

字节级映射

GPT-2 先把每个片段表示为 UTF-8 字节,再通过一张可逆映射将每个字节变成便于处理的字符。常见例子包括:

1
2
空格 0x20 → Ġ
换行 0x0A → Ċ

所以 " are" 在 BPE 内部会从下面四个初始符号开始:

1
Ġ a r e

这种设计让 GPT-2 可以从字节层面表示不同语言和符号,而不必为每个 Unicode 字符准备一个基础 Token。代价是一个非 ASCII 字符可能被拆到多个 Token 中,单个 Token 文本片段不一定能独立解码成完整的 Unicode 字符。

为什么还需要预切分?

预切分先确定哪些内容属于同一个 BPE 处理片段;字节级映射则只负责把片段转换成可逆的字符表示,不会重新划分片段。对于 How are,BPE 会分别在 "How"" are" 内部尝试合并,不能跨越两个片段的边界。

BPE 合并

BPE 合并规则(merges)规定哪些相邻符号可以合并。每条规则在数组中的位置就是 rank(优先级)。BPE 只比较同一片段中当前相邻的候选;rank 越小的规则越先执行。

How 从三个初始符号开始:

1
2
3
4
5
H o w
↓ o + w → ow rank 66
H ow
↓ H + ow → How rank 2181
How

" are" 的合并过程则是:

1
2
3
4
5
6
7
Ġ a r e
↓ Ġ + a → Ġa rank 1
Ġa r e
↓ r + e → re rank 4
Ġa re
↓ Ġa + re → Ġare rank 133
Ġare

如果一个片段不能继续合并,它就会保留为多个较小 Token。前面的 unbelievable 被拆成 unbelievable,正是这个过程的结果。

查询词表

BPE 合并结束后,Tokenizer 用每个 Token 查询前面介绍的词表:How 对应 Token ID 2437Ġare 对应 Token ID 389。查询结果按照原有顺序组成 Token ID 序列 [2437, 389]

GPT-2 的词表与合并规则是配套生成的,因此 BPE 合并结果能够在词表中找到。

词表和合并规则从哪里来?

词表和合并规则在 Tokenizer 训练阶段根据训练语料确定,后续编码文本时保持不变。它们是 Tokenizer 的数据,不是 GPT-2 用于模型计算的神经网络权重。

反分词

How are → you doing? 的续写过程中,GPT-2 在 How are 后依次生成了 Token ID 345180430。这些 ID 不需要再次分词;要把它们显示为可读文本,需要执行相反的转换:反分词(Detokenize)。

Token ID 345、1804 和 30 还原为 you doing?

ĠyouĠdoing 中的 Ġ 都会还原成前导空格,而 ? 不带前导空格。因此,这三个 Token 依次拼接后的反分词结果是 " you doing?"。它与原输入拼接后,得到完整文本 "How are you doing?"

这个过程不会倒着执行 BPE 合并规则,只需根据 ID 查回 Token 文本片段,再逆转字节级映射并拼接结果。

对于英文 ASCII 文本,单个 Token 通常可以直接显示;而对于中文等非 ASCII 文本,单个 Token 可能只包含一个 UTF-8 字符的部分字节。流式输出时,推理框架需要先积累这些字节,才能显示完整字符,不能假设每个 Token 都能独立解码为完整的 Unicode 文本。

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,验证 How are → [2437, 389],查看 GPT-2 的预切分规则,以及 GGUF 中真实的词表与 BPE 合并规则。基础环境准备见《LLM 如何逐个生成 Token?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的工具:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-tokenize \
--config Release -j

查看 Token ID 与文本片段

运行 llama-tokenize

1
2
3
4
5
./build/bin/llama-tokenize \
-m "$GPT2_MODEL" \
--no-bos \
--log-verbosity 0 \
--prompt 'How are'

输出:

1
2
2437 -> 'How'
389 -> ' are'

--no-bos 表示不添加 BOS Token。输出共有两个 Token:" are" 的前导空格属于第二个 Token,两个 Token ID 分别是 2437389

再观察一个不能按完整单词切分的例子:

1
2
3
4
5
./build/bin/llama-tokenize \
-m "$GPT2_MODEL" \
--no-bos \
--log-verbosity 0 \
--prompt 'unbelievable!'

输出:

1
2
3
4
5
  403 -> 'un'
6667 -> 'bel'
11203 -> 'iev'
540 -> 'able'
0 -> '!'

这说明 Token 既可以是单词片段,也可以是标点。

llama-tokenize 默认启用 --escape,会解析 \n 等转义序列。把输入文本改成 'How\nare',可以观察到换行对应 Token ID 198,换行后的 "are" 对应 ID 533,而不是带前导空格的 389

查看 GPT-2 的预切分规则

llama-tokenize 展示的是经过全部分词步骤得到的最终 Token,不会直接输出预切分产生的中间片段。要查看 GPT-2 使用的具体预切分规则,可以检查 llama.cpp 源码:

1
rg -n -A 1 'GPT2 system regex' src/unicode.cpp

输出:

1
2
214:// GPT2 system regex:  's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
215-static std::vector<size_t> unicode_regex_split_custom_gpt2(const std::string & text, const std::vector<size_t> & offsets) {

其中,\p{L}\p{N}[^\s\p{L}\p{N}] 分别匹配字母、数字和其他符号;前面的 ? 表示可以带上一个普通空格,其他分支用于匹配英文缩写后缀和空白。这与前面“预切分”小节概括的规则一致。

查看 GGUF 中的 Tokenizer 数据

gguf-dump 位于 llama.cpp 的 gguf-py 包中。如果当前环境里还没有这个工具,可以安装到仓库内的 Python 虚拟环境:

1
2
python3 -m venv .venv
.venv/bin/pip install --editable ./gguf-py

查看 GPT-2 的主要 Tokenizer 元数据:

1
2
.venv/bin/gguf-dump "$GPT2_MODEL" --no-tensors |
grep -E 'tokenizer\.ggml\.(model|pre|tokens|merges|bos_token_id|eos_token_id)'

筛选后的标准输出如下:

1
2
3
4
5
6
13: STRING     |        1 | tokenizer.ggml.model = 'gpt2'
14: STRING | 1 | tokenizer.ggml.pre = 'gpt-2'
15: [STRING] | 50257 | tokenizer.ggml.tokens = ['!', '"', '#', '$', '%', '&', ...]
17: [STRING] | 50000 | tokenizer.ggml.merges = ['Ġ t', 'Ġ a', 'h e', 'i n', 'r e', 'o n', ...]
18: UINT32 | 1 | tokenizer.ggml.bos_token_id = 50256
19: UINT32 | 1 | tokenizer.ggml.eos_token_id = 50256

tokenizer.ggml.pre = 'gpt-2' 表示当前模型选择了上一节查看的 GPT-2 预切分方式。

tokenizer.ggml.tokens 就是词表。它有 50257 个元素,数组下标范围是 0~50256tokenizer.ggml.merges 则按 rank 顺序保存 50000 条 BPE 合并规则。

普通输出会截断大数组。要查看指定 Token ID,可以输出完整 JSON,再使用 jq 提取目标元素:

1
2
3
4
5
6
7
.venv/bin/gguf-dump "$GPT2_MODEL" \
--no-tensors --json --json-array |
jq -r '
.metadata["tokenizer.ggml.tokens"].value as $tokens
| [0, 30, 345, 389, 1804, 2437, 50256][]
| "\(.)\t\($tokens[.])"
'

输出:

1
2
3
4
5
6
7
0	!
30 ?
345 Ġyou
389 Ġare
1804 Ġdoing
2437 How
50256 <|endoftext|>

这些条目按照 Token ID 从小到大排列,与前面的词表示例一致。它们在 How are you doing? 中的出现顺序则是 2437、389、345、1804、30

GGUF 内部的 ĠareĠyouĠdoing 都包含表示前导空格的 Ġllama-tokenize 显示这些 Token 时,会把它还原成真实空格。

查看 BPE 合并规则

从 GGUF 中提取 How" are" 用到的合并规则及其 rank:

1
2
3
4
5
6
7
8
9
10
11
12
.venv/bin/gguf-dump "$GPT2_MODEL" \
--no-tensors --json --json-array |
jq -r '
.metadata["tokenizer.ggml.merges"].value
| to_entries[]
| select(
.value == "Ġ a" or .value == "r e" or
.value == "Ġa re" or .value == "o w" or
.value == "H ow"
)
| "rank=\(.key)\t\(.value)"
'

输出:

1
2
3
4
5
rank=1	Ġ a
rank=4 r e
rank=66 o w
rank=133 Ġa re
rank=2181 H ow

这些真实规则与前面手工展开的 BPE 合并过程一一对应。

小结

本文以 How are → [2437, 389] 为例,说明了 GPT-2 如何将文本转换为 Token ID:

  • Token 是词表中的文本片段,不等同于单词或字符;Token ID 是它在词表中的整数编号。
  • GPT-2 依次经过预切分、字节级映射、BPE 合并和词表查询,得到按照原文顺序排列的 Token ID。
  • 反分词完成相反方向的转换:根据 Token ID 查询词表、还原字节并依次拼接,不需要逆向执行 BPE。

当我们与 ChatGPT 对话,或让 Agent 完成任务时,背后发挥核心作用的都是 LLM(大语言模型)。文本生成是 LLM 最基础的能力,也是理解其工作原理的起点。

本文以 GPT-2(Small) 为例,先从文本续写的整体过程入手,再解释自回归生成(Autoregressive generation)和单次预测,最后用 llama.cpp 进行验证。

文本续写

How are 续写为 you doing?

就功能而言,可以把 LLM 看作一个文本续写系统:输入 How are,输出 you doing?。从外部看,整个续写过程像是一次完成的。

自回归生成

实际上,you doing? 由多个 Token 依次生成。Token 是模型处理文本的基本单位,通常对应一个文本片段。每轮根据已有的 Token 序列预测下一个 Token,再将它追加到序列中,进入下一轮,直到满足停止条件。

you doing? 的三轮自回归生成过程

需要注意," you"" doing" 的前导空格也是 Token 的一部分,因此 Token 并不等同于单词。

一次 Token 预测

以 GPT-2 第一次预测 " you" 为例,一次 Token 预测可以分为分词(Tokenize)、GPT-2 计算、采样(Sampling)和反分词(Detokenize)四个步骤:

从 How are 预测下一个 Token " you"

分词

分词将 How are 划分为两个 Token,并将它们映射为词表(Vocabulary)中的整数编号,也就是 Token ID:

1
2
"How"  → 2437
" are" → 389

因此,模型接收的 Token ID 序列是 [2437, 389]。这些整数后续还要转换为向量才能参与计算。

GPT-2

GPT-2 接收 Token ID 序列,输出 50257 个 logits(原始预测分数)。每个 logit 对应词表中的一个 Token,表示它作为下一个 Token 的原始评分。

得到 logits 后,GPT-2 的前向计算(Forward Pass)就结束了。接下来由推理框架根据这些评分进行采样,选出下一个 Token ID。

采样

采样根据 logits 选择下一个 Token ID,可以通过 Temperature(温度)、Top-k 和 Top-p 等参数控制选择结果。本例将 Temperature 设为 0,以确定性方式选中 Token ID 345

即使 logits 相同,不同的采样参数也可能选出不同的 Token。因此,模型计算和 Token 选择是两个不同阶段。

反分词

反分词是分词的逆向转换:Token ID 345 根据同一份词表还原为文本片段 " you"

为什么无须重新分词?

反分词得到的文本片段只用于显示,采样选出的 Token ID 会直接追加到已有序列:[2437, 389] + 345 → [2437, 389, 345]。GPT-2 基于更新后的 Token ID 序列继续预测。不断重复这一过程,就形成了自回归生成。

llama.cpp 实战

下面用 llama.cpp 实际观察分词结果、候选分数与采样结果,以及最终的续写结果。

GPT-2 与 llama.cpp 在这一过程中承担不同职责:

  • GPT-2:包含模型结构和训练得到的参数,将 Token ID 序列转换为 logits。
  • llama.cpp:加载 GGUF 中的模型与 Tokenizer(分词器)的相关信息,构建并执行计算图(Computation Graph),完成采样和文本输出。

环境准备

下面的实验基于 llama.cpp(版本标签:b10435),需要预先安装 Git、CMake、cURL 和 C/C++ 编译器。

克隆代码并构建 llama-completionllama-server

1
2
3
4
5
6
7
8
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
git checkout b10435

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build \
--target llama-completion llama-server \
--config Release -j

接着从 QuantFactory/gpt2-GGUF 下载 GPT-2 Q8_0 GGUF 模型:

1
2
3
4
5
6
GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf
mkdir -p models/QuantFactory/gpt2-GGUF

curl -L --fail \
-o "$GPT2_MODEL" \
https://huggingface.co/QuantFactory/gpt2-GGUF/resolve/main/gpt2.Q8_0.gguf

查看分词结果

借助 llama-completion,可以查看输入文本的分词结果,包括 Token 及其对应的 ID:

1
2
3
4
5
6
7
./build/bin/llama-completion \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--predict 0 \
--verbose-prompt \
2>&1 |
grep -E "llama_completion: (prompt:|number of tokens in prompt)| I +[0-9]+ -> '"

输出:

1
2
3
4
0.00.366.307 I llama_completion: prompt: 'How are'
0.00.366.307 I llama_completion: number of tokens in prompt = 2
0.00.366.309 I 2437 -> 'How'
0.00.366.310 I 389 -> ' are'

查看候选分数与采样结果

先在一个终端启动 llama-server

1
2
./build/bin/llama-server \
-m "$GPT2_MODEL"

模型加载完成后,另开一个终端,请求生成一个 Token:

1
2
3
4
5
6
7
8
9
10
curl -sS http://127.0.0.1:8080/completion \
-H 'Content-Type: application/json' \
-d '{
"prompt": "How are",
"n_predict": 1,
"temperature": 0,
"return_tokens": true,
"n_probs": 3,
"response_fields": ["content", "tokens", "completion_probabilities"]
}'

省略响应中的 bytes 后,关键结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"content": " you",
"tokens": [345],
"completion_probabilities": [
{
"id": 345,
"token": " you",
"logprob": -1.0721638,
"top_logprobs": [
{"id": 345, "token": " you", "logprob": -1.0721638},
{"id": 356, "token": " we", "logprob": -2.1492436},
{"id": 262, "token": " the", "logprob": -2.3353780}
]
}
]
}

tokens[0] 表示最终选中的 Token ID,即 345;对应的文本片段是 " you"top_logprobs 列出了本轮预测中排名靠前的候选 Token,其中的 logprob(对数概率)由 logits 转换而来,并非 GPT-2 直接输出的原始 logits。Temperature 为 0 时,llama.cpp 会确定性地选择排名第一的 Token。

查看续写结果

最后运行 llama-completion,从 How are 开始连续生成三个 Token:

1
2
3
4
5
6
./build/bin/llama-completion \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--predict 3 \
--temp 0 \
--log-verbosity 1

续写结果:

1
How are you doing?

小结

本文以 How are → you doing? 为例,串起了 LLM 逐个生成 Token 的基本流程:

  • How are 被分词为 [2437, 389],第一次预测选中 Token ID 345" you"),连续预测后得到 you doing?
  • 一次 Token 预测依次经过分词、GPT-2 计算、采样和反分词;新 Token ID 会追加到已有序列,形成自回归生成。
  • GPT-2 负责根据 Token ID 计算 logits,llama.cpp 负责模型执行、Token 选择和文本输出。
0%