大模型基础扫盲------从文本到 Token、Embedding、QKV 与 Transformer 大模型处理文本的全流程解析(一)-CSDN博客

大模型基础扫盲------从文本到 Token、Embedding、QKV 与 Transformer(不定期优化修改)大模型处理文本的全流程解析(三)-CSDN博客

大模型基础扫盲------从文本到 Token、Embedding、QKV 与 Transformer(不定期优化修改)大模型处理文本的全流程解析(四)-CSDN博客

7. 位置编码:告诉模型 Token 的顺序

Transformer 的注意力计算本身不包含位置信息。

更准确地说:

如果没有位置编码,
注意力机制主要看 token 的内容,
而不知道 token 的先后顺序。

所以模型需要额外的位置信息。

这就是位置编码,英文叫 positional encoding。


7.1 为什么需要位置编码

更准确地说,Self-Attention 本身主要依赖 token 的内容向量来计算相关性。
如果没有位置信息,模型很难区分 token 的先后顺序。

例如:

我吃鱼
鱼吃我

这两个句子包含的 token 一样:

我、吃、鱼

但顺序不同,语义完全不同。

在“我吃鱼”中:

“我”是动作发出者。
“鱼”是被吃的对象。

在“鱼吃我”中:

角色完全反过来了。

如果模型不知道顺序,就无法正确区分主语、谓语、宾语之间的关系。

所以必须引入位置信息:

“我”是第 1 个 token。
“吃”是第 2 个 token。
“鱼”是第 3 个 token。

位置编码要解决两个核心问题:

1. 绝对顺序:
   当前 token 在序列中的位置是什么。

2. 相对距离:
   两个 token 之间相隔多远。

现代大模型更重视相对位置信息,因为语言中很多关系取决于 token 之间的相对距离,而不是某个 token 的绝对位置本身。


7.2 常见位置编码方式

类型

说明

代表

正弦余弦位置编码

用 sin/cos 函数生成固定向量

原始 Transformer

可学习位置 embedding

给每个位置学一个向量

BERT

RoPE 旋转位置编码

在 Q、K 上施加旋转,编码相对位置

LLaMA、Qwen、Mistral 等常见

ALiBi

通过注意力偏置引入位置信息

BLOOM

需要说明:

RoPE、RMSNorm、SwiGLU、GQA 是当前主流开源 LLM 的常见组合,
但并非所有模型都使用。
具体以模型的 config.json 和实现为准。

7.3 位置信息如何加入

方式一:直接相加

适用于原始 Transformer、BERT 等。

X = Embedding(token) + Position(position)

例如:

“我”的输入向量 = embedding_2513 + position_1
“吃”的输入向量 = embedding_1892 + position_2
“鱼”的输入向量 = embedding_3765 + position_3

方式二:RoPE 旋转

现代大模型常用 RoPE。

RoPE 不是把一个位置向量加到 token embedding 上,而是在注意力计算时对 Q、K 向量施加旋转:

q_i = rotate(q_i, position=i)
k_j = rotate(k_j, position=j)

然后 Q 和 K 再做点积。

这样注意力分数会自然包含相对位置信息:

i - j

所以对于 RoPE 模型来说:

“embedding → 位置编码”这个流程图只是教学简化。
RoPE 的位置信息实际上是在注意力计算阶段注入的。

7.4 RoPE 的长上下文外推问题

RoPE 虽然很适合表达相对位置,但它也有一个工程上非常重要的问题:

模型训练时见过的长度是有限的。

例如:

训练长度可能是 4096。
也可能是 8192。
也可能是 32768。

如果训练时只见过 8192 长度,推理时突然输入 32768 长度,RoPE 的旋转角度可能进入模型没有充分学习过的范围。

这时可能出现:

注意力分布异常
loss 升高
生成质量下降
长文本理解能力明显退化

所以现代长上下文模型通常会使用 RoPE scaling 技术。

常见方式包括:

Position Interpolation,简称 PI
NTK-aware scaling
YaRN
dynamic scaling

这些方法的目标都是:

让模型在超过原始训练长度时,
仍然能保持较稳定的位置感知能力。

