提示词缓存:LLM 成本优化实战
通过提示词缓存减少 Token 消耗的实用方案。程序员可直接应用降低 API 成本,提升经济性。
通过提示词缓存减少 Token 消耗的实用方案。程序员可直接应用降低 API 成本,提升经济性。
我写这篇文章时,OpenAI 和 Anthropic API 的缓存输入 token 价格都比普通输入 token 便宜 10 倍。
Anthropic 甚至声称提示词缓存可以将长提示的延迟降低"高达 85%",在我自己的测试中也发现了这一点。我向 OpenAI 和 Anthropic 各发送了数百个请求,发现当所有输入 token 都被缓存时,首 token 延迟有实质性下降。
既然我已经用漂亮的渐变文本和图表吸引了你的注意,你有没有问过自己……
在那些庞大的 GPU 海洋中发生了什么,使得供应商能给你 10 倍的输入 token 折扣?他们在请求之间保存了什么?这不是说保存响应然后在发送相同提示时重新使用它——通过 API 很容易验证这不会发生。写一个提示词,发送十几次,注意到即使使用情况中显示了缓存输入 token,你也会得到不同的响应。
对供应商文档中的答案不满意——这些文档虽然很好地解释了如何使用提示词缓存,但回避了实际缓存内容的问题——我决定深入探究。我钻进了 LLM 工作原理的兔子洞,直到我理解了供应商缓存的具体数据、它的用途,以及它如何为所有人加快速度和降低成本。
读完这篇文章后,你将……
在更深的层次上理解 LLM 的工作原理
建立关于 LLM 为什么以这种方式工作的新直觉
理解缓存的确切 1 和 0,以及它如何降低 LLM 请求的成本
在核心上,LLM 是巨大的数学函数。它们以数字序列作为输入,输出一个数字。LLM 内部有一个包含数十亿个精心排列操作的巨大图,这些操作将输入数字转换为输出数字。
这个庞大的操作图可以粗略地分为 4 个部分。
图中的每个节点都可以看作一个接受某些输入并产生某些输出的函数。输入在循环中被输入到 LLM,直到特殊的输出值告诉它停止。以伪代码的方式,它可能看起来像这样:
1prompt = "What is the meaning of life?";2 3tokens = tokenizer(prompt);4while (true) {⋯5 embeddings = embed(tokens);6 for ([attention, feedforward] of transformers) {⋯7 embeddings = attention(embeddings);8 embeddings = feedforward(embeddings);9 }10 output_token = output(embeddings);11 if (output_token === END_TOKEN) {⋯12 break;13 }14 tokens.push(output_token);15}16 17print(decode(tokens));
LLM 出乎意料的小
虽然上面的内容被大大简化了,但现代 LLM 的代码行数之少让我惊讶。
Sebastian Raschka 使用 PyTorch 创建开源模型的独立重新实现,以及大量其他顶级教育资源,如果你喜欢这篇文章,你会喜欢这些资源。当前领先的开源模型之一 Olmo 3,例如只需要几百行代码。
提示词缓存发生的地方是 Transformer 的"注意力"机制。我们将按顺序遍历 LLM 的工作原理,直到到达那里。这意味着我们必须通过讨论 token 来开始这个旅程。
在 LLM 可以对你的提示词做任何事情之前,它需要将其转换为它可以处理的表示形式。这是 tokenizer 和嵌入阶段之间共享的两步过程。为什么这一切都是必要的,直到我们到达嵌入时才会清楚,所以请耐心跟我一起了解 tokenizer 的作用。
Tokenizer 获取你的提示词,将其分块成小块,并为每个唯一的块分配一个称为"token"的整数 ID。例如,以下是 GPT-5 如何对提示词"Check out ngrok.ai"进行 tokenize:
Check
out
ng
rok
.ai
提示词已被分成数组 ["Check", " out", " ng", "rok", ".ai"],并转换为 token [4383, 842, 1657, 17690, 75584]。同一提示词始终会产生相同的 token。Token 也区分大小写,这是因为大小写能告诉你关于单词的信息。大写 W 的"Will"比小写 w 的"will"更可能是名字。
为什么不直接按空格或字符分割?
这是一个出人意料的大问题,详细覆盖它可能会轻易使这篇文章的长度翻倍。简短但不令人满意的答案是这是一个权衡。如果你想深入了解,Andrej Karpathy 有一个很好的视频,其中他从零开始构建 tokenizer。对于提示词缓存,知道 tokenization 将文本转换为数字就足够了。
Token 是 LLM 的基本输入和输出单位。当你问 ChatGPT 一个问题时,响应会在 LLM 的每次迭代完成时,以一次一个 token 的方式流回给你。供应商这样做是因为生成完整响应可能需要数十秒,但在 token 准备好时立即发送给你会让这个过程感觉更具交互性。
让我们提出一个经典的 LLM 问题来看看这一点。当你准备好时,点击下面的发送按钮。
提示词 token 进入,✨ AI 魔法 ✨,输出 token 出来,重复。这个过程被称为"推理",注意每个输出 token 在下一次迭代之前都会被追加到输入提示词。LLM 需要所有的上下文来生成好的答案。如果我们只输入提示词,它会继续尝试生成答案的第一个 token。如果我们只输入答案,它会立即忘记问题。整个提示词 + 答案需要在每次迭代中都被输入到 LLM。
那个 199999 <END> token 是什么?
推理必须在某个时刻停止。LLM 有各种"特殊"token 可以输出,其中之一表示响应的结束。在 GPT-5 tokenizer 中,这是 token 199999。这是 LLM 可以终止的众多方式之一。你可以通过 API 指定最大生成 token 数,供应商可能还有其他与安全相关的规则来决定何时停止。
还有用于表示会话消息开始和结束的特殊 token,这就是 ChatGPT 和 Claude 这样的聊天模型知道一条消息何时结束、另一条消息何时开始的方式。
关于 tokenizer 的最后一点:有很多种!ChatGPT 使用的 tokenizer 与 Claude 使用的不同。即使是 OpenAI 制作的不同模型也使用不同的 tokenizer。每个 tokenizer 都有自己的文本分割为 token 的规则。如果你想看看各种不同的 tokenizer 如何分割文本,可以查看 tiktokenizer。
现在我已经向你介绍了 token,让我们谈论嵌入。
我们来自 tokenizer 的 token 现在被输入到嵌入阶段。要理解嵌入,有助于理解模型的目标。
当人类用代码解决问题时,我们编写接受输入并产生输出的函数。例如,将华氏温度转换为摄氏温度。
1function fahrenheitToCelsius(fahrenheit) {⋯2 return ((fahrenheit - 32) * 5) / 9;3}
我们可以将任何数字扔进 fahrenheitToCelsius 并得到正确的答案。但是,如果我们有一个我们不知道公式的问题呢?如果我们只有下面这个神秘的输入和输出表呢?
我不期望你识别这里的函数,尽管我会说如果你在应用中粘贴一个截图,ChatGPT 会立即识别出来。
当我们知道每个输入的预期输出,但不知道产生它的函数时,我们可以"训练"一个模型来学习这个函数。我们通过给模型一块画布——那个庞大的数学操作图——并修改这个图直到模型收敛到正确函数来做到这一点。每次图更新时,我们都会运行输入通过它来看它与正确输出的接近程度。我们一直这样做直到我们满足它足够接近。这就是训练。
事实证明,在训练模型输出正确文本时,能够识别两个句子何时相似会很有帮助。但究竟是哪方面相似呢?它们可能同样悲伤、同样有趣,或同样发人深省。它们也可能在长度、节奏、语气、语言、词汇或结构上相似。我们可以从极其多的维度描述两个句子的相似性,而两个句子可能在某些维度上相似,在另一些维度上却并不相似。
Token 没有维度。它们只是普普通通的整数。而嵌入(embedding)则不同,它们有很多维度。
嵌入是一个长度为 n 的数组,表示 n 维空间中的一个位置。如果 n 为 3,那么一个嵌入可能是 [10, 4, 2],表示三维空间中 x=10、y=4、z=2 的位置。在训练 LLM 时,每个 token 都会被分配到这个空间中的一个随机初始位置,随后训练过程会不断微调所有 token 的位置,直到找到一种能够产生最佳输出的排列方式。
嵌入阶段首先会查找每个 token 对应的嵌入。用伪代码表示,可能如下所示:
1// Created during training, never changes during inference.2const EMBEDDINGS = [...];3 4function embed(tokens) {5 return tokens.map(token => {6 return EMBEDDINGS[token];7 });8}
因此,我们接收 token——一个整数数组——并将其转换成嵌入数组。也就是数组的数组,或者说一个“矩阵”。你可以在下方的 token 和嵌入之间切换,看看我在脑海中是如何理解这一过程的。
Token [75, 305, 284, 887] 会被转换成一个由三维嵌入组成的矩阵。
赋予嵌入的维度越多,它就能从越多的维度比较句子。我们一直在讨论三维嵌入,但当前模型中的嵌入拥有数千个维度。规模最大的嵌入甚至超过 10,000 个维度。
为了展示更多维度的价值,下面有 8 组不同颜色的图形,它们一开始位于一维空间中。这些图形排列在一条线上,杂乱无章,很难看出其中的规律。但随着维度增加,你会清楚地发现,它们实际上分成了 8 个彼此独立但内部相关的组。点击 2D 和 3D 按钮,就能明白我的意思。
对于这里的可视化示例来说,三维已经是我能做到的极限了。至于在数千个维度中可以实现什么,就需要发挥你的想象力了。
嵌入阶段还会完成最后一件事。在取得一个 token 的嵌入后,它会把该 token 在提示词中的位置编码进嵌入里。除了确认它对提示词缓存的工作方式没有太大影响之外,我并未深入研究其具体原理;但如果没有这一步,LLM 就无法判断提示词中各个 token 的顺序。
为了更新之前的伪代码,我们假设存在一个名为 encodePosition 的函数。它接收嵌入和位置,并返回编码了该位置信息的新嵌入。
1const EMBEDDINGS = [...];2 3// Input: array of tokens (integers)4function embed(tokens) {5 // Output: array of n-dimensional embedding arrays6 return tokens.map((token, i) => {7 const embeddings = EMBEDDINGS[token];8 return encodePosition(embeddings, i);9 });10}
总而言之,嵌入是 n 维空间中的点,你可以把它理解为其所表示文本的语义。在训练期间,每个 token 都会在这个空间中移动,使其靠近其他相似的 token。维度越多,LLM 对每个 token 的表示就能越复杂、越细致。
我们在分词器和嵌入阶段所做的全部工作,都是为了把文本转换成 LLM 能够处理的形式。现在,让我们看看这些成果在 Transformer 阶段是如何被处理的。
收藏本节
Transformer 阶段的核心,是接收嵌入作为输入,并在它们所在的 n 维空间中移动这些嵌入。它通过两种方式完成这项工作,而我们只会关注第一种:注意力。我们不会讨论“前馈”(Feedforward)或输出阶段(至少这篇文章里不会 👀)。
注意力机制的工作,是通过允许 token 相互影响彼此在 n 维空间中的位置,帮助 LLM 理解提示词中各个 token 之间的关系。它会以加权方式组合提示词中各个 token 的嵌入。输入是整个提示词的嵌入,输出则是一个新的嵌入,由所有输入嵌入加权组合而成。
例如,假设提示词是“Mary had a little”,并生成了 Mary、had、a 和 little 这 4 个 token,注意力机制可能会决定,在生成下一个 token 时应使用:
Mary 嵌入的 63%
had 嵌入的 16%
a 嵌入的 12%
little 嵌入的 9%
接下来,它会按照各自的权重对这些嵌入进行缩放,再将它们相加。这就是 LLM 判断应该在多大程度上关注(即“注意”)提示词中每个 token 的方式。
这是到目前为止整个过程中最复杂、最抽象的部分。我会先用伪代码展示它,然后再看看嵌入在通过这个过程时是如何被处理的。我本想减少这一节中的数学内容,但这里确实很难完全避开数学。你能做到的,我相信你。
注意力机制中的大多数计算都是矩阵乘法。对于这篇文章,你只需要知道矩阵乘法的一件事:输出矩阵的形状由输入矩阵的形状决定。输出矩阵的行数始终与第一个输入矩阵相同,列数始终与第二个输入矩阵相同。
了解这一点后,下面就是一个简化版注意力机制计算每个 token 所应分配权重的方式。在以下代码中,我使用 * 表示矩阵乘法。
1// Similar to EMBEDDINGS from the pseudocode2// earlier, WQ and WK are learned during 3// training and do not change during inference.4// 5// These are both n*n matrices, where n is the6// number of embedding dimensions. In our example7// above, n = 3.8const WQ = [[...], [...], [...]];9const WK = [[...], [...], [...]];10 11// The input embeddings look like this:12// [13// [-0.1, 0.1, -0.3], // Mary14// [1.0, -0.5, -0.6], // had15// [0.0, 0.8, 0.6], // a16// [0.5, -0.7, 1.0] // little17// ]18function attentionWeights(embeddings) {19 const Q = embeddings * WQ;20 const K = embeddings * WK;21 const scores = Q * transpose(K);22 const masked = mask(scores);23 return softmax(masked);24}
让我们看看嵌入在流经这个函数时会发生什么。
等等,WQ 和 WK 这两个变量是什么?
还记得我之前说过,每个 token 的嵌入一开始会被分配到一个随机位置,然后训练过程会对它们进行细微调整,直到模型收敛到一种良好的排列方式吗?
WQ 和 WK 与此类似。它们是 n×n 矩阵,其中 n 是嵌入维度;在训练开始时,它们会被赋予随机值。随后在训练过程中,它们也会不断得到调整,以帮助模型收敛到一个良好的解。
任何在训练期间会被调整的内容,都称为“模型参数”。嵌入向量以及 WQ 和 WK 矩阵中的每一个浮点数,都是一个参数。当你听到某个模型被描述为拥有“1750 亿个参数”时,指的就是这些数字。
至于 WQ 和 WK 究竟是什么,我们其实不太清楚。随着模型逐渐收敛,它们最终会表示某种对嵌入的变换,从而帮助模型生成良好的输出。它们可能在做任何事情,而解释其中究竟包含什么,仍然是一个开放且活跃的研究领域。
为了得到 Q 和 K,我们分别将嵌入乘以 WQ 和 WK。WQ 和 WK 的行数和列数始终等于嵌入维度,在本例中就是 3。这里我为 WQ 和 WK 选取了随机值,并将数值四舍五入到小数点后两位,以便阅读。
得到的 Q 矩阵有 4 行 3 列。之所以有 4 行,是因为嵌入矩阵有 4 行(每个 token 一行);之所以有 3 列,是因为 WQ 有 3 列(每个嵌入维度一列)。
K 的计算方式完全相同,只不过使用的是 WK 而不是 WQ。
Q 和 K 都是输入嵌入向新的 n 维空间中的“投影”。它们不是原始嵌入,但由原始嵌入派生而来。
然后,我们将 Q 和 K 相乘。我们会对 K 进行“转置”,也就是沿对角线翻转它,使最终得到的矩阵成为一个方阵,其行数和列数都等于输入提示词中的 token 数量。
这些分数表示每个 token 对接下来生成的 token 有多重要。左上角的数字 -0.08,表示“Mary”对于“had”有多重要。再往下一行的 -0.10,则表示“Mary”对于“a”有多重要。在矩阵运算之后,我会用可视化方式展示这一点。接下来发生的一切,都是为了将这些分数转换成权重,以便用它们混合各个嵌入。
这个分数矩阵的第一个问题是,它允许未来的 token 影响过去。在第一行中,我们唯一知道的词是“Mary”,因此它应该是唯一参与生成“had”的词。第二行也是如此:此时我们知道“Mary”和“had”,所以只有这两个词应该参与生成“a”,依此类推。
为了解决这个问题,我们对矩阵应用一个三角掩码,将未来的 token 置零。不过严格来说,我们并不是将它们设为零,而是设为负无穷。稍后我会解释原因。
第二个问题是,这些分数都是任意数值。如果能将它们转换成一种每行总和为 1 的分布,对我们来说会有用得多。这正是 softmax 函数所做的事情。softmax 的具体工作原理并不重要;它比简单地用每个数除以该行总和稍微复杂一些,但结果是一样的:每一行的总和都是 1,并且每个数都介于 0 和 1 之间。
为了解释为什么要使用负无穷,下面是用代码实现的 softmax:
1function softmax(matrix) {⋯2 return matrix.map(row => {⋯3 const exps = row.map(x => Math.exp(x));4 const sumExps = exps.reduce((a, b) => a + b, 0);5 return exps.map(exp => exp / sumExps);6 });7}
它并不只是把这些数相加,然后用每个数除以总和。相反,它会先对每个数调用 Math.exp,也就是计算 e^x。如果使用零而不是负无穷,那么 Math.exp(0) === 1,这些零仍然会产生权重。Math.exp(-Infinity) 等于 0,这才是我们想要的结果。
下面的网格展示了提示词“Mary had a little”的注意力权重示例。你可以将鼠标悬停在网格单元格上或单击它,查看每个 token 的贡献。这些权重与上面的计算结果并不一致,因为我是从出色的 Transformer Explained 网站上运行的 GPT-2 版本中提取它们的。因此,这些是来自一个真实模型的真实权重,尽管这个模型已经比较老了。
第一行中只有“Mary”,所以 Mary 对“had”的贡献为 100%。接着在第二行,生成“a”时,“Mary”贡献了 79%,“had”贡献了 21%,依此类推。LLM 认为这个句子中最重要的词是“Mary”,这大概并不令人意外,因为 Mary 在每一行中的权重都是最高的。如果我让你补全句子“Jessica had a little”,你大概不会选择“lamb”。
剩下的工作就是混合 token 嵌入,值得庆幸的是,这比生成权重要简单一些。
1// Learned during training, doesn't change 2// during inference. This is also an n*n matrix,3// where n is the number of embedding dimensions.4const WV = [[...], [...], ...];5 6function attention(embeddings) {7 const V = embeddings * WV;8 // This is the `attentionWeights` function from9 // the section above. We're wrapping it in10 // this `attention` function.11 const weights = attentionWeights(embeddings);12 return weights * V;13}
与之前类似,我们有一个在训练阶段确定的 WV 矩阵。我们用它从 token 嵌入中得到 V 矩阵。
为什么不直接混合嵌入?
当我们推导出 Q 和 K,再将它们相乘得到注意力权重时,整个操作关注的都是 token 之间的相关性。嵌入编码了 token 的各种语义信息:一个维度可能表示“颜色”,另一个表示“大小”,还有一个表示“粗鲁程度”,依此类推。权重则通过相似性来判断相关性。
WV 让模型能够决定接下来要保留哪些维度。在句子“Mary had a little”中,与 Mary 有关的重要信息是她的名字。模型也可能学到了很多关于 Bloody Mary 这款饮料或苏格兰女王 Mary 的知识。这些信息与这首童谣无关,如果继续传递下去,只会引入噪声。因此,WV 让模型能够在混合嵌入之前过滤掉无关特征。
然后,我们将 V 与生成的权重相乘,输出就是一组新的嵌入:
注意力机制的最终输出是这个输出矩阵的最后一行。来自之前 token 的所有上下文信息都通过注意力过程混合到了最后一行中,但为了得到它,仍然必须计算前面的所有行。
总而言之,输入的是嵌入,输出的是一个新的嵌入。注意力机制执行了大量精密的数学运算,并根据训练期间学到的 WQ、WK 和 WV 矩阵,按照各个 token 的重要程度将它们混合在一起。正是这种机制,让 LLM 能够知道其上下文窗口中哪些内容重要,以及它们为什么重要。
现在,我们终于掌握了讨论缓存所需的全部知识。
我在这里展示的是注意力机制的简化版本(我知道,这还算简化?),目的是突出与提示词缓存最相关的内容。在实际应用中,注意力机制还包含更多细节。如果你有兴趣深入了解,我推荐观看 3blue1brown 关于注意力机制的视频。
让我们再次看看上面的网格,但这一次,我们会看到它随着推理循环生成每个新 token 而逐步填充。点击播放即可开始动画。
每生成一个新 token,都会将它追加到输入中,然后重新完整处理一遍。但仔细观察一下,可以把动画多播放几次:之前的权重全都没有变化。第二行始终是 0.79 和 0.21。第三行始终是 0.81、0.13 和 0.06。我们重复进行了大量不必要的计算。如果刚刚才处理完“Mary had a”,那么计算“Mary had a little”时,大部分矩阵乘法都是没有必要的,而 LLM 的推理循环正是这样工作的。
只需对推理循环做两处修改,就可以避免这些重复计算:
缓存每次迭代中的 K 和 V 矩阵。
只将最新的 token 输入模型,而不是输入整个提示词。
让我们再次逐步完成矩阵乘法,不过这一次,我们已经缓存了前 4 个 token 的 K 和 V 矩阵,并且只传入单个 token 的嵌入。没错,又是矩阵运算,抱歉,不过它与上面的内容基本相同,我们会快速讲完。
计算新的 Q 时,输出只有一行。WQ 与之前相比没有变化。
接着计算新的 K,输出同样只有一行,WK 也与之前相同。
然后,我们取出这一新行,将它追加到上一次迭代缓存的 4 行 K 后面: