从99%缓存命中率看Agent如何守住请求前缀

作者:起个名字好难2026.11.06 21:26浏览量:0

简介:大模型Prompt Cache按Token前缀匹配,命中与未命中的输入成本差距可达一个数量级。本文拆解Agent运行时在请求组装链上的关键环节——系统提示词、工具排序、会话历史与上下文压缩,给出稳定前缀、追加式写入、确定性序列化等工程实践,并把命中率用作检验运行时稳定性的标尺,帮助团队同时守住成本、延迟与稳定性。

一、两个数字,一个被低估的问题

主流大模型服务的输入计费,普遍区分“缓存命中”与“未命中”两种口径:命中部分的Token按大幅折扣的单价结算,未命中部分按全价结算,两者差距可达一个数量级。与此同时,在一组面向Agent运行时的公开测试中,Prompt Cache命中率被推到了99%。

两个数字放在一起,第一反应是算账:一次Agent会话动辄几十轮请求,每轮都携带大体量的系统提示词、工具定义与会话历史。假设稳定前缀有2万Token、会话跑50轮,若每轮都未命中,累计按全价计费的输入至少是百万Token量级;若命中率达99%,这部分绝大多数转为折扣价结算,整体成本可以压缩到原来的几分之一。命中率每提高一个百分点,成本曲线就整体下移一段。

但账单只是表象。工程师对缓存并不陌生:进程内有本地缓存,服务之间有分布式键值缓存,静态内容前面还有CDN。它们处理的是同一类问题——已经算过、取过、传过的东西,能不能少做一遍。到了大模型这里,缓存没有消失,只是对象变了。

二、Prompt Cache:对象变了,规则也变了

模型处理一段输入时,会为每个Token生成中间计算状态(KV状态)。如果下一次请求从第一个Token起,有一段与历史请求完全一致的前缀,服务端就可以直接复用这段前缀对应的KV状态,只对新增Token做增量计算。这就是Prompt Cache的基本原理。

它和业务缓存有本质区别:

  • 业务缓存有显式key。 开发者设计key、按key读写,命中与否在代码里一目了然。
  • Prompt Cache没有显式key。 命中与否取决于“从请求开头算起,连续有多少Token与历史请求一致”,是纯粹的前缀匹配。

为什么必须是前缀?因为KV状态是按位置累积的:位置i的状态依赖位置0到i-1的全部Token。这意味着两段“内容相似但顺序或措辞不同”的文本,无法共享任何中间状态。一个工具换了位置、一段当前时间插在系统提示词前面、一条旧消息被重新序列化——哪怕语义完全一样——都会让断点之后的全部Token失去复用资格。人眼看着差不多的两次请求,在缓存看来可能已经不是同一段前缀。

还要注意,部分平台以固定长度的块为最小缓存单位,并对可缓存的前缀长度设有门槛。也就是说,前缀不仅要稳定,还要“足够长且按块对齐”才能吃满折扣。

agent-">三、一次Agent循环的两种命运

考虑Agent里最常见的循环:读取项目规则与工具说明 → 调用一次搜索 → 把结果交回模型 → 模型继续推理。

理想情况下,下一轮请求的消息序列是:

  1. [system] 项目规则 + 角色设定 ← 不变
  2. [tools] 工具schema列表 ← 不变
  3. [user] 任务描述 ← 不变
  4. [assistant] 思考与工具调用 ← 不变
  5. [tool] 搜索结果 ← 新增

前面四段原样保留,只在末尾追加一次工具调用与结果。从第一个Token到倒数第二段,整条前缀与上一轮完全一致,服务端直接复用已算好的KV状态,只对新增的少量Token做计算。99%的命中率,正是把这种“追加式增长”贯穿整场会话的结果。

现实中,运行时稍不留神就会把前缀切断:

  1. [system] 项目规则 + 角色设定 + 当前时间戳 ← 每轮都变,第一个Token就断了
  2. [tools] 工具schema(按注册顺序,可能重排) ← 顺序抖动
  3. [history] 旧消息被模板升级后重新渲染 ← 序列化结果变化
  4. [new] 工具调用与结果

