AI知识集锦02 提示缓存:LLM token 价格便宜10倍,但如何实现?
语言是人类交流的工具,也是AI理解世界的重要方式。提示缓存:LLM token 价格便宜10倍,但如何实现?
在我写这篇文章的时候,无论是 OpenAI 还是 Anthropic 的 API,缓存的输入 token 单价都比普通输入 token 便宜 10 倍。
Anthropic 甚至声称,对于较长的提示词,提示缓存可以将延迟降低“多达85%”。 在我自己的测试中,我发现只要提示足够长,确实如此。 我向 Anthropic 和 OpenAI 两边分别发送了上百次请求,发现对于所有输入 token 都被缓存的情况,首次 token 返回的时间延迟显著下降。
现在我已经用炫酷的渐变文字和漂亮的图表吸引了你的注意,你有没有想过这样一个问题……
究竟什么是缓存的 token 呢?
在这些庞大的 GPU 集群中,究竟发生了什么,使得服务商能为你的输入 token 提供 10 倍的价格优惠? 他们在不同请求之间到底缓存了什么? 其实,这并不是简单地保存响应并在遇到同样提示时复用结果。 用 API 很容易验证这一点:你写好一个提示,多次发送,每次即使 usage 统计里显示命中缓存的输入 token,你收到的输出依然是不同的。
模型厂商的文档虽然详细介绍了如何使用提示缓存,但对究竟缓存了什么却语焉不详,对此我并不满足。 因此,我决定更加深入地探究原理,钻研了大型语言模型的运行机制,直到我明白了服务商究竟缓存了哪些数据、这些数据的用途,以及为什么这些机制能让大家的使用变得更加快速和低成本。
在本文结束时,你将会……
- 深入理解大型语言模型(LLM)的工作原理
- 建立对LLM为何这样运作的新直觉
- 了解究竟缓存了哪些二进制数据(1和0),以及这些缓存如何降低你的LLM请求成本
LLM 架构
从本质上讲,大型语言模型(LLM)其实就是一个巨大的数学函数。它们将一串数字作为输入,经过一系列处理后输出另一个数字。在 LLM 的内部,存在一个由数十亿个精心设计的操作组成的庞大计算图,这些操作将输入的数字一步步转换成输出。
这个庞大的计算图大致可以分为四个部分。
图中的每一个节点都可以看作一个接受输入并产生输出的函数。输入会被循环地传入 LLM,直到某个特殊的输出值指示其停止。用伪代码可以这样表示:
# 定义提示
prompt = "What is the meaning of life?"
# 将人类可读的字符串用tokenizer转为token序列(数字)
tokens = tokenizer(prompt)
# 进入主生成循环,不断生成token直到遇到终止符END_TOKEN
while True:
# 将当前tokens序列转为embedding(向量表示)
embeddings = embed(tokens)
# 经过所有的transformer层(每层包含两个子层attention和feedforward)
for attention, feedforward in transformers:
embeddings = attention(embeddings) # 注意力机制
embeddings = feedforward(embeddings) # 前馈神经网络
# 根据最后的embeddings输出一个token
output_token = output(embeddings)
# 若遇到终止符,停止生成
if output_token == END_TOKEN:
break
# 否则把新token加到原序列,继续生成
tokens.append(output_token)
# 将token序列解码为最终输出文本
print(decode(tokens))
LLM 其实非常精简
虽然上面的描述已经极度简化,但令我惊讶的是,现代LLM的代码其实非常精简。
Sebastian Raschka 使用 PyTorch 重写了许多开源模型,并且他还制作了大量高质量的教育内容,如果你喜欢这篇文章,一定也会喜欢他的作品。以当前非常领先的开源模型 Olmo 3 为例,它的核心实现只需要几百行代码。
提示缓存(prompt caching)发生在Transformer的“注意力”(attention)机制中。 为了更好地理解它,我们需要按顺序梳理LLM的工作过程,直到到达这一部分。因此,我们的探索要从“什么是 token ”开始。
分词器(Tokenizer)
在 LLM 处理你的提示词之前,首先需要将其转换成模型可以理解的内部表示。这一过程主要分为两步,分别由和阶段完成。为什么要这样做,直到讲到 embedding 时才会明朗,所以请耐心跟随我们先理解分词器到底做了什么。
分词器会把你的输入提示拆分成较小的片段(chunk),对每一个独特的片段分配一个整数编号,称为“token”。举个例子,下面是 GPT-5 如何对提示词 "Check out ngrok.ai" 进行分词的:
提示词被拆分成了数组 ["Check", " out", " ng", "rok", ".ai"],并被转换为 token 序列 [4383, 842, 1657, 17690, 75584]。 同样的提示词每次都会得到相同的 token。 值得注意的是,token 对大小写敏感,这是因为单词的大小写包含了额外的信息。
例如,首字母大写的 "Will" 更可能表示一个名字,而全小写的 "will" 则更常作为助动词出现。
为什么不能直接按空格或字符切分?
这是一个出人意料的大问题,如果要详细展开,篇幅很容易翻倍。 简短但不太令人满意的回答是:这是各种折衷的结果。 如果你想深入了解,Andrej Karpathy 有一段很棒的视频,手把手演示了如何从零构建一个分词器。 对于提示缓存来说,你只需要知道分词器会把文本转化为数字即可。
Token(标记)是大语言模型(LLM)输入和输出的基本单元。 当你向 ChatGPT 提问时,模型会在每次推理迭代完成后,将生成的内容以 token 为单位实时传输给你。 之所以采用这种方式,是因为生成完整的回复可能需要几十秒,而每生成一个 token 就立即发送,能让整个交流过程更加流畅与互动。
让我们来问一个经典的大语言模型问题,亲自体验一下效果。当你准备好时,点击下方的发送按钮。
提示词的 token 输入,大语言模型开始✨“作法”✨,输出一个 token,然后循环重复。 这个过程被称为“推理”(inference)。需要注意的是,每输出一个 token,都会被加入到原始输入中,在下一轮一起送入模型。 LLM 需要完整的上下文信息,才能生成高质量的回答。 如果每次只输入提示词,它就会不断尝试生成答案的第一个 token; 如果只输入已经生成的答案,它会立刻忘记问题是什么。 因此,每次迭代,完整的“提示词+已生成答案”都会一起送进 LLM。
那个 199999 <END> token 是什么?
推理总得有个终点。LLM 在输出时会有一些“特殊 token”,其中之一就是用于标记回答结束的 token。 在 GPT-5 的分词器里,这个终止 token 是 199999。 这只是 LLM 终止输出的多种机制之一。 你也可以通过 API 设置最多生成多少个 token,服务提供者还可能有其他安全相关的终止规则。
此外,LLM 还会用特殊的 token 标记对话消息的起始和结束,像 ChatGPT 和 Claude 这样的聊天模型也正是靠这些 token 来划分一条消息的开始和结束。
关于分词器的最后一点:其实有很多不同的分词器!ChatGPT 使用的分词器和 Claude 不同,甚至 OpenAI 自家的不同模型用的分词器也可能不一样。每个分词器都有自己将文本切分为 token 的规则。如果你想看看不同分词器是如何切分同一段文本的,可以试试 tiktokenizer 这个工具。
介绍完 token 之后,接下来我们来说说“嵌入”(embeddings)。
嵌入(Embedding)
分词器输出的 token 接下来会进入“嵌入(embedding)”阶段。要理解嵌入,首先需要明白模型的目标是什么。
人类用代码解决问题时,会编写一个函数,输入数据并输出结果。比如把华氏度转为摄氏度。
// 将华氏度(fahrenheit)转为摄氏度(celsius)
function fahrenheitToCelsius(fahrenheit) {
// 先减去32,将范围从华氏刻度移动到以冰点为基准
// 然后乘以5/9,实现单位换算
return ((fahrenheit - 32) * 5) / 9;
}
我们可以把任何数字输入到 fahrenheitToCelsius 函数中,得到正确的结果。但如果我们遇到一个问题,并不知道转换的公式该怎么办呢?如果我们手头只有下方这个神秘的输入输出对照表,又该如何解决呢?
| Input | Output |
|---|---|
| 21 | 73 |
| 2 | 3 |
| 10 | 29 |
| 206 | 1277 |
我并不指望你能立刻看出这个函数的规律,不过如果你把这张截图粘贴到 ChatGPT 里,ChatGPT 通常会很快给出答案。
当我们知道每个输入对应的输出,但不知道中间的函数时,我们可以“训练”一个模型来学习这个函数。 具体做法是:给模型提供一个“画布”——一个庞大的数学运算图结构。 我们不断调整这张图,直到模型能够模拟出正确的函数。 每当图结构更新一次,我们就用已有的输入测试它,观察输出和真实结果有多接近。我们重复这个过程,直到输出足够接近为止。这就是“训练”模型的过程。
什么是图结构?
在这里,所谓“图结构”指的是由节点(点)和边(连接)组成的数学结构。在 AI 模型中,这个“图”并不是图片或者几何图形,而是指大量的“单元”(比如神经网络的神经元),它们彼此通过“连接”相互作用。
你可以把它想象成一张由许多点和线组成的网络图,每个点表示一次简单的数学运算(如加法、乘法等),而线表示数据的流动和连接关系。输入数据从图的一端进入,经过一层层节点的变换和计算,最终在另一端输出结果。
在人工神经网络(比如 ChatGPT 的底层)中,这种“图结构”实际上描述了神经元之间如何传递和处理信息,包括每个连接的权重参数。在训练过程中,我们会不断调整这些连接的强度(即参数),以便让整个图结构能更准确地映射输入与输出的对应关系。
总结来说,图结构就是 AI 模型内部数据流动和计算方式的抽象表示,是模型学习和预测的基础框架。
事实证明,在训练模型生成正确文本时,“判断两句话是否相似”是一项非常有用的能力。那么,“相似”到底意味着什么呢?它们可能在情感上同样悲伤、幽默或发人深省,也可能在长度、节奏、语气、语言、词汇乃至结构上相似。我们可以用许多不同的维度来描述两句话之间的相似性,而句子之间也可能在某些维度上很像,在其他维度上则差别很大。
什么是维度?
在大模型中,“维度”可以通俗地理解为描述一个事物所需的不同特征的数量。 就像描述一个苹果,你可能需要颜色、大小、甜度等多个特征,这些特征就是维度。
大模型理解世界的方式,是把我们输入的每个字词都转换成一个高维度的数字列表(向量)。这个列表可能长达几百甚至上千个数字。 你可以把这个列表想象成一个复杂的“特征密码”,模型用这个密码来捕捉词语的微妙含义和关联。
例如,“足球”和“篮球”的密码在“运动”这个特征上会比较接近。
这些高维度的密码被存储在模型内部一个巨大的“知识网络”(矩阵)中。当模型工作时,它通过计算这些密码之间的数学关系来“思考”和生成回答。 维度的数量至关重要:维度太少,模型就像一根筋,理解粗糙;维度足够多,模型才能精细地捕捉语言的复杂性和多样性,从而显得更“聪明”。
然而,单个维度(比如密码里的第57个数字)并没有人类可以直接理解的单一含义(比如“情感”或“时态”)。语义信息是以一种分布式和纠缠的方式,由所有维度共同编码的。 这就像一幅精美的画作,其美感是由无数像素以特定方式组合而成,而非单个像素决定的。 因此,维度是大模型将人类语言转化为机器可处理的数学形式,并构建其复杂“思维”能力的基础数学结构。
Token(词元)本身没有维度,它们只是普通的整数;而嵌入(embedding)则是具有许多维度的数字向量。
“嵌入”(embedding)就是长度为 n 的数组,可以看作是在 n 维空间中的一个位置。如果 n 等于 3,一个嵌入可以是 [10, 4, 2],对应三维空间中的 x=10,y=4,z=2 的位置。在大语言模型训练时,每个 token(词元)一开始会被随机分配在这个空间中的某个位置,训练的过程就是不断微调这些 token 的位置,最终让整体排列产生最优的输出结果。
嵌入阶段首先会查找每个 token 的嵌入向量。用伪代码表示,大致如下:
// Created during training, never changes during inference.
const EMBEDDINGS = [...];
function embed(tokens) {
return tokens.map(token => {
return EMBEDDINGS[token];
});
}
我们将 tokens(一个整数数组)转换为嵌入向量数组,也就是一个“数组的数组”,或称为“矩阵”。你可以在下面切换 token 和嵌入的视图,这样可以形象地理解这个过程。
tokens(如 [75, 305, 284, 887])会被转换为一个由 3 维嵌入构成的矩阵。
嵌入向量的维度越多,模型可用来比较句子的“角度”就越丰富。前面我们曾用 3 维嵌入做过举例,但实际上当前主流模型的嵌入维度往往有几千维,最大的模型甚至超过了一万维。
为了直观展示“增加维度”的价值,下面我用 8 组不同颜色的形状做演示: 它们一开始在一维空间里,只能排成一条线,彼此混在一起,很难区分。 但当你逐步增加到二维、三维时,结构就清晰了——8 组各自相关的“家族”一目了然。 你可以点击 2D、3D 按钮切换,体验这种变化。
这里为了便于直观展示,我最多只能用三维空间做演示。真实的大模型会用成千上万个维度,这已经超出了我们直接想象的范围,你可以试着展开想象,思考在如此高维空间中还能实现哪些复杂的区分与表达。
嵌入阶段还有最后一件重要的事情:在获取到每个 token 的嵌入向量后,模型会把每个 token 在提示中的“位置”编码进嵌入向量里。 我没有深入研究具体实现方式,不过可以确定的是,这一过程不会对提示缓存机制产生太大影响,但如果没有这个步骤,LLM 就无法判断提示中各个 token 的先后顺序。
为了更新我们之前的伪代码,假设有一个名为 encodePosition 的函数。它接受嵌入向量和一个位置参数,并返回包含位置信息的新嵌入向量。
// 预先生成的嵌入表,每个索引对应一个 token 的嵌入向量(模型训练阶段得到,推理时不变)
const EMBEDDINGS = [...];
// 输入: tokens——一个整数数组,每个代表一个 token
function embed(tokens) {
// 输出: 一个嵌入向量数组(每个 token 对应一个 n 维数组)
return tokens.map((token, i) => {
// 查找当前 token 的嵌入向量
const embeddings = EMBEDDINGS[token];
// encodePosition 用于将该 token 在句子中的“位置信息”编码进嵌入向量
return encodePosition(embeddings, i);
});
}
总结一下,**嵌入(embedding)**就是 n 维空间中的一个点,你可以把它理解为该文本的“语义含义”。 在训练过程中,每个 token 会在这个空间里被调整到靠近其相似 token 的位置。 维度越高,LLM 对每个 token 的表达就越复杂、越细致。
我们在分词和嵌入阶段所做的一切工作,都是为了把文本转换成 LLM 能够处理的形式。接下来,让我们看看这些内容在 transformer 阶段会发生什么。
Transformer 阶段
Transformer 阶段的核心,是把嵌入向量作为输入,在 n 维空间内不断调整它们的位置。 实现这种调整的方法有两种,这里我们只介绍第一种:注意力机制(attention)。至于「前馈神经网络」(Feedforward)和输出阶段,这篇文章暂不展开讨论 👀。
注意力机制的作用,是帮助 LLM 理解提示词中各个 token 之间的关系,它能够让这些 token 在 n 维空间中互相影响位置。它的做法是,把整个提示的 token 的嵌入向量按照一定的权重进行加权组合。输入是一整条提示的所有嵌入向量,输出则是一个由所有输入嵌入按权重组合而成的新嵌入向量。
举个例子,如果我们的提示是 "Mary had a little",它被分成了 4 个 token:Mary、had、a 和 little,那么注意力机制可能会决定,生成下一个 token 时应该使用:
63%的Mary的嵌入向量16%的had的嵌入向量12%的a的嵌入向量9%的little的嵌入向量
接下来,模型会根据这些权重对每个嵌入向量进行缩放,然后将它们相加,得到一个新的向量。 通过这种方式,LLM 就知道在处理提示词时,应该对每个 token 关注多少,或者说“注意”到每个 token 的程度。
这是目前为止最复杂、最抽象的部分。我会先用伪代码展示它的基本原理,然后我们一起看看嵌入向量在这个过程中是如何被操作和变化的。我本想尽量减少数学内容,但这里实在难以避免一些公式,别担心,你一定可以理解的,我相信你!
注意力机制中的大多数计算都是矩阵乘法。你只需要知道,矩阵相乘时,输出矩阵的形状由输入的两个矩阵决定:输出的行数等于第一个输入矩阵的行数,列数等于第二个输入矩阵的列数。