MLOps 视角
部署长上下文模型时,不能只看:

max_position_embeddings

还要看 config.json 中是否有:

rope_theta
rope_scaling

以及具体实现是否支持:

linear
dynamic
yarn
ntk

如果模型没有配置合适的长文本外推策略,即使显存够,长文本推理效果也可能不稳定。


8. Q、K、V 向量:注意力机制的核心

8.1 符号约定

为了避免混淆,本文使用以下符号:

q^(i) 表示第 i 个 token 的 Query 向量(Q向量:查询条件/检索意图,查询向量)
k^(i) 表示第 i 个 token 的 Key 向量(K向量:打标签,特征向量)
v^(i) 表示第 i 个 token 的 Value 向量(V向量:Q 和 K 交互,算出一组注意力权重;这组权重对所有 V 做加权求和,得到最终输出)

q_j 表示某个 Q 向量的第 j 个分量

也就是说:

上标表示 token 序号。
下标表示向量维度序号。

8.2 先搞清楚 X 是什么

句子:

我 吃 鱼

经过 embedding 后,每个 token 变成一个向量。

如果是 RoPE 模型,此时位置信息还没有直接加到 embedding 上。
如果是绝对位置编码模型,此时可能已经完成 embedding + position embedding。

为了方便观察,假设模型维度是 4。
实际模型通常是 4096、5120、8192 等。

X =
[
  [ 0.2, -0.1,  0.5,  0.3 ],   ← 我,第 1 个 token
  [ 0.8,  0.1, -0.2,  0.4 ],   ← 吃,第 2 个 token
  [-0.3,  0.6,  0.1,  0.2 ]    ← 鱼,第 3 个 token
]

每一行是一个 token 的向量。


8.3 为什么不直接用 X?为什么要变成 Q、K、V?

这是最关键的问题。

我们用一个场景讲透。


场景:你走进一个派对

你走进一个 100 人的派对,想找一个人聊天。

这个过程中有三件完全不同的事。


第一件事:你脑子里有一个“需求”

你心里想:

我想找一个喜欢户外运动的人。

这是你的 Query。

它描述的是:

你在找什么。

第二件事:每个人胸前贴着一个“标签”

派对上每个人胸前贴了一张纸条:

张三:喜欢爬山、跑步
李四:喜欢做饭、烘焙
王五:喜欢编程、数学

这是每个人的 Key。

它描述的是:

我能被什么找到。

第三件事:找到人之后,对方跟你聊的内容

你找到张三,张三跟你聊了半小时爬山经历,推荐了几条路线。

这是张三的 Value。

它描述的是:

我实际能提供什么信息。

关键:这三件事不能是同一个东西

看张三:

角色

内容

他的 Query

“我想找一个会做饭的人,因为我太懒了。”

他的 Key

“喜欢爬山、跑步。”

他的 Value

“爬过珠峰、去过尼泊尔、推荐三条入门路线。”

同一个人,三个角色,三套不同的信息。

他在找什么 ≠ 他能被什么找到 ≠ 他实际能聊什么。

换回模型

“吃”这个 token 也是一样。

角色

“吃”的信息

它的 Query,它在找什么

“谁在吃?吃的是什么?”

它的 Key,它能被什么找到

“我是一个动作,需要动作相关的查询来找我。”

它的 Value,它实际提供什么

“吃”这个动作的语义:进食、需要食物、可能有主语和宾语……

这三套信息都藏在同一个 embedding 向量里。

所以需要三组不同的投影矩阵,把它们提取出来:

q = x · W_Q
k = x · W_K
v = x · W_V

再换一个更日常的类比:图书馆找书

步骤

你做的事

对应

1

你在搜索框输入“Python 入门”

Query

2

每本书书脊上印着关键词

Key

3

找到书后翻开读到具体内容

Value

你的搜索词不等于书脊标签,书脊标签也不等于书的内容。

但它们必须配合:

Q 和 K 匹配上了,你才能拿到 V。

模型里的注意力机制,就是这个过程。

一句话:

Q:我在找什么。
K:我能被什么找到。
V:我实际提供什么。

这里需要特别强调一点:

Q、K、V 并不是 token 天然自带的三个字段。

每个 token 进入注意力层时,通常只有一个输入向量 x。
这个 x 可能来自最初的 embedding,
也可能来自上一层 Transformer 的输出。

模型会通过三组可训练的权重矩阵,
把同一个 x 投影成三种不同用途的向量:

q = x · W_Q
k = x · W_K
v = x · W_V

其中:

W_Q 是 Query 投影权重,决定“从 x 中提取什么查询信息”。
W_K 是 Key 投影权重,决定“从 x 中提取什么标签信息”。
W_V 是 Value 投影权重,决定“从 x 中提取什么内容信息”。

也就是说:

同一个 x,
经过不同的权重矩阵,
会得到三种不同的表示。

这些权重矩阵一开始通常是随机初始化的。
在训练过程中,模型会不断根据预测误差调整它们。
训练结束后,W_Q、W_K、W_V 才逐渐学会:
应该从 x 中提取哪些对语言建模有用的信息。


8.4 矩阵乘法到底在做什么

公式:

q = x · W_Q

看起来吓人,其实可以类比成加权打分。


第零步:你其实已经会了

假设你是老师,要给一个学生打“理科潜力分”。

学生四门成绩:

数学 90
语文 60
物理 85
英语 50

你心里有一个标准:理科潜力主要看数学和物理,语文英语不太重要。

于是你定权重:

数学 0.5
语文 0.1
物理 0.3
英语 0.1

计算:

理科潜力分
= 90 × 0.5 + 60 × 0.1 + 85 × 0.3 + 50 × 0.1
= 45 + 6 + 25.5 + 5
= 81.5

这就是一个加权求和。


第一步:再来一个“文科潜力分”

同一个学生,你还想打一个“文科潜力分”。

这次标准不一样:文科主要看语文和英语。

权重变成:

数学 0.1
语文 0.4
物理 0.1
英语 0.4

计算:

文科潜力分
= 90 × 0.1 + 60 × 0.4 + 85 × 0.1 + 50 × 0.4
= 9 + 24 + 8.5 + 20
= 61.5

第二步:把两组权重放在一起看

              理科权重    文科权重
数学            0.5        0.1
语文            0.1        0.4
物理            0.3        0.1
英语            0.1        0.4

这张小表格,就是一个矩阵。

把名字换掉:

学生四门成绩 → x
理科潜力分 → q 的第 1 个分量
文科潜力分 → q 的第 2 个分量
那张权重表 → W_Q

所以:

q = x · W_Q

本质上就是:

用一组权重,对 x 做加权求和,提取出一种信息。

8.5 用模型的数字走一遍

假设“吃”的 embedding 是 4 维:

x = [0.8, 0.1, -0.2, 0.4]

假设 W_Q 是 4 行 2 列,把 4 维变成 2 维:

W_Q =
[
  [ 0.5,  0.1],
  [ 0.2, -0.3],
  [-0.4,  0.8],
  [ 0.1,  0.2]
]

计算 q 的第 1 个分量:

q_1
= 0.8 × 0.5 + 0.1 × 0.2 + (-0.2) × (-0.4) + 0.4 × 0.1
= 0.40 + 0.02 + 0.08 + 0.04
= 0.54

计算 q 的第 2 个分量:

q_2
= 0.8 × 0.1 + 0.1 × (-0.3) + (-0.2) × 0.8 + 0.4 × 0.2
= 0.08 - 0.03 - 0.16 + 0.08
= -0.03

结果:

q = [0.54, -0.03]

这就是“吃”的 Query 向量。

K 和 V 的操作完全一样,只是换不同的权重矩阵:

k = x · W_K
v = x · W_V

8.6 多个 Token:矩阵形式

三个 token 各自计算 q、k、v。

写在一起就是:

Q = X · W_Q
K = X · W_K
V = X · W_V

这不是新操作。

它只是:

每个 token 分别乘同一张权重表,
然后写成矩阵形式一次算完。

GPU 非常擅长这种矩阵乘法。


8.7 W_Q、W_K、W_V 是怎么来的

它们不是人工设计出来的,而是训练出来的。


还是用老师的例子

