缓存命中:复用稳定的请求前缀

编码 Agent 在一轮任务中通常会多次请求模型。第一次请求包含系统指令、工具定义和用户问题,模型决定调用工具;第二次请求又会带上相同内容,再追加工具结果;后面的请求也是如此。上下文越来越长,其中真正新增的内容却只在末尾。

提示词缓存解决的不是“把模型回答保存在客户端”,而是让模型服务复用已经处理过的输入。对采用前缀缓存的服务来说,请求可以简单地看成下面几段:

[system instructions]
[tool definitions]
[old messages]
[current user message]
[new assistant and tool messages]

前四段在同一轮工具循环中通常不会变化,只有最后一段不断追加。如果服务端能够识别这段相同前缀,后续请求便不必再次按普通输入处理全部内容。

缓存能否命中,取决于最终发送给 Provider 的请求,而不是 Agent 内部保存了哪些对象。工具顺序变化、动态内容出现在系统指令开头、历史消息被重新序列化,都会让相同的会话产生不同前缀。另一方面,不同 Provider 表达缓存意图的方式也不一样:有的接受消息中的显式缓存标记,有的自动匹配前缀,并允许客户端提供一个缓存键帮助请求归组。

OpenCode、Microsoft Agent Framework(MAF)和 Codex 正好处在三个不同位置。OpenCode 同时处理显式断点和缓存键,MAF 停留在 Provider 中立的消息编排层,Codex 则围绕 OpenAI Responses API 传递会话级缓存键,并在压缩历史时直接考虑前缀缓存。

OpenCode:在协议边界表达缓存意图

OpenCode 的缓存实现分布在两个层次。packages/llm 负责把统一请求编译成 Anthropic Messages、Bedrock Converse 等原生协议,packages/opencode 则通过 AI SDK 对接 OpenAI、Azure 等 Provider。两条路径采用的缓存字段不同,不能只看其中一条。

为 Anthropic 和 Bedrock 放置断点

原生协议路径的入口是 packages/llm/src/cache-policy.ts。默认的 auto 策略选择三个位置:最后一个工具、最后一个 system part,以及最新一条 user message。

const AUTO: CachePolicyObject = {
  tools: true,
  system: true,
  messages: "latest-user-message",
}

const RESPECTS_INLINE_HINTS = new Set([
  "anthropic-messages",
  "bedrock-converse",
])

这三个位置对应了 Agent 请求中较稳定的边界。工具循环开始后,最新的用户消息不会变化,模型回复和工具结果只会追加在它后面。因此,在这条消息末尾放置断点,可以让同一轮中的后续模型请求复用更长的前缀。

策略不会覆盖调用方已经放置的 CacheHint,也不会把 inline hint 发给所有协议。applyCachePolicy() 先检查当前 route,只有 Anthropic Messages 和 Bedrock Converse 会进入自动标记流程:

export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
  if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
  const policy = resolve(request.cache)
  if (!policy.tools && !policy.system && !policy.messages) return request

  const hint = makeHint(policy.ttlSeconds)
  const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
  const system = policy.system ? markLastSystem(request.system, hint) : request.system
  const messages = policy.messages
    ? markMessages(request.messages, policy.messages, hint)
    : request.messages

  return LLMRequest.update(request, { tools, system, messages })
}

源码位置:packages/llm/src/cache-policy.ts

这里添加的仍是协议无关的 CacheHint。到了 anthropic-messages.ts,它才会被转换成 cache_control;到了 bedrock-converse.ts,它会被转换成 cachePoint。两个 lowering 过程都维护请求级断点预算,超过协议允许数量的标记会被丢弃,避免整个请求失败。

策略层和协议层分开有两个好处。第一,选择“缓存到哪里”不需要掺杂 Provider 的 JSON 格式;第二,断点数量、TTL 表达和字段名称仍由真正了解协议的 lowering 层负责。以后协议约束变化时,不必改动 Agent 的消息结构。

为自动前缀缓存提供会话键

