摘要

1) 一句话总结 本文仅基于基础的加法和乘法运算,从零构建并解析了大型语言模型(LLM)及Transformer架构的核心工作原理、训练过程与关键技术组件。

2) 关键要点

  • 基础原理:神经网络仅接收和输出数字,通过神经元、权重(参数)和层进行前向传播计算,并利用激活函数(如RELU)处理复杂非线性关系。
  • 模型训练:通过计算预测值与真实值的差异(损失 Loss),利用梯度下降(Gradient descent)不断微调参数以最小化损失,完成一轮训练称为一个Epoch。
  • 语言生成机制:语言生成本质上是将文本映射为数字,通过固定长度的输入(上下文长度)递归预测下一个标记(Token)。
  • 分词与嵌入(Tokenization & Embeddings):使用子词分词器(Sub-word Tokenizer)将文本拆分为Token(如构建32k大小的词汇表),并通过嵌入矩阵将其映射为多维向量,以捕捉词汇间的关联。
  • 自注意力机制(Self-Attention):通过计算查询(Query)、键(Key)和值(Value)向量的点积与权重,动态决定上下文中各词对当前预测的重要性。
  • Transformer核心组件:现代架构依赖多头注意力(并行运行多个注意力头)、残差连接(将输入直接加到输出上)、层归一化(减去均值并除以标准差以稳定训练)和位置编码(补充词序信息)。
  • 架构差异:GPT架构主要基于Transformer的解码器(Decoder)部分,专用于序列生成;而完整的Transformer包含编码器和解码器,最初用于翻译任务(包含交叉注意力机制)。
  • 预训练流程:构建预训练模型需要定义分词器、搭建Transformer架构、收集海量语料(如Common Crawl),并通过预测下一个Token进行大规模参数训练。

3) 风险/缺口

  • 梯度失控风险:在训练深度神经网络时,梯度极易失控并趋向于零或无穷大,导致“梯度消失(Gradient vanishing)”和“梯度爆炸(Gradient explosion)”问题。
  • 过拟合风险(Overfitting):模型可能在训练数据上表现良好,但在未见过的数据上泛化能力差(文中指出可通过引入Dropout层随机删除神经元连接来缓解此问题)。

正文

本文将从零开始探讨大型语言模型(LLM)的工作原理——前提只假设你懂得如何对两个数字进行加法和乘法运算。本文旨在提供一个完全自洽的解释。我们将从在纸笔上构建一个简单的生成式AI开始,然后逐步梳理深入理解现代LLM和Transformer架构所需的所有知识。文章将抛开机器学习中所有花哨的术语和行话,将一切还原为它们最简单的本质:数字。不过,我们依然会点出这些概念的专业名称,以便你在阅读其他充满术语的内容时能够有所对应。

从加法/乘法一路讲到当今最先进的AI模型,且不假设读者具备其他背景知识或查阅外部资料,这意味着我们要涵盖大量的内容。这绝不是一个“玩具级别”的LLM原理解释——理论上,一个有毅力的人完全可以根据这里提供的所有信息重现一个现代LLM。我删减了所有不必要的字句,因此这篇文章并不适合走马观花地粗读。

我们将涵盖哪些内容?

  1. 一个简单的神经网络
  2. 这些模型是如何训练的?
  3. 这一切是如何生成语言的?
  4. 是什么让LLM表现得如此出色?
  5. 嵌入(Embeddings)
  6. 子词分词器(Sub-word tokenizers)
  7. 自注意力机制(Self-attention)
  8. Softmax函数
  9. 残差连接(Residual connections)
  10. 层归一化(Layer Normalization)
  11. Dropout(丢弃法)
  12. 多头注意力(Multi-head attention)
  13. 位置嵌入(Positional embeddings)
  14. GPT架构
  15. Transformer架构

让我们开始吧。

首先需要注意的是,神经网络只能接收数字作为输入,并且只能输出数字。没有任何例外。其中的艺术在于:弄清楚如何将你的输入转化为数字,以符合你目标的方式解释输出的数字,以及最终构建一个神经网络,让它能够接收你提供的输入并给出你想要的输出(基于你为这些输出选择的解释方式)。接下来,我们将一步步演示如何从加减乘除走向像Llama 3.1这样的先进模型。