有了这些基础,下面介绍一个简化版的注意力机制是如何计算分配给每个 token 的权重的。在下面的代码中,* 表示矩阵乘法。
// 类似 EMBEDDINGS,WQ 和 WK 也是训练过程中学习得到的参数,
// 并且在推理(inference)阶段保持不变。
// 它们都是 n*n 的矩阵(n 为嵌入的维度,例如上例 n=3)。
const WQ = [[...], [...], [...]]; // 查询权重矩阵(Query Weight),用于生成 Q
const WK = [[...], [...], [...]]; // 键权重矩阵(Key Weight),用于生成 K
// 输入的嵌入向量是一个二维数组,每一行为一个 token 的嵌入:
// [
// [-0.1, 0.1, -0.3], // Mary
// [1.0, -0.5, -0.6], // had
// [0.0, 0.8, 0.6], // a
// [0.5, -0.7, 1.0] // little
// ]
function attentionWeights(embeddings) {
// Q = embeddings * WQ,得到每个 token 的查询向量(Query)
const Q = embeddings * WQ;
// K = embeddings * WK,得到每个 token 的键向量(Key)
const K = embeddings * WK;
// scores = Q * transpose(K),计算 Q 与 K 的相关性(注意力分数)
const scores = Q * transpose(K);
// mask(scores) 对得分进行遮罩处理(如防止关注未来 token,常用于自回归模型)
const masked = mask(scores);
// softmax(masked) 对得分归一化,得到每个 token 的注意力权重(所有权重加起来为 1)
return softmax(masked);
}
让我们来看一下嵌入向量是如何在这个函数中流动和变化的。
等等,WQ 和 WK 这两个变量是什么意思?
还记得我前面说过,每个 token 的嵌入向量在一开始会被随机分配一个位置,然后经过训练过程不断微调,直到模型收敛到一个较好的排列吗?
WQ 和 WK 也是类似的道理。它们是 的矩阵( 是嵌入向量的维度),在训练开始时会被赋予随机值。随后在训练期间,它们也会像嵌入一样不断被微调,以帮助模型最终找到一个好的解。
凡是在训练过程中会被不断调整的,都被称作“模型参数”。每一个嵌入向量里的浮点数、WQ 和 WK 里头的每个数字,都是一个参数。当人们说某个模型有“1750 亿参数”时,说的就是这些数字的总数。
至于 WQ 和 WK 到底是什么?说实话,我们也不是很确定。当模型训练收敛后,它们实际上变成了一种对嵌入向量空间的变换,这种变换有助于模型产生更好的输出。它们到底在做什么,目前仍是一个开放且非常活跃的研究方向。
为了得到 Q 和 K,我们分别用嵌入向量与 WQ、WK 这两个矩阵相乘。WQ 和 WK 的行数和列数始终等于嵌入维度的数量,在本例中是 3。这里我为 WQ 和 WK 随机选取了一些数值,并将它们四舍五入保留到小数点后两位,便于阅读。

