当你和 ChatGPT 对话时,每一轮请求的费用账单上都会出现一个词:cached tokens。这些被缓存的 token 只收 1/10 的价格。为什么?LLM 到底在缓存什么?本文将从最底层的矩阵运算讲起,带你理解 LLM 缓存的完整机制。
一、前置知识:Token 是什么
LLM 不认识文字,只认识数字。Tokenizer 负责把文本切成模型能理解的最小单元——Token。
主流方法是 BPE(Byte Pair Encoding):从单个字符开始,反复合并最高频的相邻对,构建出一个固定大小的词表(如 GPT-4 约 100,000 个词)。
输入文本: "我喜欢猫"
Tokenizer 处理:
"我" → Token ID: 101
"喜欢" → Token ID: 202
"猫" → Token ID: 303
最终: [101, 202, 303]
规律:
- 高频词整体编码为 1 个 token(如英文
the、Hello) - 罕见词被拆成多个子词(如
Langfuse→Lang+f+use→ 3 个 token) - 中文每个字通常 1~2 个 token
Token 序列拿到之后,就进入模型的推理流程了。
二、LLM 推理的两个阶段
一次完整的 LLM 调用分为两个阶段:
┌─────────────────────────────────────────────────────┐
│ 用户请求 │
│ Input: "我喜欢猫" → 请帮我写一首关于猫的诗 │
└────────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────┐
│ Prefill 阶段 │ 处理全部 input tokens
│ (并行,高吞吐) │ → 生成 KV Cache
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Decode 阶段 │ 逐个生成 output tokens
│ (串行,每次 1 个) │ → 复用 KV Cache
└─────────────────────┘
要理解缓存,我们必须先理解这两个阶段各自在做什么。核心就在 Transformer 的 Self-Attention 机制。
三、Self-Attention:LLM 的核心计算
3.1 直觉理解
Attention 回答的问题是:"在生成下一个词时,应该关注前面哪些词?"
比如:
"我喜欢猫,它们很___"
生成 ___ 时,模型需要重点关注 猫 和 它们,而不是 我 或 喜欢。Attention 机制就是让模型学会这种"注意力分配"。
3.2 数学原理
每个 token 会被变换为三个向量:
| 向量 | 含义 | 类比 |
|---|---|---|
| Q(Query) | "我在找什么信息" | 提问者 |
| K(Key) | "我能提供什么信息" | 标签/索引 |
| V(Value) | "我的实际内容" | 答案内容 |
计算公式:
Attention(Q, K, V) = softmax(Q × K^T / √d) × V
Q × K^T:每个 token 和所有其他 token 的相关度打分/ √d:缩放,防止数值过大softmax:归一化为概率分布(权重加起来 = 1)× V:用权重对 Value 加权求和,得到最终输出
3.3 具体 Demo
假设一个极简模型:1 层、1 个注意力头、维度 d = 4。
输入 3 个 token:["我", "喜欢", "猫"]
Step 1:Embedding
每个 Token ID 查表得到 4 维向量:
"我" → e₁ = [1.0, 0.0, 0.5, 0.3]
"喜欢" → e₂ = [0.2, 1.0, 0.1, 0.8]
"猫" → e₃ = [0.6, 0.3, 1.0, 0.1]
Step 2:通过权重矩阵算 Q、K、V
模型有三个可学习的权重矩阵 W_Q、W_K、W_V(训练时学到的参数,推理时固定):
假设 W_K(4×4 矩阵):
┌ ┐
│ 0.1 0.2 0.0 0.3 │
│ 0.4 0.1 0.2 0.0 │
│ 0.0 0.3 0.1 0.2 │
│ 0.2 0.0 0.4 0.1 │
└ ┘
K₁ = e₁ × W_K = [1.0, 0.0, 0.5, 0.3] × W_K
= [1.0×0.1+0.0×0.4+0.5×0.0+0.3×0.2, ← 0.16
1.0×0.2+0.0×0.1+0.5×0.3+0.3×0.0, ← 0.35
1.0×0.0+0.0×0.2+0.5×0.1+0.3×0.4, ← 0.17
1.0×0.3+0.0×0.0+0.5×0.2+0.3×0.1] ← 0.43
K₁ = [0.16, 0.35, 0.17, 0.43]
同理算出所有 K 和 V:
K₁ = [0.16, 0.35, 0.17, 0.43] ← "我" 的 Key
K₂ = [0.30, 0.15, 0.38, 0.14] ← "喜欢" 的 Key
K₃ = [0.12, 0.45, 0.13, 0.38] ← "猫" 的 Key
V₁ = [0.50, 0.20, 0.10, 0.40] ← "我" 的 Value
V₂ = [0.30, 0.60, 0.40, 0.10] ← "喜欢" 的 Value
V₃ = [0.70, 0.10, 0.80, 0.30] ← "猫" 的 Value
这些 K 和 V 就是我们要缓存的东西! 因为它们在后续生成新 token 时会被反复使用。
Step 3:计算 Attention(以最后一个 token "猫" 为例)
Q₃ = e₃ × W_Q = [0.25, 0.40, 0.18, 0.55] ("猫" 的 Query)
# "猫" 对每个 token 的关注度 = Q₃ · Kᵢ(点积)
score₁ = Q₃ · K₁ = 0.25×0.16 + 0.40×0.35 + 0.18×0.17 + 0.55×0.43
= 0.04 + 0.14 + 0.03 + 0.24 = 0.45
score₂ = Q₃ · K₂ = 0.25×0.30 + 0.40×0.15 + 0.18×0.38 + 0.55×0.14
= 0.075 + 0.06 + 0.068 + 0.077 = 0.28
score₃ = Q₃ · K₃ = 0.25×0.12 + 0.40×0.45 + 0.18×0.13 + 0.55×0.38
= 0.03 + 0.18 + 0.023 + 0.209 = 0.44
# 缩放 + softmax
scaled = [0.45/√4, 0.28/√4, 0.44/√4] = [0.225, 0.14, 0.22]
weights = softmax([0.225, 0.14, 0.22]) ≈ [0.36, 0.29, 0.35]
# 加权求和 Value
output₃ = 0.36 × V₁ + 0.29 × V₂ + 0.35 × V₃
= 0.36×[0.50,0.20,0.10,0.40]
+ 0.29×[0.30,0.60,0.40,0.10]
+ 0.35×[0.70,0.10,0.80,0.30]
= [0.51, 0.28, 0.43, 0.28]
这个 output₃ 会继续经过 FFN(前馈神经网络)、最终的 softmax,产出下一个 token 的概率分布。
四、KV Cache:单次请求内的缓存
4.1 问题:没有 KV Cache 会怎样?
在 Decode 阶段,模型每生成一个 token,都需要对所有历史 token 做 Attention。
如果不缓存,生成第 N 个 token 时:
第 1 个 output: 计算 input 全部 token 的 Q、K、V → Attention → 得到 token
第 2 个 output: 重新计算 input + 第1个output 的 Q、K、V → Attention → 得到 token
第 3 个 output: 重新计算 input + 第1、2个output 的 Q、K、V → Attention → 得到 token
...
每一步都在重复计算之前所有 token 的 K 和 V!极其浪费。
4.2 解决方案:存下 K 和 V
Prefill 阶段:
并行计算所有 input token 的 K、V
存入 KV Cache ← 这是一次性的 GPU 计算
Decode 第 1 步:
新 token → 只算这 1 个 token 的 Q、K、V
Q 和 Cache 里所有 K 做点积 → Attention → 输出
把新的 K、V 追加到 Cache
Decode 第 2 步:
新 token → 只算这 1 个 token 的 Q、K、V
Q 和 Cache 里所有 K 做点积 → Attention → 输出
把新的 K、V 追加到 Cache
... 以此类推
4.3 Demo:逐步生成过程
继续上面的例子。Prefill 完成后,KV Cache 状态:
┌─ KV Cache ────────────────────────────────────────┐
│ 位置 0 ("我"): K₁=[0.16,0.35,0.17,0.43] V₁=[0.50,0.20,0.10,0.40] │
│ 位置 1 ("喜欢"): K₂=[0.30,0.15,0.38,0.14] V₂=[0.30,0.60,0.40,0.10] │
│ 位置 2 ("猫"): K₃=[0.12,0.45,0.13,0.38] V₃=[0.70,0.10,0.80,0.30] │
└───────────────────────────────────────────────────┘
生成第 1 个 output token(假设模型选了 ","):
"," 的 embedding → 算 Q_new、K_new、V_new(1 次矩阵乘法)
Q_new 和 Cache 中的 [K₁, K₂, K₃] 做 Attention
→ 得到 output 向量 → FFN → softmax → 采样 → ","
Cache 追加 K₄、V₄:
┌─ KV Cache ────────────────────────────────────────┐
│ 位置 0 ("我"): K₁ V₁ │
│ 位置 1 ("喜欢"): K₂ V₂ │
│ 位置 2 ("猫"): K₃ V₃ │
│ 位置 3 (","): K₄ V₄ ← 新增 │
└───────────────────────────────────────────────────┘
生成第 2 个 output token(假设是 "它们"):
"它们" 的 embedding → 算 Q_new、K_new、V_new
Q_new 和 Cache 中的 [K₁, K₂, K₃, K₄] 做 Attention
→ 得到 output → "它们"
Cache 再追加:
┌─ KV Cache ────────────────────────────────────────┐
│ 位置 0 ("我"): K₁ V₁ │
│ 位置 1 ("喜欢"): K₂ V₂ │
│ 位置 2 ("猫"): K₃ V₃ │
│ 位置 3 (","): K₄ V₄ │
│ 位置 4 ("它们"): K₅ V₅ ← 新增 │
└───────────────────────────────────────────────────┘
4.4 KV Cache 的大小
真实模型的 KV Cache 非常大:
KV Cache 大小 = 2(K和V) × 层数 × 注意力头数 × 头维度 × 序列长度 × 精度
以 Llama 3 70B 为例:
= 2 × 80层 × 8头(GQA) × 128维 × 8192长度 × 2字节(FP16)
≈ 2.6 GB(单个请求!)
这就是为什么 GPU 显存是关键瓶颈,不是算力。
4.5 计算量对比
| 步骤 | 无 KV Cache | 有 KV Cache | 省了多少 |
|---|---|---|---|
| Decode 第 1 步 | 计算 3 个 token 的 K、V | 只计算 1 个 | 省 67% |
| Decode 第 10 步 | 计算 12 个 token 的 K、V | 只计算 1 个 | 省 92% |
| Decode 第 100 步 | 计算 102 个 token 的 K、V | 只计算 1 个 | 省 99% |
五、Prompt Cache:跨请求的缓存
5.1 问题:多轮对话的重复计算
KV Cache 只在单次请求内有效。请求结束后,Cache 就丢弃了。
多轮对话时,每轮都要把完整的上下文重新发送:
第 1 轮:
Input: [System Prompt] + [User: "你好"]
→ Prefill: 计算所有 token 的 KV → 花费 100ms
→ 回复: "你好!有什么..."
第 2 轮:
Input: [System Prompt] + [User: "你好"] + [Asst: "你好!有什么..."] + [User: "讲个笑话"]
→ Prefill: 重新计算所有 token 的 KV → 花费 200ms(更长了)
→ 前面大部分 token 和第 1 轮完全一样,但还是重算了!
第 3 轮:
→ 更长,重复更多,浪费更严重
5.2 解决方案:跨请求复用 KV Cache
服务端将 Prefill 算出的 KV Cache 持久化存储,下次请求如果前缀相同,直接复用:
第 1 轮:
Token IDs: [101, 202, 303, 404, 505]
→ Prefill 计算,生成 KV Cache
→ 以 token 序列的哈希值为 key,存入缓存池
cache[hash(101,202,303,404,505)] = KV_Cache_全部层
第 2 轮:
Token IDs: [101, 202, 303, 404, 505, 601, 602, 603, 701]
├── 前 5 个和第 1 轮一样 ──┤ ├─ 新增 ─┤
→ 服务端做前缀匹配:
hash(101,202,303,404,505) → 命中缓存!
→ 直接从缓存加载前 5 个 token 的 KV(显存读取,不需 GPU 计算)
→ 只对后 4 个新 token 做 Prefill 计算
5.3 缓存 Key 的生成:分块哈希
实际实现中,不是对整个序列算一个哈希,而是分块计算,以支持最长前缀匹配:
假设 block_size = 4(实际通常是 64 或 128)
Token 序列: [101, 202, 303, 404, 505, 601, 602, 603]
Block 0: hash([101, 202, 303, 404]) = "a1b2c3"
Block 1: hash([505, 601, 602, 603]) = "d4e5f6"
缓存结构(类似 Radix Tree):
┌──────────────────────────────────┐
│ "a1b2c3" → KV Cache (位置 0~3) │
│ "a1b2c3:d4e5f6" → KV Cache (0~7) │
└──────────────────────────────────┘
新请求到来时:
新请求 Token: [101, 202, 303, 404, 505, 601, 999, 888]
Block 0: hash([101, 202, 303, 404]) = "a1b2c3" → ✅ 命中
Block 1: hash([505, 601, 999, 888]) = "x7y8z9" → ❌ 不匹配
结果: 复用 Block 0 的 KV Cache(前 4 个 token)
Block 1 需要重新计算
5.4 缓存命中的条件
✅ 命中条件:
- 严格前缀匹配(从第一个 token 开始,连续一致)
- 达到最小缓存粒度(Anthropic ≥ 1024 tokens,OpenAI ≥ 128 tokens)
❌ 不命中的情况:
- 中间任何一个 token 不同 → 从该位置开始后面全部失效
- 相同的文本但 token 化方式不同(几乎不会发生)
- 缓存过期(TTL 通常 5~10 分钟)
5.5 一个完整的多轮对话 Demo
═══ 第 1 轮 ════════════════════════════════════════════════
发送:
[System: "你是一个AI助手,请用中文回答问题。"] ← 50 tokens
[User: "什么是机器学习?"] ← 10 tokens
处理:
前缀匹配: 无缓存,全部 60 tokens 做 Prefill
缓存写入: hash(前 60 tokens) → 存入缓存池
计费:
Input: 60 tokens × 全价
Output: 200 tokens × 全价
═══ 第 2 轮 ════════════════════════════════════════════════
发送:
[System: "你是一个AI助手,请用中文回答问题。"] ← 50 tokens (同上)
[User: "什么是机器学习?"] ← 10 tokens (同上)
[Assistant: "机器学习是...(200 tokens)"] ← 200 tokens (新增)
[User: "能举个例子吗?"] ← 8 tokens (新增)
处理:
前缀匹配: 前 60 tokens 命中缓存 ✅
→ 加载 KV Cache(几乎零 GPU 计算)
→ 只对后 208 tokens 做 Prefill
计费:
Input (cached): 60 tokens × 0.1 倍价格 ← 省钱!
Input (uncached): 208 tokens × 全价
Output: 150 tokens × 全价
═══ 第 3 轮 ════════════════════════════════════════════════
发送:
[前面所有内容,共 468 tokens] ← 大部分和第 2 轮重复
[User: "还有什么应用?"] ← 8 tokens
处理:
前缀匹配: 前 268 tokens 命中缓存 ✅ (第 2 轮时已缓存)
→ 只对后 208 tokens 做 Prefill
计费:
Input (cached): 268 tokens × 0.1 倍
Input (uncached): 208 tokens × 全价
Output: 180 tokens × 全价
六、费用模型:为什么这样定价
理解了技术原理,定价逻辑就清楚了:
费用 = GPU 计算时间 × GPU 单价
三种 Token 的 GPU 消耗
| Token 类型 | GPU 做了什么 | 算力消耗 | 定价 |
|---|---|---|---|
| Input (uncached) | 完整 Prefill:算 Q、K、V,做 Attention | 中等(但可并行,效率高) | 基准价 |
| Input (cached) | 从显存/内存加载 KV Cache | 极低(只有数据搬运) | 基准价 × 0.1~0.5 |
| Output | 完整 Decode:每个 token 跑一遍全模型 | 高(串行,GPU 利用率低) | 基准价 × 3~5 |
以 Claude Sonnet 4 为例
Input: $3 / 1M tokens
Input (cached): $0.3 / 1M tokens ← 1 折
Output: $15 / 1M tokens ← 5 倍于 input
Output 贵 5 倍,不是因为 output token 本身更复杂,而是因为:
- Decode 是串行的:生成 100 个 token 要执行 100 次前向传播
- GPU 利用率低:每次只处理 1 个 token 的 Q,但要读取所有历史的 KV
- 内存带宽瓶颈:GPU 大部分时间在等数据从显存搬过来,而不是在做计算
七、写 Agent 时的优化建议
理解了缓存机制,在设计 Agent 时可以做针对性优化:
7.1 消息结构:静态前置,动态后置
✅ 推荐结构:
┌──────────────────────────┐
│ System Prompt(固定不变) │ ← 最容易被缓存
│ Tool Definitions(固定) │ ← 其次
│ Few-shot Examples(固定) │
│ ─────────────────────── │
│ 历史对话(每轮增长) │ ← 增量变化
│ 当前用户输入(每轮变化) │ ← 最后
└──────────────────────────┘
7.2 不要在前缀中放动态内容
❌ System: "你是AI助手。当前时间:2026-04-24T16:57:42"
→ 每秒都变,1024 token 的 System Prompt 缓存永远不命中
✅ System: "你是AI助手。"
User: "当前时间是 2026-04-24,请帮我..."
→ System Prompt 稳定,始终命中缓存
7.3 Tool 定义顺序保持一致
❌ 每次请求随机排列 tools:
请求 1: [search, calculator, weather]
请求 2: [weather, search, calculator]
→ Token 序列不同,缓存失效
✅ 固定排列:
每次都是: [calculator, search, weather]
→ 前缀稳定,缓存命中
7.4 上下文裁剪策略
❌ 删掉头部(最早的消息):
轮次 5: [msg1, msg2, msg3, msg4, msg5]
轮次 6: [msg2, msg3, msg4, msg5, msg6] ← 前缀完全变了!
✅ 保留头部,裁剪或压缩中间部分:
轮次 6: [system] + [summary of msg1~4] + [msg5, msg6]
→ System Prompt 前缀始终命中
八、总结
┌─────────────────────────┐
文本输入 ───→ │ Tokenizer (BPE) │ ──→ Token IDs
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Prefill 阶段 │
│ 并行计算所有 input 的 │
★ Prompt │ Q, K, V → Attention │ ──→ KV Cache 写入
Cache 在 │ │ (存 K 和 V)
这里生效 │ 命中缓存 → 跳过此步 │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Decode 阶段 │
│ 每步: │
★ KV Cache │ 1. 算新 token 的 Q │
在这里生效 │ 2. Q × 所有历史 K → 权重 │ ──→ 逐个输出 token
│ 3. 权重 × 所有历史 V │
│ 4. 新 K,V 追加到 Cache │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Detokenize │ ──→ 文本输出
└─────────────────────────┘
两层缓存,各司其职:
| 缓存类型 | 缓存什么 | Key 是什么 | 作用范围 | 省了什么 |
|---|---|---|---|---|
| KV Cache | 每层的 K、V 矩阵 | token 的位置 | 单次请求内 | Decode 时不重算历史 token |
| Prompt Cache | 整段前缀的 KV Cache | token 序列的哈希 | 跨请求 | Prefill 时不重算相同前缀 |
理解了这些,你就明白了 LLM 账单上每一分钱的来龙去脉。