OpenAI 一类协议不使用上述 inline hint。OpenCode 的 AI SDK 路径会在 packages/opencode/src/provider/transform.ts 中根据 Provider 设置缓存键:

if (input.providerOptions?.setCacheKey !== false) {
  if (
    input.model.api.npm === "@ai-sdk/deepinfra" ||
    input.model.api.npm === "@ai-sdk/cerebras"
  ) {
    result["prompt_cache_key"] = input.sessionID
  } else if (
    input.model.api.npm === "@ai-sdk/openai" ||
    input.model.api.npm === "@ai-sdk/azure" ||
    input.model.api.npm === "@ai-sdk/xai" ||
    input.providerOptions?.setCacheKey === true
  ) {
    result["promptCacheKey"] = input.sessionID
  }
}

源码位置:packages/opencode/src/provider/transform.ts

缓存键使用 sessionID,让同一会话的请求保持稳定。它不是历史内容的替代品,也不意味着同一个键下的任意请求都会命中。服务端仍要比较请求前缀,缓存键只是提供稳定的归组依据。因此,缓存键固定而工具列表每次乱序,依然得不到理想的命中结果。

OpenCode 还把缓存用量归一化到会话统计。不同 SDK 和协议对 inputTokens 的定义并不完全一致,Session.getUsage() 会读取 cacheReadInputTokenscacheWriteInputTokens,算出非缓存输入,再分别记录缓存读取和写入:

