Codex 每问一句,都要把整段对话重发一遍

前段时间翻 OpenAI 的工程博客,读到 Michael Bolin 写的《Unrolling the Codex agent loop》,今年一月发的,是一个系列的开篇。这篇讲的是 Codex 最里面那个循环怎么转,信息密度比大多数产品说明高出好几档。
大部分人看这种文章,注意力会停在模型怎么思考上。我读完记住的却是另一件事:Codex 每问一句,都要把整段对话原封不动重新发一遍,而且它到今天也不打算改。
一次对话,几百次请求#
先把循环说清楚。你写一句话描述需求,Codex 组一个 prompt 发给模型。模型要么直接回你一段话,要么要求调工具,比如跑一条 shell 命令、读一个文件。要调工具的话,Codex 执行完把输出拼回 input,再发一次请求。
这个「再发一次」会一直重复,直到模型不要再工具、直接给你回复为止。这段过程在 Codex 里叫一个 turn。
一个 turn 里跑几百次工具调用是常事,每一次都要重新请求一次模型。而每一次请求,Codex 都会把之前所有内容带上:你的原始消息、模型前面想过的东西、它调过哪些工具、工具返回了什么。全带上。
所以随着对话变长,发给 API 的 JSON 总量是按平方涨的。官方自己也认这一点,原文写的是「你说得对」。
Responses API 其实给了办法,一个叫 previous_response_id 的参数,只要给个 id,服务端自己去取之前的状态,你只发新增那一小段。Codex 没用。
两个理由。一是让每个请求完全无状态,服务端不用记东西,实现起来简单。二是要支持零数据保留(ZDR)的客户,而这个能力的前提是服务端不存会话状态等人来取。
这里有个细节挺耐人寻味。ZDR 客户按理说会失去「模型记得上一轮想过什么」这件事,因为推理内容留不下来。但 Codex 往 reasoning item 里塞了一个 encrypted_content,加密后的推理内容,服务端能解开。OpenAI 存的是 ZDR 客户的解密密钥,不是他们的数据。这个损失就被绕过去了。
缓存命中时,平方变线性#
传输量按平方涨,听着吓人。但网络不是主要成本,采样模型才是。所以真正的解法不在传输层,在计算层,也就是提示词缓存。
缓存命中时,模型采样从平方变成线性。这是整篇里最值钱的一句话。
条件是精确前缀匹配。缓存只认「这次请求的开头跟上次一个字节都不差」的情况。由此推出一条通用规则:静态内容放前面,变量放后面,图片和工具定义在两次请求之间必须完全一致。
Codex 的 prompt 结构就是按这条规则排的。
一个 prompt 里装了哪几样东西#
这部分值得细看,它解释了 Codex 一堆看着奇怪的设计。
instructions 字段来自配置文件,或者模型自带的 base instructions。tools 是工具定义,包括 Codex 自己的 shell 工具、plan 工具、联网搜索,加上你通过 MCP 挂上去的。然后 input 里依次放四样东西,最后才是你说的话。
- 一条 developer 消息,写沙箱和审批策略,讲清楚哪些目录可写、什么时候该问人。这条只管 Codex 自己那个 shell 工具,MCP 工具不受它约束,各管各的。
- 一条 developer 消息,你自己在 config.toml 里写的 developer_instructions,可以不写。
- 一条 user 消息,装项目里的各种约定。来源按范围从宽到窄排:$CODEX_HOME 下的 AGENTS.override.md 和 AGENTS.md;然后从 git 根目录一层层走到当前目录,每层都找 AGENTS.override.md、AGENTS.md 或你在配置里指定的文件名,总量默认封顶 32 KiB;配了 skills 的话,再加一段说明和每个 skill 的元数据。
- 一条 user 消息,装环境信息,就两行,当前目录和你的 shell。
然后是用户原话。
注意第三条那个 32 KiB 上限,加上「越具体的越靠后」。两件事放一起看,是在管前缀的体积和顺序。
还有一点容易漏:system、tools、instructions 这三项的拼接顺序由服务端决定,客户端只决定内容。也就是说你能控的是装什么,控不了怎么排。这更说明客户端得把自己的东西当成被动数据来设计,别指望它聪明。
三种把缓存打掉的操作#
缓存这么要紧,那什么会让它失效。官方列了三条。
会话中途改工具列表,比如临时挂上一个 MCP server。换模型,因为第三项里带着模型专属的 instructions,一换整段都不匹配。改沙箱配置、审批模式,或者切了工作目录。
三条的共同点是都动了前缀里的东西。第一条尤其容易踩。MCP 服务器可以随时发 notifications/tools/list_changed 通知,说自己的工具列表变了。一个长会话跑到一半,你挂的某个服务端临时改了工具集,Codex 要是立刻响应,整段历史就得按新前缀重算一遍,全价。
官方自己踩过。早期加 MCP 支持的时候有个 bug,工具枚举顺序不稳定,每次请求里工具定义的顺序都不一样,缓存永远命中不了,修在 PR #2611。
现在的做法很明确:配置变了,追加一条新消息,别改旧的。沙箱或审批模式变了,追加一条 developer 消息,格式跟原来那条一样;工作目录变了,追加一条 user 消息,格式跟原来的 environment_context 一样。旧内容一个字节不动,前缀还是前缀。
这个做法有个直接后果。你在同一个会话里 cd 到别的目录,或者在界面上把审批从 ask 改成 auto,多出来的成本不是那一条小消息,是整段历史重新采样一次。会话越长,这个动作越贵。
上下文满了怎么办#
再往下就是上下文窗口。它算的是输入加输出,一个 turn 里几百次工具调用,塞满是早晚的事。
Codex 的办法是压缩。超过阈值就把 input 换成一段更小的、能代表这段对话的新列表。
早期得手动敲 /compact,做法是拿整段对话加上一篇 SUMMARY.md 里的总结指令请求一次模型,把返回的摘要当成新的 input。后来 Responses API 上了 /responses/compact 端点,现在的 Codex 超过 auto_compact_limit 就自动调它。返回的列表里有一个 type=compaction 的特殊项,里面是加密内容,保留模型对原对话的理解,而不只是一段人写的摘要。
压缩是唯一一次合法的打断前缀。所以它被做成一个显式端点,而不是让客户端自己截断字符串。这个区别在工程上很大:前者保住的是语义,后者保住的只是长度。
对写自己 harness 的人意味着什么#
把这篇里的东西归成几条能直接用的判断。
prompt 的排布顺序是接口,不是风格问题。 系统提示词、工具定义、项目约定,一旦进入会话就该当成只读。要改就追加,别回头编辑。这条在 Codex 里是硬编码的,第三方 harness 经常忽略,然后在长会话里被账单教育。
会话中途改配置,账面代价远小于实际代价。 换模型、加工具、切目录,看着都是小动作,实际会让整段历史重新付一次输入的钱。要省钱,就别在长会话里做这些。
长会话的成本拐点在命中率上,不在 token 单价上。 同一段对话,命中率高是线性,命中率低是平方,中间差的是数量级。
项目约定文件要短。 32 KiB 不是「够用」,是上限。写满的文件每次请求都跟着前缀走一遍,而且它一变,前面所有缓存全废。
最后#
我自己的判断是,Codex 这一层的设计,是被计价单位逼出来的。
模型按 token 计价,只对前缀匹配的部分打折。这一个约束往下传,变成了请求要无状态、配置要追加、约定文件要短、压缩要走专门端点。四件看起来不相关的事,源头是同一个。
反过来看,这也解释了为什么有些自己搭的 agent 跑起来比 Codex 贵那么多。多半不是模型选错了,是它们把 prompt 当成一个随时可以重新拼装的字符串。缓存不认这个。
参考来源#
- OpenAI Engineering,Unrolling the Codex agent loop(Michael Bolin,2026-01-23):https://openai.com/index/unrolling-the-codex-agent-loop
- Codex CLI 开源仓库:https://github.com/openai/codex
- Ars Technica 对该文的报道:https://arstechnica.com/ai/2026/01/openai-spills-technical-details-about-how-its-ai-coding-agent-works
- OpenAI 提示词缓存文档:https://platform.openai.com/docs/guides/prompt-caching