写 coding agent 时,慢通常不只来自模型本身。真正让人感到拖沓的,是每一轮都要把一大段工作现场重新交给模型: base instructions、工具 schema、项目规则、权限状态、历史消息、工具结果、最新用户输入,最后还要等模型吐出第一个 token。 长任务跑到中后段,这个“重新交现场”的成本会越来越明显。

OpenAI 的 prompt caching 文档 要求复用的前缀一致,建议稳定内容放在前面、变化放在后面。但缓存门槛、计费、TTL 和 key 的作用随模型代际变化:GPT-5.6 及以后由服务端自动路由,prompt_cache_key 可用于分开用户或客户的缓存计量;较早模型则用稳定 key 优化路由。Responses 的命中观察字段是 usage.input_tokens_details.cached_tokens,不能混用 Chat Completions 的字段路径。

prompt_cache_key 不是命中开关。较早模型把它用作路由提示,新模型可用它隔离缓存计量;两者都不能靠同一个 key 修复已经变化的前缀。客户端能主动维护的是有序输入、工具说明和上下文更新的稳定性。

取材范围。 OpenAI 官方文档说明 prompt cache 的服务端条件:exact prefix、缓存门槛、路由、retention、 prompt_cache_key 和 cached_tokens。Codex 公开源码只能证明客户端如何构造请求、 保存历史、触发 compaction、记录 token usage。服务端如何序列化内部 token、如何放置 KV cache、 如何调度机器,本文不把它写成 Codex 源码事实。

第九篇追六个问题:

  1. prompt cache 到底缓存的是哪一层视图?
  2. prompt_cache_key 在 Codex 里从哪里来,怎样按 session 与请求族生成?
  3. Codex 请求里的 instructions、tools、input 怎样形成稳定前缀?
  4. thread settings、环境上下文和工具结果为什么要作为动态尾部处理?
  5. compaction 为什么既能减压,也会重写未来的缓存形状?
  6. Codex 用哪些指标把 cache 效果和体感速度记录下来?

一、缓存匹配 API 请求,不匹配聊天窗口

很多性能讨论会直接问“有没有命中缓存”。这个问法太早了。先要问: 哪份内容进入了这次 API request?顺序是什么?哪些字段会参与 prefix match? UI 里显示的历史、磁盘里的 rollout、runtime 临时构造的 request input,这三者不总是同一份东西。

层 负责实现 作用 和 prompt cache 的关系
可见历史 客户端事件转换 让用户看到 turn、工具、hook、事件和最终回答。 只提供观察,不等于下一轮完整发给模型。
持久记录 rollout / thread store 恢复、回放、fork 和审计。 恢复时用来重建 runtime history,但不是 provider cache 本身。
模型视图 Prompt / Responses request 本轮实际交给模型的 instructions、tools、input 和控制字段。 provider 只能在这份有序输入上做 prefix 复用。

因此,Codex 的性能文章不能只看网络请求耗时。它要先看模型视图怎么构造。 Prompt 结构就把这件事拆开了:conversation input、可用 tools、 parallel_tool_calls、base_instructions、output schema。 到 ModelClient::build_responses_request 时,它们会变成 Responses API 的请求字段: instructions、input、tools、reasoning、text 和 prompt_cache_key。

形状示意:
Responses request
  instructions: stable base instructions
  tools:        model-visible tool schemas
  input:        prior context + dynamic turn tail
  text:         output schema / verbosity
  prompt_cache_key: session/request-family cache key

这段示意省略了很多字段,但足够说明本文的读法:先观察 request shape,再解释速度。 cached_tokens 只是结果指标;稳定前缀才是 runtime 能主动维护的工程纪律。

二、缓存 key:跟随 session 和请求族

ModelClient 是 session-scoped,跨 turn 保留认证、provider 和传输状态。但“作用域是 session”不代表 key 永远等于 thread id。thread 的持久身份、当前运行 session 与内部辅助会话的请求族,是三个需要分开看的值。

ModelClient::prompt_cache_key() 优先返回 override;对于有 parent thread id 的内部会话,返回 source:parent_thread_id;其余使用 responses_metadata.session_id。build_responses_request 把结果写入请求。这样相关辅助请求可以共享一个稳定的请求族 key,普通请求则跟随当前 session;不能再概括成“默认就是 thread_id”。

prompt_cache_key 不指定缓存截止到哪条消息,也不保证命中。还要分清公开 API 合同与 ChatGPT 请求适配:当前客户端另有 responses_session_id(),非根 agent 保留实际 session id,其余会使用所选 key 建立请求亲和性。这是可见的客户端 header 选择,不能据此推出所有模型都采用相同的服务端路由算法。

Guardian 审查会话等调用方可以在 with_session_options 中传入 key override。它表达哪些请求应被放进同一请求族;新用户消息本身无需每次制造新 key。缓存是否复用仍应由 usage 观测。

三、稳定前缀:instructions、tools、context base

官方文档建议把静态内容放在开头。Codex 源码里对应三类材料:base instructions、model-visible tool schemas、以及初始上下文或上下文 diff 建立出的 context base。

3.1 instructions 要稳定

普通 Responses 分支从 prompt.base_instructions.text 构造顶层 instructions。当前 Responses Lite 分支 则把工具放进 AdditionalTools、把基础指令变成 input 前缀消息,顶层 instructions 为空且不发送 tools。前缀项 id 由 thread 命名空间和可见内容稳定生成;并不是每次请求随机生成一套 id。连续请求应稳定的是实际采用的请求形状。

这里的关键不是“指令永远不变”。模型、personality、collaboration mode 或配置切换时,它当然会变。 关键是普通连续 turn 不应该把临时信息塞进 base instructions。临时信息应该进入后面的 input tail, 这样 stable prefix 才有机会复用。

3.2 tools 是前缀的一部分

工具定义会参与可复用前缀,但缓存资格应按所选模型合同判断,不能把固定 1024-token 门槛推广到所有模型。普通 Responses 使用顶层 tools;Responses Lite 使用 input 中的 AdditionalTools。两条分支都来自当前 StepContext 捕获的工具路由,后续请求才观察新的工具集合。

这解释了一个常见的体感问题:工具越多,单轮请求越重;但如果工具 schema 足够稳定, 它们也更适合成为缓存前缀的一部分。反过来,如果每轮都无谓改变工具列表或工具 schema 顺序, prompt cache 会先在 request shape 上受伤。

3.3 context base:一次注入,后续用 diff

第 2 篇区分了 reference_context_item 和 WorldState 基线。完整环境已经建立时,运行时追加环境变化;基线失效时重新注入完整上下文。现有 prompt caching 测试仍验证普通连续请求保留前次 input 前缀,再追加新用户消息。性能不变量是旧内容稳定,而不是必须永远调用某个旧版 settings-diff 函数。

这不是为了让测试好看。它直接保护 prompt cache:把稳定规则和环境上下文作为已确认的前缀, 后续变化用新消息追加,而不是每轮重新把同一批上下文改写成另一种形状。

3.4 完整输入与 WebSocket 增量传输分开看

WebSocket continuation 先比较完整逻辑请求。只有非 input 属性兼容,且前次 input 加服务端输出仍是当前 input 的前缀,才可以用上次 response id 加新增 input 继续。压缩、配置变化或前缀不匹配会让这次传输发送完整 input;这不等于服务端缓存一定全未命中。

形状示意(省略字段):
普通 Responses: instructions + tools + input
Responses Lite: input = AdditionalTools + 基础指令消息 + history

完整逻辑输入: S + 前次输出 + 新工具结果
WebSocket 可续接: previous_response_id + 新工具结果
无法续接: 完整 input
缓存效果: 单独观察 usage,不能从传输字节数推出

四、动态尾部:改变可以发生,但要晚一点发生

动态信息不可避免。用户会改变 cwd、权限、model、reasoning effort;工具会返回新的 stdout/stderr; hooks 可能补 additional context;MCP 工具列表可能刷新;compact 可能替换 history。 Codex 的策略不是消灭变化,而是尽量让变化出现在稳定前缀之后。

prompt caching suite 里有两个直接证据。一个测试把 thread settings 改掉后,断言 prompt_cache_key 不变,并且第二次请求以前一次 input 为前缀,再追加新的 permissions message、environment message 和用户消息。另一个测试在 per-turn overrides 下做同样断言: key 仍然稳定,model switch 和环境变化作为后续消息进入 input。

变化来源 Codex 的处理 保护的性能属性
用户新输入 作为新的 user message 追加。 旧 prefix 不被重新排版。
thread settings override 生成 settings / environment update,接在已有 input 后。 保留前面已经稳定的上下文。
tool output 记录到 history,再由 for_prompt() 规范化后进入下一次 request。 保证 call/output 成对,不让 malformed history 破坏模型视图。
hook additional context 变成 contextual fragment 写入 model context。 把策略补充变成可观察的输入变化。
compaction 用 summary / replacement history 改写后续基线。 降低上下文压力,同时建立新的可恢复前缀。

这也是第 8 篇 hooks 铺垫的原因。hook 如果在 prompt 提交前或工具完成后补上下文, 它不是“免费附加信息”;它会改变后续模型视图。一个小 hook 是否影响性能,要看它把内容插到哪里、 是否每轮变化、是否破坏早期 prefix。

五、compaction:减压之后,缓存形状也换了