最终得到的 Q 矩阵有 4 行 3 列。之所以有 4 行,是因为嵌入向量矩阵有 4 行(每个 token 一行);有 3 列,是因为 WQ 矩阵有 3 列(每列对应一个嵌入维度)。
K 的计算方式和 Q 完全一样,只不过把 WQ 换成了 WK。

Q 和 K 都是对输入嵌入向量在新的 n 维空间中的“投影”。它们不是原始的嵌入向量,而是由嵌入向量经过变换得到的。
接下来,我们将 Q 和 K 进行相乘。这里我们会对 K 取转置,也就是沿对角线翻转,这样得到的矩阵就是一个方阵,其行数和列数等于输入文本中 token 的数量。

这些分数表示每个 token 对下一个生成的 token 有多重要。 左上角的数字 -0.08,表示 "Mary" 对 "had" 有多重要。 下一行的 -0.10,则表示 "Mary" 对 "a" 有多重要。 稍后我会用可视化的方式展示这个矩阵。 接下来要做的,就是把这些分数转换成可以用来混合嵌入向量的权重。
这个分数矩阵的第一个问题是:它允许后面的 token(未来的信息)影响前面的 token(过去的信息)。 比如在第一行时,我们实际只知道 "Mary",所以只有 "Mary" 应该对 "had" 的生成产生影响。 同理,在第二行,我们只知道 "Mary" 和 "had",所以也只有这两个词应该对 "a" 的生成产生影响。 后面的依次类推。
为了解决这个问题,我们会对这个矩阵应用一个“上三角掩码”,将未来 token 的位置全部屏蔽。这里不是直接把它们设为 0,而是将它们赋值为负无穷。 为什么要这么做,我马上会解释。

