花 200 美元订阅 ChatGPT Pro 的人,最近在 X 上反复问同一个问题:为什么 Codex 的周额度一天就见底了?Relux Works 的两位工程师花了整整一个夏天,翻遍 3808 份会话记录,找到一个至今仍存在的输入 token 泄漏。结论有点反直觉:烧掉额度的不是写代码,而是 agent 无所事事地「等待」。

模型不记事,等待就是在重读全文#

大语言模型没有「记忆」。它每走一步,都要把整段对话重新发给自己,然后只吐出一个动作:一次工具调用,或者一段文字。如果对话已经堆到 24 万 token,那么哪怕这一步只是执行一条 tail -n 18 的日志命令,成本也是 24 万输入 token。

所以算账很简单:一件任务的成本,等于「步数」乘以「上下文大小」。想省钱只有两条路,要么少走几步,要么把上下文压小。

有人会说还有缓存。确实,KV 缓存能让重复的前缀免于重算,在他们分析的档案里,97.8% 的输入 token 命中了缓存,OpenAI 对缓存输入也只收正常价的 10%。但缓存不是免费的。当等待把同样的上下文重读几百上千次,即使打了九折,总额也会爆炸。作者打了个比方:这个循环就像每次迭代都把整个进程的堆内存重新拷贝一遍。

通知和轮询,一字之差,成本天差地别#

agent 等一个外部事件(编译完成、测试跑完、子任务结束),有两种姿势。

第一种是「通知」:agent 走一步,说一句「完成时叫我」,然后交出控制权。等待期间零成本,事件发生时一条消息进来,只算一步。

第二种是「轮询」:agent 自己隔一会儿就问一句「好了吗」。每一次询问都是一步,每一步都是完整的上下文。

整篇文章的核心公式只有一句话:等待成本 = 检查次数 × 上下文大小。

轮询本身不坏。如果 agent 能在两次检查之间睡一觉,且睡觉不占步骤,那轮询和通知一样便宜。问题出在「睡不着」的时候。

这里有个细节很能说明问题。你让模型「写个 1,五秒后写个 2」,它能做得漂亮:一条 sleep 5 命令阻塞五秒,然后写 2,一步搞定,上下文只付一次。但如果你让它「等编译结束」,时长未知,可能一分钟,也可能三小时。模型没法 sleep 三个小时(编译一分钟就结束的话它会睡过头,而且单条命令有时长上限)。于是它只能选一个间隔,比如 30 秒,然后 sleep、检查、sleep、检查,循环下去。

作者用 async/await 的语言讲得很清楚:已知等待是 await(挂起,事件唤醒),未知等待是 sleep-poll(定时轮询),最糟的是 spin(完全挂不起,只能空转)。对普通代码,spin 顶多烧一个 CPU 核;对 agent,spin 每一次空转都要重读它的全部记忆。

Codex 有睡眠工具,但只有一个模型领得到#

其实 Codex 最近已经有正确的工具了,叫 clock.sleep:睡多少毫秒,有消息就提前叫醒。它 6 月 18 日随 0.141.0 落地,7 月 8 日起改名 clock.sleep,实现很干净,最长能睡 12 小时,能被新输入打断。

但模型根本拿不到它。这个工具靠一条规则注册:只有模型目录条目声明了 clock 支持,或者配置里强制开启时才给。内置目录里只有 gpt-6-astra 一个模型带 clock,其余 gpt-5.6-sol、gpt-5.5、gpt-5.4 以及所有自定义 provider 上的模型,列表都是空的。档案里 3808 个会话总共调了 420 次 sleep,其中 418 次是 gpt-6-astra。

没有 sleep 的模型,唯一能等的方式是在 shell 里跑命令:sleep 30、tail 日志之类。而这一步受制于 Codex 的 unified exec 机制:命令在伪终端里启动,yield_time_ms 定时器默认 10 秒、上限 30 秒就把控制权交还模型;进程没结束的话,模型可以用一次空输入的 write_stdin 继续读输出,最长能等五分钟。

真正致命的是那个整数和浮点的小 bug。他们的模型 muse-spark 走自定义 provider,工具参数里的数字被写成了浮点:60000.0 而不是 60000。Codex 的解析器只认整数,直接拒绝。档案里这个模型的 10 次 write_stdin 全部失败,25 次带 yield_time_ms 的调用有 23 次报错 invalid type: floating point 60000.0, expected u64。也就是说,连强制开启 sleep 都救不了它,因为它的 duration_ms 也是整数。所有等待工具对它全部失效,一次 shell 调用最长活不过 10 秒。

goal 模式拔掉了最后的刹车#

