在 Codex 里切一次 thinking,缓存到底发生了什么
About 15 min read
从一张截图出发,沿着 Responses API、prompt_cache_key 与 previous_response_id 追到源码,厘清「缓存失效」的真实含义。
Contents · 6 sections · jump to a heading
我是在一张 X 截图里看到这句话的,切换 thinking effort,会被注入到系统提示词,中途切换思考等级会使缓存失效。
下面还有一句补充,因为它被设置为上下文窗口开启时的初始指令。
第一眼看上去很合理,甚至有点吓人。我们平时把 thinking 当成一个旋钮,想快一点就调低,遇到难题再调高。可如果这个旋钮其实会改写上下文最前面的几百个 token,那它就不再只是推理预算,而是一次上下文结构变更。
我顺着这两张图去翻了 Codex 的公开仓库,也对照了 OpenAI 的 Prompt Caching 文档。我的结论是,截图里的方向大概率是对的,但「缓存失效」这四个字需要说得更精确一点。
切换 effort 不等于把对话记忆删掉,也不等于服务端把旧缓存清空了。更准确的说法是,这次请求的可复用前缀变了,所以它不能再拿原来那条前缀的缓存来用。旧条目可能还在,如果你在保留期内切回原来的 effort,也有机会再次命中。
这点小小的措辞差异,决定了我们后面会不会把一个性能问题误判成模型失忆。
大家说缓存时,经常把两件事揉在一起。
第一种是 Prompt Caching,也就是把请求中已经处理过的前缀保留下来。官方文档写得很直白,只有完全相同的前缀才能命中,静态的 system 或 developer 指令、工具定义、示例和共享上下文,应该放在前面,变化的用户输入放在后面。
第二种是会话增量复用。Codex 通过 previous_response_id 告诉 Responses API,前一个响应已经处理过了,这一轮只需要接着追加新的输入。它不是同一个缓存实现,但用户看到的表现很像,都是少传一点、少算一点、快一点。
把它画成一条请求链,大概是这样。
1稳定指令 → 工具定义 → 环境上下文 → 历史消息 → 当前用户输入2↑3可复用的前缀边界
如果 effort 只影响一个独立的请求参数,Prompt 的前缀可能不动。如果服务端把 effort 解释成一条放在上下文最前面的内部指令,那前缀就从第一个 token 开始不同。两种实现对用户的结果很接近,源码证据却完全不是一回事。
先看公开代码里最容易被误读的部分。Codex 客户端没有把 high、medium 之类的文字直接拼进 instructions 字符串,而是把它序列化到 Responses API 的 reasoning.effort 字段。
下面这段来自 Reasoning 结构体,它把 effort、summary 和 context 分成了三个字段。
1pub struct Reasoning {2pub effort: Option<ReasoningEffortConfig>,3pub summary: Option<ReasoningSummaryConfig>,4pub context: Option<ReasoningContext>,5}
在请求构造阶段,客户端根据当前模型和用户选择生成这个结构,然后把它放进请求体。这里的 instructions 仍然来自 prompt.base_instructions.text,不是把 effort 文本追加进去。
1let reasoning = Self::build_reasoning(model_info, effort, summary);23let request = ResponsesApiRequest {4model: model_info.slug.clone(),5instructions,6input,7reasoning: Some(reasoning),8prompt_cache_key,9..10};
所以,至少在公开客户端这一层,不能写成 Codex 把 thinking 等级直接硬编码进 system prompt。这一层只负责把选择传给服务端。
但这并没有推翻截图里的观察。
服务端收到 reasoning.effort 之后,是否会把它转换成模型上下文起点的一条内部 instruction,公开仓库没有实现。截图里的回复正好补上了这个不可见部分。它是一个很有价值的工程线索,但不是我们能从客户端源码逐字还原出的字符串。
这就是读开源代码时很容易踩的坑,客户端请求格式和模型最终看到的 rendered prompt,中间还隔着一层服务端渲染。
接下来是更关键的地方。
Codex 默认用 session id 作为 prompt_cache_key。这个 key 会在同一条会话里保持稳定,目的是让相似请求尽量被路由到同一组缓存位置。
1fn prompt_cache_key(&self, responses_metadata: &CodexResponsesMetadata) -> String {2self.prompt_cache_key_override3.clone()4.unwrap_or_else(|| responses_metadata.session_id.clone())5}
这里的 key 不是一把绕过内容匹配的万能钥匙。OpenAI 文档的描述是,路由会同时参考 prompt_cache_key 和前缀 hash。相同 key 可以帮助请求找到同一台机器,但不同的 prompt 前缀仍然不是同一条缓存。
拿一个简单的例子来说。
1请求 A,key=session-1,前缀=Instructions + effort: medium + history2请求 B,key=session-1,前缀=Instructions + effort: high + history
两次请求的 key 一样,history 也一样,但中间那一小段不同。对于前缀缓存来说,B 不是 A 的续写,而是一条新的前缀。它可能新写一条缓存,之后的 high 请求再复用这条;它不会把 A 的 medium 缓存强行当成自己的。
这也解释了为什么「切换思考等级」会让人感觉突然变慢。不是模型忘了前面聊过什么,而是服务端需要重新处理一段此前已经处理过的上下文。上下文仍然在请求里,KV cache 的复用断了。
Codex 客户端还有一道很明确的判断。它会比较前后两个 Responses 请求的属性,只有 reasoning 完全相同,才认为它们适合复用同一条 WebSocket 会话和 previous_response_id。
1previous_tools == current_tools2&& previous_tool_choice == current_tool_choice3&& previous_reasoning == current_reasoning4&& previous_include == current_include5&& previous_prompt_cache_key == current_prompt_cache_key
这段代码的含义很朴素,effort 从 medium 变成 high 时,previous_reasoning == current_reasoning 不成立,客户端就不能把这次请求当成同一套请求属性下的增量追加。
这里要小心一个细节,公开测试并没有声称「改变 effort 后仍然命中同一个模型缓存」。它测试的是另一件事,线程设置改变之后,prompt_cache_key 保持不变,而且旧的输入前缀仍然按顺序出现在新请求里。
1assert_eq!(2body1["prompt_cache_key"], body2["prompt_cache_key"],3"prompt_cache_key should not change across overrides"4);56// The entire prefix from the first request should be identical and reused7// as the prefix of the second request.
这是一种很好的测试边界。客户端能保证自己没有因为切换设置而重写历史,也能保证 key 不乱跳;至于服务端如何把 reasoning 渲染到模型上下文,仍然属于另一层。
我会给它一个「方向正确,表述需要降噪」的评价。
说对的地方有三个。
第一,thinking effort 不是只影响最终答案长度的 UI 开关,它会进入推理请求,并且会参与请求属性的兼容性判断。
第二,如果服务端把 effort 转换成上下文起点的内部 instruction,那么切换等级确实会让原来的前缀不再完全匹配。那一次请求会丢掉原缓存命中,延迟和输入 token 成本都有可能上升。
第三,Codex 的代码已经把「不要频繁改变上下文」当成明确的工程约束。仓库自己的开发规则写着,模型可见的 context 要增量构建,避免频繁变化导致 cache miss,而且所有注入片段都应该有明确的大小上限。
需要降噪的地方也有三个。
第一,缓存失效不等于缓存被删除。更像是你拿着同一把钥匙去开了一扇内容不同的门,旧门还在,但这一次进不去。
第二,历史没有消失。只要 Codex 继续把历史消息放进 input,模型仍然能看到它们,代价只是不能完全享受上一轮的复用路径。
第三,「被注入 system prompt」目前是服务端行为的合理解释,不是公开 Codex 客户端里能直接 grep 出来的事实。我们能确定的是请求里有 reasoning.effort,能确定的是客户端会因为 reasoning 变化而放弃增量兼容,不能确定的是服务端内部 instruction 的具体文本和位置。
如果你的任务会连续跑很久,我觉得最实用的建议不是永远锁死一个 effort,而是把切换当成一次有成本的阶段切换。
探索阶段可以用较低等级,快速读代码、找入口、列出不确定性。进入实现阶段后,如果任务真的需要更深推理,再升到高等级,并接受这一轮可能重新处理上下文。不要在每个小问题之间来回拨动,medium、high、medium、high 这样切,性能表现通常不会很好看。
如果你在做自己的 Agent Harness,可以直接从这件事得到几条工程规则。
把稳定的系统指令、工具 schema、共享资料放在前面,把时间戳、用户输入和本轮状态放在后面。OpenAI 文档对 Prompt Caching 的建议就是这个顺序。
为不同的模型设置或工作阶段使用稳定的 cache key,但不要以为 key 能掩盖前缀变化。它解决的是路由,不是内容相等性。
监控 cached_tokens。如果切换 effort 后它突然变成零,先确认是前缀变了,还是缓存条目已经过期、流量路由发生变化。不要看到零就得出「模型把上下文忘了」的结论。
如果你要验证自己的实现,别只打印最终答案。把每一轮的模型、reasoning.effort、prompt_cache_key、输入前缀摘要、previous_response_id 是否存在,以及 usage 里的缓存 token 一起记下来。没有这些字段,性能问题最后都会退化成一句「感觉这轮变慢了」。
说到底,这件事最有意思的地方,不在于某个等级到底应该叫 low 还是 high,而在于我们终于开始把 Agent 的上下文当成一种有生命周期、有成本、有拓扑结构的运行时状态。
以前我们把 prompt 当成一段文本,改几个字,好像只是改了配置。现在更接近真实的情况是,prompt 是一条缓存图上的路径。你在最前面挪动一个节点,后面所有节点都可能失去复用;你在末尾追加一条用户消息,前面的稳定路径仍然可以继续用。
这就是为什么 Agent 工程越来越像操作系统工程。思考等级是调度策略,system prompt 是启动区,工具定义是 ABI,历史消息是追加写入的日志,而 Prompt Cache 是运行时留下来的页表。你当然可以切换策略,只是别假装它没有成本。
我最后会把截图里的那句话改成这样。
「切换 thinking effort 可能改变模型上下文的最前缀,因此当前请求可能无法复用原来的 Prompt Cache;它不会自动删除历史,也不代表旧缓存永远消失。」
这句话没有原推那么刺激,但更接近源码、文档和实际运行时共同能支持的结论。
如果你正在写自己的 Harness,下一次遇到「怎么突然变慢了」,先看 cache,再看 prompt,最后才去怀疑模型。