AI Agent 上下文缓存系列第一篇。从"账单莫名翻倍"到"命中率双峰之谜",一个生产级长上下文 Agent 的 prompt cache 踩坑、诊断与修复全记录。
做长上下文 Agent 的团队,账单曲线大概率都经历过同一个阶段:功能越做越顺,成本越烧越凶。我们的文档 Agent 单次请求的输入平均是几万 token,长会话冲刺期能到几十万。查了模型服务商的控制台才发现一个更扎心的事实——平台明明提供了打两折的 prompt 缓存,我们的命中率却常年卡在一半左右。
剩下那一半,全按原价付。
这篇文章记录我们把命中率从"一半"推到"九成"的完整过程。它不是一次改动的结果,是三个阶段的接力:先懂原理、再做体系化重排、最后靠一次限时诊断挖出真凶。真凶小到你不会相信——但它每天烧掉的钱是真的。
先懂规则:前缀字节级匹配
主流模型服务的隐式缓存(OpenAI、DashScope、DeepSeek 都类似)规则其实一句话就能说完:
从 prompt 第一个字节开始做最长前缀匹配,匹配到哪,缓存命中到哪。
命中部分按标准价的 10%~20% 计费,未命中部分全价。官方文档的建议也直白:"请将重复内容置于提示词开头,差异内容置于末尾。"
这条规则有一个残酷的推论:前缀中段哪怕变了一个字节,其后的所有内容——不管多稳定——全部按全价重算。 缓存不看"内容像不像",只看"字节一不一样"。
我们的第一个事故就死在这条推论上。早期版本的 prompt 开头拼了一行:
当前服务器时间:2026-04-09 14:32:07
精确到秒。意味着任何两次请求的前缀从第一行起就不同,后面跟着的几万字文档正文,每一轮都全价重发。一行"看起来很贴心"的时间戳,把整个缓存机制干废了。
这类"前缀污染源"远不止时间戳。我们后来整理过一份清单:
- 时间戳、随机数、UUID 出现在 prompt 前部
- 列表类内容(工具清单、文件清单)排序不稳定——同样十个元素,这次这个顺序、下次那个顺序
- 带 TTL 的概要块跨过刷新边界后内容变化
- 检索结果(RAG chunks)按相似度排序,天然每轮都不同
前两类是低级错误,修掉就好。第三、四类才是结构性难题——它们必须存在,又必须每轮变。
体系化:把 prompt 想象成沉积岩
对付"必须变的内容",思路只有一个:按变化频率分层,稳定的沉底,易变的浮顶。
我们把整个 prompt 重构成四层:

分层本身不难,难的是三个配套动作:
① 把"每轮都变"的内容彻底赶出前缀。 最典型的是 RAG 检索结果。旧版实现把每轮检索到的 top 20 个 chunk 直接拼进 system prompt 中段——每轮 query 不同、chunk 就不同,等于每轮都在缓存链条的腰上砍一刀。修复方式不是"给 chunk 排个稳定顺序"(治标),而是把检索整个搬进一个独立子流程:主流程只发起一次"研究"调用,子流程在自己独立的上下文里检索、阅读、消化,最后只把一份几百字的摘要交回主历史。检索原文从此不再出现在主 prompt 里。
顺带的收益比预期大:主流程单轮 token 直接掉了一个量级级别的零头,因为那些"读完就不需要逐字保留"的原文不再跟着历史被一轮轮重放。
② 排序冻结。 所有进入前缀的列表——工具、子 agent、引用文件——一律按确定性键排序(名字、ID)。这是一行 sorted() 的事,但没人盯着就一定会烂。
③ 上下文压缩只压 L3,不动 L1+L2。 长会话触发摘要压缩时,框架默认行为是把整个历史打包重写——这等于把缓存底座也炸了。我们改成在 L2 末尾埋一个边界标记,压缩时找到标记、只压标记之后的对话历史,前面的稳定段原封不动。摘要之后,缓存不清零。
还有一件事必须先做:埋点。 每次 LLM 调用记录 cached_tokens / prompt_tokens,落库。没有这个数字,后面所有优化都是盲人摸象——这一点很快就会得到验证。
反转:命中率的双峰之谜
重排上线后,命中率涨了,但埋点数据画出来的分布让人睡不着觉:

