Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
I 版 · 大模型原理 第 02 章 The Hidden Bill of Multi-turn
I · 大模型原理 02 / 22 进阶

KV Cache:多轮对话的隐性账单

The Hidden Bill of Multi-turn
预计阅读 26 分钟
难度 进阶
关键词 KV Cache · 前缀稳定性
本机状态 未读

一个 Agent 的单次任务往往要发十几轮请求,每一轮都带着前面所有轮次的完整历史。如果服务端每次都从头算一遍,你付的钱里有一大半是重复劳动。KV Cache 把这份重复劳动变成缓存命中,而命中率取决于一个平时根本不会被注意的细节:前缀稳定性。

编辑部注 · 本章是全站最重要的成本一章。读完后请务必把「前缀稳定性破坏点清单」写进你自己的笔记。

先看一个真实的账单形态。同一个 Agent 任务,第一轮请求 2,000 token 的输入,第二轮变成 2,400,第三轮 2,800……输入长度线性增长,而如果每一轮都要从头算一遍全部前缀,总成本就会呈平方级增长。这不是假设,这是绝大多数「感觉 Agent 很贵」的真实原因。

一、重复计算发生在哪

生成式模型是自回归的:生成第 t 个 token 时,需要前面所有 token 的信息。而在 Transformer 里,每一层的信息都浓缩成两个张量——K(键)和 V(值)。

关键点在于:这两个张量只依赖已经出现过的前缀,与后面要生成什么完全无关。

所以第 2 轮请求里,那 2,000 个 token 的历史算出来的 K/V,和第 1 轮里算出来的完全一样。既然一样,就没有理由重算。把每一层的 K/V 存下来,下一轮直接复用,这就是 KV Cache。

同一个前缀,两种命运 灰色 = 缓存命中复用 深色 = 必须重新计算 A · 前缀稳定 → 命中 第 1 轮:system + 历史 [2,000] 全部符合 第 1 轮无缓存 第 2 轮:完全相同的前缀 [2,000] 仅 +400 ✓ 只算新增 第 2 轮输入成本 ≈ 400 token 量级,而非 2,400 B · 前缀被改动 → 未命中 第 1 轮:system + 历史 [2,000] 第 1 轮无缓存 system 开头多了一句「今天是周三」 全量 ✗ 整段前缀作废 第 2 轮输入成本 ≈ 2,400 token 量级,缓存的 2,000 全部浪费 缓存是按 token 逐位比对前缀的:从第一个 token 开始,一旦有一位不同,其后全部失效。 所以改动永远要加在尾部(追加),不要动头部(系统提示、工具定义、历史顺序)。
图 1 A 与 B 的区别只在于「前缀有没有被改动」。同样把输入从 2,000 加到 2,400,A 只需处理新增的 400,B 需要处理全部 2,400。成本差异不是百分比,而是数量级。

二、前缀稳定性:被忽略的成本开关

KV Cache 是按前缀逐位比对命中的。从序列的第一个 token 开始,一旦某一位不同,其后全部失效。这个机制带来一个反直觉但极其重要的结论:

在中间插入内容,比在末尾追加内容昂贵得多。

常见的「插在中间」的操作,在工程里几乎随处可见:

常见写法为什么破坏前缀正确做法
System Prompt 里塞 当前时间:{now}每轮时间都变,第一个 token 就不同把动态信息放到最后一条 user 消息里,或只精确到天/小时并接受偶发失效
每轮重新序列化工具列表,顺序不稳定字典遍历顺序或 JSON 键序变化 → 字节不同工具定义做稳定排序,序列化后缓存字符串而非重复生成
把最新用户消息插到历史前面历史整体后移,全部前缀作废永远追加到尾部
每轮重新注入「记忆」到 system 里记忆内容变化 → 开头就不同记忆放在尾部或独立的一条消息;或作为工具按需检索
历史消息每次重新渲染(重新拼 Markdown)换行、空格、引号形式细微变化即失效保持历史消息**字节级不变**,原始字符串原样回传
把工具执行结果截断长度随轮次变化被截断位置之后的内容全变截断策略要稳定(固定头尾保留量)
最容易被忽略的一条

「重新序列化历史」是隐蔽的前缀杀手。很多 Harness 会在每轮把消息数组拼成一次性的提示字符串,中间经过模板渲染、去空行、合并连续换行等「美化」步骤。这些步骤哪怕只差一个空格,前缀就废了。

判断方法很简单:把你的请求体打印出来,对比第 1 轮与第 2 轮的字节前缀,看从第几个字符开始不同。如果第一个差异出现在前 1% 的位置,说明你的缓存命中率基本上是 0。

