前段时间翻 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