速不通 LLM

The Simple Mathematics of Large Language Models PDF

句子与字词

我们将一句话看作一串字词按照特定顺序出现的概率——计算这句话的概率时,可以把它拆成多个步骤:先看第一个字词出现的概率,再看第一个字词出现的情况下,第二个字词出现的概率……一直到最后一个字词。

比方说句子「我喜欢吃苹果」。

将其拆分为字词「我」「喜欢」「吃」「苹果」。

那么这个句子整体出现的概率:

P(我,喜欢,吃,苹果)P(\text{我,喜欢,吃,苹果})

可以拆成:

  1. 「我」出现的概率: P()P(\text{我})

  2. 已经有「我」后,「喜欢」出现的概率: P(喜欢)P(\text{喜欢} \mid \text{我})

  3. 已经有「我喜欢」后,「吃」出现的概率: P(我,喜欢)P(\text{吃} \mid \text{我,喜欢})

  4. 已经有「我喜欢吃」后,「苹果」出现的概率: P(苹果我,喜欢,吃)P(\text{苹果} \mid \text{我,喜欢,吃})

最后把它们乘起来:

P()×P(喜欢)×P(我,喜欢)×P(苹果我,喜欢,吃)P(\text{我}) \times P(\text{喜欢} \mid \text{我}) \times P(\text{吃} \mid \text{我,喜欢}) \times P(\text{苹果} \mid \text{我,喜欢,吃})

就得到:

P(我,喜欢,吃,苹果)=P()×P(喜欢)×P(我,喜欢)×P(苹果我,喜欢,吃)P(\text{我,喜欢,吃,苹果}) = P(\text{我}) \times P(\text{喜欢} \mid \text{我}) \times P(\text{吃} \mid \text{我,喜欢}) \times P(\text{苹果} \mid \text{我,喜欢,吃})

将之推广到任意一个句子(序列),我们就得到:

P(w1,w2,,wn)=P(w1)P(w2w1)P(w3w1,w2)P(wnw1,,wn1).P(w_{1},w_{2},\ldots ,w_{n}) = P(w_{1})\cdot P(w_{2}\mid w_{1})\cdot P(w_{3}\mid w_{1},w_{2})\cdot \cdot \cdot P(w_{n}\mid w_{1},\ldots ,w_{n - 1}).

分词、token

前面将「我喜欢吃苹果」拆分为字词「我」「喜欢」「吃」「苹果」,如果用不同的方式(分词器),也可以将其拆分为「我」「喜」「欢」「吃」「苹」「果」。具体采用哪种拆分,取决于模型训练时预先确定好的分词规则。

用分词器将一个句子拆分出来的最小单元,我们称其为 token。它可以是一个完整的词(如「喜欢」),也可以是一个词根(如「un- 」),还可以是一个单字(如「苹」),甚至是一个字节或一个表情符号。

因此前面概率公式里的 wiw_{i} 严谨说来指的并非人类理解的「字词」,而是分词器拆分的 token。

token 构成的集合被称为词汇表(Vocabulary),通常用符号 VV 表示。词汇表的大小记作 V|V|

假如词汇表有 50000 个 token,那么光是由 10 个 token 组成的上下文(句子)就有 500001050000^{10} 种——比可观测宇宙中的原子数量还要多。

我们无法为每个可能的上下文单独存储一个概率。最好有个函数能够接受任意的上下文,并生成关于下一个 token(字词)的概率分布。

即:

f(token1,token2,,tokeni1)=P(tokenitoken1,,tokeni1)f(\text{token}_1, \text{token}_2, \ldots, \text{token}_{i-1}) = P(\text{token}_i \mid \text{token}_1, \ldots, \text{token}_{i-1})

这个函数,就是语言模型。

模型与函数

怎么得到这个函数呢——我们不能「设计」它,语法规则、语义关系、修辞手法……没有人能手工写出一套规则,让计算机根据上下文精确地算出下一个词的概率。

我们可以设计一套框架,让模型从大量的文本中自己把规律「调」出来。

具体来说:

1. 定义一个带参数的结构

选一个灵活的架构(如 Transformer),里面包含数十亿个可调节的数值(参数),初始时全部是随机的。