三、显存估算:缓存的代价

缓存不是免费的,它占显存。估算公式不长,记住它就能在面试里立刻给出数量级。

kv_cache_footprint.pypython
def kv_cache_bytes(
    batch: int,        # 并发请求数
    seq_len: int,      # 序列长度(含历史)
    n_layers: int,     # 层数
    n_kv_heads: int,   # K/V 头数(注意:不一定等于注意力头数)
    head_dim: int,     # 每个头的维度
    bytes_per_num: int = 2,   # fp16=2, fp8=1
) -> int:
    # 每层缓存 K 和 V 两份,所以乘 2
    return 2 * batch * seq_len * n_layers * n_kv_heads * head_dim * bytes_per_num
# 一个典型的 70B 级模型(GQA,kv_heads 远少于 query_heads)
total = kv_cache_bytes(
    batch=32, seq_len=32_000, n_layers=80, n_kv_heads=8, head_dim=128
)
print(f"{total / 1024**3:.1f} GiB")   # 大约 31 GiB —— 仅缓存,不含权重
# 关键观察:这个数是随 seq_len 线性长的
for s in (8_000, 32_000, 128_000):
    n = kv_cache_bytes(32, s, 80, 8, 128)
    print(f"seq_len={s:>7}: {n/1024**3:6.1f} GiB")

三点必须记住:

  1. KV Cache 显存随序列长度线性增长,与注意力的算力平方增长不是同一回事。算力是平方问题,显存是线性问题——两者要分开讲。
  2. GQA / MQA 的工程意义就在这里:把 K/V 的头数从 h 降到 h/8 甚至 1,缓存直接缩小同样倍数。这是长上下文能跑起来的关键手段之一。
  3. 缓存有淘汰策略。服务端不会让你无限占显存,通常按 LRU 或分页(PagedAttention)管理。这意味着你的前缀如果排在很久不用的位置,可能已经被淘汰——命中率还受并发与调度影响,不只看你自己。

四、动手:把「前缀稳定性」变成可检查的东西

下面的脚本可以直接接进你自己的 Harness 里做日常检查。

prefix_guard.pypython
import json, hashlib
from typing import Any
def serialize_stable(obj: Any) -> str:
    """稳定序列化:键排序、分隔符固定、禁用 ascii 转义以保证同一对象字节一致"""
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def prefix_fingerprint(messages: list[dict], tools: list[dict]) -> str:
    """对 tools + 前 N-1 条消息取指纹,验证相邻两轮是否共享同一前缀"""
    stable_part = {
        "tools": tools,                 # 工具定义必须在 messages 之前,且顺序稳定
        "history": messages[:-1],       # 最后一条是新增内容,不参与指纹
    }
    return hashlib.sha256(serialize_stable(stable_part).encode()).hexdigest()[:16]
def append_only_messages(system: str, history: list[dict], user_input: str) -> list[dict]:
    """唯一推荐的组装方式:头部固定,只在尾部追加"""
    fixed_head = [{"role": "system", "content": system}]
    return fixed_head + history + [{"role": "user", "content": user_input}]
# --- 使用示例:连续两轮,观察指纹是否一致 ---
tools = [{"name": "get_weather"}, {"name": "get_time"}]   # 注意:顺序必须写死
h1 = [{"role": "user", "content": "北京天气"}]
fp1 = prefix_fingerprint(h1, tools)
h2 = h1 + [{"role": "assistant", "content": "正在查询"},
           {"role": "tool", "content": "晴 24℃"}]
fp2 = prefix_fingerprint(h2, tools)   # 注意这里的「-1」语义:最后一轮的新输入不入指纹
print(fp1, fp2)   # 这里的差异是因为 history 长度变了,实际检查时应比较「相同长度部分」
实操任务
  • 写一个函数,输入是两轮的完整请求体,输出第一个不同字符的偏移量
  • 用它在你的 Harness 上跑一遍,看第 1→2 轮的偏移量是不是接近 0;
  • system prompt 里所有动态变量(时间、用户名、记忆片段)挪到尾部,再测一次,记录偏移量的变化。

验收标准:能说出你的 Harness 里至少五个前缀破坏点,并指出各自的修法。

五、自测

