生成式 AI 太火爆了,可以说无处不在,频频刷爆我们的朋友圈。你可能已经试用过 ChatGPT 了,甚至已经把它当作你的智能小助手。
但我知道很多人心里会有个疑问:这些 AI 模型的智能究竟是怎么来的?今天咱们就来聊聊这个话题。我会用大家都能懂的,而不是那些让人头大的高等数学术语来解释生成式文本模型的工作原理,揭开它的神秘面纱,把它变成简单的计算机算法。
LLM 的功能
首先,我要澄清人们对 LLM 工作原理的一个重大误解。大多数人认为这些模型可以回答问题或与你聊天,但实际上它们只能接收你提供的一些文本作为输入,然后猜测下一个词(更准确地说,下一个 Token)是什么。
让我们从 Token 开始了解 LLM 的奥秘。
Token
Token 是 LLM 理解的文本基本单位。虽然将 Token 看作单词很方便,但对 LLM 来说,目标是尽可能高效地编码文本,所以在许多情况下,Token 代表的字符序列比整个单词都要短或长。标点符号和空格也被表示为 Token,可能是单独或与其他字符组合表示。
LLM 使用的所有 Token 统称为其词汇,因为它可以用来表示任何可能的文本。字节对编码(BPE)算法通常用于 LLM 生成 Token 词汇。为了让你对规模有个大致的了解,GPT-2 语言模型是开源的,可以详细研究,其词汇量为 50,257 个 Token。
LLM 词汇中的每个 Token 都有一个唯一的标识符,通常是一个数字。LLM 使用分词器在常规文本字符串和等效的 Token 数列表之间进行转换。如果你熟悉 Python 并想尝试 Token,可以安装 OpenAI 的 tiktoken 包:
$ pip install tiktoken
然后在 Python 提示符中尝试以下内容:
>>> import tiktoken
>>> encoding = tiktoken.encoding_for_model("gpt-2")
>>> encoding.encode("The quick brown fox jumps over the lazy dog.")
[464, 2068, 7586, 21831, 18045, 625, 262, 16931, 3290, 13]
>>> encoding.decode([464, 2068, 7586, 21831, 18045, 625, 262, 16931, 3290, 13])
'The quick brown fox jumps over the lazy dog.'
>>> encoding.decode([464])
'The'
>>> encoding.decode([2068])
' quick'
>>> encoding.decode([13])
'.'
在这个实验中,你可以看到对于 GPT-2 语言模型,Token 464 代表单词 “The”,而 Token 2068 代表单词 “quick”,包括一个前导空格。该模型使用 Token 13 表示句号。
由于 Token 是通过算法确定的,你可能会发现一些奇怪的现象,比如这三个变体的单词 “the”,在 GPT-2 中都编码为不同的 Token:
>>> encoding.encode('The')
[464]
>>> encoding.encode('the')
[1169]
>>> encoding.encode(' the')
[262]
BPE 算法并不总是将整个单词映射到 Token。事实上,使用频率较低的单词不能成为独立的 Token,必须使用多个 Token 进行编码。以下是一个使用两个 Token 编码的单词示例:
>>> encoding.encode("Payment")
[19197, 434]
>>> encoding.decode([19197])
'Pay'
>>> encoding.decode([434])
'ment'
下一个 Token 预测
如上所述,给定一些文本,语言模型会预测下一个紧跟其后的 Token。如果用 Python 伪代码展示可能会更清晰,下面是如何运行这些模型以获取下一个 Token 的预测:
predictions = get_token_predictions(['The', ' quick', ' brown', ' fox'])
该函数接收一个由用户提供的提示词编码而来的输入 Token 列表。在这个例子中,我假设每个单词都是一个独立的 Token。为了简化,我使用每个 Token 的文本表示,但正如你之前看到的,实际上每个 Token 会作为一个数字传递给模型。
这个函数的返回值是一个数据结构,它为词汇表中的每个 Token 分配一个紧随输入文本之后的概率。如果基于 GPT-2,这个函数的返回值将是一个包含 50,257 个浮点数的列表,每个浮点数预测相应 Token 将会出现的概率。
在上述例子中,你可以想象,一个训练良好的语言模型会给 Token “jumps” 一个较高的概率来紧跟提示词 “The quick brown fox” 后面。同样假设模型训练得当,你也可以想象,随机单词如 “potato” 继续这个短语的概率会非常低,接近于 0。
为了能够生成合理的预测,语言模型必须经过训练过程。在训练期间,它会被提供大量文本以进行学习。训练结束时,模型能够使用它在训练中见到的所有文本构建的数据结构来计算给定 Token 序列的下一个 Token 概率。
这与你的预期有何不同?我希望这现在看起来不再那么神奇了。
生成长文本序列
由于模型只能预测下一个 Token 是什么,因此生成完整句子的唯一方法是多次循环运行模型。每次循环迭代都会生成一个新的 Token,从返回的概率中选择该 Token。然后将该 Token 添加到下一次循环迭代的输入中,直到生成足够的文本为止。
让我们看一个更完整的 Python 伪代码,展示这种方法的工作原理:
def generate_text(prompt, num_tokens, hyperparameters):
tokens = tokenize(prompt)
for i in range(num_tokens):
predictions = get_token_predictions(tokens)
next_token = select_next_token(predictions, hyperparameters)
tokens.append(next_token)
return ''.join(tokens)
generate_text() 函数将用户提示作为参数。这可能是一个问题。
tokenize() 辅助函数使用 tiktoken 或类似库将提示转换为等效的 Token 列表。在 for 循环中,get_token_predictions() 函数是调用 AI 模型以获取下一个 Token 的概率,如前面的例子所示。
select_next_token() 函数的作用是获取下一个 Token 的概率(或预测)并选择最佳 Token 以继续输入序列。函数可以只选择概率最高的 Token,这在机器学习中称为 “贪心选择(greedy selection)”。更好的是,它可以使用随机数生成器来选择一个符合模型返回概率的 Token,从而为生成的文本添加一些变化。这也会使模型在多次给出相同提示时产生不同的响应。
为了使 Token 选择过程更加灵活,LLM 返回的概率可以使用超参数进行修改,这些超参数作为参数传递给文本生成函数。超参数允许你控制 Token 选择过程的 “贪婪” 程度。
如果你使用过 LLM,你可能熟悉 temperature 超参数。temperature 越高,Token 概率越平坦,这增加了选择不太可能的 Token 的机会,最终使生成的文本看起来更有创造性或更不寻常。你可能还使用了另外两个超参数,称为 top_p 和 top_k,它们控制被考虑选择的最高概率的 Token 数量。
一旦选定了一个 Token,循环就会迭代,模型会接收到一个包含新 Token 在末尾的输入,并生成另一个紧随其后的 Token。num_tokens 参数控制循环运行的迭代次数,换句话说,就是要控制生成多少文本。生成的文本可能(而且经常)在句子中间断开,因为 LLM 没有句子或段落的概念,它只处理一个 Token。为了防止生成的文本在句子中间断开,我们可以将 num_tokens 参数视为最大值而不是确切的 Token 数,在这种情况下,我们可以在生成句号 Token 时停止循环。
如果你已经理解了这些内容,那么恭喜你,你现在已经大致了解了 LLM 是如何工作的。在下一部分,我会更深入一些,但仍然尽量避免涉及支撑这一技术的数学原理,因为它相当复杂。
模型训练
在不使用数学表达式的情况下讨论如何训练模型实际上是很困难的。我将从一个非常简单的训练方法开始展示。
鉴于任务是预测 Token 的后续 Token,一种简单的训练模型的方法是获取训练数据集中所有连续 Token,并用它们构建一个概率表。
让我们用一个简短的词汇表和数据集来做这个。假设模型的词汇表包含以下五个词元:
['I', 'you', 'like', 'apples', 'bananas']
为了使这个例子简短而简单,我不会将空格或标点符号作为 Token。
我们使用一个由三句话组成的训练数据集:
- I like apples
- I like bananas
- you like bananas
我们可以构建一个 5x5 的表格,并在每个单元格中写下表示单元格所在行的 Token 被单元格所在列的 Token 跟随的次数。以下是根据数据集中的三句话构建的表格:
- | I | you | like | apples | bananas |
I | 2 | ||||
you | 1 | ||||
like | 1 | 2 | |||
apples | |||||
bananas |
希望这很清楚。数据集中有两次出现 “I like”,一次出现 “you like”,一次出现 “like apples” 和两次出现 “like bananas”。
现在我们知道每对 Token 在训练数据集中出现的次数,我们可以计算每个 Token 跟随另一个 Token 的概率。为此,我们将每行中的数字转换为概率。例如,表格中间行的 Token “like” 后面跟一次 “apples” 和两次 “bananas”。这意味着 “like” 后面 33。3% 的概率是 “apples”,剩下的 66。7% 的概率是 “bananas”。
以下是计算出的所有概率的完整表格。空单元格的概率为 0%。
- | I | you | like | apples | bananas |
I | 100% | ||||
you | 100% | ||||
like | 33.3% | 66.7% | |||
apples | 25% | 25% | 25% | 25% | |
bananas | 25% | 25% | 25% | 25% |
对于 “I”、“you” 和 “like” 行来说,计算很简单,但 “apples” 和 “bananas” 行却出现了问题,因为它们没有任何数据。由于数据集中没有任何示例显示这些 Token 后面跟随其他 Token,这里我们的训练中存在一个 “漏洞”。
为了确保模型在缺乏训练的情况下仍能生成预测,我决定将 “apples” 和 “bananas” 后续 Token 的概率平均分配到其他四个可能的 Token 上,这显然可能会产生奇怪的结果,但至少模型在遇到这些 Token 时不会卡住。
训练数据中的漏洞问题实际上非常重要。在真正的 LLM 中,训练数据集非常庞大,因此你不会发现像我上面这个小例子中那样明显的训练漏洞。但由于训练数据覆盖率低而导致的小的、更难检测到的漏洞确实存在,并且相当普遍。
在这些训练不足的区域中,LLM 对 Token 的预测质量可能会很差,但通常是难以察觉的。这是 LLM 有时会产生幻觉的原因之一,这种情况发生在生成的文本读起来很流畅但包含事实错误或不一致时。
使用上面的概率表,你现在可以想象 get_token_predictions()函数的实现方式。在 Python 伪代码中它可能是这样的:
def get_token_predictions(input_tokens):
last_token = input_tokens[-1]
return probabilities_table[last_token]
比想象的更简单,对吧?这个函数接受一个由用户提示词生成的序列。它取序列中的最后一个 Token,并返回该 Token 在概率表中对应的那一行。
例如,如果你用 ['you', 'like'] 作为输入 Token 调用这个函数,那么该函数会返回 “like” 的那一行,“like” 会给予 “apples” 33.3% 的概率来继续句子,而 “bananas” 则是另外的 66.7%。根据这些概率,上面展示的 select_next_token() 函数每三次应该会选择一次 “apples”。
当 “apples” 被选为 “you like” 的延续时,句子 “you like apples” 就会形成。这是一个在训练数据集中不存在,但完全合理的原创句子。希望你开始了解这些模型如何通过重用模式和拼接它们在训练中学到的不同部分来生成看似原创的想法或概念。
上下文窗口
我在上一节中训练小模型的方法称为马尔可夫链。
这种技术的一个问题是,只使用一个 Token(输入的最后一个)来进行预测。任何出现在最后一个 Token 之前的文本在选择如何继续时都没有影响,所以我们可以说这种解决方案的上下文窗口等于一个 Token,这个窗口非常小。由于上下文窗口如此小,模型会不断 “忘记” 思路,从一个词跳到另一个词,缺乏一致性。
可以通过构建一个更大的概率矩阵来改进模型的预测。为了使用两个 Token 的上下文窗口,需要增加额外的表行,这些行代表所有可能的两个 Token 序列。在示例中使用的五个 Token 中,每一对 Token 将在概率表中新增 25 行,加上已经存在的 5 个单 Token 行。模型将不得不再次训练,这次不仅看 Token 对,还要看 Token 组的三元组。在每次 get_token_predictions() 函数的循环迭代中,当可用时,将使用输入的最后两个 Token 来查找较大概率表中的对应行。
但是,2 个 Token 的上下文窗口仍然不够。为了生成一致且至少有基本意义的文本,需要更大的上下文窗口。没有足够大的上下文,新生成的 Token 不可能与之前 Token 中表达的概念或想法相关联。那么我们该怎么办呢?将上下文窗口增加到 3 个 Token 将为概率表增加 125 行,并且质量仍然很差。我们需要将上下文窗口扩大到多大呢?
OpenAI 开源的 GPT-2 模型使用了一个 1024 个 Token 的上下文窗口。为了使用马尔可夫链实现这么大的上下文窗口,每行概率表都必须代表一个长度在 1 到 1024 个 Token 长的序列。使用上面示例中的 5 个 Token 词汇表,有 5 的 1024 次方种可能的序列长度为 1024 Token。需要多少表行来表示这些?我在 Python 会话中做了计算(向右滚动以查看完整数字):
>>> pow(5, 1024)
55626846462680034577255817933310101605480399511558295763833185422180110870347954896357078975312775514101683493275895275128810854038836502721400309634442970528269449838300058261990253686064590901798039126173562593355209381270166265416453973718012279499214790991212515897719252957621869994522193843748736289511290126272884996414561770466127838448395124802899527144151299810833802858809753719892490239782222290074816037776586657834841586939662825734294051183140794537141608771803070715941051121170285190347786926570042246331102750604036185540464179153763503857127117918822547579033069472418242684328083352174724579376695971173152319349449321466491373527284227385153411689217559966957882267024615430273115634918212890625
这行数太多了!而这只是表的一部分,因为我们还需要长度为 1023 的序列,1022 的序列,等等,一直到 1,因为我们想确保在输入中没有足够 Token 时也能处理较短的序列。马尔可夫链是有趣的,但它们确实存在一个很大的可扩展性问题。
而且一个 1024 Token 的上下文窗口已经不再那么大了。随着 GPT-3,上下文窗口增加到 2048 个 Token,然后在 GPT-3.5 中增加到 4096。GPT-4 开始时为 8192 个 Token,后来增加到 32K,然后又增加到 128K(没错,128,000 个 Tokens!)。现在开始出现上下文窗口为 1M 或更大的模型,这允许在做 Token 预测时有更好的一致性和回忆能力。
总之,马尔可夫链使我们以正确的方式思考文本生成问题,但它们有很大的问题,阻止我们考虑其作为可行的解决方案。
从马尔可夫链到神经网络
显然,我们不能再考虑使用概率表的方案,因为合理上下文窗口的概率表会需要庞大的 RAM。我们可以做的是用一个函数来替代概率表,该函数返回 Token 概率的近似值,这些概率是通过算法生成的,而不是存储在一个庞大的表格中。事实上,这正是神经网络擅长的事情。
神经网络是一种特殊的函数,它接受一些输入,对这些输入进行计算,然后返回一个输出。对于语言模型来说,输入是表示提示词的 Token,输出是下一个 Token 的预测概率列表。
神经网络之所以被称为 “特殊” 的函数,是因为它们除了函数逻辑外,还受一组外部定义的参数控制。最初,网络的参数是未知的,因此函数产生的输出完全没有用。神经网络的训练过程在于找到能使函数在训练数据集上表现最佳的参数,假设如果函数在训练数据上表现良好,那么在其他数据上也会表现良好。
在训练过程中,参数会使用一种称为反向传播的算法进行小幅度的迭代调整,这个算法涉及大量数学运算,所以本文不会详细讨论。每次调整后,神经网络的预测结果会略有改善。参数更新后,网络会再次根据训练数据集进行评估,评估结果用于指导下一轮调整。这个过程会持续进行,直到函数在训练数据集上表现出良好的下一个 Token 预测为止。
为了让你了解神经网络工作的规模,可以考虑 GPT-2 模型有大约 15 亿个参数,而 GPT-3 将参数数量增加到 1750 亿。据说 GPT-4 有大约 1.76 万亿个参数。以现有硬件条件训练这种规模的神经网络需要很长时间,通常是数周或数月。
有意思的是,由于参数众多,都是在没有人为干预的情况下通过漫长的迭代过程计算出来的,因此很难理解模型的工作原理。一个训练有素的 LLM 就像一个黑匣子,非常难以调试,因为模型的大部分 “思考” 都隐藏在参数中。即使是训练该模型的人也难以解释其内部工作原理。
层、Transformer 和注意力机制
你可能会好奇,在神经网络函数内部发生了哪些神秘的计算,在参数调优的帮助下,可以将一列输入 Token 转换为合理的下一个 Token 的概率。
一个神经网络被配置为执行一系列操作,每个操作称为一个 “层”。第一层接收输入,并进行某种转换。转换后的输入进入下一层,再次被转换。如此反复,直到数据到达最后一层,并进行最后一次转换,生成输出或预测结果。
机器学习专家设计出不同类型的层,对输入数据进行数学转换,并找出如何组织和组合层以达到预期的结果。有些层是通用的,而有些层则专为处理特定类型的输入数据而设计,如图像或在 LLM 中的 Token 化文本。
当前在 LLM 中用于文本生成最流行的神经网络架构称为 Transformer。使用这种设计的 LLM 被称为 GPT,即生成式预训练 Transformer。
Transformer 模型的显著特点是其执行的一种称为注意力机制的层计算,这使得它们能够在上下文窗口中的 Token 之间推导出关系和模式,并将这些关系和模式反映在下一个 Token 的概率中。
注意力机制最初用于语言翻译器,作为一种找到输入序列中最重要的 Token 以提取其含义的方法。这种机制使得现代翻译器能够在基本层面上 “理解” 一个句子,通过关注(或将 “注意力” 引向)重要的词或 Token。
LLM 是否具有智能?
到现在,你可能已经开始对 LLM 在生成文本的方式上是否表现出某种形式的智能形成一种看法。
我个人并不认为 LLM 具备推理能力或提出原创思想的能力,但这并不意味着它们毫无用处。由于它们在上下文窗口中的 Token 上进行的巧妙计算,LLM 能够发现用户提示中存在的模式,并将这些模式与训练期间学到的类似模式相匹配。它们生成的文本主要由训练数据的片段组成,但它们将词(实际上是 Token)拼接在一起的方式非常复杂,在许多情况下,产生的结果感觉是原创且有用的。
鉴于 LLM 容易产生幻觉现象,我不会信任任何由 LLM 生成,未经人工验证就直接传递给终端用户的工作流。
未来几个月或几年内出现的更大规模的 LLM 会实现类似于真正智能的东西吗?我觉得这在 GPT 架构下不会发生,因为它有很多限制,但谁知道呢,也许通过未来的一些创新,我们会实现这一目标。