Agent 缓存工程:从 KV Cache、Prompt Cache 到语义缓存 / Agent Caching Engineering
📅 创建时间:2026-07-29 🏷️ 标签:#AgentCaching #PromptCaching #KVCache #PrefixCache #SemanticCache #CostOptimization 📚 前置知识:[[14-context-engineering]] [[/04-ai/01-llm-engineering/09-decoder-only-llm]]
📋 本章目标
- 区分 CPU/数据库缓存、KV Cache、Prefix Cache、响应缓存和语义缓存
- 理解“缓存命中”究竟复用了哪一段工作,而不是只会背命中率
- 从 Prefill、Decode 和 Token 前缀解释 Prompt Cache 的底层原理
- 掌握缓存友好的 Agent 上下文布局、Append-Only 历史和稳定工具定义
- 能为 Tool、RAG、多 Agent 和 Coding Agent 设计分层缓存
- 掌握缓存键、TTL、版本、租户隔离、失效、污染和可观测性
- 能估算缓存对 TTFT、Token 成本和吞吐量的影响
第0部分:先回答最容易混淆的问题——“命中缓存”到底命中了什么?
“这个请求命中缓存了。”这句话本身几乎没有信息量。你必须继续问:哪一层缓存?缓存键是什么?复用了什么计算?返回值是否直接复用?
同一个 Agent 请求,可能依次经过六类完全不同的缓存:
用户请求
│
▼
┌──────────────────────────────────────────────────────────────┐
│ ① 响应缓存:相同请求是否可以直接返回完整旧答案? │
└──────────────────────────┬───────────────────────────────────┘
│ miss
▼
┌──────────────────────────────────────────────────────────────┐
│ ② 语义缓存:有没有“意思相近”的旧问题和可信答案? │
└──────────────────────────┬───────────────────────────────────┘
│ miss
▼
┌──────────────────────────────────────────────────────────────┐
│ ③ RAG / Tool 缓存:检索、Embedding、API、文件分析能否复用? │
└──────────────────────────┬───────────────────────────────────┘
│ 组装 messages
▼
┌──────────────────────────────────────────────────────────────┐
│ ④ Prompt / Prefix Cache:相同 Token 前缀能否跳过 Prefill? │
└──────────────────────────┬───────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ ⑤ KV Cache:本次生成时,历史 Token 的 K/V 是否已经保存? │
└──────────────────────────┬───────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────┐
│ ⑥ 基础设施缓存:模型权重、容器层、数据库页、CDN 等 │
└──────────────────────────────────────────────────────────────┘其中,第④层命中时,模型仍然会生成一个新答案;第①层命中时,应用通常根本不会调用模型。这是理解 Agent 缓存的第一条分界线。
0.1 一张表先建立全局认识
| 缓存类型 | 缓存的内容 | 常见缓存键 | 命中后还调用模型吗 | 主要收益 |
|---|---|---|---|---|
| KV Cache | 历史 Token 的 Key/Value 张量 | 当前序列与请求内部状态 | 会 | 加速逐 Token Decode |
| Prefix/Prompt Cache | 已计算前缀的中间状态 | 完全一致的 Token 前缀 | 会 | 降低 Prefill 延迟和输入成本 |
| 精确响应缓存 | 完整模型输出 | 模型、参数和规范化请求的哈希 | 不会 | 最大限度降低成本与延迟 |
| 语义缓存 | 问题、向量和旧答案 | Embedding 相似度 + 约束 | 通常不会 | 复用意思相近的问题 |
| Tool Cache | 工具调用结果 | 工具版本 + 规范化参数 | 视流程而定 | 避免重复 I/O 和外部 API |
| RAG Cache | Embedding、候选文档、重排结果 | 查询、索引版本、过滤条件 | 会 | 降低检索延迟与费用 |
0.2 Cache Hit、Cache Miss 和 Cache Invalidation
请求 key ──查询──> Cache
│
┌─────────┴─────────┐
│ │
找到了 没找到/已过期
Cache Hit Cache Miss
│ │
复用结果 执行真实计算或 I/O
│
└──写回 Cache
数据已变化,但旧值仍在 Cache
│
└──必须删除、过期或换版本号:Cache Invalidation缓存最难的通常不是“存进去”,而是判断旧值什么时候不再可信。
第1部分:KV Cache——一次生成内部的模型状态缓存
1.1 为什么自回归生成会重复计算?
Decoder-Only 模型一次只生成一个新 Token。假设 Prompt 是 A B C:
第1步:输入 A B C → 生成 D
第2步:输入 A B C D → 生成 E
第3步:输入 A B C D E → 生成 F如果每一步都重新计算所有历史 Token 的 Key 和 Value,A B C 会被反复计算。Causal Attention 决定了过去 Token 的表示不会被未来 Token 改写,因此历史 K/V 可以保存。
没有 KV Cache
Step 1: [A K/V][B K/V][C K/V] → D
Step 2: [A K/V][B K/V][C K/V][D K/V] → E
Step 3: [A K/V][B K/V][C K/V][D K/V][E K/V]→ F
└──────反复计算──────┘
有 KV Cache
Step 1: 计算 A B C 的 K/V,保存 → D
Step 2: 读取 A B C,只计算 D 的 K/V → E
Step 3: 读取 A B C D,只计算 E 的 K/V → F1.2 KV Cache 缓存的不是“答案”
KV Cache 保存的是每层 Attention 中历史 Token 的 Key/Value 张量,不是自然语言答案,也不是数据库里的问答记录。它主要优化当前序列的推理过程。
一个常用的近似公式是:
KV Cache bytes
≈ 2 × 层数 × Token 数 × KV Head 数 × Head Dimension × 每元素字节数
↑
K 和 V 两份以 32 层、8 个 KV Heads、Head Dimension 128、上下文 8192、FP16 为例:
2 × 32 × 8192 × 8 × 128 × 2 bytes
= 1,073,741,824 bytes
≈ 1 GiB / 每条序列所以 KV Cache 是典型的“用显存换计算”。上下文越长、并发序列越多,显存压力越大。
1.3 Prefill 与 Decode
完整 Prompt: [System][Tools][History][User]
│
▼
Prefill:并行处理整段 Prompt,建立初始 KV Cache
通常计算密集,直接影响 TTFT
│
▼
Decode:每次生成一个新 Token,并把新 K/V 追加到 Cache
通常更受显存带宽限制,影响 TPOT- TTFT(Time To First Token):从发出请求到收到第一个 Token。
- TPOT(Time Per Output Token):生成阶段相邻 Token 的平均时间。
- Prompt/Prefix Cache 主要减少重复 Prefill。
- KV Cache 主要避免每个 Decode step 重算历史 K/V。
更完整的模型内部推导见 [[/04-ai/01-llm-engineering/09-decoder-only-llm]]。本章后续重点转向 Agent 工程层。
第2部分:Prefix Cache / Prompt Cache——跨请求复用相同前缀
2.1 它与 KV Cache 是什么关系?
可以把 Prompt Cache 理解成:**把某次 Prefill 得到的前缀状态保留下来,让后续请求复用。**底层经常复用 KV 状态,但工程语义与一次请求内部的 KV Cache 不同。
KV Cache:
关注一次序列怎样逐 Token 生成
生命周期通常跟随当前请求或会话
Prefix / Prompt Cache:
关注多个请求之间是否拥有相同 Token 前缀
生命周期、计费和匹配规则由推理服务决定2.2 为什么强调“前缀”而不是“包含相同内容”?
假设两次请求分别是:
Request A Tokens:
[S1][S2][T1][T2][H1][U1]
Request B Tokens:
[S1][S2][T1][T2][H1][U1][R1][U2]
└────────────完全一致的公共前缀────────────┘B 可以复用 A 的公共前缀。如果只改变中间一个 Token:
Request C Tokens:
[S1][S2][T1][T9][H1][U1][R1][U2]
└──命中──┘ X
└────后续状态通常都要重算────┘Attention 的后续状态依赖前面的 Token,所以变化点之后的缓存通常全部失效。相同文字出现在后面,不等于能跳过中间计算。
2.3 文本看起来一样,为什么仍可能 Miss?
缓存比对的是服务端实际接收到并 Tokenize 后的输入,不是人眼看到的“意思”。下面这些变化都可能改变前缀:
- System Prompt 多一个空格、换行或时间戳。
- JSON 属性顺序变化。
- Tools Schema 顺序变化。
- 工具描述动态插入环境信息。
- 模型或 Tokenizer 版本变化。
- 图片、音频或多模态附件变化。
- Provider 自动注入的模板变化。
- Compaction 用摘要替换了旧历史。
人眼:两个 Tool Schema 看起来等价
A = {"name":"read","type":"object"}
B = {"type":"object","name":"read"}
字节序列不同 → Token 序列可能不同 → Prefix Cache 可能失配2.4 Coding Agent 为什么特别受益?
Coding Agent 每轮请求都可能携带很长且稳定的前缀:
┌────────────────────────────────────────────────────────────┐
│ 稳定区:适合缓存 │
├────────────────────────────────────────────────────────────┤
│ Provider / System 指令 │
│ Tool Schemas │
│ 安全规则 │
│ 项目级 AGENTS.md / CLAUDE.md │
│ 未改变的历史消息 │
├────────────────────────────────────────────────────────────┤
│ 动态区:放在后部 │
├────────────────────────────────────────────────────────────┤
│ 当前时间、工作区状态、最新 Tool Result、用户新消息 │
└────────────────────────────────────────────────────────────┘只要 Agent Loop 采用 Append-Only:
Round 1: [Stable Prefix][User 1]
Round 2: [Stable Prefix][User 1][Assistant 1][Tool 1]
Round 3: [Stable Prefix][User 1][Assistant 1][Tool 1][Assistant 2]每轮都能复用上一轮的大部分前缀。反之,如果每轮重新排序工具、改写历史或把动态时间放进 System Prompt,缓存命中会从变化位置断掉。
2.5 不同模型服务的缓存接口为什么看起来不一样?
各家 API 都可能复用相同前缀,但产品接口通常分成三类:
| 形态 | 开发者看到什么 | 典型特点 |
|---|---|---|
| 自动前缀缓存 | 不标记缓存点,响应中报告 cached tokens | 接入简单;最小长度、TTL 和路由规则由服务控制 |
| 显式 Prompt Caching | 在内容块上声明 cache breakpoint / cache control | 可控制复用边界;需要理解写入、读取和过期计费 |
| 推理引擎 Prefix Caching | 在 vLLM 等服务端配置中启用 | 能控制块表、容量和驱逐;运维复杂度更高 |
在实践中,OpenAI 风格接口常把缓存读取量放在 usage 的输入 Token 明细里;Anthropic 风格接口会区分 cache creation 与 cache read;DeepSeek 等服务也会分别报告命中和未命中的输入 Token。字段名称、最低缓存长度、TTL、价格和隔离边界会调整,接入时必须以当前 Provider 文档为准,不能把某一家的规则当成行业标准。
{
"usage": {
"input_tokens": 52000,
"cache_read_tokens": 41000,
"cache_creation_tokens": 3000,
"output_tokens": 1200
}
}这段示意数据表达的是:41,000 个输入 Token 复用了已有前缀,3,000 个 Token 新建了可复用状态;它不表示完整答案被缓存。真实 SDK 的字段结构应通过适配层归一化:
@dataclass
class NormalizedUsage:
input_tokens: int
cache_read_tokens: int
cache_write_tokens: int
output_tokens: int
def normalize_usage(provider: str, raw: dict) -> NormalizedUsage:
"""把不同 Provider 的 usage 字段转换成内部统一口径。"""
...这样上层监控计算的是同一种 Token Cache Ratio,不会把某家的“缓存创建 Token”误当成“缓存命中 Token”。
第3部分:一次 Agent Loop 中,缓存到底在哪里命中?
考虑一个代码 Agent:用户要求“找到登录超时的原因并修复”。
Round 1
┌──────────────┬─────────────┬───────────┐
│ System+Tools │ 项目规则 │ 用户任务 │
└──────────────┴─────────────┴───────────┘
└────── Prefill 全量计算 ──────┘
模型决定:调用 grep
Round 2
┌──────────────┬─────────────┬───────────┬────────────┐
│ System+Tools │ 项目规则 │ 用户任务 │ grep 结果 │
└──────────────┴─────────────┴───────────┴────────────┘
└──────── cache hit ─────────┘ └─新增 Prefill─┘
模型决定:调用 read
Round 3
┌──────────────┬─────────────┬───────────┬────────────┬───────────┐
│ System+Tools │ 项目规则 │ 用户任务 │ grep 结果 │ read 结果 │
└──────────────┴─────────────┴───────────┴────────────┴───────────┘
└──────────────── cache hit ────────────────┘ └─新增部分─┘注意:grep/read 结果本身第一次仍要处理;命中的是它们前面的公共 Token 前缀。
3.1 Compaction 与缓存的冲突
Context Engineering 会在历史太长时压缩上下文:
压缩前:
[System][Tools][M1][M2][M3][T1][M4][T2][M5]
└────────────大量旧前缀可复用────────────┘
压缩后:
[System][Tools][Summary][M5]
↑ 新 Token 序列Compaction 能减少以后每轮的输入长度,却会在发生压缩的那一轮破坏旧前缀。正确决策不是“永远不压缩”,而是比较:
保留长历史的未来成本
vs
一次缓存失效 + 更短上下文的未来收益长任务中,主动在稳定边界做一次高质量 Compaction,通常比每轮微调摘要更缓存友好。
3.2 多 Agent 的缓存形态
主 Agent Stable Prefix
│
├── 子 Agent A:[共享规则][A 的任务][A 的历史]
├── 子 Agent B:[共享规则][B 的任务][B 的历史]
└── 子 Agent C:[共享规则][C 的任务][C 的历史]
↑
从分叉点开始不同共享前缀可能被复用,但各分支的动态历史不能互相命中。为了追求命中率而把所有子 Agent 历史塞回主线程,往往会增加 Token、污染上下文,得不偿失。
第4部分:缓存命中率怎么计算——不要被一个百分比骗了
4.1 请求命中率与 Token 命中率不是一回事
Request Hit Rate = 命中缓存的请求数 / 总请求数
Token Cache Ratio = 缓存读取 Token 数 / 总输入 Token 数假设 10 个请求全部“命中”,但每次只命中 100 Token、总输入 10,000 Token:
请求命中率 = 10 / 10 = 100%
Token 命中率 = 1,000 / 100,000 = 1%“100% 命中”并不代表节省了 100% 的输入计算。
4.2 一个完整成本例子
假设一次 Agent 任务调用模型 20 轮,每轮输入 50,000 Token,其中平均 40,000 Token 命中 Prefix Cache,10,000 Token 需要新计算:
总输入 Token = 20 × 50,000 = 1,000,000
缓存读取 Token = 20 × 40,000 = 800,000
未缓存输入 Token = 20 × 10,000 = 200,000
Token Cache Ratio = 800,000 / 1,000,000 = 80%若普通输入单价为 P,缓存读取单价为 0.1P,忽略缓存写入差异:
无缓存成本 = 1,000,000 × P
有缓存成本 = 200,000 × P + 800,000 × 0.1P
= 280,000 × P
节省比例 = 72%80% Token 命中率并不等于节省80%,实际结果取决于 Provider 的缓存读取、写入和普通输入价格。
4.3 应该同时观察哪些指标?
| 指标 | 回答的问题 |
|---|---|
cache_read_tokens | 多少输入 Token 从缓存读取? |
cache_creation_tokens | 本次新写入了多少缓存? |
uncached_input_tokens | 多少 Token 仍执行了普通 Prefill? |
| Token Cache Ratio | 大部分输入是否真正被复用? |
| TTFT p50/p95 | 缓存是否改善了首 Token 延迟? |
| Cost per successful task | 缓存是否降低了完成一个任务的总成本? |
| Eviction / Expiration Rate | 缓存是否因容量或 TTL 频繁失效? |
| Correctness after hit | 命中是否返回了过期或越权结果? |
最终目标应是“每个成功任务的成本和延迟下降”,而不是孤立追求命中率。
第5部分:精确响应缓存——直接复用整个答案
5.1 最简单的实现
import hashlib
import json
def canonical_json(value: dict) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def response_cache_key(request: dict) -> str:
material = {
"model": request["model"],
"messages": request["messages"],
"tools": request.get("tools", []),
"temperature": request.get("temperature", 1),
"prompt_version": "support-agent-v7",
}
return hashlib.sha256(canonical_json(material).encode("utf-8")).hexdigest()
def complete_with_cache(request, cache, llm):
key = response_cache_key(request)
cached = cache.get(key)
if cached is not None:
return cached
response = llm.complete(request)
cache.set(key, response, ttl_seconds=300)
return response关键不是 SHA-256,而是缓存键必须包含所有会影响答案的输入。
5.2 常见错误:漏掉隐式依赖
下面这个键看似合理,其实危险:
key = hash(user_question)“我的订单什么时候到?”的答案还依赖:
- 当前用户身份与租户。
- 订单数据库版本或更新时间。
- System Prompt 版本。
- 模型及推理参数。
- 工具权限和地域。
- 当前时间。
如果缓存键不包含用户或租户,用户 A 甚至可能命中用户 B 的订单答案,这已经不是性能 Bug,而是数据泄漏。
5.3 适用与不适用
适合精确缓存:
- 确定性的文档摘要。
- 相同代码版本的静态分析。
- 固定知识库版本上的 FAQ。
- 温度为 0、输入完整、允许短时间复用的任务。
不适合直接复用:
- 当前天气、库存、价格和余额。
- 具有副作用的工具调用。
- 强个性化或权限敏感回答。
- 希望每次采样不同结果的创作任务。
第6部分:Semantic Cache——问题不同,但意思可能相同
6.1 基本数据流
新问题:“怎么重置登录密码?”
│
▼ Embedding
查询向量 q
│
▼ Vector Search
旧问题:“忘记密码后如何修改?” similarity = 0.94
│
├── 通过阈值、租户、时效和权限校验 → 返回旧答案
└── 未通过 → 正常调用 LLM,并写入新缓存伪代码:
def semantic_cached_answer(question, tenant_id, knowledge_version):
vector = embed(question)
candidates = semantic_cache.search(
vector=vector,
filters={
"tenant_id": tenant_id,
"knowledge_version": knowledge_version,
},
top_k=3,
)
best = candidates[0] if candidates else None
if best and best.similarity >= 0.93 and not best.is_expired:
return best.answer
answer = run_agent(question)
semantic_cache.insert(question, vector, answer)
return answer6.2 相似不等于答案可以复用
“怎样关闭账户?”
“怎样关闭我公司的管理员账户?”两句话 Embedding 可能很接近,但权限和操作后果完全不同。语义缓存必须叠加结构化约束:租户、语言、产品版本、角色、地区、知识版本和时间窗口。
6.3 阈值的本质是风险权衡
阈值太高:大量相似问题 miss,节省有限
阈值太低:错误复用旧答案,正确率下降
低风险 FAQ 可以较积极缓存
医疗/法律/财务 应提高阈值,甚至禁用直接回答缓存
有副作用的操作 不应仅凭语义相似复用执行结果生产系统应在离线评估集上绘制“命中覆盖率—错误复用率”曲线,而不是拍脑袋选择 0.9。
第7部分:Tool Cache 与 RAG Cache
7.1 Tool Cache 缓存的是观察结果,不是工具调用意图
Agent → tool: read_file(path="src/auth.ts", revision="abc123")
│
▼
cache key = tool_version + normalized_args + data_version
│
┌──────────┴──────────┐
│ hit │ miss
▼ ▼
返回旧内容 真正读文件 → 写缓存对于 Coding Agent,文件路径不够成为缓存键;文件内容可能已经改变。可以加入 Git blob SHA、mtime+size 或内容哈希。
7.2 有副作用的工具不能像查询一样缓存
安全:
get_weather(city="北京")
search_docs(query="退款")
read_file(path, revision)
危险:
send_email(to, body)
charge_card(order_id)
delete_database(name)第二类工具需要的是 Idempotency Key(幂等键),不是“命中后再返回一次看起来相同的结果”。幂等键用于保证重试不会重复执行副作用:
request id = task-42/send-email/step-7
第一次:执行发送,记录完成
网络超时后重试:发现该 id 已完成,返回原执行结果,不再次发送7.3 RAG 至少有四个可缓存位置
Query
│
├─① Query Embedding Cache
▼
Vector Search
│
├─② Candidate Retrieval Cache
▼
Reranker
│
├─③ Rerank Result Cache
▼
Context Assembly
│
└─④ Final Answer Cache(可选、风险最高)缓存键必须绑定索引版本:
key = hash({
"query": normalized_query,
"embedding_model": "text-embedding-v4",
"index_version": "kb-2026-07-29-03",
"filters": {"tenant": tenant_id, "language": "zh-CN"},
"top_k": 20,
})如果知识库更新但 index_version 没变,旧检索结果会继续命中,用户就会看到“明明文档改了,Agent 仍回答旧内容”。
第8部分:缓存失效——两件最难的事之一
8.1 四种常见策略
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| TTL | 到时间自动过期 | 简单、通用 | 过期前可能旧,过期后可能浪费 |
| Versioned Key | 键中加入 Prompt/数据/模型版本 | 精确、易回滚 | 需要管理版本传播 |
| Event Invalidation | 数据变化时主动删除相关键 | 新鲜度高 | 事件链复杂,容易漏删 |
| Content Addressing | 用内容哈希作为键 | 内容不变即可永久复用 | 不适合依赖隐式外部状态的结果 |
生产系统通常组合使用:
cache key:
tenant / agent-version / model / tool-version / data-version / input-hash
再附加:
TTL + 容量淘汰 + 数据变更事件8.2 Stampede:热点键同时失效
10,000 个请求 ──同时查询──> 热点缓存键(刚过期)
│
└──10,000 次全部 miss──> 10,000 次模型/API 调用常见解决方法:
- Singleflight:同一 key 只允许一个请求回源,其他等待。
- TTL Jitter:给过期时间加入随机抖动,避免同时失效。
- Stale-While-Revalidate:短时间返回旧值,后台刷新。
- 提前刷新:热点键接近过期时异步更新。
8.3 Negative Cache
如果检索或工具反复查询不存在的数据,也可以短暂缓存“未找到”:
get_order("ORD-NOT-EXIST") → 404
缓存 30 秒的 NOT_FOUND但 Negative Cache 的 TTL 应更短,否则数据刚创建后仍会被旧的“不存在”遮住。
第9部分:缓存安全——命中错误比未命中更危险
9.1 租户隔离必须进入缓存键
错误:cache:{question_hash}
正确:cache:{tenant_id}:{user_scope}:{policy_version}:{question_hash}缓存层不能绕开源系统权限。即使两个用户提出相同问题,也不代表他们有权看到相同结果。
9.2 不要缓存敏感原文
Prompt Cache、日志和应用缓存中可能出现:
- API Key、Cookie、OAuth Token。
- 客户代码与商业机密。
- PII、医疗和财务信息。
- Tool Result 中的数据库记录。
设计前必须确认 Provider 的保留策略、加密方式、区域、组织隔离和 Zero Data Retention 条件。应用缓存则应使用最小化数据、加密、访问控制和审计。
9.3 Cache Poisoning
攻击者构造恶意问题
↓
系统生成了带错误指令的答案
↓
语义缓存把它标记为可复用
↓
正常用户提出相似问题并命中恶意答案只有经过验证、来源可信、权限范围明确的输出才应进入共享语义缓存。用户私有内容默认只能进入用户或租户隔离的缓存空间。
第10部分:缓存友好的 Agent 上下文设计
10.1 推荐布局:稳定内容在前,动态内容在后
┌──────────────────────────────────────────────────────────────┐
│ Layer 1:Provider / System 基础指令 极稳定 │
├──────────────────────────────────────────────────────────────┤
│ Layer 2:Tools Schema 与安全策略 稳定 │
├──────────────────────────────────────────────────────────────┤
│ Layer 3:项目规则、Skill、固定参考材料 较稳定 │
├──────────────────────────────────────────────────────────────┤
│ Layer 4:Append-Only 对话与工具历史 持续追加 │
├──────────────────────────────────────────────────────────────┤
│ Layer 5:当前时间、工作区状态、最新请求 高动态 │
└──────────────────────────────────────────────────────────────┘这并不是说所有稳定内容都应该永久塞进 Prompt。无关内容仍应按需加载;缓存能降低重复计算成本,但不能消除上下文污染、注意力稀释和 KV 显存占用。
10.2 十条工程规则
- 固定 System Prompt 模板,不在开头插入每轮时间戳。
- 对 Tool Schema 做确定性排序和规范化序列化。
- 使用明确的 Prompt Version,而不是原地悄悄修改。
- 对话历史优先 Append-Only,不随意重写中间消息。
- 动态状态放在靠后位置,并标明数据版本。
- Compaction 在清晰检查点批量进行,不要每轮微调摘要。
- Tool/RAG 键包含工具、模型、索引和权限版本。
- 对副作用使用幂等键,不把普通缓存当事务系统。
- 同时监控命中率、TTFT、正确率和任务总成本。
- 缓存失败必须能安全回源;缓存不是单点真相来源。
10.3 一个常见反模式
system_prompt = f"""
你是代码助手。
当前时间:{datetime.now()}
当前分支:{git_branch()}
当前未提交文件:{git_status()}
下面是固定安全规则……
"""时间位于最前面,每轮都会变化,后续所有固定规则和工具定义都可能无法复用前缀。更合理的结构是:
[固定 System Prompt]
[固定 Tools]
[固定安全规则]
[历史消息]
[Current Environment Snapshot: timestamp, branch, status]
[本轮用户请求]第11部分:如何排查“缓存命中率突然下降”
11.1 排障树
缓存命中率下降
│
├─ 输入前缀变了吗?
│ ├─ System Prompt 发布新版本
│ ├─ Tools 顺序或 JSON 序列化不稳定
│ ├─ 动态时间/随机 ID 被放到前部
│ └─ Compaction 改写了历史
│
├─ 服务边界变了吗?
│ ├─ 模型或 Tokenizer 切换
│ ├─ Provider / Region / Project 切换
│ ├─ Cache TTL 到期
│ └─ 容量压力导致驱逐
│
├─ 指标口径变了吗?
│ ├─ 按请求统计改成按 Token 统计
│ ├─ 流量结构变化,新会话占比升高
│ └─ 短请求增多,低于最小缓存门槛
│
└─ Agent 行为变了吗?
├─ Tool Result 变长
├─ 更多分支和子 Agent
└─ 重试导致大量不同前缀11.2 给每次请求记录“前缀指纹”
不要把完整 Prompt 打进日志。可以对结构化片段分别计算哈希:
{
"model": "model-x",
"system_hash": "sha256:...",
"tools_hash": "sha256:...",
"project_rules_hash": "sha256:...",
"prompt_version": "agent-v12",
"input_tokens": 52140,
"cache_read_tokens": 43820,
"cache_creation_tokens": 8320,
"ttft_ms": 740
}当命中率下降时,可以快速发现是 tools_hash 每轮都变,还是 Prompt 发布导致所有旧缓存自然冷启动。
11.3 冷启动不是故障
新版本刚部署、模型刚切换、缓存刚扩容时,命中率短暂下降很正常。应该观察从冷到热的恢复曲线,而不是看到一分钟的低命中率就回滚。
第12部分:面试高频问答
Q1:KV Cache 和 Prompt Cache 有什么区别?
标准回答:KV Cache 是 Transformer 自回归推理中保存历史 Token 的 K/V 张量,避免每个 Decode step 重算历史;Prompt/Prefix Cache 是跨请求复用相同 Token 前缀的 Prefill 状态。前者解释“一次生成为什么能高效继续”,后者解释“多次相似请求为什么能跳过重复前缀计算”。
Q2:为什么 Prefix Cache 要求前缀一致?
标准回答:Causal Attention 中每个位置的状态依赖它之前的 Token。一旦中间 Token 变化,后续位置的 Attention 输入也随之变化,因此变化点之后的中间状态通常不能继续复用。
Q3:为什么 Append-Only 有利于 Coding Agent?
标准回答:每轮只在旧 messages 后追加 assistant、tool 和 user 消息,旧 Token 前缀保持不变,服务可以复用上一轮的大部分 Prefill 状态。若重写中间历史,变化点之后的缓存会失效。
Q4:缓存命中是否代表模型不会被调用?
标准回答:不一定。响应缓存或语义答案缓存命中时可能不调用模型;Prompt Cache 命中时模型仍需处理新增输入并完成 Decode,只是复用了公共前缀计算。
Q5:如何设计 LLM 响应缓存键?
标准回答:包含所有影响输出和权限的因素,例如模型、消息、工具、推理参数、Prompt 版本、数据版本、租户和权限范围;使用规范化序列化后再哈希。不能只用用户问题作为键。
Q6:TTL 越长越好吗?
标准回答:不是。TTL 越长通常命中越高,但陈旧数据和安全风险也越大。TTL 应由数据变化速度、错误代价、回源成本和主动失效能力决定。
Q7:语义缓存最大的风险是什么?
标准回答:语义相似不代表答案可复用,可能跨用户、跨权限、跨产品版本返回错误或敏感答案。因此必须加入租户、权限、版本、时效过滤,并用评估集校准相似度阈值。
Q8:缓存与幂等有什么区别?
标准回答:缓存用于避免重复计算或读取;幂等保证重复执行请求不会产生额外副作用。查询工具可使用缓存,支付、发邮件等副作用工具需要幂等键和持久化执行记录。
Q9:怎样提高 Prompt Cache 命中率?
标准回答:稳定 System Prompt 和 Tools Schema,把静态内容放前、动态内容放后,确定性排序 JSON 和工具,采用 Append-Only 历史,在明确检查点做 Compaction,并避免无意义的模型、区域和配置切换。
Q10:命中率很高,为什么成本仍然很高?
标准回答:可能只统计了请求命中而不是 Token 命中;输出 Token、Tool/API、缓存写入仍然收费;上下文过长仍占 KV 显存;Agent 轮次和重试过多。应该看每个成功任务总成本,而非单一百分比。
第13部分:自测题
测试1:识别缓存层
模型在同一次生成中,生成第 500 个 Token 时不再重新计算前 499 个 Token 的 K/V。这是哪一种缓存?
- A. Semantic Cache
- B. Prompt Cache
- C. KV Cache
- D. Tool Cache
测试2:前缀变化
System Prompt、Tools 和前 20 轮消息都没有变化,只在末尾追加一条 Tool Result。哪些内容最有机会命中 Prefix Cache?
测试3:安全
客服 Agent 使用 hash(question) 作为语义缓存键。为什么即使相似度判断完全准确,它仍可能泄漏数据?
测试4:成本计算
某任务输入 500,000 Token,其中 350,000 是缓存读取。普通输入价格为 P,缓存读取价格为 0.2P。忽略写入和输出,成本相对无缓存降低多少?
测试5:工程选择
下面哪项最不利于 Prompt Cache?
- A. Tool Schema 按工具名稳定排序
- B. 每轮在 System Prompt 开头写入当前毫秒时间戳
- C. 只在历史末尾追加 Tool Result
- D. Prompt 发布时显式修改版本号
测试6:Tool Cache
为什么 read_file(path) 的缓存键通常还需要文件版本或内容哈希?
测试7:Compaction
Compaction 明明缩短了上下文,为什么执行 Compaction 的那一轮仍可能变慢?
第14部分:自测答案
测试1答案
**C,KV Cache。**它保存历史 Token 在各 Attention 层的 K/V,避免 Decode 时重复计算。
测试2答案
从 System Prompt 开始,到旧历史的最后一个 Token 为止,都是与上一轮完全一致的公共前缀,有机会命中。新追加的 Tool Result 需要执行新的 Prefill。
测试3答案
缓存键没有租户、用户身份和权限范围。两个用户可以提出语义完全相同的问题,但有权看到的数据不同。用户 B 可能命中用户 A 基于私有订单生成的答案。
测试4答案
无缓存成本 = 500,000P
有缓存成本 = 150,000P + 350,000 × 0.2P
= 220,000P
降低比例 = (500,000 - 220,000) / 500,000
= 56%测试5答案
**B。**最前面的时间戳每轮都变,使公共 Token 前缀几乎从开头就断裂。动态信息应放到稳定前缀之后。
测试6答案
相同路径的文件内容可能已经变化。若只用路径作键,Agent 会读取旧内容并基于旧代码推理。Git blob SHA、revision 或内容哈希可以把缓存绑定到具体内容版本。
测试7答案
Compaction 用新的 Summary Token 替换旧历史,破坏了之前的公共前缀,因此服务需要为新序列重新 Prefill。之后各轮因为上下文更短、且新摘要可形成稳定前缀,才逐渐获得收益。
核心总结
- 先问哪一层缓存。 KV Cache、Prompt Cache、响应缓存、语义缓存和 Tool Cache 复用的是完全不同的工作。
- **Prefix Cache 复用计算,不等于复用答案。**模型仍需处理新增 Token 并生成新输出。
- **缓存命中依赖 Token 前缀稳定。**稳定内容在前、动态内容在后、Append-Only 历史和确定性 Tool Schema 是 Coding Agent 的关键设计。
- **缓存键必须表达全部依赖。**模型、Prompt、工具、数据、租户、权限和版本遗漏任何一项,都可能产生陈旧结果或数据泄漏。
- **不要迷信命中率。**同时观察 Token 命中、TTFT、正确率和每个成功任务的总成本。
- **失效与安全比写入更难。**TTL、版本化、事件失效、租户隔离和防缓存污染必须从设计阶段进入系统。
相关笔记
- [[14-context-engineering]] - 上下文由哪些信息组成,以及如何压缩与编排
- [[15-harness-loop-skills]] - 如何在 Context 外层建立验证、执行和持续循环
- [[04-memory-management]] - Agent 的短期、长期与外部记忆
- [[07-rag-advanced]] - 混合检索、重排与 RAG 数据流
- [[27-coding-agents-comparison]] - Reasonix 的 Cache-First 与 Append-Only 设计
- [[/04-ai/01-llm-engineering/09-decoder-only-llm]] - KV Cache 的模型内部原理
下一步学习
- [ ] 阅读 15 - Harness、Loop 与 Skills
- [ ] 为一个 Agent 请求记录
system_hash、tools_hash、缓存 Token 和 TTFT - [ ] 对比 Append-Only 与每轮重写 System Prompt 时的 Token 缓存命中率
- [ ] 为一个只读 Tool 设计包含版本、租户和规范化参数的缓存键
学习状态:🟡 开始学习