别急着清 History——理解 KV Cache 后你会重新看待 LLM 对话策略

很多人在使用 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 需要知道:

  1. 用户的原始问题
  2. 之前执行了哪些工具调用
  3. 当前收集到了哪些 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)

  1. 分块哈希:将 token 序列按固定大小(如 16 token)分块,对每块内容计算 hash
  2. 逐块匹配:新请求到来时,从头逐块比对 hash,找到最长匹配前缀
  3. 复用 KV Block:匹配的块直接引用已缓存的 GPU 显存中的 KV 数据
  4. 只计算尾部:从第一个不匹配的块开始做 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 更合理:

  1. 推理引擎不支持 prefix caching(少见,主流引擎都支持)
  2. 每轮 assistant reasoning 极长(如 DeepSeek 的 thinking 动辄 2000+ token),且你确定这些 reasoning 对后续决策无帮助
  3. 需要跨 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。

Comments

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注