双峰。 不是"平均命中六成"那种温和的不理想,而是要么全中、要么全灭。这个形状本身就是线索:一定有某个变量在"抽刀切换"——它不变时前缀完美匹配,它一变整条前缀报废。
我们给这次诊断设了两条纪律,现在回看都是对的:
限时 4 小时。 缓存失效的服务端机制外部不可见,这种问题可以无限挖下去。设一个止损点:到点没结论就退回保守优化,不恋战。
不猜,做对照。 列出全部 8 个嫌疑变量(活动文件切换、检索顺序、概要 TTL、记忆块、技能指令、@引用注入……),然后从日志里找同一会话内相邻的一对请求——一次全中、一次全灭,逐字段 diff。相邻 hit/miss 对是信噪比最高的样本:两次请求间只隔了一轮对话,变了的东西屈指可数。
Diff 的结果指向一个谁都没排进前三的位置。
真凶:卡在第 15,461 字符处的四个占位符
我们的 system prompt 是一个大模板,静态规则文本占绝对大头,中间嵌了四个占位符:
{user_memory} ← 用户记忆
{user_skills} ← 技能指令
{project_context} ← 项目信息
{doc_context} ← 引用文档
这四个占位符的位置,在模板的第 15,461 个字符处。它们的内容属于"慢变"——不是每轮都变,但跨轮之间经常变:用户切了个活动文件,doc_context 变了;记忆整理任务跑了一次,user_memory 变了。
于是缓存的命运变成了掷硬币:这四段内容恰好没变 → 前缀匹配穿透全场,全 hit;任何一段变了 → 匹配在 15K 字符处戛然而止,后面的工具 schema、全部对话历史统统全价——而 system prompt 加工具定义总共约 28K token,每次 miss 等于把三分之二的稳定内容白白重付一遍。双峰分布完美解释。
修复方案几乎是行为艺术级的简单:把这四段内容从 system prompt 里整体搬出去,拼成一条带标记的消息,插到 messages 列表的第一条。
# system prompt 模板:删掉全部易变占位符,100% 静态
system_prompt = MAIN_PROMPT.format(
# 只剩内容跨请求恒定的静态占位符
)
# 四段易变内容拼成一条打了标记的 HumanMessage,放 messages 最前
session_ctx = "[SESSION_CTX]\
\
" + "\
\
".join(
s for s in (memory, skills, project, docs) if s
)
messages = [HumanMessage(content=session_ctx), *history]变的东西还是在变——但它现在变在缓存前缀的后面。system prompt + 工具 schema 这约 28K token 的大头,从此跨轮、跨会话都字节级恒定。
效果:缓存命中点从 ~10K token 推进到 ~28K token,跨轮首次调用的命中率从一成多提到接近一半,实付 token 成本降了约三成。改动本身:删四行占位符,加一个几行的拼接函数。
复盘:三条可以直接抄走的经验
① 缓存友好性是字节级的洁癖,不是架构级的美感。 分层设计画得再漂亮,一个时间戳、一次不稳定排序、一个卡在中段的占位符,就能让下游几万 token 的缓存全部失效。上线前值得做一次笨功夫验证:同样输入连跑十次,diff 每次实际发出的 prompt,逐字节一致才算过关。
② 没有埋点,就没有"双峰"这个线索。 平均值会骗人——"平均命中 51%"听起来像是普遍性的小毛病,分布图才暴露它是二元开关。每次 LLM 调用记录 cached_tokens,成本极低,是所有缓存优化的地基。
③ system prompt 里不要放任何会变的东西。 这是我们用真金白银换来的最硬的一条。模板就该是纯静态文本;会变的内容——哪怕是"慢变"——一律走 messages 通道,让它们排在稳定前缀之后。"慢变"放在前缀里,就是一枚跨轮定时炸弹。
最后是一个心态问题。prompt cache 优化的手感很不"算法":没有巧妙的数据结构,全是排序冻结、占位符搬家、边界标记这种朴素操作。但它的杠杆是实打实的——同样的产品、同样的模型、同样的流量,账单少三成。
在 LLM 应用里,最贵的往往不是你多发了什么,而是你让本可以打一折的内容,按了原价。
AI Agent 上下文缓存系列: ① 缓存命中率只有一半?你的 Agent 可能正在为一个时间戳买单(本篇)
如果这篇对你有用,欢迎关注 / 在看,后面会继续拆更多 Agent 上下文工程的实战细节。