const inputTokens = safe(input.usage.inputTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
const cacheWriteInputTokens = safe(
  Number(input.usage.cacheWriteInputTokens ?? 0),
)
const adjustedInputTokens = safe(
  inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)

源码位置:packages/opencode/src/session/session.ts

这一步很重要。只发送缓存标记而不记录读取量,开发者无法判断优化是否生效,也无法准确解释一轮请求的成本。

OpenCode 的上下文压缩会裁剪旧工具输出,并使用新的摘要替换一部分历史。压缩发生后,请求前缀自然会变化,下一次请求可能只能命中较短的稳定部分。源码能够证明压缩改变了哪些消息,却不能据此断言整套压缩专门为缓存设计。更稳妥的理解是:缓存策略负责表达可复用边界,压缩策略负责控制上下文长度,两者最终都会影响发送到 Provider 的字节序列。

MAF:框架编排消息,Provider 决定缓存

MAF 的核心抽象是 IChatClientChatClientAgent 负责合并 Agent 默认配置、本次运行配置、历史消息和上下文 Provider 的结果,然后把请求交给底层客户端。当前主请求链没有统一生成 cache_controlprompt_cache_key 等 Provider 原生字段。

这不是“MAF 不支持缓存”,而是职责边界不同。MAF 不假设底层一定使用 Anthropic、OpenAI 或其他协议,缓存能力由具体 IChatClient 实现和调用方传入的选项决定。框架本身只保证消息和工具能以结构化形式到达这个边界。

CreateConfiguredChatOptions() 会先复制本次请求的选项,再合并 Agent 级 instructions:

requestChatOptions.Instructions =
    !string.IsNullOrWhiteSpace(requestChatOptions.Instructions) &&
    !string.IsNullOrWhiteSpace(this.Instructions)
        ? $"{this.Instructions}\n{requestChatOptions.Instructions}"
        : (!string.IsNullOrWhiteSpace(requestChatOptions.Instructions)
            ? requestChatOptions.Instructions
            : this.Instructions);

同一方法还会合并工具列表。如果 Agent 默认工具和每次运行传入的工具保持一致,它们便可以形成稳定请求的一部分;如果应用每轮动态改变 instructions 或工具顺序,MAF 不会自动替应用恢复稳定前缀。

源码位置:src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs

MAF 的 AdditionalProperties 会随着 ChatOptions 合并,这给具体客户端传递额外选项留下了通道。不过,通用框架能够透传属性,不等于框架已经定义了跨 Provider 的缓存协议。应用需要查看所用 IChatClient 的实际实现,确认缓存字段名称、支持范围和用量返回方式。

MAF 也有完整的 Compaction 体系。CompactionProvider 在请求模型前建立 CompactionMessageIndex,执行配置的策略,最后只把仍被包含的消息继续向下传递:

return new AIContext
{
    Instructions = context.AIContext.Instructions,
    Messages = messageIndex.GetIncludedMessages(),
    Tools = context.AIContext.Tools
};

源码位置:src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs

CompactionMessageIndex 不把历史当作可以任意删除的字符串数组。它会区分 system、user、assistant text、summary 和 tool call 等组,并把工具调用与对应结果放在同一组中。截断策略可以排除较旧的非 system 组,摘要策略则用一条带标记的 assistant 消息替换旧内容。这样做首先保证消息协议和上下文语义完整,不能简单归因于缓存。

从缓存角度看,结论反而很朴素:压缩前后,最终消息序列不同,因此命中长度也会变化。MAF 不替应用选择缓存断点,也不在 ChatClientAgent 中统一解析缓存读取量。要观察命中情况,应从具体 Provider 客户端返回的 usage 数据入手,而不是只看 MAF 的总输入 token。

Codex:缓存键贯穿普通请求与压缩请求

Codex 的模型调用主要围绕 OpenAI Responses API。请求由 base_instructions、工具列表和历史 input 组成,客户端还会设置 prompt_cache_key。默认值不是随机生成,而是当前会话 ID:

fn prompt_cache_key(
    &self,
    responses_metadata: &CodexResponsesMetadata,
) -> String {
    self.prompt_cache_key_override
        .clone()
        .unwrap_or_else(|| responses_metadata.session_id.clone())
}

构造 Responses 请求时,Codex 把这个值放进请求:

let prompt_cache_key = Some(
    self.prompt_cache_key(responses_metadata),
);

源码位置:codex-rs/core/src/client.rs

同一会话因而拥有稳定的缓存键。Codex 的远程压缩请求也复用普通 Responses 请求构造逻辑,并把 prompt_cache_key 带到 /responses/compact。普通推理和压缩不是两套互不相干的会话,它们共享相同的缓存归组信息。

缓存键之外,Codex 还直接处理历史形状。本地压缩调用如果遇到上下文窗口溢出,会从历史开头移除最旧条目。源码注释明确写出了两个目标:保留基于前缀的缓存,并留下最近消息。

// Trim from the beginning to preserve cache (prefix-based)
// and keep recent messages intact.
error!(
    "Context window exceeded while compacting; removing oldest history item. Error: {e}"
);
history.remove_first_item();

源码位置:codex-rs/core/src/compact.rs

这里容易产生误解。删除历史开头一定会改变完整请求前缀,注释中的“preserve cache”不是说删除后还能命中原来的整段历史,而是描述压缩重试时的取舍:每次从同一侧逐步裁剪,并保留最近消息,避免在历史中间做更复杂且不稳定的重排。

压缩成功后,Codex 会从旧历史中提取用户消息,在预算内保留最近的用户文本,然后追加摘要。这个摘要在 replacement history 中使用 user role,而不是 MAF 的 assistant summary:

history.push(ResponseItem::Message {
    id: None,
    role: "user".to_string(),
    content: vec![ContentItem::InputText { text: summary_text }],
    phase: None,
    internal_chat_message_metadata_passthrough: None,
});

源码位置:codex-rs/core/src/compact.rs

之后,Codex 会把 canonical initial context 重新插入到最后一条真实用户消息或摘要之前。也就是说,压缩不是简单地把全部历史替换成一句话,它重新构造了一份有固定上下文位置、近期用户消息和摘要的历史。新历史第一次发送时必然包含新内容,后续请求继续在它后面追加,才有机会再次形成稳定前缀。

Codex 同样没有把缓存命中藏在一个总 token 数里。Responses API 返回的 input_tokens_details 会被转换为内部 TokenUsage

TokenUsage {
    input_tokens: val.input_tokens,
    cached_input_tokens: input_tokens_details.cached_tokens,
    cache_write_input_tokens: input_tokens_details.cache_write_tokens,
    output_tokens: val.output_tokens,
    reasoning_output_tokens: output_tokens_details.reasoning_tokens,
    total_tokens: val.total_tokens,
}

源码位置:codex-rs/codex-api/src/sse/responses.rs

这些字段随后进入完成事件、会话 token 统计和压缩分析。对于编码 Agent,这比只记录总 token 更有用:输入突然增加时,可以分辨是新内容变多、缓存没有命中,还是刚刚发生了缓存写入。

三种实现体现了不同的抽象边界

把三套源码放在一起比较,差异不在于谁“打开了缓存开关”,而在于谁负责把缓存意图送到 Provider。

维度OpenCodeMAFCodex
请求协议多 Provider,包含原生协议和 AI SDKIChatClient 抽象OpenAI Responses API 为主
显式断点Anthropic 与 Bedrock 路径支持框架主链不生成不使用 inline 断点
会话缓存键多个 AI SDK Provider 使用 sessionID由具体客户端或应用决定默认使用 session ID
缓存用量归一化 read、write 与普通输入取决于具体客户端转换并保存 cached 与 cache write token
压缩方式裁剪旧工具输出并生成摘要基于消息组截断或摘要本地与远程压缩并存

MAF 最适合说明 Provider 中立框架的边界:框架可以稳定地组织 instructions、tools 和 messages,却不能在不了解协议时替所有客户端生成正确缓存字段。OpenCode 展示了多 Provider 产品必须做的分流:统一策略只描述断点位置,协议 lowering 再决定 cache_controlcachePoint,自动缓存路径则使用缓存键。Codex 的协议范围较集中,因此可以让缓存键贯穿普通请求、WebSocket 复用和压缩请求,并把缓存 token 纳入统一统计。

实现编码 Agent 时应当固定什么

首先要固定稳定内容的顺序。系统指令、项目指令和工具定义应该按确定顺序构造。不要从无序集合直接生成工具数组,也不要把时间戳、随机 ID 或每轮变化的状态放在系统指令开头。动态内容越靠前,能够复用的前缀越短。

其次要让历史只在末尾追加。工具调用、工具结果和 assistant 回复应保持原样,不要为了展示效果回头改写旧消息。需要清理历史时,应明确这是一次前缀重建:压缩后的第一次请求建立新前缀,后续请求继续在它后面追加。

缓存键应当稳定,但不能跨越不该共享的边界。会话 ID 通常是合理默认值,因为同一会话最有可能共享请求前缀。若多个会话拥有完全相同的静态前缀,是否使用更高层级的键要依据 Provider 语义决定,不能仅为了提高命中率而让不同用户或不同权限上下文共用标识。

最后必须记录至少三类输入:普通输入 token、缓存读取 token 和缓存写入 token。可以计算一个用于观察趋势的读取占比:

缓存读取占比=缓存读取 token普通输入 token+缓存读取 token+缓存写入 token\text{缓存读取占比} = \frac{\text{缓存读取 token}} {\text{普通输入 token} + \text{缓存读取 token} + \text{缓存写入 token}}

这个数值不能脱离 Provider 的 usage 定义直接横向比较,但很适合观察同一模型、同一调用链的变化。如果工具列表没有变化,Agent 循环中的读取占比却突然下降,应检查请求序列化、动态 instructions、工具排序以及最近是否执行了上下文压缩。

提示词缓存最终是一项请求结构工程。断点和缓存键只是把意图传给服务端,真正决定命中长度的仍是请求前缀。OpenCode 把这件事落实在多协议适配层,MAF 把决定权留给 IChatClient,Codex 则围绕固定协议把缓存键、压缩和用量统计连成一条链。自己实现 Agent 时,也应该先确定抽象边界,再选择对应机制,而不是在消息构造完成后临时补一个缓存字段。