2. 让它反复试错

把海量真实文本喂给它。每看一个上下文,它就预测下一个 token,然后与真实值对比:

  • 给真实 token 的概率越高 → 误差越小

  • 给真实 token 的概率越低 → 误差越大

这个误差可以直观地理解为模型的「惊讶程度」。

3. 自动调整参数

把误差对每一个参数的影响计算出来——这个计算依赖于链式法则——它把最终误差沿着计算路径逐层拆解,分配给每一个参数——然后所有参数自动朝「让误差更小」的方向微调一点点。

4. 重复

在数万亿个 token 上反复执行步骤 2-3,参数从「随机乱猜」逐步变成「能给出合理预测」。

这个过程,就叫训练(Training)。训练语言模型,就是让它在「真实文本」上的平均惊讶程度不断降低。

一个被训练好的语言模型,本质上就是一个从「任意 token 序列」到「下一个 token 概率分布」的可计算函数

数学与简单例子

函数(模型)怎么处理 token 呢?

实际上,我们会将 token 映射成一个固定长度的数值列表(向量),这个过程叫作嵌入(Embedding)

我们来个简单的例子。

假设词汇表有 5 个 token:[猫, 狗, 车, 人, 树]

嵌入维度设为 3(即每个 token 用一个 3 维向量表示)。

这 5 个 token 的嵌入向量分别是(数值为示意):

token维度 1维度 2维度 3
1.20.80.1
1.10.90.0
-1.20.31.5
0.9-0.50.3
-0.81.2-1.1

每个维度可以粗略理解为一种「特征」,但不要把它理解成现实中的某个具体属性。模型并不会规定「第 1 维代表动物性、第 2 维代表大小」,这些数值及其组合关系,是模型在训练过程中从大量文本中自动学习出来的。

这些向量被组织成一张嵌入矩阵 EE

E=[1.20.80.11.10.90.01.20.31.50.90.50.30.81.21.1]E = \begin{bmatrix} 1.2 & 0.8 & 0.1 \\ 1.1 & 0.9 & 0.0 \\ -1.2 & 0.3 & 1.5 \\ 0.9 & -0.5 & 0.3 \\ -0.8 & 1.2 & -1.1 \end{bmatrix}

反过来,当模型看到 token「猫」时,它去矩阵里取第 1 行:

E(「猫」)=[1.2,0.8,0.1]E(\text{「猫」}) = [1.2,0.8,0.1]

然后分别去找猫和狗的距离:

(1.21.1)2+(0.80.9)2+(0.10.0)2=0.01+0.01+0.01=0.030.17\sqrt{(1.2-1.1)^2 + (0.8-0.9)^2 + (0.1-0.0)^2} = \sqrt{0.01 + 0.01 + 0.01} = \sqrt{0.03} \approx 0.17

猫和车的距离:

(1.2(1.2))2+(0.80.3)2+(0.11.5)2=2.42+0.52+(1.4)2=5.76+0.25+1.96=7.972.82\sqrt{(1.2-(-1.2))^2 + (0.8-0.3)^2 + (0.1-1.5)^2} = \sqrt{2.4^2 + 0.5^2 + (-1.4)^2} = \sqrt{5.76 + 0.25 + 1.96} = \sqrt{7.97} \approx 2.82

显然,在这个例子中,「猫」和「狗」的向量距离较近,说明它们在这个向量空间中的表示比较相似。

这里用欧几里得距离可以直观地理解两个向量之间的距离。实际上,在神经网络中,更常使用点积来衡量两个向量之间的关联程度。(Attention 中,也会使用点积来衡量 Query 和 Key 的匹配程度。)

ab=k=1dakbk=a1b1+a2b2++adbda \cdot b = \sum_{k=1}^{d} a_k b_k = a_1 b_1 + a_2 b_2 + \cdots + a_d b_d

「猫」和「狗」的点积

1.2×1.1+0.8×0.9+0.1×0.0=1.32+0.72+0=2.041.2 \times 1.1 + 0.8 \times 0.9 + 0.1 \times 0.0 = 1.32 + 0.72 + 0 = 2.04

「猫」和「车」的点积:

