site logo

Marico's space

别急着清除历史记录——理解 KV 缓存如何改变你的 LLM 对话策略

AI技术与应用 2026-06-09 17:33:51 4

最近在折腾 AI 应用开发,踩了一个挺反直觉的坑:大家普遍觉得对话历史越长、消耗的 token 越多,所以习惯性地早早总结压缩。搭 Agent 循环的时候,有些人把多轮对话合并成一条"无状态消息"来"省 token"。这两个操作看起来都是聪明之举,实际上都是反向优化(anti-optimization)。这篇文章从 KV 缓存的原理出发,解释为什么保持原始历史完整才是最优策略。

最常见的误区:主动总结压缩历史

场景

你和 LLM 聊了 20 轮,上下文窗口 128K 里用了 8K。你开始犯嘀咕:"这么长的历史,每次请求都带上,这不是浪费吗?"

于是你做了一个"优化":让 LLM 把之前的对话总结成摘要,然后用这个摘要开启新对话。

原始对话(20轮,8000个token): [system] [user_1] [asst_1] [user_2] [asst_2] ... [user_20] [asst_20] "优化后"(摘要,500个token): [system] [user: Here's a summary of the previous conversation: ...500 words...] [user_21]

看起来输入从 8000 token 降到了 600,省了 93%?

为什么这是反向优化

1. 你把 KV 缓存给摧毁了

在原始对话中,前 19 轮的 KV 已经在上一轮请求时计算好并缓存在 GPU 显存里了。当第 21 轮到来时:

原始方式: [system][user_1][asst_1]...[user_20][asst_20] ← 全部命中缓存(0计算) [user_21] ← 只计算这一段(几十个token) 摘要方式: [system][summary...500 tokens][user_21] ← 全部是新内容,需要全量重算(550个token)

原始方式只需要计算几十个 token(新消息),而摘要方式需要计算 550 个 token。你"省 token"的操作反而创造了十倍的计算开销。

2. 摘要本身就是额外开销

创建摘要时,虽然之前的 8000 token 有缓存覆盖(计算成本低),但仍然需要 LLM 生成 500 token 的摘要输出。更关键的是,这 500 个摘要 token 在新对话中作为全新输入时,需要全部重新计算(零缓存)。你相当于花了 500 token 生成摘要,然后又花了 500 token 重新计算它——净增加开销。

3. 不可逆的信息丢失

总结时,你无法预测未来对话轮次需要哪些细节。LLM 可能在第 30 轮需要第 3 轮里的某个具体参数,但它已经在总结时丢失了。

正确的思维模型

已有的历史 = 免费的(被 KV 缓存覆盖,0计算)
只有新的尾部内容 = 实际计算成本

类比:你在读一本 200 页的书,已经读到了第 180 页。新翻一页只需要读 1 页。但如果把前 180 页撕掉,写成 1 页摘要,然后声称"我只需要读 1 页摘要"——但你本来也只需要读 1 页新内容!撕书的动作浪费了时间。

什么时候才应该真正总结

只有当你真正接近上下文窗口上限时才需要。比如 128K 的窗口已经用了 120K,新消息加进去就要溢出——这时候你才被迫压缩。

但在那之前(比如只用了 10%~50%),保持原始历史完整是最优策略。别跟 KV 缓存对着干。

对 API 计费的影响

你可能会说:"就算命中缓存,API 提供商不还是按输入 token 数量收费吗?"

实际上,主流提供商已经对缓存 token 给出了大幅折扣(远不止一半):

提供商 模型 新输入 token 缓存输入 token 缓存折扣
OpenAI GPT-5 系列 $1.25 $0.125 90%
OpenAI GPT-4.1 $2.00 $0.50 75%
OpenAI GPT-4.1 Mini $0.40 $0.10 75%
Anthropic Claude Sonnet 4.x $3.00 $0.30 90%
Anthropic Claude Opus 4.x $15.00 $1.50 90%
Anthropic Claude Haiku $0.80 $0.08 90%
Google AI Studio Gemini 2.5 Pro $1.25 $0.125 90%
Google AI Studio Gemini 2.5 Flash $0.15 $0.015 90%
Google AI Studio Gemini 2.0 Flash $0.10 $0.025 75%

国内提供商通常给出更激进的缓存折扣,尤其是 DeepSeek 系列(缓存 token 价格低至新 token 的 1/10 甚至更低)。

这意味着:在 API 计费层面,保持原始历史完整同样经济。假设你有 8000 token 的历史:

  • 保持原样:8000 × 缓存价格(全价的 10%~25%)+ 新消息 × 全价
  • 替换成摘要:500 × 全价(摘要是新内容,没有缓存)+ 新消息 × 全价 + 生成摘要的输出成本

表面看 8000 → 500 好像省了,但 8000 token 按 10% 价格折算 = 相当于 800 token 的全价。算上摘要输出成本和信息丢失,收益微乎其微甚至为负。

对于自部署模型(vLLM/TGI):没有按 token 计费,开销纯粹取决于 GPU 计算。这里保持原始历史的优势是压倒性的——缓存命中 = 零额外计算。

Agent 循环中的同样问题

上述误区在 Agent 循环设计中有一个变体:把多轮工具调用历史合并成单条"无状态消息"来"省 token"。我们通过一个具体例子来分析。

背景

在 Agentic RAG 迭代搜索场景中,Agent 每轮调用 LLM 决定下一步动作(搜索、放弃、结束)。LLM 需要知道:

  1. 用户的原始问题
  2. 之前执行了哪些工具调用
  3. 目前收集到了哪些证据

问题是:如何把这些信息传递给 LLM?这本质上和"是否压缩历史"是同一个问题。

两种方案

方案 A:全量合并(无状态合并)

每次调用 LLM 时,把所有历史压缩成一条或两条用户消息:

def build_messages(): msgs = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": query}, ] # 把所有轨迹合并成一段文本 msgs.append({"role": "user", "content": f"[Executed tool calls]\n{trace_text}"}) # 把所有证据合并成一个 JSON msgs.append({"role": "user", "content": f"[Current evidence]\n{evidence_json}"}) return msgs

动机:消息更少,结构更简单,而且省略了历史上 LLM 的 assistant 回复(可能包含冗长的思考/推理过程)——直觉上省了 token。

方案 B:标准多轮对话(有状态消息)

保持完整对话结构,每轮追加 assistant 的 tool_call + tool result:

messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": query},
] for each iteration: response = llm.chat(messages, tools=...) messages.append(response.message) # assistant with tool_calls result = execute_tool(response.tool_call) messages.append({"role": "tool", "content": result, "tool_call_id": ...})

一个具体例子

假设 Agent 运行 3 轮,每轮工具返回约 500 token 的证据,每轮 LLM 推理约 200 token。

方案 A:3 轮的输入 token

Round 1: system(100) + user(50) = 150
Round 2: system(100) + user(50) + trace(30) + evidence(500) = 680
Round 3: system(100) + user(50) + trace(60) + evidence(1000) = 1210 Total input = 2040

每次都是全新内容 → KV 缓存命中率 ≈ 0% → 全部 2040 token 都需要从头计算。

方案 B:3 轮的输入 token

Round 1: system(100) + user(50) = 150
Round 2: system(100) + user(50) + asst_1(200) + tool_1(500) = 850
Round 3: system(100) + user(50) + asst_1(200) + tool_1(500) + asst_2(200) + tool_2(500) = 1550 Total input = 2550

多了 assistant 消息(+400 token),但关键区别在于:

  • Round 2 的前 150 token 和 Round 1 完全相同 → 命中缓存
  • Round 3 的前 850 token 和 Round 2 完全相同 → 命中缓存

实际需要计算的 token:

Round 1: 150 (全量计算)
Round 2: 700 (前 150 命中缓存,只计算新的 700)
Round 3: 700 (前 850 命中缓存,只计算新的 700) 实际计算量 = 1550

对比表

指标 方案 A(全量合并) 方案 B(标准多轮)
总输入 token 2040 2550
KV 缓存命中率 0% ~60%
实际 GPU 计算量 2040 1550
LLM 理解难度 较高(非标准格式) 低(原生训练格式)

结论:方案 A 看起来 token 更少,实际计算量反而更多。

深入解析:Prefill、Decode 和 KV 缓存

LLM 推理的两个阶段

你肯定注意到了:LLM 收到输入后,第一个 token 出来很慢,但后面的 token 出来很快。这反映的就是两个阶段:

1. Prefill(预填充):处理所有输入 token,在每个 Transformer 层计算每个 token 的 Key 和 Value 向量,存入 KV 缓存。这是计算密集型——需要对 N 个 token 做完整的注意力矩阵运算,复杂度 O(N²)。

2. Decode(解码):逐个生成输出 token。对于每个新生成的 token,只需要用它的 Query 和 KV 缓存中已有的 Keys 做注意力计算,复杂度 O(N)。然后把这个新 token 的 K 和 V 追加到缓存,供下一个 token 使用。

类比:

  • Prefill = 读完整本书并做笔记(耗时,对应首 token 慢 TTFT)
  • Decode = 根据笔记写答案(相对轻松,对应后续 token 快)

所以你体验到的"先卡住再喷涌"就是 Prefill → Decode 的分界线。

什么是 KV 缓存

每个 Transformer 层的自注意力计算:

Attention(Q, K, V) = softmax(Q × K^T / √d) × V

对于一个有 32 层、Key 维度 128、32 个注意力头(类似 LLaMA-7B)的模型,1000 个 token 的 KV 缓存大小:

32 layers × 2(K and V) × 32 heads × 1000 tokens × 128 dims × 2 bytes(fp16)
≈ 512 MB

一旦计算完成,这些 K 和 V 向量在 Decode 阶段生成后续 token 时可以反复复用,不需要对历史 token 重新计算。这就是 KV 缓存的核心价值。

Decode 阶段:每步只计算一个 token

在 Decode 阶段,每一步始终计算恰好 1 个新 token的 Q/K/V。新 token 的 KV 直接追加到缓存的下一个槽位:

Block5 (容量 16): slot 0: token_a 的 KV ← 已经计算好 slot 1: token_b 的 KV ← 已经计算好 slot 2: token_c 的 KV ← 新 token,只计算这一个,写入这里 slot 3~15: 空

Block 是 KV 缓存的存储管理单位(类似内存分页),不是计算单位。当一个 block 没满时,新 token 的 KV 直接写入同一个 block 的下一个槽位,不影响已有值,也不需要重算整个 block。

跨请求的前缀缓存

关键洞察:如果两个请求有相同的前缀,前缀的 KV 向量是相同的,不需要重新计算。

例子:Agent 循环中的标准多轮对话

假设 system prompt = "You are a search assistant",用户问题 = "What is GraphRAG?"

Round 1 请求:

[system: You are a search assistant] [user: What is GraphRAG?] ←────────── 150 tokens ───────────→

Prefill 计算 150 个 token 的 KV → 存入缓存,key = hash("You are a search assistant|What is GraphRAG?")

LLM 返回:call search({"query": "GraphRAG"})

Round 2 请求:

[system: You are a search assistant] [user: What is GraphRAG?] [asst: search(...)] [tool: Result A] ←──── identical to Round 1 ────→ ←────── new 700 tokens ──────→ ←────────────────────── 850 tokens ──────────────────────────→

推理引擎发现:前 150 个 token 的哈希匹配缓存!

已缓存:tokens 1~150 的 KV(直接复用,0计算)
待计算:tokens 151~850 的 KV(只计算新的 700 个 token)

Round 3 请求:

[same 850 tokens above] [asst: search(...)] [tool: Result B] ←─ cache hit ─→ ←── new 700 ──→

缓存命中 850 token,只需计算 700 token。

全量合并方案

Round 2 请求:

[system: You are a search assistant] [user: What is GraphRAG?] [user: [Executed tools]\n search→5 results] [user: [evidence]\n{...500 chars...}] ←──── same as Round 1 ────→ ←─────────── entirely new content ───────────────→

前 150 token 匹配,剩余 530 token 是新内容。

Round 3 请求:

[system: You are a search assistant] [user: What is GraphRAG?] [user: [Executed tools]\n search→5 results\n search→3 results] [user: [evidence]\n{...1000 chars...}] ←──── same as Round 1 ────→ ←───────── content changed! ─────────────────→

第三条消息的内容从 "search→5 results" 变成了 "search→5 results\n search→3 results" —— 从这一点开始缓存全部失效:

缓存命中:150 token(只有 system + user query)
待计算:1060 token

对比方案 B 在同一轮只需计算 700 token。差距随着迭代增加而加速扩大。

前缀匹配的严格顺序性

前缀缓存是从开头开始逐块顺序匹配的。原因是注意力机制中的位置编码——同样一个 token,放在位置 0 和位置 16 的 KV 值是不同的。

这意味着:如果在开头插入新 token,整个缓存失效,所有内容都需要重新计算。

缓存中: [block0][block1][block2][block3][block4]
新请求: [new_block][block0'][block1'][block2'][block5][block6] ✗ → 第一个 block 不匹配,后续 block 即使内容相同也无法复用

你不能跳着去匹配后面的 block——位置变了,KV 值就变了。

这也解释了为什么把 system prompt 放在最开头是有益的——它是所有请求共享的固定前缀,确保开头部分始终能命中缓存。

前缀缓存实现机制(vLLM)

  1. Block 哈希:把 token 序列切分成固定大小的 block(如 16 个 token),对每个 block 内容计算哈希
  2. 顺序 block 匹配:新请求到来时,从开头逐块比较哈希,找最长匹配前缀
  3. 复用 KV Blocks:匹配上的 block 直接引用 GPU 显存中的缓存 KV 数据
  4. 只计算尾部:从第一个不匹配的 block 开始 prefill
缓存请求: [block0][block1][block2][block3][block4]
新请求: [block0][block1][block2][block5][block6] ✓ ✓ ✓ ✗ → 从这里开始计算

可视化对比

方案 B(标准多轮)—— 每轮只计算新的尾部内容 Round 1: [████████] 计算 150
Round 2: [--------][██████████████] 计算 700 (前 150 命中缓存)
Round 3: [--------------------][████] 计算 700 (前 850 命中缓存) 总计算量 = 1550 方案 A(全量合并)—— 内容从第 3 条消息开始每轮都变化 Round 1: [████████] 计算 150
Round 2: [--------][██████████████] 计算 530 (前 150 命中缓存)
Round 3: [--------][████████████████] 计算 1060 (前 150 命中缓存,其余全变) 总计算量 = 1740

随着轮数增加,方案 A 的劣势加速扩大。

方案 A 的隐藏成本

1. 模型理解能力下降

LLM 训练时见到的工具使用格式是这样的:

assistant: I'll search for... [tool_call: search({query: "..."})]
tool: [results...]
assistant: Based on results, I'll now... [tool_call: ...]

用纯文本模拟是这样的:

user: [Executed tool calls] [0] search({"query": "..."}) → 5 results [1] search({"query": "..."}) → 3 results

模型需要额外的"认知开销"来理解这种非标准格式,可能导致:

  • 重复执行已经执行过的工具调用(因为结构没有原生格式清晰)
  • 无法正确区分哪些信息来自工具,哪些来自用户

2. 无法表达工具调用失败

标准方式中,工具调用失败可以明确返回:

{"role": "tool", "content": "Error: timeout after 10s", "tool_call_id": "..."}

LLM 看到后会调整策略。但在方案 A 中,你只能写 → 0 results,LLM 无法区分"没找到结果"和"搜索出错"。

3. 丧失并行工具调用能力

标准格式支持一次性返回多个 tool_calls,推理引擎知道它们是同一轮的并行调用。方案 A 的扁平轨迹文本无法表达这种结构。

方案 A 什么时候有优势

公平地说,有几个场景方案 A 更合理:

  1. 推理引擎不支持前缀缓存(很少见——主流引擎都支持)
  2. 每轮 assistant 推理非常长(如 DeepSeek 的思考经常超过 2000+ token),且你确定这段推理对后续决策没有帮助
  3. 需要跨会话恢复——无状态设计允许从任意中间状态恢复,不依赖完整对话历史

对于第 2 点,更好的做法是:保持标准多轮格式,但在追加历史 assistant 消息时截断推理部分,只保留 tool_call 结构。这样既省 token,又保留了缓存和格式优势。

推荐实现

messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": query},
] for i in range(max_iterations): response = await llm.chat.completions.create( model=model, messages=messages, tools=tools_schema ) assistant_msg = response.choices[0].message if not assistant_msg.tool_calls: break # 追加 assistant 消息(可选:截断推理部分以节省 token) messages.append(assistant_msg.model_dump()) # 执行工具并追加结果 for tool_call in assistant_msg.tool_calls: result = await execute(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False), })

简单、标准、缓存友好。

总结

全量合并 标准多轮
Token 数量 略少 略多
实际推理成本 更高(无缓存) 更低(高缓存命中率)
模型理解准确度 较低 良好(原生格式)
工程复杂度 需要手动序列化 框架原生支持
可观测性 差(结构丢失) 良好(每轮清晰)

不要为了省几百个 token 就牺牲 KV 缓存和原生格式的巨大优势。这种表面上的"优化"实际上是反向优化——就像为了省内存而关掉 CPU 缓存,得不偿失。

一句话总结

已有的历史是免费的,只有新内容才收费。别自己把缓存搞坏了。