本文是「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 计算注意力。若每次生成都从头计算整个序列,历史越长,每一步的计算量越大。
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 数,可据此确认前缀是否稳定。
时间戳导致缓存未命中
检查迁移 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,避免相同内容在多处拼装后产生不必要的前缀变化。
多轮调用的成本
调整顺序后,可估算一个历史从零近似匀速增长到几万 token 的 50 轮迁移任务:
- 不命中时,每轮都需要重新处理完整输入;若历史长度近似线性增长,总输入量会随轮数呈二次增长。
- 命中时,稳定前缀可能按服务商定义的 cached input 路径处理,新增内容和缓存写入仍有各自成本。
成本需要同时考虑普通输入、cache read、cache write、输出 token 和缓存有效期。我在迁移 Agent 上调整顺序后观察到输入费用和首 token 延迟下降,但该结果受模型、缓存 TTL、调用间隔和任务轮次影响,不能外推为固定比例。
限制包括缓存过期时间和最小长度门槛:间隔过久的两次调用无法复用缓存,短前缀也可能不满足缓存条件。因此,该机制适用于长历史的多轮调用;短对话和一次性调用的收益较小。对于透明的 prompt cache 实现,命中缓存不应改变模型接收到的有效输入;输出是否一致仍受采样参数、模型版本和服务端非确定性影响。
上下文内容的限制
缓存不减少历史本身。历史持续增长时,上下文最终会达到窗口上限,且无关内容增加会降低模型对迁移目标和约束的关注。迁移 Agent 运行到五十轮时,上下文包含搜索 LegacyTable 调用方返回的原始文件列表、类型检查的报错堆栈和几次失败尝试的完整输出。迁移目标和约束仍在历史中,但 Agent 无法稳定关注这些内容。后续需要处理历史的保留、压缩和丢弃条件。