一个简单的神经网络

让我们来推演一个可以对物体进行分类的简单神经网络:

  • 可用的物体数据: 主导颜色(RGB)和体积(毫升)
  • 分类目标: 树叶(Leaf)或 花朵(Flower)

树叶和向日葵的数据可能长这样:树叶有其特定的RGB值和体积,花朵也有其对应的值。

现在我们来构建一个执行此分类的神经网络。我们需要决定输入和输出的解释方式。我们的输入已经是数字了,所以可以直接将它们喂给网络。我们的输出是两个物体(树叶和花朵),神经网络无法直接输出这些概念。我们来看看可以使用的两种方案:

  • 我们可以让网络输出一个单一的数字。如果数字是正数,我们说它是树叶;如果是负数,我们说它是花朵。
  • 或者,我们可以让网络输出两个数字。我们将第一个数字解释为代表树叶的值,第二个数字解释为代表花朵的值,并规定哪个数字更大,分类结果就是哪个。

这两种方案都允许网络输出我们可以解释为树叶或花朵的数字。在这里我们选择第二种方案,因为它能很好地推广到我们稍后要讨论的其他事物上。

一些专业术语:

  • 神经元/节点(Neurons/nodes): 网络中的数字节点。
  • 权重(Weights): 连接线上的乘数。
  • 层(Layers): 神经元的集合称为层。你可以把这个网络看作有3层:包含4个神经元的输入层,包含3个神经元的中间层,以及包含2个神经元的输出层。

要计算这个网络的预测/输出(称为“前向传播”),你需要从左边开始。我们已经有了输入层神经元的数据。为了“向前”移动到下一层,你需要将节点中的数字与对应连线的权重相乘,然后将它们全部加起来。运行整个网络后,如果我们看到输出层中的第一个数字更高,我们就会将其解释为“网络将这些(RGB, 体积)值分类为树叶”。一个训练有素的网络可以接收各种(RGB, 体积)的输入,并正确地对物体进行分类。

模型本身并没有树叶或花朵的概念,也不知道(RGB, 体积)是什么。它的工作就是精确地接收4个数字并精确地输出2个数字。是我们自己将这4个输入数字解释为(RGB, 体积),也是我们自己决定观察输出数字并推断“如果第一个数字更大,它就是树叶”。最后,也是由我们来选择正确的权重,使得模型能够接收我们的输入数字并给出正确的两个数字,从而让我们得到想要的解释结果。

这带来了一个有趣的副作用:你可以使用完全相同的网络,不输入RGB和体积,而是输入另外4个数字(如云量、湿度等),并将输出的两个数字解释为“一小时后晴天”或“一小时后下雨”。如果你把权重校准得很好,你可以让同一个网络同时做两件事——分类树叶/花朵,以及预测一小时后是否下雨!网络只是给你两个数字,你是将其解释为分类、预测还是其他东西,完全取决于你。

为了简化而省略的内容(忽略它们不会影响理解):

  • 激活层(Activation layer): 这个网络缺少的一个关键部分是“激活层”。这是一种高级的说法,意思是我们将每个节点中的数字应用一个非线性函数(RELU是一个常见的函数,如果数字是负数,就把它变成零;如果是正数,就保持不变)。如果没有激活层,网络中所有的加法和乘法都可以坍缩成一个单一的层。这有助于网络处理更复杂的情况。
  • 偏置(Bias): 网络通常还会包含与每个节点相关的另一个数字,这个数字只是简单地加到乘积中以计算节点的值,这个数字被称为“偏置”。模型中所有这些不是神经元/节点的数字通常被称为“参数”。
  • Softmax: 我们通常不会直接解释输出层的数字。我们会将这些数字转化为概率(即让所有数字都变为正数,且总和为1)。通常使用“softmax”函数来实现这一点,它可以处理正数和负数。

这些模型是如何训练的?