本章自测本章为 DeepSeek 类岗位高频考点
Q1为什么在 system prompt 里加一句「当前时间:14:03」会让成本显著上升?
C。多出的 token 量可以忽略,真正的问题是位置。前缀比对从第一个 token 开始,头部一处改动就让其后全部作废。通用的纪律是:动态内容一律放尾部,静态内容一律放头部。
Q2某模型 80 层、K/V 头数 8、head_dim 128、fp16 存储。单条 32k 上下文的 KV Cache 大约多大?
A。代入公式:2 × 1 × 32000 × 80 × 8 × 128 × 2 = 2.6×10⁹ 字节 ≈ 1.05 GiB。关键是别漏掉那个 2(K 和 V 各一份)。面试时能当场笔算出这个数,比说出「很大」有价值得多。
Q3GQA(分组查询注意力)主要解决什么问题?
D。GQA 不改变注意力的平方复杂度,也不改变权重体积,它压的是 K/V 的头数——也就是缓存大小。n_kv_heads 从 64 降到 8,缓存直接缩到 1/8。这解释了为什么长上下文模型几乎都用 GQA 或 MQA。

六、小结

结论依据你应该做的动作
历史不该被重算K/V 只依赖前缀,与后文无关确保前缀字节稳定,最大化命中
头部改动代价最高前缀逐位比对,一错全废动态内容(时间、记忆)一律放尾部
缓存占显存,随长度线性增长2 · B · L · n_layers · n_kv · d_head · bytes长上下文要为缓存留预算,不只是权重
命中率不完全由你决定服务端有淘汰与调度策略高频复用的前缀要短且稳定,别指望超长前缀一直驻留

前缀稳定性是那种「懂了就能立刻省一半钱、不懂也完全不知道自己在亏」的知识。面试里能主动提起它,说明你真的在为自己的系统付过账。本刊编辑部

下一章换一个角度:既然总参数可以很大而激活参数可以很小,成本结构还能再动一次手脚。

面试官会怎么问

共 4 条 · 其中 3 条高频 · 先自己答一遍,再展开对照

KV Cache 是什么?为什么多轮对话能省下来钱? 高频
自回归解码时,每生成一个 token 都要重新做一遍注意力计算,而每一层的 K(键)和 V(值)只依赖已经出现过的前缀。既然前缀没变,重算出来的 K/V 就是一样的——于是把它们缓存起来,下一步直接复用,只计算新 token 的那一份。
多轮对话之所以能省,是因为第二轮的 prompt 里,绝大部分是第一轮已经算过的内容。前缀完全一致时,这段直接命中缓存,不重复计算,在按 token 计价的 API 上直接体现为折扣。

追问链

  1. 那缓存命中率取决于什么?
  2. 显存占用怎么估?和 batch size 是什么关系?
缓存命中率取决于什么?—— 这道题几乎决定你懂不懂成本。 高频
取决于前缀稳定性。缓存的匹配方式是"从头开始逐 token 比对,直到第一个不同点",也就是说:前缀里任何一处变化,都会让从该点之后的所有缓存全部失效
常见破坏点包括:system prompt 里塞了当前时间戳或随机 ID、把工具列表按使用频率每次重排、把检索结果按时间排序每次都变、few-shot 示例顺序随机、会话 ID 注入到 prompt 开头。这些写法看起来无害,实际每一轮都在让缓存重置。

追问链

  1. 那你系统里具体哪些地方在破坏缓存?你怎么改的?
  2. 改成稳定前缀之后,命中率大概提升了多少?
加分点:能当场列出一份"破坏点清单"并给出改造后的数字,是这道题的最强答法。
Agent 场景下,token 成本的大头在哪? 高频
不在单次输入,而在多轮累积的历史。一个十轮的 Agent 任务,第 n 轮要把前面 n−1 轮的全部消息重新送进去,输入 token 的总量大致是平方级累积的——这和注意力复杂度是两个独立但叠加的成本问题。
所以成本杠杆的优先级通常是:① 前缀缓存友好(提升命中率,直接对应 API 折扣);② 工具结果裁剪(只回关键字段,不要把整页 JSON 塞进去);③ 历史摘要化(保留决策与结论,丢掉过程);④ 状态外置(大块数据落盘,按需回读);⑤ 模型分级路由(简单环节用便宜档位)。
加分点:把成本按"输入 / 输出 / 命中缓存"三段分别报价再谈优化,能立刻显出你算过账。
长上下文和 RAG 该怎么选?
判据是召回精度需求与成本结构,不是"哪个更先进"。
适合长上下文:全集较小且必须整体把握(例如一次代码评审要把整个文件读完)、需要跨段落的推理与全局一致性。
适合 RAG:库很大、每次只需要极少相关片段、需要精确引用来源。
真实系统几乎都是混合:用检索把候选收窄到可控范围,再把候选完整放进上下文,这样既控制了 token 又保住了精度。反过来做——把整个库塞进长上下文——成本和延迟都不可接受,而且要面对"中间内容被忽略"的注意力衰减问题。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes