写 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 源码事实。
第九篇追六个问题:
- prompt cache 到底缓存的是哪一层视图?
prompt_cache_key在 Codex 里从哪里来,怎样按 session 与请求族生成?- Codex 请求里的
instructions、tools、input怎样形成稳定前缀? - thread settings、环境上下文和工具结果为什么要作为动态尾部处理?
- compaction 为什么既能减压,也会重写未来的缓存形状?
- 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。
读完那一篇,性能、恢复和客户端事件转换才能互相对应:用户看到的事实、磁盘保存的事实、
模型下一轮看到的事实,必须能互相对齐。
参考源码与文档
- OpenAI Prompt caching guide
- OpenAI Prompt Caching 201
- OpenAI latest model guide:reasoning models 与 prompt caching 建议
- openai/codex 固定源码快照
Prompt结构与get_formatted_input_for_request()ModelClient与 turn-scopedModelClientSession注释with_session_options()与 session / 内部请求族 keybuild_responses_request()构造 instructions、input、tools 与prompt_cache_keyResponsesApiRequest与 websocket request 保留prompt_cache_key- session 初始化
ModelClient与 Guardian review override run_turn构造 sampling request input 的顺序ContextManager、reference_context_item与for_prompt()- token usage 更新与本地估算
TokenUsageInfo与TokenCountEvent- turn start 记录 token usage 起点与 tracing 字段
- turn stop 计算 cached / non-cached token usage
- turn 完成事件记录 duration 与 time-to-first-token
get_context_remaining计算剩余 context window- remote compact v2 clone history、trim tool outputs、构造 prompt
- compact attempt 与共享请求通道
- 测试:tools 与 instructions 在连续请求中保持稳定
- 测试:cached contextual prefix 跨请求复用
- 测试:thread settings override 保持 cached prefix 和 key
- 测试:per-turn overrides 保持 cached prefix 和 key
- remote compact v2 使用共享采样通道与
prompt_cache_key