在上面的例子中,我们神奇地拥有了让我们输入数据并获得良好输出的权重。但是这些权重是如何确定的呢?设置这些权重(或“参数”)的过程被称为“训练模型”,我们需要一些训练数据来训练它。

假设我们有一些数据,我们有输入,并且已经知道每个输入对应的是树叶还是花朵,这就是我们的“训练数据”。因为我们为每组(R,G,B,体积)数字都有树叶/花朵的标签,所以这是“标记数据”。

它的工作原理如下:

  • 从随机数开始,即把每个参数/权重设置为一个随机数。
  • 现在,我们输入对应于树叶的数据。假设我们希望输出层中代表树叶的数字更大(例如树叶为0.8,花朵为0.2)。
  • 我们知道我们在输出层想要的数字,也知道我们从随机选择的参数中得到的数字。对于输出层中的所有神经元,我们计算我们想要的数字和我们拥有的数字之间的差值。然后把这些差值加起来。我们可以把这个称为我们的“损失(Loss)”。理想情况下,我们希望损失接近于零,即我们想要“最小化损失”。
  • 一旦我们有了损失,我们就可以稍微改变每个参数,看看增加或减少它是否会增加损失。这被称为该参数的“梯度(Gradient)”。然后我们可以将每个参数向损失下降的方向(与梯度相反的方向)移动一小段距离。一旦我们稍微移动了所有参数,损失应该会降低。
  • 不断重复这个过程,你就会降低损失,最终得到一组“训练好”的权重/参数。这整个过程被称为“梯度下降(Gradient descent)”。

几点注意事项:

  • 你通常有多个训练样本,所以当你稍微改变权重以最小化一个样本的损失时,可能会使另一个样本的损失变大。解决这个问题的方法是将损失定义为所有样本的平均损失,然后对该平均损失求梯度。每一个这样的循环被称为一个“Epoch(轮次)”。
  • 我们实际上不需要“来回移动权重”来计算每个权重的梯度——我们可以直接从数学公式中推导出来。

在实践中,训练深度网络是一个困难且复杂的过程,因为梯度在训练过程中很容易失控,趋向于零或无穷大(称为“梯度消失”和“梯度爆炸”问题)。现代模型包含数十亿个参数,训练模型需要庞大的计算资源。

这一切是如何帮助生成语言的?

记住,神经网络接收一些数字,根据训练好的参数做一些数学运算,然后输出一些其他的数字。一切都在于解释和训练参数。如果我们能把这两个数字解释为“树叶/花朵”,我们也能把它们解释为“句子中的下一个字符”。

但是英语中不止有两个字母,所以我们必须将输出层中的神经元数量扩展到,比如说,英语中的26个字母(再加上空格、句号等符号)。每个神经元可以对应一个字符,我们观察输出层中数字最高的神经元,并说它对应的字符就是输出字符。现在我们有了一个可以接收输入并输出字符的网络。

如果我们把网络中的输入替换为这些字符:“Humpty Dumpt”,并要求它输出一个字符,将其解释为“网络建议的我们刚刚输入的序列的下一个字符”会怎样?我们大概可以把权重设置得足够好,让它输出“y”——从而补全“Humpty Dumpty”。但有一个问题:我们如何将这些字符列表输入到网络中?我们的网络只接受数字!

一个简单的解决方案是为每个字符分配一个数字。假设a=1,b=2等等。现在我们可以输入“humpty dumpt”并训练它给我们“y”。

现在我们可以通过向网络提供一个字符列表来预测前面的一个字符。我们可以利用这个事实来构建一个完整的句子。例如,一旦我们预测出了“y”,我们就可以把这个“y”附加到我们已有的字符列表中,把它喂给网络,让它预测下一个字符。如果训练得当,它应该会给我们一个空格,依此类推。到最后,我们应该能够递归地生成“Humpty Dumpty sat on a wall”。我们拥有了生成式AI。更重要的是,我们现在拥有了一个能够生成语言的网络!