prompt cache 不能替代 context window。历史越来越长时,Codex 仍然要 compact。 ContextManager 会维护 token usage、估算 token count,也会在 history rewrite 时 bump history_version。get_context_remaining 使用统一的 context-window status,报告 auto-compact scope 与有效完整窗口两种剩余额度中的较小值;它不是简单地用模型标称窗口减去累计 usage。

当前 remote compact v2 在 独立 attempt 中克隆 history、按窗口裁剪工具输出、保留历史元数据,并追加 CompactionTrigger。它通过相同的 ModelClientSession 流式请求通道提交;结果收集 必须等到 response.completed,并且恰好得到一个 compaction output,才可安装替换历史。请求已发出、收到输出项和替换历史已安装,是三个不同状态。

compact 的性能含义有两面:它减少未来请求需要携带的历史,但也会建立新的前缀形状。 旧前缀不再是后续模型视图的主线;summary、保留的工具结果、重新注入的 initial context, 会一起成为新的 cache 候选。

所以不要把 compact 理解成“触发一次总结就更快”。如果 summary 太粗,语义会丢; 如果保留太多,窗口压力还在;如果 compact 后没有稳定的 replacement history,恢复和后续 cache 都会漂。 Codex 把 compact 放在 history / rollout / token usage 这一整套机制里,就是为了让减压之后仍然能继续重建同一条工作主线。

六、指标:cached、non-cached 和 first token

provider cache 是否命中,最终要靠 usage 观察。Codex 的 TokenUsage 里有 input_tokens、cached_input_tokens、output_tokens、 reasoning_output_tokens 和 total_tokens。 turn 结束时,on_task_finished 会拿本轮开始前的 token usage snapshot, 和当前 total token usage 做差,得到这次 turn 的输入、cached input、non-cached input、输出和总量。

这些值会写进 tracing span、session telemetry histogram 和 analytics event。 同一个收尾路径还记录 time_to_first_token_ms 和 turn duration。 这使得 Codex 可以把两个层面的性能分开看:一层是 provider 侧有多少输入 tokens 来自 cache; 另一层是用户体感的首 token 和整体 turn 时长。

指标 说明 不要误读成
cached_input_tokens provider 报告本轮有多少输入 token 命中 cache。 模型“少看了”这些内容。
non_cached_input() 输入 token 中没有命中的部分。 所有非缓存 token 都是浪费。
time_to_first_token_ms 用户等待首个可见输出的时间。 只由 prompt cache 决定。
total_tokens 本轮 token 总账。 上下文窗口中仍完整保留的语义质量。

这套指标也解释了为什么性能文章要排在 hooks 后面。一个 post-tool hook 可以让模型看见不同反馈; 一个 permission hook 可以减少无效审批等待;一个 stop hook 可以让 turn 多跑一次; 它们都会影响体感速度,但不一定直接体现在 cached tokens 上。源码里的性能观测必须把 cache、 tool loop、客户端事件转换和 turn lifecycle 放在一起看。

七、常见误读:把 cache 当成单点开关

到这里可以把几个误读收掉。它们很常见,因为 prompt cache 的 API 字段看起来很简单, 但 runtime 真正维护的是一条长请求链。

误读 更准确的读法 源码检查点
prompt_cache_key 决定命中。 key 影响缓存域和路由,命中还要 exact prefix。 ModelClient::prompt_cache_key() 与 request shape 分开。
聊天历史在,模型就会完整看到。 模型看到的是 for_prompt() 之后的 input。 ContextManager::for_prompt() 会规范化、补 call/output、过滤不支持的图片和音频,并移除本地元数据。
工具越少一定越快。 工具 schema 是成本,也是可缓存前缀;关键是稳定和按需暴露。 tools 字段由 prompt.tools 构造,测试断言常规工具表稳定。
compact 之后缓存自然更好。 compact 会换掉未来前缀,需要稳定 replacement history 承接。 remote compact 复用 prompt_cache_key,同时改写 history。
缓存命中等于用户一定觉得快。 首 token 还受工具循环、审批、网络、compaction 和输出长度影响。 turn 收尾同时记录 token usage、TTFT 和 duration。

八、下一篇:为什么要回到 rollout 和恢复

prompt cache 把性能问题压到模型视图;但模型视图从哪里来?它来自 history、context updates、 compaction replacement、tool results 和 rollout reconstruction。只要这些记录不能稳定恢复, 下一轮 request shape 就会漂,cache 分析也会失去参照。

所以下一篇回到持久化和恢复:Codex 怎样把一次 turn 的过程写进 rollout,怎样从 RolloutItem 重建 history,怎样处理旧 rollback 记录、fork、compact 前后状态和 token usage。 读完那一篇,性能、恢复和客户端事件转换才能互相对应:用户看到的事实、磁盘保存的事实、 模型下一轮看到的事实,必须能互相对齐。

参考源码与文档