第二个问题是,这些分数其实只是一些任意的数字。 如果能够把它们变成每一行加起来等于 1 的分布,会对我们更有意义。 这正是 softmax(归一化)函数的作用。softmax 的细节其实不是特别重要,它比单纯的“将每个数字除以本行和”要复杂一点,但结果就是: 每一行的值相加等于 1,而且每个数字都在 0 到 1 之间。
Softmax函数在模型运算中的概念、作用和意义
Softmax函数,也叫归一化指数函数,是人工智能领域,特别是深度学习中的一个核心数学工具。 它的核心任务,是扮演一个公平的裁判或概率转换器的角色。
想象一下,一个模型在识别一张图片是猫、狗还是兔子时,它的内部计算会为每个类别生成一个原始的“得分”。这些得分可能是任意大小的正数或负数,比如猫得5分,狗得2分,兔子得1分。我们无法直接理解这些分数代表的可能性有多大。 这时,Softmax函数就登场了。它的工作流程非常清晰:
指数放大:首先,它对所有得分取指数()。这是一个关键步骤,因为指数函数会显著放大高分和低分之间的差距。例如,5分和2分的差距,经过指数运算后会变得非常大。 归一化求和:然后,它把所有经过指数放大的值加起来,得到一个总和。 计算概率:最后,用每个类别的指数值除以这个总和。神奇的事情发生了:输出的每个数字都变成了一个介于 0 和 1 之间的值,并且所有数字加起来正好等于 1。
经过Softmax处理后,原来的得分就变成了直观的概率。例如,猫的概率可能是 0.8,狗是 0.15,兔子是 0.05。这让我们一眼就能看出模型最确信的答案是“猫”,并且能了解它对其他选项的犹豫程度。 因此,Softmax函数在模型运算中的意义非常重大:
提供可解释性:它将模型晦涩的内部计算结果,翻译成人类容易理解的“概率语言”,告诉我们模型对每个预测结果的信心有多大。 便于决策与优化:输出为标准概率分布后,我们可以轻松地选择概率最高的类别作为最终答案。更重要的是,这种概率形式可以与交叉熵损失函数完美配合,指导模型在训练过程中如何调整自身参数,朝着正确答案的方向“学习”。
总结来说,Softmax函数是连接模型复杂计算与人类可理解决策之间的桥梁。它通过巧妙的数学转换(指数放大与归一化),将原始分数转化为清晰、规范的概率分布,是多分类任务中不可或缺的一环。

为了说明为什么要用负无穷,下面用代码演示一下 softmax 的实现:
function softmax(matrix) {
// 对输入的二维数组(矩阵)每一行做 softmax 归一化
return matrix.map(row => {
// 对每个元素做指数运算(e^x),得到当前行所有元素的指数值
const exps = row.map(x => Math.exp(x));
// 计算这些指数值的总和,用于归一化
const sumExps = exps.reduce((a, b) => a + b, 0);
// 返回本行每个元素归一化后的概率(指数值 / 总和),得到 0~1 之间、总和为1的概率分布
return exps.map(exp => exp / sumExps);
});
}
它其实并不是简单地把数字相加然后分别除以总和,而是先对每个数字进行 Math.exp 运算(即 )。如果我们用 0 而不是负无穷进行掩码,Math.exp(0) === 1,这些 0 依然会对结果产生权重。而 Math.exp(-Infinity) 等于 0,这才是我们想要的效果。
下面的网格展示了一个关于提示词 “Mary had a little” 的注意力权重示例。你可以将鼠标悬停或点击网格单元格,查看每个 token 的贡献。这些权重与上面计算的例子并不完全对应,因为我从非常棒的 Transformer Explained 网站上实际运行的 GPT-2 版本中获取了它们。所以这些是来自真实(虽然有些老)的模型的真实权重。
在第一行中,只有 “Mary” 这个词,所以 “Mary” 对 “had” 的生成贡献了 100%。接着在第二行,“Mary” 的贡献是 79%,而 “had” 的贡献是 21%,用于生成 “a”,以此类推。可以看到,LLM(大模型)认为本句中最重要的词就是 “Mary”,因为在每一行中,“Mary” 的权重都是最高的。如果我让你补全 “Jessica had a little”,你大概率不会选择 “lamb” 这个词作为结尾。
接下来,我们只需要将 token 的嵌入向量进行加权混合。这个过程比生成注意力权重简单得多。
// WV(Value 权重矩阵):这是在训练过程中学习得到的参数,推理时保持不变。
// 维度是 n*n,其中 n 是嵌入向量(embedding)的维度。
const WV = [[...], [...], ...];
function attention(embeddings) {
// 首先将输入的 embedding(嵌入向量)乘以 WV 矩阵,得到 Value 表示。
const V = embeddings * WV;
// attentionWeights 是之前定义过的注意力权重计算函数,
// 这里用它来根据 embedding 计算出每个 token 的注意力分数(归一化概率)。
const weights = attentionWeights(embeddings);
// 最终输出:将注意力权重乘以 Value,完成加权混合,得到加权后的向量输出。
return weights * V;
}
和之前类似,我们有一个在训练阶段确定下来的 WV 矩阵。我们用这个矩阵将 token 的嵌入向量转换为 V 矩阵。