你第一天当老师,完全不知道什么权重好。

你随便猜:

数学 × 0.9 + 语文 × 0.9 + 物理 × 0.9 + 英语 × 0.9

结果预测不准。

于是你开始调:

第 2 天:数学 × 0.7 + 语文 × 0.3 + 物理 × 0.7 + 英语 × 0.3
第 3 天:数学 × 0.6 + 语文 × 0.2 + 物理 × 0.5 + 英语 × 0.2
...
第 1000 天:数学 × 0.5 + 语文 × 0.1 + 物理 × 0.3 + 英语 × 0.1

这组权重不是谁告诉你的,是你看了很多学生、对了多次答案后调出来的。

模型也是这样。


一次训练循环长什么样

  1. 给模型看:
今天天气很___
  1. 模型用当前参数预测:
汽车
  1. 正确答案是:
  1. 模型发现自己猜错了。
  2. 反向传播会告诉模型:
  3. 哪些参数往哪个方向调一点点,
    下次遇到类似情况,猜对的概率会更大。
  4. 这个过程重复几十亿次、几万亿次。

训练前:

W_Q、W_K、W_V 都是随机数。

训练后:

这些矩阵被海量数据“逼”出有意义的投影方向。

例如:

“吃”的 Q 可能自然地去寻找动作发出者和动作对象。
“鱼”的 K 可能更容易被“吃”这类动作查询命中。

8.8 本节小结

1. X 是 token embedding 或上一层 hidden state 组成的矩阵。
2. Q、K、V 不是 token 自带的字段,而是通过权重矩阵投影出来的。
3. W_Q、W_K、W_V 是可训练的投影权重。
4. Q 表示“我在找什么”。
5. K 表示“我能被什么找到”。
6. V 表示“找到我后,我提供什么信息”。
7. 投影权重 W_Q/W_K/W_V 是模型参数;后面 softmax 得到的注意力权重是动态计算结果,两者不是同一个东西。

8.9 形状速查表:MLOps 必须熟悉的维度关系

假设:

batch_size = B
序列长度 = L
模型主维度 hidden_size = d_model
Query 头数 num_attention_heads = n_q
KV 头数 num_key_value_heads = n_kv
每个头维度 head_dim = d_head

通常有:

d_model = n_q × d_head

例如:

hidden_size = 4096
num_attention_heads = 32
head_dim = 128

那么:

4096 = 32 × 128

输入 X 的形状通常是:

X: [B, L, d_model]

如果使用标准 MHA:

W_Q: [d_model, n_q × d_head]
W_K: [d_model, n_q × d_head]
W_V: [d_model, n_q × d_head]

如果使用 GQA:

W_Q: [d_model, n_q × d_head]
W_K: [d_model, n_kv × d_head]
W_V: [d_model, n_kv × d_head]

其中:

n_kv < n_q

例如:

num_attention_heads = 32
num_key_value_heads = 8

则:

Q 头数 = 32
K/V 头数 = 8

每 4 个 Q 头共享一组 KV 头。

投影后:

Q: [B, L, n_q, d_head]
K: [B, L, n_kv, d_head]
V: [B, L, n_kv, d_head]

在推理框架中,通常会转成:

Q: [B, n_q, L, d_head]
K: [B, n_kv, L, d_head]
V: [B, n_kv, L, d_head]

KV Cache 显存大小主要和下面几个字段有关:

num_hidden_layers
num_key_value_heads
head_dim
缓存的 token 数量
精度类型 bytes

所以看模型配置时,不要只看:

num_attention_heads

一定要看:

num_key_value_heads

因为它直接决定 KV Cache 的显存占用。

 工程注记
在 PyTorch 中,nn.Linear 的权重形状通常是:

[out_features, in_features]

实际计算是:

x @ W.T

本文为了教学直观,把投影矩阵写成:

[d_model, output_dim]

本质相同。


9. 注意力计算:从分数到输出

有了 Q、K、V 之后,注意力机制做三步:

1. Q 和 K 算相关性分数
2. softmax 把分数变成权重
3. 权重加权 V,得到输出

完整公式:

Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V

