很多人在使用 LLM 时有个直觉:对话越长 token 越贵,应该尽早做摘要压缩 history。在构建 Agent Loop 时,也有人把多轮对话合并成一条”无状态消息”来省 token。这两种做法看似聪明,实际上都是反优化。本文从 KV Cache 原理出发,解释为什么保持原始 history 不动才是最优策略。
最常见的误区:主动做摘要压缩 History
场景
你和 LLM 聊了 20 轮,context window 用了 8K/128K。你开始焦虑:”这么长的 history,每次请求都要发过去,太浪费 token 了吧?”
于是你做了一个”优化”:让 LLM 把前面的对话总结成一段摘要,然后用摘要开启新对话。
原始对话(20轮,8000 token):
[system] [user_1] [asst_1] [user_2] [asst_2] ... [user_20] [asst_20]
"优化"后(摘要,500 token):
[system] [user: 以下是之前对话的摘要:...500字...] [user_21]
看起来输入从 8000 token 减少到了 600 token,省了 93%?
为什么这是反优化
1. 你摧毁了 KV Cache
原始对话中,前 19 轮的 KV 在上次请求时已经算好并缓存在 GPU 显存中。第 21 轮请求到来时:
原始方式:
[system][user_1][asst_1]...[user_20][asst_20] ← 全部 cache 命中(0计算)
[user_21] ← 只算这一条(几十token)
摘要方式:
[system][摘要...500 token][user_21] ← 全新内容,全部重算(550 token)
原始方式实际只需计算几十个 token(新消息),摘要方式反而要计算 550 个 token。你为了”省 token”,制造了十倍的计算开销。
2. 摘要本身就是额外开销
做摘要时,虽然之前的 8000 token 被 cache 覆盖(计算开销小),但你仍然需要 LLM 生成 500 token 的摘要输出。更关键的是,这 500 token 的摘要在新对话中会作为全新 input 再被完整计算一次(没有任何 cache)。相当于你为了”省 token”,先花 500 token 生成摘要,再花 500 token 重新计算摘要——净增了开销。
3. 信息不可逆丢失
摘要时你无法预判后续对话需要哪些细节。LLM 可能在第 30 轮需要第 3 轮的某个具体参数,但摘要时已经丢了。
正确的心智模型
已有的 history = 免费的(被 KV Cache 覆盖,0 计算开销)
只有新增的尾部 = 实际计算开销
打个比方:你在看一本 200 页的书,已经看到第 180 页。每次翻新一页只需要读 1 页内容。如果你这时候把前 180 页撕掉,写了一页摘要,然后声称”我只需要读 1 页摘要就够了”——但你本来就只需要读 1 页新内容啊!撕书这个操作本身还浪费了时间。
什么时候才应该做摘要?
只有当你真的快撞到 context window 上限时。 比如 128K 的窗口已经用了 120K,再加新消息就溢出了——这时候别无选择,必须压缩。
但在此之前(比如只用了 10%~50%),保持原始 history 不动就是最优策略。不要和 KV Cache 作对。
对 API 计费的影响
你可能想说:”即使 cache 命中,API 提供商不还是按 input token 数收费吗?”
事实上,主流厂商都已经对 cached token 提供大幅折扣(远不止半价):
| 厂商 | 模型 | New Input Token | Cached Input 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 系(cached token 价格低至 new token 的 1/10 甚至更低)。
这意味着: 在 API 计费层面,保持原始 history 不动同样是经济的。假设你有 8000 token 的 history:
- 保持原样:8000 × cached 价格(1~2.5 折)+ 新消息 × 全价
- 做摘要替换:500 × 全价(摘要是新内容,无 cache)+ 新消息 × 全价 + 摘要生成的输出费用
表面上 8000 → 500 看似省了,但 8000 token 按 1 折计算 = 等效 800 token 全价。加上摘要输出费用和信息丢失,收益微乎其微甚至为负。
对于 自部署模型(vLLM/TGI):没有按 token 计费,开销纯粹取决于 GPU 计算量。此时保持原始 history 的优势是压倒性的——cache 命中 = 零额外计算。
Agentic Loop 中的同类问题
上述误区在 Agent Loop 设计中有一个变体:把多轮工具调用历史合并成一条”无状态消息”来”省 token”。接下来用一个具体例子分析这种做法。
背景
在 Agentic RAG 的迭代搜索场景中,Agent 每轮调用 LLM 决定下一步操作(搜索、丢弃、结束)。LLM 需要知道:
- 用户的原始问题
- 之前执行了哪些工具调用
- 当前收集到了哪些 evidence
问题来了:怎么把这些信息传给 LLM? 这本质上和上面”要不要压缩 history”是同一个问题。
两种方式
方式 A:全量合并(Stateless Merge)
每次调用 LLM 时,把所有历史压缩成一两条 user 消息:
def build_messages():
msgs = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": query},
]
# 全部 trace 合成一条文本
msgs.append({"role": "user", "content": f"[已执行的工具调用]\n{trace_text}"})
# 全部 evidence 合成一条 JSON
msgs.append({"role": "user", "content": f"[当前 evidence]\n{evidence_json}"})
return msgs
动机:看起来消息少、结构简单,且省掉了历史中 LLM 的 assistant 回复(可能包含冗长的 thinking/reasoning),直觉上能省 token。
方式 B:标准多轮对话(Stateful Messages)
保持完整的对话结构,每轮追加 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 的 evidence,LLM 每轮 reasoning 约 200 token。
方式 A 三轮的 input tokens
第 1 轮: system(100) + user(50) = 150
第 2 轮: system(100) + user(50) + trace(30) + evidence(500) = 680
第 3 轮: system(100) + user(50) + trace(60) + evidence(1000) = 1210
总 input = 2040
每次都是全新内容 → KV Cache 命中率 ≈ 0% → 2040 token 全部需要 GPU 从头计算。
方式 B 三轮的 input tokens
第 1 轮: system(100) + user(50) = 150
第 2 轮: system(100) + user(50) + asst_1(200) + tool_1(500) = 850
第 3 轮: system(100) + user(50) + asst_1(200) + tool_1(500)
+ asst_2(200) + tool_2(500) = 1550
总 input = 2550
多了 assistant 消息(+400 token),但关键区别:
- 第 2 轮的前 150 token 与第 1 轮完全相同 → cache 命中
- 第 3 轮的前 850 token 与第 2 轮完全相同 → cache 命中
实际需要计算的 token:
第 1 轮: 150(全算)
第 2 轮: 700(前 150 命中 cache,只算新增 700)
第 3 轮: 700(前 850 命中 cache,只算新增 700)
实际计算量 = 1550
对比表
| 指标 | 方式 A(全量合并) | 方式 B(标准多轮) |
|---|---|---|
| 总 input token 数 | 2040 | 2550 |
| KV Cache 命中 | 0% | ~60% |
| 实际 GPU 计算量 | 2040 | 1550 |
| LLM 理解难度 | 较高(非标准格式) | 低(原生训练格式) |
结论:方式 A 看似 token 少,实际计算量更大。
深入理解:Prefill、Decode 与 KV Cache
LLM 推理的两个阶段
你一定注意过:LLM 收到输入后,吐出第一个 token 比较慢,后续 token 则刷刷刷很快。这正是两个阶段的体现:
1. Prefill(预填充):处理所有 input token,为每个 token 在每一层 Transformer 计算出 Key 和 Value 向量,存入 KV Cache。这是计算密集型的——需要对 N 个 token 做全量 attention 矩阵运算,复杂度 O(N²)。
2. Decode(解码):逐个生成 output token。每生成一个新 token,只需要用它的 Query 去和 KV Cache 中已有的 Key 做 attention,复杂度 O(N)。然后把新 token 的 K、V 追加到 cache 中,为下一个 token 服务。
打个比方:
– Prefill = 读完一整本书,做好笔记(耗时,对应 TTFT 慢)
– Decode = 根据笔记逐句写答案(相对轻松,对应后续 token 快)
所以你感受到的”先卡一下,然后刷刷刷出来”就是 Prefill → Decode 的分界。
什么是 KV Cache?
Transformer 每一层的 Self-Attention 计算:
Attention(Q, K, V) = softmax(Q × K^T / √d) × V
对于一个有 32 层、每层 Key 维度 128、共 32 个 attention head 的模型(类似 LLaMA-7B),处理 1000 个 token 的 KV Cache 大小:
32层 × 2(K和V) × 32头 × 1000 token × 128维 × 2字节(fp16)
≈ 512 MB
这些 K、V 向量一旦算好,在 Decode 阶段生成后续 token 时可以反复复用——不需要对历史 token 重新计算,这就是 KV Cache 的核心价值。
Decode 阶段:每次只算一个 token
Decode 时每步永远只计算 1 个新 token 的 Q/K/V。新 token 的 KV 直接追加到 cache 的下一个 slot:
Block5 (容量16):
slot 0: token_a 的 KV ← 已算好
slot 1: token_b 的 KV ← 已算好
slot 2: token_c 的 KV ← 新 token,只算这一个,写入这里
slot 3~15: 空
Block 是 KV Cache 存储管理的单位(类似内存分页),不是计算单位。一个 block 没填满时,新 token 的 KV 直接写入同一 block 的下一个 slot,不影响已有的值,也不需要重算整个 block。
跨请求的 Prefix Caching
关键洞察:如果两次请求共享相同的前缀,那么前缀部分的 KV 向量完全一样,不需要重算。
举例:Agent Loop 中的标准多轮对话
假设 system prompt = “你是搜索助手”,用户问题 = “什么是 GraphRAG?”
第 1 轮请求:
[system: 你是搜索助手] [user: 什么是GraphRAG?]
←────────── 150 token ───────────→
Prefill 计算 150 个 token 的 KV → 存入 cache,key = hash(“你是搜索助手|什么是GraphRAG?”)
LLM 返回:调用 search({“query”: “GraphRAG”})
第 2 轮请求:
[system: 你是搜索助手] [user: 什么是GraphRAG?] [asst: search(...)] [tool: 结果A]
←──── 和第1轮完全相同 ────→ ←────── 新增 700 token ──────→
←────────────────────── 850 token ──────────────────────────→
推理引擎发现:前 150 token 的 hash 和之前缓存的匹配!
已缓存: token 1~150 的 KV(直接复用,0 计算)
需计算: token 151~850 的 KV(只算新增的 700 token)
第 3 轮请求:
[同上 850 token] [asst: search(...)] [tool: 结果B]
←─ cache 命中 ─→ ←── 新增 700 ──→
cache 命中 850 token,只需计算 700 token。
如果用全量合并方式
第 2 轮请求:
[system: 你是搜索助手] [user: 什么是GraphRAG?] [user: [已执行工具]\n search→5条] [user: [evidence]\n{...500字...}]
←──── 和第1轮相同 ────→ ←─────────── 全新内容 ───────────────→
前 150 token 匹配,后面 530 token 是新内容。
第 3 轮请求:
[system: 你是搜索助手] [user: 什么是GraphRAG?] [user: [已执行工具]\n search→5条\n search→3条] [user: [evidence]\n{...1000字...}]
←──── 和第1轮相同 ────→ ←───────── 内容变了!─────────────────→
第 3 个消息的内容从 "search→5条" 变成了 "search→5条\n search→3条"——从这里开始 cache 全部失效:
cache 命中: 150 token(只有 system + user query)
需要计算: 1060 token
对比方式 B 同一轮只需计算 700 token。差距随着迭代轮数增加越来越大。
前缀匹配的严格顺序性
Prefix caching 是从头逐块顺序匹配的。原因是 attention 机制中的 positional encoding——同一个 token 出现在位置 0 和位置 16,其 KV 值是不同的。
这意味着:如果在开头插入了新 token,整个 cache 失效,全部重算。
缓存中: [block0][block1][block2][block3][block4]
新请求: [new_block][block0'][block1'][block2'][block5][block6]
✗ → 第一个块就不匹配,后面即使内容相同也无法复用
不可能跳过前面去匹配后面——位置变了,KV 值就变了。
这也解释了为什么 system prompt 放在最前面是有利的——它是所有请求共享的固定前缀,保证了开头部分的 cache 始终命中。
Prefix Caching 的实现机制(vLLM)
- 分块哈希:将 token 序列按固定大小(如 16 token)分块,对每块内容计算 hash
- 逐块匹配:新请求到来时,从头逐块比对 hash,找到最长匹配前缀
- 复用 KV Block:匹配的块直接引用已缓存的 GPU 显存中的 KV 数据
- 只计算尾部:从第一个不匹配的块开始做 prefill
已缓存的请求: [block0][block1][block2][block3][block4]
新请求: [block0][block1][block2][block5][block6]
✓ ✓ ✓ ✗ → 从这里开始计算
可视化对比
方式 B(标准多轮)—— 每轮只计算尾部新增部分
第1轮: [████████] 计算 150
第2轮: [--------][██████████████] 计算 700 (前150 cache命中)
第3轮: [--------------------][████] 计算 700 (前850 cache命中)
总计算量 = 1550
方式 A(全量合并)—— 每轮从第3条消息开始就变了
第1轮: [████████] 计算 150
第2轮: [--------][██████████████] 计算 530 (前150 cache命中)
第3轮: [--------][████████████████] 计算 1060 (前150 cache命中,后面全变了)
总计算量 = 1740
随着轮数增加,方式 A 的劣势加速放大。
方式 A 还有哪些隐藏代价?
1. 模型理解能力下降
LLM 在训练时见到的 tool use 格式是:
assistant: I'll search for... [tool_call: search({query: "..."})]
tool: [results...]
assistant: Based on results, I'll now... [tool_call: ...]
你用纯文本模拟这个过程:
user: [已执行的工具调用]
[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 call 能力
标准格式支持一次返回多个 tool_calls,推理引擎知道它们是同一轮的并行调用。方式 A 的扁平 trace 文本无法表达这种结构。
什么时候方式 A 有优势?
公平地说,存在少数场景方式 A 更合理:
- 推理引擎不支持 prefix caching(少见,主流引擎都支持)
- 每轮 assistant reasoning 极长(如 DeepSeek 的 thinking 动辄 2000+ token),且你确定这些 reasoning 对后续决策无帮助
- 需要跨 session 恢复——无状态设计允许从任意中间状态恢复,不依赖完整对话历史
对于第 2 点,更好的做法是:保持标准多轮格式,但在追加历史 assistant 消息时截断 reasoning 部分,只保留 tool_call 结构。这样既省 token 又保留 cache 和格式优势。
推荐做法
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 消息(可选:截断 reasoning 以省 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),
})
简洁、标准、cache 友好。
总结
| 全量合并 | 标准多轮 | |
|---|---|---|
| Token 数量 | 略少 | 略多 |
| 实际推理开销 | 更大(无 cache) | 更小(cache 命中高) |
| 模型理解准确度 | 较差 | 好(原生格式) |
| 工程复杂度 | 需手动序列化 | 框架原生支持 |
| 可观测性 | 差(丢失结构) | 好(每轮清晰) |
不要为了省几百 token 而放弃 KV Cache 和原生格式带来的巨大优势。 表面上的”优化”实际上是反优化——就像为了省内存而关掉 CPU cache,得不偿失。
一句话总结
已有的 history 是免费的,新增的内容才是开销。别主动摧毁 cache。