一个 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。
二、前缀稳定性:被忽略的成本开关
KV Cache 是按前缀逐位比对命中的。从序列的第一个 token 开始,一旦某一位不同,其后全部失效。这个机制带来一个反直觉但极其重要的结论:
在中间插入内容,比在末尾追加内容昂贵得多。
常见的「插在中间」的操作,在工程里几乎随处可见:
| 常见写法 | 为什么破坏前缀 | 正确做法 |
|---|---|---|
System Prompt 里塞 当前时间:{now} | 每轮时间都变,第一个 token 就不同 | 把动态信息放到最后一条 user 消息里,或只精确到天/小时并接受偶发失效 |
| 每轮重新序列化工具列表,顺序不稳定 | 字典遍历顺序或 JSON 键序变化 → 字节不同 | 工具定义做稳定排序,序列化后缓存字符串而非重复生成 |
| 把最新用户消息插到历史前面 | 历史整体后移,全部前缀作废 | 永远追加到尾部 |
| 每轮重新注入「记忆」到 system 里 | 记忆内容变化 → 开头就不同 | 记忆放在尾部或独立的一条消息;或作为工具按需检索 |
| 历史消息每次重新渲染(重新拼 Markdown) | 换行、空格、引号形式细微变化即失效 | 保持历史消息**字节级不变**,原始字符串原样回传 |
| 把工具执行结果截断长度随轮次变化 | 被截断位置之后的内容全变 | 截断策略要稳定(固定头尾保留量) |
「重新序列化历史」是隐蔽的前缀杀手。很多 Harness 会在每轮把消息数组拼成一次性的提示字符串,中间经过模板渲染、去空行、合并连续换行等「美化」步骤。这些步骤哪怕只差一个空格,前缀就废了。
判断方法很简单:把你的请求体打印出来,对比第 1 轮与第 2 轮的字节前缀,看从第几个字符开始不同。如果第一个差异出现在前 1% 的位置,说明你的缓存命中率基本上是 0。
三、显存估算:缓存的代价
缓存不是免费的,它占显存。估算公式不长,记住它就能在面试里立刻给出数量级。
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")三点必须记住:
- KV Cache 显存随序列长度线性增长,与注意力的算力平方增长不是同一回事。算力是平方问题,显存是线性问题——两者要分开讲。
- GQA / MQA 的工程意义就在这里:把 K/V 的头数从 h 降到 h/8 甚至 1,缓存直接缩小同样倍数。这是长上下文能跑起来的关键手段之一。
- 缓存有淘汰策略。服务端不会让你无限占显存,通常按 LRU 或分页(PagedAttention)管理。这意味着你的前缀如果排在很久不用的位置,可能已经被淘汰——命中率还受并发与调度影响,不只看你自己。
四、动手:把「前缀稳定性」变成可检查的东西
下面的脚本可以直接接进你自己的 Harness 里做日常检查。
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 里至少五个前缀破坏点,并指出各自的修法。
五、自测
2 × 1 × 32000 × 80 × 8 × 128 × 2 = 2.6×10⁹ 字节 ≈ 1.05 GiB。关键是别漏掉那个 2(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 是什么?为什么多轮对话能省下来钱? 高频
多轮对话之所以能省,是因为第二轮的 prompt 里,绝大部分是第一轮已经算过的内容。前缀完全一致时,这段直接命中缓存,不重复计算,在按 token 计价的 API 上直接体现为折扣。
追问链
- 那缓存命中率取决于什么?
- 显存占用怎么估?和 batch size 是什么关系?
缓存命中率取决于什么?—— 这道题几乎决定你懂不懂成本。 高频
常见破坏点包括:system prompt 里塞了当前时间戳或随机 ID、把工具列表按使用频率每次重排、把检索结果按时间排序每次都变、few-shot 示例顺序随机、会话 ID 注入到 prompt 开头。这些写法看起来无害,实际每一轮都在让缓存重置。
追问链
- 那你系统里具体哪些地方在破坏缓存?你怎么改的?
- 改成稳定前缀之后,命中率大概提升了多少?
Agent 场景下,token 成本的大头在哪? 高频
所以成本杠杆的优先级通常是:① 前缀缓存友好(提升命中率,直接对应 API 折扣);② 工具结果裁剪(只回关键字段,不要把整页 JSON 塞进去);③ 历史摘要化(保留决策与结论,丢掉过程);④ 状态外置(大块数据落盘,按需回读);⑤ 模型分级路由(简单环节用便宜档位)。
长上下文和 RAG 该怎么选?
适合长上下文:全集较小且必须整体把握(例如一次代码评审要把整个文件读完)、需要跨段落的推理与全局一致性。
适合 RAG:库很大、每次只需要极少相关片段、需要精确引用来源。
真实系统几乎都是混合:用检索把候选收窄到可控范围,再把候选完整放进上下文,这样既控制了 token 又保住了精度。反过来做——把整个库塞进长上下文——成本和延迟都不可接受,而且要面对"中间内容被忽略"的注意力衰减问题。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。