1.2×(1.2)+0.8×0.3+0.1×1.5=1.44+0.24+0.15=1.051.2 \times (-1.2) + 0.8 \times 0.3 + 0.1 \times 1.5 = -1.44 + 0.24 + 0.15 = -1.05

因此,在这个简化的例子中,「猫」和「狗」的向量方向更加一致,而「猫」和「车」的方向差异更大。

在真实的语言模型中,这并不意味着模型看到「猫」就会预测「狗」。Embedding 只是把 token 转换成模型可以进行数学运算的向量。

真正预测下一个 token 时,模型还会结合当前 token 以及前面的上下文,通过 Transformer 进行计算,得到一个能够表示当前语境的向量,然后根据这个向量对词汇表中的 token 进行打分,最终得到下一个 token 的概率。

上下文

在静态嵌入里,「苹果」永远只有一个向量。但在「我吃了苹果」和「我买了苹果手机」两句话里,「苹果」的含义显然不同。

模型需要在看到具体上下文之后,动态地为「苹果」生成一个新的、融合了上下文信息的表示。

怎么做呢?

对上下文里的每一个词,先计算它与上下文中每个位置的关联得分,再将其转换为「注意力权重」,然后把所有词的内容按权重混合起来,从上下文中提取信息。

比如「我吃了苹果」:

  • 「吃」和「苹果」的关联度很高 → 权重很大

  • 「我」和「苹果」的关联度中等 → 权重中等

  • 「了」和「苹果」的关联度很低 → 权重很小

混合之后,「苹果」的新表示会向「吃」的方向偏移——于是它变成了「食物语境下的苹果」。

权重从哪来——Attention

我们需要一个方法,计算「当前位置 ii 」和「上下文位置 jj 」之间的关联度。

最直接的方案:用点积。

关联度(i,j)=hihj\text{关联度}(i, j) = h_i \cdot h_j

点积越大,说明两个向量方向越一致,关联度越高。

点积有一个性质:

hihj=hjhih_i \cdot h_j = h_j \cdot h_i

这意味着,用这种方法计算出来的关联度天然是对称的: iijj 的关联度,必然等于 jjii 的关联度。

语言不是这样。

看「猫坐在垫子上」这句话:

  • 「猫」对「坐」的影响很大(猫是动作的发出者)

  • 「坐」对「猫」的影响相对较小(坐只是描述了猫的状态)

因此,我们需要一种能够表达有方向的关联关系的方法。

想象两个人在对话:

  • A说:「我想找一个能陪我跑步的伙伴。」

  • B说:「我是一个喜欢晨跑的人。」

A 和 B 的匹配度很高——因为 A 的「需求」和 B 的「特征」对上了。

但反过来呢?

  • B说:「我想找一个能教我做饭的人。」

  • A说:「我是一个爱跑步的人。」

B 的需求和 A 的特征就对不上。

同一个 A,在面对不同需求时,被打出的「匹配分」是不同的。

这里的核心是:「我想找什么」和「我能提供什么」是两种不同的信息,它们不共享同一个评价标准。

在语言模型里,当我们在处理「坐」这个词时:

  • 「坐」作为当前位置,它想知道:「谁能告诉我,是谁在坐?」(这是 Query)

  • 「猫」作为上下文词,它可以提供:「我是主语,我是动物。」(这是 Key)

当 Query 和 Key 匹配上时,关联度得分就高。

反过来,如果让「猫」作为 Query 去问「坐」:「你能告诉我什么?」它得到的回答只是「一个动作」,作用显然小得多。

**所以,我们需要把同一个词映射成两种不同的表述——一种用来「提出需求」(Query),一种用来「提供信息」(Key)。**同一个词在这两种角色下,应该有不同的数值形态。

数学上我们可以**对同一个向量 ** ee ,用两个不同的矩阵 WQW^QWKW^K 分别去乘它,得到两个不同的新向量。一个用于「提出需求」,一个用于「提供匹配」。

WQW^QWKW^K 都是可学习的参数矩阵——它们的内容不是设计出来的,而是在训练过程中逐渐被「挤」出来的,使得「需求」和「匹配」能够更好地对齐。

然后:

qi=WQhiq_i=W^Qh_i

表示当前位置 ii :「我在寻找什么」。

kj=WKhjk_j=W^Kh_j

表示位置 jj :「我能提供什么信息」。

于是,位置 ii 对位置 jj 的注意力得分可以计算为:

rij=qikj=(WQhi)(WKhj){ r_{ij} = q_i\cdot k_j = (W^Qh_i)\cdot(W^Kh_j) }

表示:**位置 ** ii 关注位置 jj

反过来:

rji=qjki=(WQhj)(WKhi){ r_{ji} = q_j\cdot k_i = (W^Qh_j)\cdot(W^Kh_i) }

表示:**位置 ** jj 关注位置 ii

由于 WQW^QWKW^K 是两个不同的投影矩阵,一般情况下:

rijrjir_{ij}\neq r_{ji}

因此,我们虽然仍然使用对称的点积操作,但通过让同一词以不同角色参与计算,使最终得到的注意力得分具有方向性。

Query 和 Key 可以理解为两种不同的角色:Query 表示「我正在寻找什么」,Key 表示「我能提供什么」。二者匹配得越好,注意力得分越高。

有了注意力得分之后,一个很自然的问题就出现了:

我们算出「坐」对「猫」的得分很高(「坐」这个位置的 Query 和「猫」的 Key 匹配)——然后呢?这个得分用来做什么?

如果只是算出一个分数放在那里,没有任何意义。这个分数最终是为了做一件事:从「猫」那里提取一些信息,加到「坐」的表示里去。

那么,「猫」应该贡献什么信息?

一个最直接的想法是:把「猫」的原始向量 ee_{\text{猫}} 直接拿过来,按权重加进「坐」的表示里。

但这里有一个问题:

决定「猫」是否值得被关注的特征,和「猫」实际贡献出去的内容,可以是不同的特征。

举个例子:

  • 假设「猫」被「坐」关注,是因为它在句子中承担主语等语法角色。

  • 但「坐」真正要从「猫」那里获取的,是「毛茸茸的小动物」这个语义内容

前者决定「值不值得关注」,后者决定「关注了之后拿到什么」。它们是两回事。

如果 Key 和 Value 使用同一个向量,模型就会面临一个两难:

  • 为了让「坐」关注到自己,「猫」需要把 Key 调成「名词、主语」——但这组特征未必是它想传递的语义。

  • 为了传递「毛茸茸的小动物」这个语义,它可能需要把 Value 调成另一种形态——但这可能让它不再容易被「坐」匹配到。

一个向量要同时服务于两个不同的目标,通常只能两头妥协,无法同时做到最优。

这就引入第三个投影矩阵:

vj=WVhjv_j = W^V h_j

它产生 Value(值),负责提供实际传递的信息。

Key 和 Value 承担不同职责:

  • Key: 用于匹配,决定「一个位置在当前 Query 下有多容易被匹配到」。

  • Value: 用于传递,决定「这个位置实际提供什么信息」。

两个投影矩阵各自独立:

  • 模型可以单独调整 Key,让某些词更容易被匹配到,而不改变它们实际传递的内容。

  • 模型也可以单独调整 Value,让某些位置传递不同的信息,而不影响它们被匹配的方式。

**Key 决定一个位置在当前 Query 下有多容易被匹配到,Value 决定匹配成功后向当前位置传递什么信息。**两者解耦,模型就有了更大的自由度去优化预测。

例如,「坐」这个位置在处理当前上下文时,可能通过 Query 与「猫」的 Key 产生较高的匹配分数,于是更加关注「猫」;但最终从「猫」那里获取什么信息,则由「猫」的 Value 决定。

可以粗略把这三个步骤理解为 Attention:

Query-Key 匹配确定关注程度按照关注程度获取 Value\boxed{\text{Query-Key 匹配}\rightarrow\text{确定关注程度}\rightarrow\text{按照关注程度获取 Value}}

也就是:

Q 决定「我想找什么」,K 决定「我有什么可以匹配」,V 决定「真正传递什么」。

实际上, WQW^QWKW^KWVW^V 这三个投影矩阵也是被训练出来的。它的灵感来源于信息检索(Information Retrieval)。