9.1 第一步:Q 和 K 做点积,算相关性

点积就是对应位置相乘再求和:

q · k = q_1 k_1 + q_2 k_2 + q_3 k_3 + ...

假设正在处理“吃”,也就是第 2 个 token。

“吃”的 Query:

q^(2)

去和所有可见 token 的 Key 做点积:

计算

含义

假设结果

q^(2) · k^(1)

“吃”和“我”有多相关

5.1

q^(2) · k^(2)

“吃”和“吃”自己有多相关

1.0

q^(2) · k^(3)

“吃”和“鱼”有多相关

4.3

模型认为:

吃应该多关注“我”和“鱼”,少关注自己。

这很合理:

谁吃?我。
吃什么?鱼。

矩阵形式:

Q K^T =
[
  [q^(1)·k^(1), q^(1)·k^(2), q^(1)·k^(3)],
  [q^(2)·k^(1), q^(2)·k^(2), q^(2)·k^(3)],
  [q^(3)·k^(1), q^(3)·k^(2), q^(3)·k^(3)]
]

Q K^T 一次性算出所有 token 对之间的相关性分数。


9.2 为什么用点积?

也可以用其他打分方式,例如:

MLP 打分
加性注意力
余弦相似度

但点积有一个决定性优势:

Q K^T 是一次矩阵乘法,GPU 可以高度并行。

在大模型中,序列长度可能是几千、几万,效率是硬约束。


9.3 第二步:缩放 + softmax

为什么要除以 sqrt(d_k)

d_k 是 K 向量的维度,也就是每个注意力头的维度。

如果 Q 和 K 的每个元素近似满足:

均值为 0
方差为 1

那么点积会累加 d_k 项。

结果近似:

点积方差 ≈ d_k
点积标准差 ≈ sqrt(d_k)

维度越大,点积值可能越大。

如果点积太大,进入 softmax 后会变得非常尖锐:

几乎全部权重集中在一个 token 上。

这可能导致:

梯度变小
训练不稳定
注意力分布过早坍缩

所以要除以:

sqrt(d_k)

让数值更稳定。


9.4 softmax 把分数变成权重

softmax 公式:

softmax(x_i) = exp(x_i) / sum_j exp(x_j)

效果是:

把一组任意实数变成一组和为 1 的权重。

例如:

scores = [5.1, 1.0, 4.3]

softmax 后近似:

weights =  [0.68, 0.01, 0.31] 

这些权重加起来等于 1。


9.5 第三步:权重加权 V

假设“吃”对所有 token 的注意力权重是:

对“我”:0.68
对“吃”:0.01
对“鱼”:0.31

那么“吃”的输出向量是:

output^(2)
= 0.68 · v^(1)
+ 0.01 · v^(2)
+ 0.31 · v^(3)

也就是说:

“吃”的新表示,
融合了“我”和“鱼”的信息。

此时“吃”不再是孤立的“吃”,而是:

谁在吃?我。
吃什么?鱼。

这个上下文融合后的“吃”。

一句话:

Q 和 K 决定“关注谁”。
V 决定“从被关注的对象那里拿到什么”。

9.6 举例:指代消解

句子:

这本书很有趣,它让我学到了很多。

当模型处理“它”时:

  1. “它”的 Q 去和可见位置的 K 匹配。
  2. 发现“书”的 K 和“它”的 Q 很相关。
  3. 模型给“书”较高注意力权重。
  4. 从“书”的 V 中取出信息。
  5. “它”的新表示就融合了“书”的信息。

于是模型更容易理解:

“它”指的是“这本书”。

9.7 注意力计算步骤汇总

步骤

公式

作用

1

Q = X W_Q

生成查询向量

2

K = X W_K

生成键向量

3

V = X W_V

生成值向量

4

Q K^T

算所有 token 对的相关性分数

5

÷ sqrt(d_k)

缩放,防止 softmax 过尖锐

6

softmax

把分数变成权重

7

权重 × V

融合其他 token 的信息

9.8 带 mask 的注意力计算伪代码

实际工程中,注意力计算通常写成如下形式:

scores = Q @ K.transpose(-2, -1)
scores = scores / sqrt(d_k)