正常情况下,模型还有一个逃逸口:结束回合、安静下来。即使不 sleep,这也是免费的暂停,等人回来再继续。

goal 模式把这个口也堵死了。线程一空闲,goal 扩展就立刻给模型发一个新的回合:「继续朝目标前进」。源码里 on_thread_idle 调用 continue_if_idle,马上启动回合,没有退避,没有空续次数的计数器,每个 goal 的 token 预算默认也是空的。作者调侃,任何 goal 模式本质上都是一个 while true; do codex “continue”; done 的死循环,只是被做进了产品里。

「立刻」是可以量化的。源会话里,一个回合结束到下一个回合开始,间隔只有 0.02 到 0.05 秒,中位数 0.03 秒。一个回合通常十秒,正好够模型读一遍上下文、调用一次轮询、写一句「没有变化」。

更妙的是合约本身。continuation.md 里写着一句等待规则:等待只有在本回合轮询了活进程时才被承认。「我在等」而不轮询,等于没有进展。模型自己只能把 goal 移到 complete 或 blocked,而 blocked 要连续三次相同阻塞才允许,等子进程根本不算阻塞。于是陷阱成型了:你不能结束回合等(立刻被重启);重启不轮询就算没进展;所以每个回合都得轮询;每次轮询都付全量上下文。

这个陷阱的结果就是他们观察到的数据。48 分钟里 goal 模式发起了 173 次续跑、466 次模型调用,每次重读 12 万到 47 万 token,一共 1.49 亿输入 token,折合每小时 1.88 亿。

同样的功能,Claude Code 怎么做的#

Claude Code 也有 goal 模式,叫 /goal,差别在 dispatcher。Claude 在每回合结束后用一个独立的小模型检查条件,满足才开新回合;后台命令或子 agent 还在跑,检查就顺延,结果作为新回合到达;后台工作拖久了,第一次回查要 30 分钟,之后间隔翻倍,且没有人类时最多回查三次;连续几回合没有工具调用就直接停循环。

对比之下,Codex 对每一种情况给的答案都一样:0.03 秒后开新回合。作者点出的关键在最后一句:区别不在于模型训练,而在于「谁来等」,是一个能在事件到来时唤醒模型的 wrapper,还是一个只能自己追问的模型。

数据不会撒谎#

从 7 月到 9 月,作者统计了两台机器的全部 Codex 会话:3808 份文件,59.2 亿输入 token、1.29 亿输出 token。开 goal 的会话只有 52 个,占 1.5%,却吃掉了整整一半的输入 token(29.2 亿)。最贵的 7 个会话(18 亿到 57 亿不等)全开了 goal。十周里,周窗口 13 次冲到 99%、11 次打满 100%,「额度已用尽」的错误出现在 28 个自然日;从窗口开始到 99%,中位数 25 小时,最快 7 小时。

gpt-6-astra 那两个能用 sleep 的 goal 会话给出了对照组:一小时等待花 1000 万到 1500 万 token,比空转便宜 6 到 18 倍,但仍比通知贵 20 到 30 倍,因为 goal 模式每回合后还是不断重启它。睡眠降低了循环的单价,但没有消除循环本身。

怎么自救#

作者给 Codex 提了四个小改动:goal 激活时无条件注册 clock.sleep;上一次 goal 回合是等待或空回合时,在续跑之间加延迟;接受 60000.0 这种写法;在 continuation.md 里把 clock.sleep 和长 write_stdin 列为合法等待。

对普通用户,最实用的两条是:在 ~/.codex/config.toml 里加 [features] sleep_tool = { mode = “always_on” },重启后任何模型都能拿到 sleep 工具(但没训练过的模型可能睡过头,得自己观察);再用作者开源的 codex-rollout-audit 脚本扫一遍 ~/.codex/sessions,看看自己有没有踩进这个坑。

我的看法#

这篇文章让我最受触动的,不是那个整数浮点 bug(那只是导火索),而是它揭示的一个更深层的道理:在 agent 系统里,「等待」不是边角料,而是一等公民的设计问题。谁在等、怎么等、等的时候付不付钱,这三个问题直接决定了一个 agent 的经济性。

对普通用户最直接的教训是:别在 Codex 里用 goal 模式跑需要长时间等待多进程的任务,除非你的模型恰好是 gpt-6-astra,或者你已经手动开了 sleep。否则你以为 agent 在「工作」,它其实在烧你的额度空转。作者最后那句话值得贴在显示器上:在模型学会「不花一步就等待」之前,谁在等,谁就付钱。


参考来源:Why Codex burns a weekly limit in a day while the agent waits for the tide(Relux Works)