在数据库里,你要查一本书:

  • 你输入的关键词是 Query(你在找什么)。

  • 书上的标签/索引是 Key(它能提供什么匹配)。

  • 书的内容本身是 Value(实际拿到的信息)。

模型把这个逻辑借了过来:它自己造数据库,自己造查询。 WQW^QWKW^K 就是造查询和造标签的工具。没人告诉它标签怎么造,它通过训练自己学会了怎样造标签能更好地匹配查询,怎样造查询能更精准地找到有用的信息。

缩放点积

我们已经通过 Query 和 Key 的点积得到了匹配分数:

qikjq_i\cdot k_j

但在实际的 Attention 中,这个分数还要先进行缩放:

rij=qikjdkr_{ij}=\frac{q_i\cdot k_j}{\sqrt{d_k}}

其中 dkd_k 是 Key 向量的维度,即 Key 向量中数值的个数。

为什么要除以 dk\sqrt{d_k} ,为什么除以的是 dk\sqrt{d_k}

因为点积是 dkd_k 个乘积项的总和。随着向量维度 dkd_k 增大,点积的数值波动也会变大,而且其典型尺度大致与 dk\sqrt{d_k} 成正比。

如果直接把这些分数交给 Softmax,容易使输出过于集中:最高分的位置接近 1,其他位置接近 0。这样会让 Softmax 的梯度变得很小,不利于模型训练。

除以 dk\sqrt{d_k} ,可以把点积的数值缩放到更合适的范围。

因此,实际的 Attention 使用的是缩放点积

rij=qikjdk{ r_{ij}=\frac{q_i\cdot k_j}{\sqrt{d_k}} }

接下来,再把这些缩放后的分数交给 Softmax,将它们转换成注意力权重。

Softmax: 把得分转成权重

我们把注意力得分 rijr_{ij} 算出来了——它告诉我们「当前位置 ii 」对「上下文位置 jj 」的关注程度有多高。

rijr_{ij} 只是一个原始分数,可以是正数、负数、很大或很小。我们真正需要的是一组能够表示「注意力分配」的权重:分数越高的位置,应该获得越大的权重。

Softmax 函数恰好可以把任意一组实数转换成满足以下条件的权重:

  1. 非负(不能有负的贡献)

  2. 总和为 1(使它们可以被理解为一组分配给不同位置的权重)

αij=erijmerim\alpha_{ij} = \frac{e^{r_{ij}}}{\sum_{m} e^{r_{im}}}