为什么不直接混合嵌入向量?
当我们通过 Q 和 K 相乘得到注意力权重时,其实是在衡量各个 token 之间彼此的相关性。嵌入(embedding)本身编码了各种各样的语义信息,比如某一维可能代表“颜色”、另一维代表“大小”,还有可能是“粗鲁程度”等等。而注意力权重则是用 token 之间的相似性来计算它们的相关性。
WV 的作用,就是让模型决定哪些信息维度需要被保留下来。以句子 “Mary had a little” 为例,对于 “Mary” 这个词,重要的是她的名字。模型同样可能学到了一些关于饮品“血腥玛丽”或“苏格兰女王玛丽”的特征,但这些与儿歌场景并无关系,如果一并保留会引入噪声。通过 WV,模型可以在混合嵌入向量前,过滤掉这些无关的特征信息,只保留真正有用的内容。
接下来,我们将计算得到的 V 与注意力权重相乘,输出就是一组新的嵌入向量:

注意力机制的最终输出,是这个输出矩阵的最后一行。所有前面 token 的上下文信息,已经通过注意力机制混合到了这最后一行中——但为了得到它,前面的所有行也都必须一一计算出来。
总的来说,输入是嵌入向量,输出也是新的嵌入向量。注意力机制通过复杂的数学计算,把不同 token 按照它们的重要性进行加权混合,而这些重要性由模型在训练过程中学到的 WQ、WK 和 WV 矩阵共同决定。这就是让大模型能够在上下文窗口内判断“哪些信息重要、为什么重要”的关键机制。
现在,我们终于掌握了关于缓存所需的全部知识,可以开始讨论缓存机制了。
注意力机制还有更多内容
这里展示的是一个简化版的注意力机制(没错,其实已经很简化了),主要是为了突出与提示词缓存相关的核心原理。实际上注意力机制还有更多细节,如果你想更深入了解,推荐观看 3blue1brown 制作的关于注意力机制的视频。
提示词缓存
让我们再来看一遍上面的表格,这一次,你将看到在推理循环中,每生成一个新 token,这个格子是如何被逐步填充的。点击播放可以开始观看动画演示。
每生成一个新 token,模型都会把它加到输入序列末尾,并将整个序列重新处理一遍。但如果你仔细观察动画,反复播放几次,就会发现:前面已经计算出的权重其实是不会改变的。比如第二行的结果总是 0.79 和 0.21,第三行也一直是 0.81、0.13、0.06。换句话说,我们重复进行了许多完全没有必要的计算。对于 "Mary had a little" 这样的输入,如果你刚刚处理过 "Mary had a",许多矩阵乘法其实根本不需要再做,这正是目前大模型推理循环的工作方式。
你可以通过对推理循环做两点改动,避免这些重复计算:
- 在每次迭代时缓存
K和V矩阵。 - 每次只将最新生成的
token输入模型,而不是整个提示词序列。
我们再来快速过一遍这个矩阵乘法的过程,不过这一次,前 4 个 token 的 K 和 V 矩阵已经被缓存,我们只需要传入一个新 token 的嵌入向量。是的,又要做矩阵运算,但别担心,这和前面讲的内容基本一致,我们会很快带你理解。
计算一个新的 Q,只会产生一行输出。WQ 和之前是一样的,没有发生变化。