敏锐的读者会注意到,我们实际上无法将完整的“Humpty Dumpty”输入到网络中,因为输入层只有12个神经元(对应“humpty dumpt”的长度)。那么我们如何在下一次传递中放入“y”呢?解决方案很简单,把最前面的“h”踢出去,发送最近的12个字符。所以我们会发送“umpty dumpty”,网络会预测一个空格。

我们在最后一行只给模型喂入“ sat on the wal”时丢弃了大量信息。那么当今最新最好的网络是怎么做的呢?差不多就是这样。我们可以输入到网络中的序列长度是固定的(由输入层的大小决定)。这被称为“上下文长度(Context length)”——提供给网络以进行未来预测的上下文。现代网络可以有非常大的上下文长度(几千个词)。

细心的读者还会注意到,我们对相同的字母在输入和输出端有不同的解释!例如,输入“h”时我们只用数字8表示,但在输出层,我们要求模型输出26个数字,看哪个最高。为什么我们不在两端使用相同、一致的解释呢?我们可以这么做,只是在语言的情况下,自由选择不同的解释能让你有更好的机会构建更好的模型。

是什么让大型语言模型表现得如此出色?

逐字生成“Humpty Dumpty sat on a wall”与现代LLM能做的事情相去甚远。从我们上面讨论的简单生成式AI到类似人类的机器人,有许多差异和创新。让我们逐一了解:

嵌入(Embeddings)

我们之前说,为每个字符任意分配一个数字并不是最好的方法。如果我们能在每次迭代中,除了稍微移动权重之外,也移动输入数字,看看是否能通过使用不同的数字来表示“a”从而获得更低的损失呢?这被称为“嵌入(Embedding)”。它是输入到数字的映射,正如你所见,它需要被训练。

然而,在现实中,嵌入包含不止一个数字。因为很难用一个单一的数字来捕捉概念的丰富性。所以,我们不把每个字符表示为一个单一的数字,而是分配一组数字。我们把有序的数字集合称为“向量(Vector)”。

  • 如果我们为每个字符分配一个向量,我们如何将“humpty dumpt”喂给网络?很简单。假设我们为每个字符分配了一个包含10个数字的向量。那么输入层就不再是12个神经元,而是120个神经元。
  • 我们如何找到这些向量?训练嵌入向量与训练参数没有区别。你现在有120个输入,你只需要移动它们来看看如何最小化损失。

我们将这种由相同大小的向量组成的有序集合称为矩阵。这被称为嵌入矩阵(Embedding matrix)。你告诉它对应于你的字母的列号,查看矩阵中的那一列就会给你用来表示该字母的向量。

子词分词器(Subword Tokenizers)

到目前为止,我们一直以字符作为语言的基本构建块。这有其局限性。如果我们直接为单词分配嵌入,并让网络预测下一个单词会怎样?网络只懂数字,所以我们可以为“humpty”、“dumpty”等单词各分配一个长度为10的向量。

Token”是我们嵌入并喂给模型的单个单元的术语。我们之前的模型使用字符作为Token,现在我们提议使用整个单词作为Token。

使用单词分词有一个深远的影响:英语中有超过18万个单词。这意味着输出层需要数十万个神经元。此外,由于我们单独处理每个单词,并且从随机数字嵌入开始,非常相似的单词(例如“cat”和“cats”)一开始将没有任何关系。

我们能利用这种明显的相似性来简化问题吗?可以。当今语言模型中最常见的嵌入方案是将单词分解为子词(Subwords),然后对它们进行嵌入。在cat的例子中,我们会将cats分解为两个Token:“cat”和“s”。这使得模型更容易理解概念,同时也减少了我们需要的Token数量。

分词器(Tokenizer)的作用是接收输入文本,将其拆分为Token,并为你提供相应的数字,以便你在嵌入矩阵中查找该Token的嵌入向量。

自注意力机制(Self Attention)

到目前为止,我们只看到了一种简单的神经网络结构(前馈网络),每一层都与下一层完全连接。让我们探索一种特别重要的结构:自注意力机制。

在人类语言中,我们要预测的下一个词取决于前面的所有词。然而,某些词的依赖程度可能比其他词更高。例如:“Damian有一个私生子,是个女孩,他在遗嘱中写道,他所有的财物……都将属于____”。这里的词可能是“她”或“他”,它具体取决于句子中更早的一个词:女孩/男孩。

我们需要不仅取决于位置,还取决于该位置内容的权重。我们如何实现这一点?

自注意力机制有点像把每个词的嵌入向量加起来,但它不是直接相加,而是对每个向量应用一些权重。如果humpty, dumpty, sat的嵌入向量分别是x1, x2, x3,它会在相加之前将每个向量乘以一个权重。比如 output = u1x1 + u2x2 + u3*x3。我们如何找到这些权重u1, u2, u3?

理想情况下,我们希望这些权重取决于我们正在聚合的向量,同时也取决于我们即将预测的词。由于我们还不知道要预测的词,自注意力机制使用紧挨着我们要预测的词的前一个词(即我们拥有的句子中的最后一个词)。

为了得到这些权重,我们为x1构建一个向量(称之为k1),为最后一个词x3构建一个单独的向量(称之为q3),然后取它们的点积。这会给我们一个数字,它同时取决于x1和x3。我们如何得到k1和q3?我们构建一个微型的单层神经网络从x1得到k1,构建另一个网络从x3得到q3。使用矩阵表示,我们得出权重矩阵Wk和Wq,使得 k1 = Wkx1,q3 = Wqx3。所以 u1 = k1 · q3。

自注意力机制中发生的另一件事是,我们不直接取嵌入向量本身的加权和。相反,我们取该嵌入向量的某个“值(Value)”的加权和,这个值是通过另一个小型单层网络获得的(v1 = Wv*x1)。

最后,标量u1, u2, u3不一定会加起来等于1。如果我们需要它们作为权重,我们会应用softmax函数。

这整个过程可以放在一个盒子里,称为“自注意力块”。这个块接收嵌入向量,并吐出一个用户选择长度的单一输出向量。这个块有三个参数:Wk, Wq, Wv。

你会注意到,到目前为止,事物的位置似乎并不相关。这意味着虽然注意力机制可以弄清楚该注意什么,但这并不取决于词的位置。然而,词的位置在语言中很重要。因此,当使用注意力机制时,我们通常不会直接将嵌入向量喂给自注意力块。我们稍后会看到如何在喂给注意力块之前将“位置编码”添加到嵌入向量中。

Softmax

Softmax试图解决这个问题:在我们的输出解释中,我们希望网络选择最高值的神经元。我们计算损失为网络提供的值与我们想要的理想值之间的差。但理想值是多少?如果设为无穷大,问题就无法处理了。

我们需要一种方法将最后一层的输出转换到(0,1)范围内,并保留顺序。Softmax函数就是做这个的。如果你给它10个任意数字,它会给你10个输出,每个都在0和1之间,而且重要的是,这10个数字加起来等于1,这样我们就可以把它们解释为概率(机会)。这允许模型在预测时不仅盲目选择最大值,还可以根据概率探索第二好的选项。你会在几乎每个语言模型的最后一层找到softmax。

残差连接(Residual connections)

残差连接是指:我们将自注意力块的输出,在传递给下一个块之前,加上原始的输入。这要求自注意力块的输出维度必须与输入相同。为什么这样做?随着网络变得越来越深,训练它们变得越来越困难。残差连接已被证明有助于解决这些训练挑战。

层归一化(Layer Normalization)

层归一化是一个相当简单的层,它接收进入该层的数据,并通过减去均值并除以标准差来对其进行归一化。这稳定了输入向量并有助于训练深度网络。

为了防止归一化去除了对预测有用的信息,层归一化层有一个缩放(Scale)和一个偏置(Bias)参数。对于每个神经元,你只需将其乘以一个标量,然后加上一个偏置。这些标量和偏置值是可以训练的参数。

Dropout(丢弃法)

Dropout是一种简单但有效的方法,用于避免模型过拟合(模型在训练数据上表现良好,但在未见过的数据上泛化能力差)。