三种典型破坏者:

  1. 动态信息前置。 把当前时间、随机ID、实时状态插进系统提示词,等于每轮请求的第一个Token就在变化,其后所有内容都无法命中。
  2. 工具排序不确定。 工具列表若按注册顺序、哈希遍历顺序或字典插入顺序生成,跨进程、跨重启后就会抖动。
  3. 旧消息改写。 升级提示词模板、重新截断历史、调整序列化格式,都会让“看起来没变”的历史在新请求里产生不同的Token序列。

四、运行时的真正职责:一条确定性的请求组装链

把视野从单次请求拉高到Agent运行时层,会发现命中率不是由某一行代码决定的,而是由整条请求组装链的确定性决定的。这条链上至少有六个环节:

1. 系统提示词:会话期内冻结。 系统提示词位于前缀最前端,一字之差即整条前缀失效。它应当像版本化的静态资源一样对待,动态信息一律不许混入。

2. 工具定义:序列化确定、排序稳定。

  1. # 反例:依赖注册顺序,重启后可能变化
  2. tools = list(tool_registry.values())
  3. # 正例:按稳定键排序后再序列化
  4. tools = sorted(tool_registry.values(), key=lambda t: t.name)

3. 会话历史:append-only。 已确认的消息不再修改、不再重排、不再重新格式化。需要修正历史时,宁可开辟新的会话分支,也不要原地改写。

4. 上下文压缩:把断点变成计划内事件。 压缩本质上是一次前缀重置——所有KV状态作废,压缩后的第一轮请求几乎必然未命中。因此压缩应当低频、可预期:按Token阈值触发、在任务边界触发,而不是每轮都做增量截断。

5. 运行时状态:统一后置。 当前时间、任务进度、重试计数这类每轮变化的信息,应集中放在消息序列末尾:

  1. messages = [
  2. {"role": "system", "content": SYSTEM_PROMPT}, # 会话期冻结
  3. *history, # append-only
  4. {"role": "user",
  5. "content": f"[环境] 当前时间 {now()},请继续。"} # 动态信息放最后
  6. ]

6. Provider边界:收敛组装逻辑。 不同服务对缓存粒度与计费口径的细节可能不同,“构造稳定前缀”的逻辑应收敛在统一的组装层,而不是散落在各业务调用点。这样适配多模型或切换服务时,前缀策略才不会失控。

事实上,包括百度智能云在内的主流大模型服务平台,普遍提供上下文缓存能力,并在计费上区分命中与未命中。接口是现成的,真正拉开差距的,是调用方能否持续送出稳定的前缀。

五、把命中率当作运行时的体温计

组装链上任何一环引入不确定性,都会立刻反映在命中率上。这正是命中率最有价值的地方:它不只是账单上的一项数字,还能反向检验运行时是否稳定。

实践中可以建立三个习惯:

  • 按会话维度统计命中率,而不是只看全局均值。单个会话的命中率曲线,能直接暴露前缀在哪一轮断裂。
  • 对命中率骤降设置告警。 一次发布后命中率整体下滑,往往意味着提示词模板变了、工具列表变了,或序列化逻辑引入了抖动——这是比功能故障更隐蔽的回归。
  • 在请求日志中记录前缀指纹。 对前缀做哈希并落日志,排查“这两轮为什么没命中”时,对比指纹比逐字段比对消息快得多。

此外,命中还有一份隐性收益:复用KV状态能明显降低首Token延迟(TTFT),对交互式Agent的体感提升非常直接。成本与延迟在同一条前缀上汇合,守住了前缀,就同时守住了两者。

六、结语:三层缺一不可

回头看,守住缓存命中率是三层工程的叠加:

  • 提示词工程解决“前缀怎么设计”:稳定内容在前,动态内容在后;
  • 应用架构解决“上下文怎么组织”:工具结果与新消息向后追加,历史不可变;
  • 运行时层解决“组装是否确定”:无论Agent跑多少步、经历多少次重试与压缩,没变的部分都被原样保留。

前缀设计得再漂亮,如果运行时每轮都重排工具、改写旧消息,缓存一样守不住;反过来,运行时再确定,若前缀里塞满每轮变化的时间戳,也无济于事。99%的命中率,归根结底不是运气,而是一条确定性组装链的自然结果。对认真做Agent工程的团队来说,守住请求前缀,就是守住成本、延迟与稳定性三者的交汇点。