计算一个新的 K,也只会生成一行输出,且所用的 WK 矩阵和之前相同。

然后,我们会把这新生成的一行,追加到上一轮缓存下来的 4 行 K 矩阵数据后面:

现在我们得到了提示词中所有 token 的 K 矩阵,但实际上只需要计算出最后一行即可。
我们按照这种方式继续计算,就能得到新的分数:

以及新的权重:

在整个过程中,我们只计算所需的新内容,之前已经计算过的值完全无需重复计算。接下来,我们同样只需要计算并获取 V 矩阵的新一行:

然后把它追加到我们已经缓存的 V 矩阵后面:

最后,将新的权重与新的 V 相乘,得到最终的新嵌入向量:

我们只需要这一行新的嵌入向量。由于缓存下来的 K 和 V,这一行已经包含了之前所有 token 的上下文信息。
缓存下来的数据其实就是嵌入向量分别乘以
WK和WV后得到的K和V矩阵。因此,这种缓存方式通常被称为 “KV 缓存”。

没错,前面讲到的这些 K 和 V 矩阵,其实就是服务提供商在大型数据中心里保存的“0101”数据,也是它们能够为我们带来便宜十倍、响应更快 token 的关键所在。
服务提供商会在请求完成后,为每个提示词保存这些矩阵 5-10 分钟。如果你在这段时间内用相同的开头再次发起请求,它们就可以直接复用已缓存的 K 和 V,而无需重新计算。更有意思的是,即使你的新请求和之前的缓存只部分匹配,也可以只复用那一部分,而不需要整个完全一样。
下图通过轮流展示几个有相似前缀的提示词,演示缓存(cache)如何被反复利用。每隔一段时间,缓存会被清空,以展示缓存重新填充的过程。
OpenAI 和 Anthropic 在缓存机制上有很大的不同。OpenAI 会自动帮你完成所有缓存相关的工作,在可能的情况下会将请求路由到已缓存的结果。在我的实验中,当我发送请求并立刻再次发送同一请求时,命中缓存的几率大约有 50%。不过,由于长上下文窗口会显著延长首字节响应时间(time-to-first-byte),这会导致实际体验上性能有时不稳定。
Anthropic 给你更多的控制权,让你可以自行决定何时缓存以及缓存多长时间。你需要为这种灵活性额外付费,但在我的实验中,只要你要求缓存提示词,Anthropic 会 100% 命中缓存。这对于需要处理长上下文窗口、追求延迟可预测性的应用场景来说,可能是更加合适的选择。
等等,temperature 是怎么回事?
大多数大模型服务商提供了一些参数,让你可以调整模型输出结果的随机性。 常见的参数有 temperature(温度)、top_p 和 top_k。 这些参数都会影响推理循环的最后一步 —— 也就是模型根据每个 token 的概率,从全部词汇中挑选下一个 token 的过程。 这个过程发生在注意力机制生成最终嵌入向量之后,因此缓存提示词(prompt cache)不会受到这些参数的影响。你可以随意修改这些参数,而不用担心会让缓存失效。
总结
写这篇文章的过程中,我学到了很多知识,感觉非常充实。大语言模型(LLM)是非常迷人的技术,我认为整个行业对它们的探索还只是刚刚开始,未来还有巨大的潜力可以挖掘。
致谢
为了完成这篇文章,我查阅、学习了大量优秀的资源,以下是其中对我帮助最大的:
- Sebastian Raschka 的《Build a Large Language Model (From Scratch)》 从零开始构建大型语言模型
- Andrej Karpathy 的《Neural Networks: Zero to Hero》 神经网络:从零开始
- 3blue1brown 的神经网络视频课程
- Aeree Cho 等人的《Transformer Explainer》 Transformer 解释器
如果你喜欢本文,相信你也一定会喜欢这些资源。
关于作者
山姆·罗斯(Sam Rose)
Sam Rose 是 ngrok 的一名高级开发者教育专家,专注于创建帮助开发者充分利用 ngrok 的内容。