通过在训练期间插入Dropout层,你实际上是在随机删除插入Dropout的层之间一定百分比的直接神经元连接。这迫使网络以大量的冗余进行训练。本质上,你是在同时训练许多不同的模型——但它们共享权重。

在推理(预测)时,Dropout会使用包含所有权重的完整网络,并简单地将权重乘以 (1 - 删除概率)。这已被证明是一种非常好的正则化技术。

多头注意力(Multi-head Attention)

这是Transformer架构中的关键块。多头注意力基本上是并行运行几个注意力头(它们都接收相同的输入)。然后我们获取它们所有的输出并简单地将它们拼接(concatenate)起来。

这里发生的是,我们为每个头生成相同的键(key)、查询(query)和值(value)输入。但在使用这些k, q, v值之前,我们基本上在它们之上应用了一个线性变换(对每个k, q, v单独应用,且每个头单独应用)。

位置编码和嵌入(Positional encoding and embedding)

位置嵌入与任何其他嵌入没有区别,只是我们不嵌入词汇,而是嵌入数字1, 2, 3等。所以这个嵌入是一个与词嵌入长度相同的矩阵,每一列对应一个数字。仅此而已。

GPT架构

GPT架构主要用于生成序列中的下一个词。如果你一直跟随着本文,这应该很容易理解。 它包含:

  • 输入词的嵌入 + 位置嵌入
  • GPT Transformer块(包含层归一化、多头注意力、残差连接、前馈网络等)
  • 输出层(线性层 + Softmax)

GPT实际上是原始Transformer论文中被称为“解码器(Decoder)”的部分。

Transformer架构

这是推动近期语言模型能力快速加速的关键创新之一。Transformer不仅提高了预测准确性,而且比以前的模型更容易/更高效地训练,允许更大的模型尺寸。

如果你想做翻译(例如把德语翻译成英语),该怎么训练模型? Transformer最初就是为此任务创建的,由一个“编码器(Encoder)”和一个“解码器(Decoder)”组成。

  • 编码器: 接收德语句子并给出一个中间表示(同样,基本上是一堆数字)。
  • 解码器: 生成单词。唯一的区别是,除了喂给它迄今为止生成的单词外,我们还喂给它编码后的德语句子。

一些关键点:

  • 前馈网络(Feed forward): 独立应用于每个位置。位置x的神经元与位置y的前馈网络没有链接。这很重要,防止网络在训练时“向前偷看”。
  • 交叉注意力(Cross-attention): 解码器中有一个多头注意力机制,其箭头来自编码器。这里的查询(Query)来自解码器(生成的序列),但值(Value)和键(Key)来自编码器的输出(源语言序列)。
  • Nx: 表示这个块被链式重复N次。你将编码器完整运行一次,然后将该表示喂给所有N个解码器层。

现在你拥有了从简单的求和与乘积操作构建起来的Transformer架构的完整解释!理论上,这些笔记包含了你从零开始编写Transformer代码所需的一切。

构建预训练模型

此时,我们已经具备了设计和训练LLM所需的所有部件。让我们把这些碎片拼凑起来,构建一个英语语言模型:

  1. 首先,构建一个分词器(Tokenizer),假设词汇表大小为32k。
  2. 接下来,构建一个采用Transformer架构的LLM。输出向量和嵌入矩阵都必须有32k个元素。
  3. 收集英语语料库作为训练数据(例如抓取整个互联网的数据,如Common Crawl)。
  4. 通过要求模型预测下一个Token来开始训练模型。定义损失函数,并推动网络预测正确的Token。
  5. 对数百亿或数万亿个Token执行此操作,将为你提供一组权重,这就是你的预训练模型。这个模型能够预测下一个Token,现在可以用来补全句子,甚至整篇文章。

附录

  • 矩阵乘法: 如果权重矩阵称为“W”,输入称为“x”,那么就是结果(点积)。
  • 标准差: 减去均值,求平方,求和,除以N,最后取平方根。
  • 位置编码: 原始论文中使用正弦和余弦函数来生成位置编码,这样可以确保每个位置都有唯一的向量,且数值保持在0和1之间,不会在训练期间爆炸。

关联主题