scores = scores.masked_fill(mask == 0, float("-inf"))

attn_weights = softmax(scores, dim=-1)

output = attn_weights @ V

解释:

Q @ K.transpose(-2, -1)
计算每个 Query 对每个 Key 的相关性分数。

除以 sqrt(d_k)
防止点积过大导致 softmax 过于尖锐。

masked_fill(mask == 0, -inf)
把不能看的位置 mask 掉。

softmax(scores, dim=-1)
对每一个 Query 自己的所有 Key 分数做归一化。
也就是说,softmax 是按行做的。
每一行权重加起来等于 1。

attn_weights @ V
用注意力权重对 Value 做加权求和。
得到融合上下文后的新表示。

在 decoder-only 自回归模型中,mask 通常同时包含:

causal mask:不能看未来
padding mask:不能看 padding

可以粗略理解为:

final_mask = causal_mask & padding_mask

不同框架的实现细节可能不同,但核心思想一致:

先算分数,再 mask,再 softmax,最后加权 V。

 工程注记
实际实现中要避免某一行全部被 mask 成 -inf,否则 softmax([-inf, -inf, ...]) 可能产生 NaN。
因此 padding、空输入、异常 batch 都需要在数据侧或框架侧处理。

注意区分两种“权重”:

W_Q、W_K、W_V、W_O 是模型参数,也叫投影权重。
它们在训练过程中被更新。

softmax(scores) 得到的 attn_weights 是注意力权重。
它不是固定参数,而是每次输入都会动态计算出来的结果。


10. 因果掩码:自回归模型不能偷看后文

很多现代大模型是 decoder-only 自回归模型。

它们生成文本时是从左到右逐 token 生成的。

例如:

我 → 今天 → 很 → 开心

在训练和生成时,第 t 个位置只能看到:

1, 2, ..., t

不能看到:

t + 1, t + 2, ...

这就是因果掩码,英文叫 causal mask。


10.1 因果掩码怎么实现

在:

Q K^T

算出分数后、softmax 之前,把不能看的位置设成负无穷:

scores =
[
  [s11, -inf, -inf],
  [s21, s22, -inf],
  [s31, s32, s33]
]

经过 softmax 后:

-inf 位置的权重变成 0。

等价地,也可以理解成下面这种可见性掩码:

[
  [1, 0, 0],
  [1, 1, 0],
  [1, 1, 1]
]

对应句子:

当前位置

能看到

我、吃

我、吃、鱼


10.2 为什么因果掩码很重要

如果没有因果掩码,模型训练时就能直接看到后面的答案。

例如预测:

我 吃 ___

如果模型能直接看到后面的“鱼”,任务就太简单了。

这样学不到真正的语言建模能力。


10.3 双向模型不用因果掩码

BERT 这类双向编码器可以看到整个句子。

它更适合:

分类
实体抽取
填空
语义理解

GPT 类生成模型使用因果掩码,因为要逐 token 生成。

10.4 训练并行,生成串行

这是大模型工程中非常重要的一个概念。

生成时:

模型是一个 token 一个 token 往后生成的。

例如:

今天
今天天气
今天天气很
今天天气很好

每一步都需要依赖上一步生成的结果。
所以 Decode 阶段本质上是串行的。

但训练时不一样。

训练时,模型不需要真的先生成再学习。
训练数据里已经有完整句子。

例如:

我 吃 鱼

借助因果掩码,模型可以在一次前向传播中同时学习:

位置 1:
输入:我
目标:吃

位置 2:
输入:我 吃
目标:鱼

位置 3:
输入:我 吃 鱼
目标:EOS 或下一个 token

也就是说:

训练时,多个位置的 loss 可以并行计算。
生成时,下一个 token 必须逐步生成。

这就是为什么:

训练可以高度并行,效率很高。
推理生成阶段则更容易受显存带宽和 KV Cache 影响。

 MLOps 视角
训练关注:

吞吐
loss
梯度
优化器
显存峰值

推理关注:

TTFT
TPOT
tokens/s
KV Cache
batch 吞吐
显存带宽

这两个阶段的瓶颈完全不同。

更多推荐