一个 Agent 的单次任务往往要发十几轮请求,每一轮都带着前面所有轮次的完整历史。如果服务端每次都从头算一遍,你付的钱里有一大半是重复劳动。KV Cache 把这份重复劳动变成缓存命中,而命中率取决于一个平时根本不会被注意的细节:前缀稳定性。
编辑部注 · 本章是全站最重要的成本一章。读完后请务必把「前缀稳定性破坏点清单」写进你自己的笔记。
先看一个真实的账单形态。同一个 Agent 任务,第一轮请求 2,000 token 的输入,第二轮变成 2,400,第三轮 2,800……输入长度线性增长,而如果每一轮都要从头算一遍全部前缀,总成本就会呈平方级增长。这不是假设,这是绝大多数「感觉 Agent 很贵」的真实原因。
一、重复计算发生在哪
生成式模型是自回归(Autoregressive)的:生成第 t 个 token 时,需要前面所有 token 的信息。而在 Transformer 里,每一层的信息都浓缩成两个张量——K(键)和 V(值)。
关键点在于:这两个张量只依赖已经出现过的前缀,与后面要生成什么完全无关。
所以第 2 轮请求里,那 2,000 个 token 的历史算出来的 K/V,和第 1 轮里算出来的完全一样。既然一样,就没有理由重算。把每一层的 K/V 存下来,下一轮直接复用,这就是 KV Cache。
二、前缀稳定性:被忽略的成本开关
KV Cache 是按前缀逐位比对(Prefix Stability)命中的。从序列的第一个 token 开始,一旦某一位不同,其后全部失效。这个机制带来一个反直觉但极其重要的结论:
在中间插入内容,比在末尾追加内容昂贵得多。
常见的「插在中间」的操作,在工程里几乎随处可见:
| 常见写法 | 为什么破坏前缀 | 正确做法 |
|---|---|---|
System Prompt 里塞 当前时间:{now} | 每轮时间都变,第一个 token 就不同 | 把动态信息放到最后一条 user 消息里,或只精确到天/小时并接受偶发失效 |
| 每轮重新序列化工具列表,顺序不稳定 | 字典遍历顺序或 JSON 键序变化 → 字节不同 | 工具定义做稳定排序,序列化后缓存字符串而非重复生成 |
| 把最新用户消息插到历史前面 | 历史整体后移,全部前缀作废 | 永远追加到尾部 |
| 每轮重新注入「记忆」到 system 里 | 记忆内容变化 → 开头就不同 | 记忆放在尾部或独立的一条消息;或作为工具按需检索 |
| 历史消息每次重新渲染(重新拼 Markdown) | 换行、空格、引号形式细微变化即失效 | 保持历史消息字节级不变,原始字符串原样回传 |
| 把工具执行结果截断长度随轮次变化 | 被截断位置之后的内容全变 | 截断策略要稳定(固定头尾保留量) |
「重新序列化历史」是隐蔽的前缀杀手。很多 Harness 会在每轮把消息数组拼成一次性的提示字符串,中间经过模板渲染、去空行、合并连续换行等「美化」步骤。这些步骤哪怕只差一个空格,前缀就废了。
判断方法很简单:把你的请求体打印出来,对比第 1 轮与第 2 轮的字节前缀,看从第几个字符开始不同。如果第一个差异出现在前 1% 的位置,说明你的缓存命中率基本上是 0。
三、显存估算:缓存的代价
缓存不是免费的,它要占相当可观的KV Cache 显存(KV Cache Footprint)。估算公式不长,记住它就能在面试里立刻给出数量级。
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") # 大约 312 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(Grouped-Query Attention) / MQA(Multi-Query Attention) 的工程意义就在这里:把 K/V 头数(n_kv_heads) 从 h 降到 h/8 甚至 1,每个头的 维度(head_dim) 不变,缓存直接缩小同样倍数。这是长上下文能跑起来的关键手段之一。
- 缓存有淘汰策略(Cache Eviction)。服务端不会让你无限占显存,通常按 LRU 或分页(PagedAttention)管理。这意味着你的前缀如果排在很久不用的位置,可能已经被淘汰——命中率还受并发与调度影响,不只看你自己。这也就是缓存命中率(Cache Hit Rate)不完全由你决定的原因。
3.1 缓存也有一本账:命中 / 未命中怎么计价
显存讲的是「技术代价」,真正决定上不上缓存的往往是钱。多数按 token 计费的服务里,输入 token 因为能复用前缀缓存,价格会分档:
- 缓存命中(cache hit):这段前缀此前已经算过、还在缓存里,这次只做增量计算,单价最低;
- 缓存未命中(cache miss):这段前缀要么是新的、要么已被淘汰,要完整重算,单价最贵(往往是命中的数倍)。
所以同样的输入 token,因为「前缀有没有命中」会落到完全不同的价位。这带来两个反直觉但必须算清的结论:
- 「输入便宜、输出贵」不一定成立。 传统说法是输出 token 更贵,但一个长前缀如果每次都未命中,输入端的累计成本可能反过来成为大头。
- 压缩上下文可能在「省钱」上适得其反。 为了省 token 而压缩/改写历史,一旦改动发生在前缀部分,缓存就从命中变未命中——token 数省了,单价却翻了数倍,总价可能不降反升。
def estimate_cost(input_tokens: int, output_tokens: int,
hit_ratio: float, # 输入里缓存命中的比例 0~1
input_hit_price: float, # 每 token 命中价($)
input_miss_price: float, # 每 token 未命中价($)
output_price: float) -> float:
hit = input_tokens * hit_ratio
miss = input_tokens * (1 - hit_ratio)
return (hit * input_hit_price
+ miss * input_miss_price
+ output_tokens * output_price)
# 例:同样 80k 输入 + 2k 输出,命中率 0% vs 90% 的差别
LOW = estimate_cost(80_000, 2_000, 0.0, 0.50, 2.50, 10.0)
HIGH = estimate_cost(80_000, 2_000, 0.9, 0.50, 2.50, 10.0)
print(f"命中 0%:${LOW/1e6:.2f}/M-token 折算") # 未命中为主,输入占大头
print(f"命中 90%:${HIGH/1e6:.2f}/M-token 折算") # 命中为主,输入成本骤降面试里能把「缓存命中率是一条成本线、不只是延迟线」讲清,比背显存公式更能体现你真正在生产里管过这块。判断标准一句话:改任何「省 token」的优化(压缩、去重、重排历史),都要先问一句——它会不会把前缀从「命中」打成「未命中」? 会的话,省的 token 可能远抵不上单价的上涨。
四、动手:把「前缀稳定性」变成可检查的东西
下面这套做法用一个前缀指纹(Prefix Fingerprint),把「相邻两轮是否共享同一前缀」从「肉眼比对」落成「可自动跑的检查」——上一节讲的那些前缀破坏点,用它能被断言出来,而不是靠人记得。脚本可以直接接进你自己的 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 里至少五个前缀破坏点,并指出各自的修法。
五、常见误区与追问
5.1 误区:命中率只由自己请求的写法决定
错在哪:以为只要自己两轮请求前缀一致,KV Cache 就一定能命中。为什么自然:前缀确实按字节比对,一致就该命中。正确做法:服务端还有 LRU 或分页(PagedAttention)淘汰策略,并发高、显存紧时你的长前缀可能已被换出。给个数字:70B 级模型在 32k 上下文下单条缓存约 10 GiB,32 路并发就是 300 GiB 量级。判据:高频复用的前缀要短而稳定,别指望超长前缀一直驻留;命中率不完全由你,要按最坏情况设计。
5.2 误区:GQA / MQA 缩小的是「模型」或「算力」
错在哪:把 KV Cache 显存、权重体积、注意力算力混为一谈。为什么自然:它们都叫「省资源」,直觉上是一回事。正确做法:GQA/MQA 只缩小 KV Cache 里的 K/V 头数,从 64 降到 8 甚至 1;它不改变注意力 O(n²) 复杂度,也不改变权重体积。判据:被问「GQA 解决了公式里哪个因子」时,答案必须是 n_kv_heads,而不是 n_layers、head_dim 或注意力复杂度。长上下文能跑起来,靠的是这一项的缩小。
5.3 误区:动态信息放 system prompt 开头无所谓
错在哪:以为模型能「理解」就不影响成本。为什么自然:从语言理解角度,加一句时间确实无伤大雅。正确做法:KV Cache 按前缀逐位比对,第一个 token 不同,其后全部失效,命中率直接掉到 0。判据:把第 1 轮与第 2 轮请求体打印出来,比对字节前缀,看第一个不同字符的偏移量;如果偏移量落在前 1% 内,说明缓存基本作废。纪律:时间、用户名、记忆等动态内容一律放尾部。
5.4 误区:KV Cache 显存和注意力算力都是「随长度增长」,一起优化即可
错在哪:把两种增长当成同一种瓶颈。为什么自然:都和序列长度正相关,容易合并思考。正确做法:前者线性(∝n),后者平方(∝n²),是两套独立瓶颈、两套独立手段。判据:算 seq_len 翻倍时两者各自变化倍率——显存约 ×2,算力约 ×4;优化时一个靠减 n_kv_heads / 量化 / 分页,另一个靠 FlashAttention / 分块 / 稀疏。把「长上下文优化」说成单一手段,是面试里最容易被追问的点。
5.5 误区:缓存越大越好,把整个历史都留着
错在哪:认为多留历史只会更准,没有代价。为什么自然:上下文越长,模型可利用的信息越多,看似有益。正确做法:KV Cache 显存 = 2·B·L·n_layers·n_kv_heads·head_dim·bytes,是硬预算;超了就 OOM 或被淘汰。判据:单条 32k、fp16 约 10 GiB,64 并发就 600 GiB 量级,还不算权重。结论:历史要外置(放文件 / 工具),上下文只留指针;「全留着」既不经济也可能因淘汰而失效。
5.6 误区:前缀缓存(Harness 侧)和 KV Cache 是一回事
错在哪:把两个层次的复用混为一谈。为什么自然:名字都带「缓存」,效果都是省重算。正确做法:前缀缓存是 Harness 在请求级复用相同前缀的计算结果(如 system + 工具定义),KV Cache 是推理引擎在层内缓存 K/V 张量;前者靠字节稳定命中、由应用侧保证,后者由引擎管理、还受淘汰影响。判据:前者你能完全控制(稳定序列化),后者只能间接影响(短前缀 + 高命中)。两者协同,但归属不同层。
六、自测
2 × 1 × 32000 × 80 × 8 × 128 × 2 = 1.05×10¹⁰ 字节 ≈ 9.8 GiB。两个最容易漏的因子:最前面的 2(K 和 V 各一份,漏了就得到 5 GiB),以及 n_layers(每一层都要各存一份)。面试时能当场笔算出这个数,比说出「很大」有价值得多。n_kv_heads 从 64 降到 8,缓存直接缩到 1/8。这解释了为什么长上下文模型几乎都用 GQA 或 MQA。七、小结
| 结论 | 依据 | 你应该做的动作 |
|---|---|---|
| 历史不该被重算 | K/V 只依赖前缀,与后文无关 | 确保前缀字节稳定,最大化命中 |
| 头部改动代价最高 | 前缀逐位比对,一错全废 | 动态内容(时间、记忆)一律放尾部 |
| 缓存占显存,随长度线性增长 | 2 · B · L · n_layers · n_kv · d_head · bytes | 长上下文要为缓存留预算,不只是权重 |
| 命中率不完全由你决定 | 服务端有淘汰与调度策略 | 高频复用的前缀要短且稳定,别指望超长前缀一直驻留 |
前缀稳定性是那种「懂了就能立刻省一半钱、不懂也完全不知道自己在亏」的知识。面试里能主动提起它,说明你真的在为自己的系统付过账。本刊编辑部
下一章换一个角度:既然总参数可以很大而激活参数可以很小,成本结构还能再动一次手脚。
八、参考与延伸
本章的机制部分只讲到「够用」为止。想往下深挖,下面这几份材料按「先看图、再看代码、最后读论文」的顺序排好了。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看图(建立直觉)
- vLLM:用 PagedAttention 把 LLM 服务做到又快又省 —— 这篇博客把 KV Cache 碎片问题讲得最直白,还配了显存占用的对比图。读本章第三节「淘汰策略」卡住时,来这儿看 PagedAttention 怎么消灭碎片最快。
- Lilian Weng · The Transformer Family v2.0 —— 把各种注意力变体(稀疏、线性、GQA/MQA)按「改了什么」整理成谱系。想理解「长上下文优化都有哪几条路」,读这一篇的目录结构就够了。
再看代码(动手实现)
- 动手学深度学习(中文,zh.d2l.ai) —— 配套可跑代码,把注意力、缓存、序列长度的关系用 NumPy 实现了一遍。把本章的显存估算公式照着敲一遍,比看十遍都记得牢。
最后读论文(对齐一手定义)
- GQA:Training Generalized Multi-Query Transformer Models(arXiv:2305.13245) —— GQA/MQA 的一手来源。重点看它怎么把 K/V 头数降下来,以及为什么 MQA 训练更难、GQA 是它的折中。
- Efficient Memory Management for Large Language Model Serving with PagedAttention(arXiv:2309.06180) —— PagedAttention 原论文,也是 vLLM 的基础。想搞清楚「服务端淘汰」到底按什么粒度发生,看它的 KV 块管理那节。
◇ 面试官会怎么问
共 7 条 · 其中 5 条高频 · 先自己答一遍,再展开对照
KV Cache 是什么?为什么多轮对话能省下来钱? 高频
多轮对话之所以能省,是因为第二轮的 prompt 里,绝大部分是第一轮已经算过的内容。前缀完全一致时,这段直接命中缓存,不重复计算,在按 token 计价的 API 上直接体现为折扣。
追问链
- 那缓存命中率取决于什么?
- 显存占用怎么估?和 batch size 是什么关系?
缓存命中率取决于什么?—— 这道题几乎决定你懂不懂成本。 高频
常见破坏点包括:system prompt 里塞了当前时间戳或随机 ID、把工具列表按使用频率每次重排、把检索结果按时间排序每次都变、few-shot 示例顺序随机、会话 ID 注入到 prompt 开头。这些写法看起来无害,实际每一轮都在让缓存重置。
追问链
- 那你系统里具体哪些地方在破坏缓存?你怎么改的?
- 改成稳定前缀之后,命中率大概提升了多少?
Agent 场景下,token 成本的大头在哪? 高频
所以成本杠杆的优先级通常是:① 前缀缓存友好(提升命中率,直接对应 API 折扣);② 工具结果裁剪(只回关键字段,不要把整页 JSON 塞进去);③ 历史摘要化(保留决策与结论,丢掉过程);④ 状态外置(大块数据落盘,按需回读);⑤ 模型分级路由(简单环节用便宜档位)。
长上下文和 RAG 该怎么选?
适合长上下文:全集较小且必须整体把握(例如一次代码评审要把整个文件读完)、需要跨段落的推理与全局一致性。
适合 RAG:库很大、每次只需要极少相关片段、需要精确引用来源。
真实系统几乎都是混合:用检索把候选收窄到可控范围,再把候选完整放进上下文,这样既控制了 token 又保住了精度。反过来做——把整个库塞进长上下文——成本和延迟都不可接受,而且要面对"中间内容被忽略"的注意力衰减问题。
把同一个请求的序列长度从 8k 提到 32k,KV Cache 显存和注意力算力各变成原来的几倍?这两个倍率分别对应什么工程手段? 高频
依据是两条完全不同的增长律:KV Cache 显存
∝ n(公式 2 · batch · seq · n_layers · n_kv_heads · head_dim · bytes),而注意力算力 ∝ n²(中间那张 [n, n] 分数矩阵)。对应的手段也完全不同:显存这条线靠 GQA / MQA 降 n_kv_heads、量化降 bytes、PagedAttention 降碎片;算力这条线靠 FlashAttention / 分块计算 把单次前向算便宜。
- 判据一:显存是「装得下多少并发」的容量问题,算力是「要算多久」的时长问题。
- 判据二:把「长上下文优化」说成一个笼统手段,几乎一定会在追问里露馅。
追问链
- 这两个倍率里,哪一个会先成为你在线上遇到的瓶颈?
- 如果显存够、算力也够,但并发就是上不去,你会先查什么?
你的 Agent 每轮都往 system prompt 里写「当前时间:…」,前缀缓存命中率几乎为 0。除了把时间挪到末尾,还有哪些更稳的改法?各自代价是什么? 高频
① 做成工具——把「取当前时间」变成
get_current_time,模型需要时才调,system prompt 一个字节都不动;代价是多一次工具往返。② 降精度并接受偶发失效——只写到「天」甚至「周」,命中率大幅回升;代价是少数跨天边界的请求会拿到略过期的时间。
③ 挪到尾部——放进最后一条 user 消息,动态内容不再破坏前缀。
判据:任何时候都问一句「这段字节跨轮会变吗」——会变的,一律不许出现在前缀里。
追问链
- 你项目里还有哪些「跨轮会变」的字段被写在了头部?
- 你怎么量化这次改动带来的收益?
应用侧的前缀缓存和推理引擎侧的 KV Cache 是一回事吗?这两者里,你能完全控制哪一个?
前缀缓存(应用侧)指你的请求前缀保持字节稳定,从而命中服务端已有的计算结果——你能完全控制,靠稳定序列化保证(键排序、分隔符固定、不做「美化」式重排)。
KV Cache(引擎侧)是推理框架在层内缓存的 K/V 张量,由引擎按 LRU 或分页管理,还受并发与显存压力影响——你只能间接影响,做法是把高频复用的前缀做短、做稳。
判据:能被你的 JSON 序列化规则左右的,是前者;会被别人抢占显存而失效的,是后者。两者协同,但归属不同层。
追问链
- 如果服务端把你的长前缀淘汰了,应用侧还能做什么?
- 「稳定序列化」具体要固定哪几件事?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。