
最近在折腾 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 提供商不还是按输入 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 → 500 好像省了,但 8000 token 按 10% 价格折算 = 相当于 800 token 的全价。算上摘要输出成本和信息丢失,收益微乎其微甚至为负。
对于自部署模型(vLLM/TGI):没有按 token 计费,开销纯粹取决于 GPU 计算。这里保持原始历史的优势是压倒性的——缓存命中 = 零额外计算。
上述误区在 Agent 循环设计中有一个变体:把多轮工具调用历史合并成单条"无状态消息"来"省 token"。我们通过一个具体例子来分析。
在 Agentic RAG 迭代搜索场景中,Agent 每轮调用 LLM 决定下一步动作(搜索、放弃、结束)。LLM 需要知道:
问题是:如何把这些信息传递给 LLM?这本质上和"是否压缩历史"是同一个问题。
每次调用 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。
保持完整对话结构,每轮追加 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。
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 都需要从头计算。
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),但关键区别在于:
实际需要计算的 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 更少,实际计算量反而更多。
你肯定注意到了: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 → Decode 的分界线。
每个 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 阶段,每一步始终计算恰好 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 向量是相同的,不需要重新计算。
假设 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 放在最开头是有益的——它是所有请求共享的固定前缀,确保开头部分始终能命中缓存。
缓存请求: [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 的劣势加速扩大。
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
模型需要额外的"认知开销"来理解这种非标准格式,可能导致:
标准方式中,工具调用失败可以明确返回:
{"role": "tool", "content": "Error: timeout after 10s", "tool_call_id": "..."}
LLM 看到后会调整策略。但在方案 A 中,你只能写 → 0 results,LLM 无法区分"没找到结果"和"搜索出错"。
标准格式支持一次性返回多个 tool_calls,推理引擎知道它们是同一轮的并行调用。方案 A 的扁平轨迹文本无法表达这种结构。
公平地说,有几个场景方案 A 更合理:
对于第 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 缓存,得不偿失。
已有的历史是免费的,只有新内容才收费。别自己把缓存搞坏了。