深入理解 LLM 缓存机制:从矩阵运算到 Prompt Cache

0 阅读11分钟

当你和 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(如英文 theHello
  • 罕见词被拆成多个子词(如 LangfuseLang + 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 时:

1output: 计算 input 全部 token 的 Q、K、V → Attention → 得到 token
第 2output: 重新计算 input + 第1output 的 Q、K、V → Attention → 得到 token
第 3output: 重新计算 input + 第12output 的 Q、K、V → Attention → 得到 token
...

每一步都在重复计算之前所有 token 的 K 和 V!极其浪费。

4.2 解决方案:存下 K 和 V

Prefill 阶段:
  并行计算所有 input tokenKV
  存入 KV Cache ← 这是一次性的 GPU 计算

Decode1 步:
  新 token → 只算这 1tokenQKV
  QCache 里所有 K 做点积 → Attention → 输出
  把新的 KV 追加到 Cache

Decode2 步:
  新 token → 只算这 1tokenQKV
  QCache 里所有 K 做点积 → Attention → 输出
  把新的 KV 追加到 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: 计算所有 tokenKV → 花费 100ms
  → 回复: "你好!有什么..."

第 2 轮:
  Input: [System Prompt] + [User: "你好"] + [Asst: "你好!有什么..."] + [User: "讲个笑话"]Prefill: 重新计算所有 tokenKV → 花费 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 tokensPrefill
  缓存写入: 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 tokensPrefill

计费:
  Input (cached):   60 tokens × 0.1 倍价格   ← 省钱!
  Input (uncached): 208 tokens × 全价
  Output:           150 tokens × 全价

═══ 第 3 轮 ════════════════════════════════════════════════

发送:
  [前面所有内容,共 468 tokens]                    ← 大部分和第 2 轮重复
  [User: "还有什么应用?"]8 tokens

处理:
  前缀匹配: 前 268 tokens 命中缓存 ✅ (第 2 轮时已缓存)
  → 只对后 208 tokensPrefill

计费:
  Input (cached):   268 tokens × 0.1Input (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 Cachetoken 序列的哈希跨请求Prefill 时不重算相同前缀

理解了这些,你就明白了 LLM 账单上每一分钱的来龙去脉。