它的作用很直接:

  • 对得分取指数 erije^{r_{ij}} ,保证结果为正数(因为 e任何数>0e^{\text{任何数}} > 0

  • 除以所有位置的总和,保证所有权重加起来等于 1(因为分母是所有分子的和)

Softmax 还有一个附带效果:通过指数函数放大不同分数之间的差异**。**

举个例子,假设某一步的注意力得分是:

  • 「猫」:4

  • 「垫子」:1

  • 「的」:0

经过 Softmax 后,权重可能变成:

  • 「猫」:0.936

  • 「垫子」:0.047

  • 「的」:0.017

这就是模型「聚焦」于重要词的机制——高分词几乎占据了全部权重,低分词几乎被忽略。

加权平均

有了权重之后,将所有 Value 按照注意力权重进行加权求和。由于 Softmax 后的权重总和为 1,因此这个结果也可以直观地理解成一种加权平均。

hi=jαijvjh_i = \sum_{j} \alpha_{ij} \cdot v_j

其中 vj=WVhjv_j = W^V h_j 是 Value 向量(「被关注后实际传递的内容」)。

回到「苹果」的例子:

  • 在「我吃了苹果」里,Attention 会根据当前上下文调整不同 token 的权重,使「苹果」的位置融合「吃」等上下文提供的信息。因此,「苹果」的表示会包含更多与食物有关的上下文信息。

  • 在「我买了苹果手机」里,「苹果」的位置又会融合「手机」等上下文提供的信息,因此它的表示会包含更多与品牌、电子产品有关的上下文信息。

所以,同一个 token「苹果」,在不同上下文中经过 Attention 后,可以得到不同的表示。

同一个 token,因为所处的上下文不同,会从上下文中获取不同的信息,于是得到不同的上下文表示。

堆叠多层

我们已经有了一个最核心的机制:Attention。

输入Q/K/V 投影注意力得分Softmax加权平均输出\text{输入} \rightarrow \text{Q/K/V 投影} \rightarrow \text{注意力得分} \rightarrow \text{Softmax} \rightarrow \text{加权平均} \rightarrow \text{输出}

一次 Attention 可以让每个 token 根据当前上下文,从其他位置获取信息,得到一个融合了上下文的新表示(输出)。

但一次 Attention 能做的事情仍然有限。我们可以把得到的新表示继续交给下一层,让模型在已经融合了一部分上下文信息的基础上,再进行一次信息交互。

于是,把这样的处理重复多次:

hi(0)=eih_i^{(0)} = e_i

hi(1)=Attention1(h(0))ih_i^{(1)} = \text{Attention}_1(h^{(0)})_i

hi(2)=Attention2(h(1))ih_i^{(2)} = \text{Attention}_2(h^{(1)})_i

\cdots

hi(L)=AttentionL(h(L1))ih_i^{(L)} = \text{Attention}_L(h^{(L-1)})_i

每一层都有自己独立的 WQ,WK,WVW^Q,W^K,W^V ,因此可以学习不同的关注方式。

实际中,一层并不只有 Attention,还会包含前馈网络、残差连接、归一化等组件。我们这里先把这些细节省略,只关注 Attention 如何逐层传递和加工信息。

把这些组件组织起来,并重复堆叠多层,就构成了 Transformer。

经过 LL 层之后,我们得到了每个位置的新表示。对于最后一个位置 nn ,取出:

hn(L)h_n^{(L)}

在因果语言模型中,当前位置只能关注自己以及它之前的位置,不能看到后面的 token。对于最后一个位置 nn ,这个向量已经融合了当前位置及其之前上下文的信息。

现在,模型要利用这个向量预测下一个 token。

具体来说,模型需要回答:

词汇表中的每一个 token,哪个最适合作为下一个 token?

因此,我们需要把当前的上下文表示 hn(L)h_n^{(L)} 转换成对词汇表中每个 token 的一个分数。

对于词汇表中的某个 token vv ,用点积计算它与当前上下文表示的匹配分数:

zv=hn(L)uvz_v=h_n^{(L)}\cdot u_v

其中, uvu_v 是 token vv 对应的输出向量。词汇表中的每个 token 都有一个这样的向量。

这样,我们就得到了一组分数:

z1,z2,,zVz_1,z_2,\ldots,z_{|V|}

它们分别表示词汇表中不同 token 作为下一个 token 的匹配程度。

但这些分数还不是概率。于是,再对所有分数统一进行 Softmax:

ezvuVezu\frac{e^{z_v}}{\sum_{u\in V}e^{z_u}}

这样,就得到了词汇表中每一个 token 作为下一个 token 的概率。

位置编码

回顾一下注意力得分的计算:

rij=qikjr_{ij} = q_i \cdot k_j

如果 qqkk 中没有位置信息,那么这个点积本身并不知道两个 token 在句子中的位置。

但在语言中,「狗咬人」和「人咬狗」虽然包含完全相同的 3 个 token,却表示不同的含义。

如果模型不知道每个 token 位于什么位置,就无法利用「谁在前、谁在后」这一信息,也就无法区分这两句话。

因此,我们需要把位置信息加入模型,让 Attention 不仅知道「有哪些 token」,还知道「它们分别位于什么位置」。

很容易想到一个直接的方案:给每个位置加一个「位置标记」。

xi=ei+pix_i = e_i + p_i

其中:

  • eie_i 是 token 的嵌入向量(「猫」的语义)

  • pip_i 是位置向量(「第 i 个位置」的标记)

两者相加,得到一个既包含语义、又包含位置的新向量。

但语言中的很多关系并不只是「你在第几个位置」,而是「你和我相隔多远」。例如,一个词更关心前一个词、后两个词,还是相隔很远的词。

因此,现代模型常用 RoPE 等方法,把位置信息融入 Query 和 Key,使 Attention 能够利用 token 之间的位置关系。