本文是「Agent 开发实践与思考」系列第 7 篇。系列目录:
- 2024
- 07-02 我手写了一个 Agent:从一问一答到”思考-行动-观察”循环
- 08-06 工具调用踩坑记:schema、描述与错误返回怎么写
- 09-10 系统提示词写到几千字之后:让 Agent 迁移 Vue 业务与资产到 React
- 10-15 给 Agent 做记忆:分层、状态与遗忘
- 11-01 迁移知识库的 embedding 召回评测方法
- 11-19 迁移经验检索的工程细节:混合检索、重排与拒答
- 12-24 上下文窗口的成本课:KV Cache、Prompt Caching 与消息顺序(本篇)
自回归生成的账
在 Transformer 的注意力(Attention)机制中,每一个 Token 会被投射成三个向量:
- Q (Query/查询): 当前 Token 想找什么信息?
- K (Key/键): 这个 Token 本身具备什么特征?
- V (Value/值): 这个 Token 包含的实际内容是什么?
模型生成是逐个 token 进行的。每生成一个新 token,都要把它和前面所有 token 放在一起算注意力。如果每生成一个 token 就把整个序列从头重算一遍,历史越长,每一步越贵,这是朴素做法的浪费所在。
KV cache 解决的就是这个重复计算。在预测下一个 token 时,模型用当前序列最后一个 token 的 query 去和前面所有 token 的 key、value 做注意力。关键在于,前面那些 token 的 key 和 value 只取决于它们自身和更前面的内容,算过一次就不会再变。所以推理引擎把每一层算过的 key/value 矩阵存下来,后续每一步只算新 token 的那一行,再拿它去查缓存里的历史 K/V。重算整段历史变成了查表加一行新计算。
有了这层机制,一次请求的成本拆成了两段。第一段叫 prefill:把输入的所有 token 过一遍,逐层算出它们的 K/V 填进缓存,这段的计算量随输入长度增长,是长输入的主要开销。第二段叫 decode:逐个生成输出,每步只处理一个新 token,开销远小于 prefill。这正好解释了我的观察:长历史的请求慢,慢在开头那一下,后面的生成反而快。
顺带一提,KV cache 存在显存里,历史越长占得越多,这也是长上下文请求在服务端更贵的原因之一。缓存不是白来的,是用显存换计算。
这套成本结构在单轮问答里其实不显眼,一问一答,历史短,prefill 占比不高。是 Agent 的多轮循环把它放大了:轮数多、历史只增不减;未命中跨请求缓存时,每轮都要重新处理前面所有轮次的输入。对聊天产品这通常是优化项,对 Agent 则是架构设计的一部分。
前缀相同则命中
顺着这个思路再想一步。在标准因果 Transformer 的前缀计算中,前 K 个 token 的 K/V 只由这 K 个 token 及其之前的内容决定,跟后面来了什么没关系。那么两次请求之间,如果开头的几千个 token 保持等价,服务端理论上可以直接复用上次算好的那部分缓存,跳过部分重算。
Prompt caching 做的是跨请求复用一段已经计算过的前缀,但它不是 KV cache 的同义词。KV cache 是一次推理过程中复用历史 token 的中间结果;Prompt caching 则是服务商是否跨请求保留并复用这些中间结果的产品能力。即使前缀命中,实际计算、调度和计费仍由服务商决定,不能仅凭 KV cache 原理推出。
以下描述只对应我在 2024 年接触到的具体产品和 API 版本,不代表厂商当前的统一行为:OpenAI 当时提供自动的前缀缓存能力,前缀达到产品要求后由服务端判断是否命中;Claude 则需要在消息上显式打 cache 标记,标记之前的内容进入缓存。缓存标记、最小长度、TTL、命中计费和价格都可能变化,应以对应版本的官方文档为准。
在支持前缀缓存的实现里,命中通常要求从起点到缓存位置的序列保持一致,具体比较粒度由服务商决定,不一定是应用层看到的字节比较。中间内容变化后,变化点之后的缓存通常不能继续复用,因为后面的 K/V 是在旧前缀上算出来的。实际还要考虑服务商的缓存分段、过期时间、最小长度和模型版本规则,不能只根据本地字符串相同就判断一定命中。
在当时使用的 Claude API 版本里,cache 标记可以打多个。我的做法是打两个:一个在工具定义末尾,罩住系统提示词加工具定义这段最稳定的前缀;一个打在对话历史靠后的位置,罩住已经发生的轮次。这样即使本轮追加了新内容,历史部分仍然有机会命中。字段名称、标记位置和可缓存内容要以实际 SDK 与 API 版本为准。命中情况不用猜,API 返回的 usage 字段里有 cache write 和 cache read 的 token 数,跑几轮看数字才能确认前缀是否稳定。
缓存命中率 0 的一次踩坑
明白原理之后我回去看迁移 Agent 的代码,问题立刻现形。
排查过程其实绕了一圈。开缓存的第一天我以为配置没生效,把 SDK 版本、请求参数都查了一遍,参数是对的。后来把每次请求的 usage 打出来对比,发现 cache write 每轮都在写,cache read 恒为 0。写而不读,说明每次请求的前缀和上次的都对不上,缓存刚写完就被自己作废了。顺着这个线索往前看消息列表,才定位到那行时间戳。
我当时在系统提示词里拼了当前时间,写法大概是这样:
const pad = (n: number): string => String(n).padStart(2, "0");
const now = new Date();
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
const systemPrompt = `你是 Vue 转 React 的迁移 Agent。
当前时间:${timestamp}
当前迁移单元:LegacyTable
...(后面是几千字的迁移规则和资产契约)`;加这行的初衷是让模型知道今天是哪天,好处理”上周改的文件”这类相对时间。但每一轮调用,这行的时间戳都不一样。系统提示词在消息列表的最前面,它变了,等于前缀从开头就变了。开了 prompt caching 跑了一天,看用量统计,cache read 是 0。几千字的系统提示词没有进入缓存读取路径,仍按普通输入路径处理。
修法是把时间戳从系统提示词里拿掉,改在每轮 user 消息里附带。改动只有几行,改完命中率立刻上来了。这个坑可以推广成一条规则:易变的内容必须往后放。系统提示词和工具定义放在最前面,保持稳定,几个版本都不动一个字节;当前时间、会话状态、用户这一轮的输入,这些每轮都变的东西放在消息列表最后面。真要动态信息,宁可让 Agent 用工具去查,也不能污染前缀。
消息列表里每样东西的稳定性
顺手把整个消息列表按变动频率重新排了一遍。
系统提示词最稳定,改一次发一个版本,放最前。迁移 Agent 的系统提示词包含迁移规则、命名约定和输出格式,这些跨迁移单元不变。
工具定义次稳定。之前写过工具描述和 schema 怎么打磨(见工具调用的手艺),那时只关心模型能不能选对工具,现在多了一条理由:工具定义也在前缀里,改描述、调顺序、增删字段,缓存都会断。要改就攒一批一起改,别零零碎碎动。
接下来是阶段内冻结的项目状态:LegacyTable 的迁移进度、调用方列表、关键决策和验证证据。它的结构和位置应固定;只有在一个阶段内内容不变时,才具有缓存稳定性。需要频繁更新的执行状态应放在稳定前缀之后,或者等到阶段边界批量更新。
上篇加进来的经验检索结果放在项目状态之后、对话历史之前。检索结果可能随查询变化,放在这里时,它的变更不会破坏系统提示词、工具定义和阶段 checkpoint 的缓存前缀。检索本身也应在请求组装前完成,不能把动态结果拼进系统提示词。
对话历史以追加为主。应用层中间编辑任何一条历史消息,变化点之后的前缀通常需要重新建立缓存;具体失效粒度仍取决于服务商。迁移状态比如当前正在改哪个文件、哪些调用方已切换,如果每轮都变,也应放在稳定前缀之后,用一小段结构化文本带过去。
当前输入和最易变的状态永远放最后。
还有一个容易忽略的点:同一份内容的不同序列化方式也可能影响命中。JSON 的 key 顺序、空格和换行最好保持一致;但服务商按字节、token 还是内部规范化表示比较,由其实现决定。拼 prompt 的代码最好收敛到一处,避免看似相同的内容在多处拼装后产生不必要的前缀变化。
成本账
调好顺序之后可以粗算一笔账。一个 50 轮的迁移任务,历史从零匀速增长到几万 token:
- 不命中时,每轮都需要重新处理完整输入;若历史长度近似线性增长,总输入量会随轮数呈二次增长。
- 命中时,稳定前缀可能按服务商定义的 cached input 路径处理,新增内容和缓存写入仍有各自成本。
因此成本要同时看普通输入、cache read、cache write、输出 token 和缓存有效期,不能把它简化成“只有增量按全价”。我在迁移 Agent 上改完顺序后观察到输入费用和首 token 延迟都下降,但这个结果同时受模型、缓存 TTL、调用间隔和任务轮次影响,不把它外推成固定比例。
限制也要说清楚。缓存有过期时间,间隔太久的两次调用吃不到;有最小长度门槛,短前缀不值得缓存;所以短对话、一次性调用,开缓存意义不大。它适合长历史多轮调用这一类场景,恰好就是 Agent 的典型形态。对于透明的 prompt cache 实现,命中缓存不应改变模型接收到的有效输入;输出是否一致仍受采样参数、模型版本和服务端非确定性影响。
收尾
缓存解决不了另一个问题。历史只增不减,缓存命中再便宜,窗口总有塞满的一天;而且历史越长,里面无关内容越多,模型的注意力被稀释。迁移 Agent 跑到五十轮时,上下文里堆满搜索 LegacyTable 调用方时返回的原始文件列表、类型检查的报错堆栈、几次失败尝试的完整输出。开头那段迁移目标和约束,虽然还在历史里,Agent 已经无法稳定地关注到它们。下一步要处理的是历史本身:哪些该留,哪些该压,哪些用完就该扔。这个等我把方案跑稳了再写。

