Codex 等待子任务时,为什么还会消耗 token?一次可以自己复现的源码实验
已经验证: 空等待超时后,Codex 会再次请求模型;延长等待仍允许用户输入提前唤醒。
尚未验证: 这些请求具体扣掉多少订阅额度,以及提示词冲突是否导致模型主动频繁轮询。
实验日期:2026-09-08 至 2026-09-09。真实模型调用使用 GPT-6 Astra / low。本 Gist 包含图解、原始实验的脱敏结果、可执行脚本、固定源码链接和复现步骤。无需先懂 Rust;最快的复现只需 Python 和 Codex CLI。
2026-09-09 更新:这次真的调用了 Astra low,也跑了真实子 agent。
先前的 17 次受控运行使用本地 mock;下面新增的是 4 个真实主会话、4 个真实子 agent,共 18 次模型请求。全部从会话日志核验为 Astra low。两轮回答不同的问题,不能混算。
这次要回答读者的质疑:“轮询花不了多少,真正的大头是不是子 agent 重复加载上下文?”
| 实际测量 | 结果 | 可以说明什么 |
|---|---|---|
| 两次空超时后的模型请求 | 输入 36,041,其中缓存命中 35,584;输出 25 | 输出短不代表输入少;也不能按全部未缓存输入算费用 |
| 同一个短任务,子 agent 继承 all vs none | 两种创建顺序下,all 都多 3,123 输入 token | 继承父历史的输入开销确实存在 |
| 两组子 agent 的缓存命中 | 第一组 all 未缓存输入更少,第二组 all 完全未命中 | 不能只按上下文长度推算实际费用 |
| 真实子 agent 完成后的长等待 | 两组都提前返回,没有空超时 | 这次补上了真实完成通知路径;没有测精确通知延迟 |
结论:少让主 agent 空转、少给子 agent 塞无关历史,两项都值得优化。谁是额度大头,本实验仍未证明。 没有逐请求订阅扣额,也没有做跨 TTL 的等待测试。
flowchart LR
A["主 agent 空等待超时"] --> B["又一次模型请求"]
C["子 agent 继承父历史"] --> D["更多输入上下文"]
B --> E["分别统计:输入、缓存命中、输出"]
D --> E
E --> F["再结合实际计量规则判断成本<br/>不能只看输入总量"]
第一次阅读请先看 真实测试完整报告与复现步骤。已有 Git 下载目录时,真实测试入口是 python3 live_run.py wait2,随后按报告执行其余三项与分析器;会消耗账户额度。只想验证程序等待机制、不调用真实模型,请继续使用下方第二轮的 controlled.py。
真实测试数据:live-results.json;预先协议:LIVE-PROTOCOL.md;运行脚本:live_run.py;分析脚本:live_analyze.py。发布用脚本只改了路径、命名和版本检查,已用本次保存的真实日志重新核验;没有为发布再次调用模型。
2026-09-08:第二轮受控实验(本地 mock)
原始问题是:Codex 明明在等子助手,为什么额度还会掉?是不是等待也一直在“烧 token”?
答案要分两层:程序安静挂起的那段时间,不会因为时间流逝就持续请求模型;但一次等待到期后,模型可能被叫回来处理上下文,只决定一句“继续等”。重复发生时,就多了很多没有新信息的请求。我们确认了这种可避免的开销机制;尚未证明它是 Astra 额度消耗快的主要原因,也没有测出能省多少额度。
像一位负责人等同事交报告:每半分钟重新翻一遍资料,再问“好了没”,会增加工作量。让程序等到有事再通知,可以省掉中间这些询问。这个比喻不表示缓存内容每次都按原价收费。
这次用一个容易控制的实验来验证:让 Codex 等待,到第 65 秒再发来同一条用户输入。 两组主动改变的只有等待上限;它不是“子助手完成”的替代证明。
| 同样在第 65 秒收到输入 | 每次最多等 30 秒 | 最多等 10 分钟 |
|---|---|---|
| 全部父模型请求 | 4 次 | 2 次 |
| 没有新信息的超时 | 2 次 | 0 次 |
| 输入发出到下一次请求 | 30.72 毫秒 | 49.31 毫秒 |
flowchart LR
subgraph S["短等待:共 4 次请求"]
S0["0 秒:开始等"] --> S1["30 秒:没消息<br/>再次请求模型"]
S1 --> S2["60 秒:没消息<br/>再次请求模型"]
S2 --> S3["65 秒:收到输入<br/>继续处理"]
end
subgraph L["长等待:共 2 次请求"]
L0["0 秒:开始等"] --> L1["中间挂起<br/>没有模型请求"]
L1 --> L2["65 秒:收到输入<br/>提前结束等待"]
end
长等待并没有硬等满 10 分钟;它省掉了第 30、60 秒两次“还没消息”的模型请求。请求数减半,不代表费用或额度消耗减半。 两组都在预先设定的 1 秒响应界限内;这里也不声称长等待比短等待响应更快。
第二轮共 17 次正式运行、7 组成对检查,全部通过。2.75 秒的加速对照各重复三对、交替顺序;另有上面的真实 30 秒尺度检查和三次用户插话测试。还测到:通用执行工具 exec/wait 的外层短 yield(提前返回“还在运行”)本身也会增加请求,所以只调 wait_agent 的参数可能不够。
为少用 token,这一轮让真实 Codex 程序运行工具,本地模拟服务扮演模型,没有新增真实模型推理调用。它能检查因果机制,不能测量 Astra 自主选择短等待的概率或实际费用。上一轮真实 Astra low 调用的记录保留在下文,二者不混算。
**第二轮的历史限制(第三轮已有真实成功样本):**原计划中的子助手完成通知集成测试没有跑通:创建返回成功,却未观察到子助手模型请求,原因未定位。六次调试失败已记录,正式实验前改用固定时刻的用户输入。因而本轮没有新增证明子助手完成、失败、请求批准三种通知的端到端可靠性,也没有把失败调试当作产品缺陷。
阅读顺序:第二轮完整结果 → 预先写定的方案及修订 → 原始数据。下面保留第一轮源码追踪及实验全过程。
自己复现第二轮:不调用真实模型
准备 Python 3.10+、Git 和 Codex CLI;先确认 codex --version。本次验证版本为 0.153.4、macOS。下载全部文件,并在下载目录运行:
git clone https://gist.github.com/acmerfight/ddfad0c835d2fefb3f26b7bd3f2b09ee.git codex-wait-experiment
cd codex-wait-experiment
python3 controlled.py --codex "$(command -v codex)" --output results.json
python3 analyze.py如果 command -v codex 没有输出,请先安装 CLI,或把它替换成已有程序的绝对路径。部分 macOS 安装的路径为 /Applications/ChatGPT.app/Contents/Resources/codex,以本机实际路径为准。也可点击 Gist 的 Download ZIP,解压后进入文件所在目录运行后两条命令。
完整运行含两次 65 秒等待,另需若干启动时间;无需支付模型推理费用来复现这一轮。脚本写入 results.json,最后的分析应输出 runs: 17、paired_checks: 7、all_passed: true。分析只核验判据,不要求你的毫秒数与本文一致。运行会覆盖下载的结果文件;需要保留原始版本时先复制目录。
controlled.py 与 reproduce.py 要放在一起。analyze.py 适用于完整运行;不要用 --pilot 或 --skip-scale 的不完整数据生成正式报告。模型、平台或 CLI 版本变化可能影响结果;app-server 会读取部分本机配置,故没有声称整个进程完全离线或完全隔离。不要加 --live:那是第一轮中单独的真实模型测试选项。
先用一分钟理解问题
把主代理想成一位负责人,把子代理想成正在工作的同事。负责人可以把“同事做完了就叫我”交给程序处理,也可以每隔半分钟重新看一遍项目资料,再问“做完了吗?”
模型每次重新参与判断,都可能使用上下文 token。即使答案只是“还没做完,继续等”,也不代表这次请求没有输入成本。
这里几个词的含义:
| 名词 | 在本实验中的意思 |
|---|---|
| 模型请求 | Codex 再请模型生成一次回答或工具调用 |
| token | 模型处理内容的计量单位,不等于字数,也不直接等于额度百分比 |
| 上下文 | 模型作答时可使用的先前消息、工具定义和结果 |
| 缓存 | 复用此前处理过的内容;复用不代表完全免费 |
| 超时 | 这次等待的期限到了;不表示子任务失败 |
| mock / 模拟服务 | 本机的“假模型服务器”,按脚本返回固定结果,不调用真实模型 |
| low | 模型推理强度设置;它不关闭输入上下文处理 |
图 1:真正发生的循环
flowchart TD
A["模型决定等待"] --> B["程序等待消息<br/>默认期限 30 秒"]
B --> C{"有新消息吗?"}
C -->|"消息先到"| D["立即返回:有消息"]
C -->|"期限到了,仍无消息"| E["返回:等待超时"]
D --> F["工具结果加入历史"]
E --> F
F --> G["再次请求模型<br/>带上逻辑上下文"]
G --> H{"模型下一步做什么?"}
H -->|"继续等待"| B
H -->|"处理结果或结束"| I["推进任务"]
classDef wait fill:#e8f2ff,stroke:#2563eb,color:#172554
classDef repeat fill:#fff1e5,stroke:#c65d12,color:#7c2d12
classDef done fill:#e9f7ef,stroke:#258452,color:#14532d
class B wait
class E,F,G repeat
class D,I done
蓝色部分本来就支持事件等待。橙色部分解释了空超时怎样进入下一轮推理。图中的“继续等待”仍是模型的选择;我们没有发现运行时内部每 30 秒自动调用一次模型的循环。
1. 我们要验证什么?
起点是 这篇 Reddit 讨论:作者报告短等待频繁超时,并提出它可能解释额度快速消耗。以下实验独立检查运行机制,不把作者的账号扣额当作本次测量。
我们把问题拆成四个能实际检查的问题:
- 没有消息的等待超时,会不会产生下一次模型请求?
- 只提高默认等待时间,能不能阻止模型显式要求短等?
- 等待设得很长,用户还能不能及时打断?
- 换成
low后,输入 token 会不会消失?
2. 固定实验版本,避免“测的不是同一个东西”
| 对象 | 本次使用的版本 | 用途 |
|---|---|---|
| 源码 | c7f81afc191d74ef6e96a5add25eee05662d2215 |
读代码、编译仓库定向测试、提取等待函数 |
| 已安装 CLI | codex-cli 0.153.4 |
运行时模拟测试、真实模型测试 |
| 模型请求 | gpt-6-astra,low |
每条模拟请求都检查 effort 字段;真实调用显式指定 |
| 测试机器 | macOS / Apple Silicon | 其他系统尚未逐一验证 |
| 脚本 | Python 3.10+,仅标准库 | 不需要 pip 安装依赖 |
| 提取函数测试 | Rust 1.95.0、Tokio 1.53.1 | Tokio 虚拟时间,不需要真的等 25 分钟 |
源码提交与已安装 CLI 是两条独立证据链。 没有声称安装的 CLI 就是从这个提交构建的二进制。新版行为可能变化,复现结果应保留自己的版本号。
3. 第一轮:沿着源码找到“超时后发生什么”
可以按这张表逐项检查,链接固定到实验提交:
| 观察点 | 对应源码 | 看什么 |
|---|---|---|
| 默认期限 | config/mod.rs:236 |
最小 10 秒、默认 30 秒、最大 1 小时 |
| 参数怎么生效 | wait.rs:53 |
省略用默认值;太短向上钳制;太长报错 |
| 等待的实现 | wait.rs:187 |
等 watch 消息变化,或者到期 |
| 为什么续跑 | stream_events_utils.rs:327 |
工具调用标记 needs_follow_up = true |
| 结果进入历史 | turn.rs:2232 |
收集工具结果并写入历史 |
| 准备下一次请求 | turn.rs:419 |
从历史构造逻辑输入,再请求模型 |
关键代码可以理解成:
等消息,最多等到 deadline
消息到了 → 返回“有消息”
用户说话 → 返回“被新输入打断”
期限到了 → 返回“超时”
工具返回后 → 把结果交回模型
没有看到专门把“无新信息的超时”留在运行时继续等待的分支。因此,短期限如果被反复选择,就会反复经过模型。
另一个发现是提示词相互牵制:多代理指导 希望按分钟长等,Astra 内置模板 却要求等待调用不超过 60 秒,并希望频繁向用户更新进展。我们在实际捕获的模拟 HTTP 请求中同时看到了两种指导。
这能提出一个值得验证的原因,但尚未做移除其中一条指导的行为对照实验,所以不能把它宣布为短轮询的唯一根因。
4. 第二轮:用“假模型”测真正的 Codex
为了精确知道发出了多少次请求,我们让真实 Codex 连接本机模拟服务。模拟服务每次返回预先写好的工具调用;Codex 则真正执行等待。这样不用依赖模型随机决定做什么,也不消耗真实模型额度。
图 2:模拟的只是回答,等待与网络请求都是真的
sequenceDiagram
participant P as 测试脚本
participant C as 真实 Codex CLI
participant S as 本机模拟模型服务
P->>C: 启动,设置 Astra / low
C->>S: 第 1 次请求
S-->>C: 请调用 wait_agent
Note over C: 真正执行等待,直到超时
C->>S: 第 2 次请求,包含超时结果
S-->>C: 再等待一次,或结束
Note over P,S: 记录请求次数、间隔、历史条目、effort
原始测量如下。多数测试把时间缩到毫秒以便快速检查;其中一项保留了真实 30 秒等待,另有一项测试 25 分钟期限的提前唤醒。
| 测试条件 | 原始实测结果 | 验证了什么 |
|---|---|---|
| 连续三次请求等 30ms,最小值 10ms | 4 次请求;间隔约 50–52ms | 每次空超时都会交回模型 |
| 默认值改成 500ms,显式请求 30ms | 约 51ms 后下一次请求 | 只改默认值挡不住显式短等待 |
| 默认值 500ms,省略 timeout 参数 | 约 521ms 后下一次请求 | 省略参数时才用默认值 |
| 最小值改成 500ms,显式请求 30ms | 约 542ms,返回钳制说明 | 最小值能约束短等待 |
| 最大值 2000ms,却请求 2001ms | 约 28ms,返回错误 | 超过最大值会报错 |
| 原生默认 30 秒,省略参数 | 30,042ms 后下一次请求 | 真实墙钟验证 |
| 25 分钟期限,约 200ms 后注入用户输入 | 250.5ms 后下一次请求 | 长期限允许提前唤醒 |
表中时间是相邻请求到达本机服务的间隔,包含等待和客户端处理开销。最后一行也包含注入前的约 200ms,不能称为“用户输入后的响应延迟为 250.5ms”。公开脚本另外记录 steer_to_next_request_ms,将这两种时间分开。
三次空超时实验中,历史条目数为 7 → 9 → 11 → 13。每轮新增一条工具调用和一条结果。初始 7 条依赖环境;复现脚本检查的是相邻增长关系,不要求每个人都从 7 开始。
图 3:延长“最多等多久”,不会要求必须等满
sequenceDiagram
participant U as 用户或测试脚本
participant R as Codex 运行时
participant M as 模型
M->>R: wait_agent,最多等 25 分钟
Note over R: 正在等事件,没有再次请求模型
U->>R: 约 200ms 后发来新输入
R-->>M: 立即返回:被新输入打断
Note over R,M: 第二次请求约在第 250.5ms 到达
这个测试通过真实 app-server 的 turn/steer 接口注入输入;不是在图上假设它能唤醒。
5. 第三轮:直接测试原源码函数,再跑仓库测试
我们原样提取 WaitOutcome 和 wait_for_activity,只用同形枚举替代它依赖的会话活动类型,配上真正的 Tokio 消息通道。文件与提取片段都校验 SHA-256。六项测试覆盖:无消息到期、邮箱提前唤醒、用户输入提前唤醒、两种已排队事件、通道关闭。
这里使用虚拟时间:程序时钟可以推进 25 分钟,无需现实等待 25 分钟。六项全部通过。通道关闭会立即被报告成 TimedOut,所以单独看到这个布尔值,也不能断定一定等满了期限。
随后从固定提交编译并执行了仓库自带的 11 项 V2 等待测试,全部通过。其余 2,452 项被筛选条件排除,不能写成“整个 Codex 测试套件通过”。
6. 第四轮:一次真实 Astra / low 调用
我们还明确要求真实模型调用两次 wait_agent(timeout_ms=100),然后只回答 DONE。没有启动子代理。实际完成了两次等待并正常结束。
| 整个回合的服务端用量汇总 | 数值 |
|---|---|
| 输入 token | 44,532 |
| 其中缓存输入 | 22,016 |
| 缓存写入字段 | 0 |
| 输出 token | 45 |
| 其中推理输出字段 | 0 |
这是整个回合,包括首轮输入和最终回答。不能把 44,532 全部算作等待损耗;复现也不应以得到相同 token 数为成功条件。
这项实验是强制执行两次等待,证明运行路径在 low 下成立;它不测量模型在自然任务里主动轮询的频率。
7. 现在自己复现:先运行不消耗模型额度的版本
在 Gist 页面点 Download ZIP,解压后在该文件夹打开终端。请下载全部文件,源函数测试还需要随附的锁文件。
先检查 Python 与 Codex:
python3 --version
codex --version需要 Python 3.10 或更高。没有 Codex 时,安装 Node.js 后可以安装固定 CLI 版本;已经有合适版本就跳过:
npm install -g @openai/codex@0.153.4也可以从前面的 0.153.4 release 下载对应系统的可执行文件。
运行完整模拟测试:
python3 reproduce.py成功标志: 七项 PASS,最后显示 7/7 selected scenarios passed,并生成 wait-results.json。其中一个场景确实等待 30 秒,其他场景通常很快;启动开销因机器不同而异。
只想先做快速检查,可以跳过那项 30 秒测试:
python3 reproduce.py --quick程序不在 PATH 时,自己指定位置:
python3 reproduce.py --codex /path/to/codexWindows 可以将 python3 换成 py -3,并指定解压得到的 codex.exe。脚本采用跨平台标准库,但本次只在 macOS 验证;遇到平台差异应记录错误,不要当成产品缺陷的证据。
模拟模式不要求模型订阅或 API key,模型端点固定为 127.0.0.1。它仍会启动本机 Codex;特别是 app-server 可能读取本机设置或启动配置中的扩展,因此不要把“不调用真实模型”理解成整个进程完全离线。测试只使用临时命令行设置,没有修改全局 Codex 配置。
结果 JSON 不保存完整请求正文、认证头、本机绝对路径或会话 ID。它保留请求次数、间隔、历史条目数和断言所需的工具结果。
可选 A:复现六项原源码函数测试,不需要模型账号
需要先有 Rust / Cargo、just 和 cargo-nextest:
rustup toolchain install 1.95.0 --profile minimal
cargo +1.95.0 install --locked just
cargo +1.95.0 install --locked cargo-nextest
python3 source_probe.py --run脚本下载固定提交的一个公开源文件,验证哈希,然后生成小型测试项目并运行。无需克隆或编译整个 Codex。成功标志是 6 tests run: 6 passed。
已经有源码时也可读取本地文件,哈希仍必须匹配:
python3 source_probe.py --checkout /path/to/codex --run可选 B:复现仓库原生的 11 项测试
这一步需要 Git、完整 Rust 构建环境、网络以及较多磁盘空间。首次依赖下载和编译比上面的小项目重得多。
git clone https://github.com/openai/codex.git codex-wait-source
cd codex-wait-source
git checkout c7f81afc191d74ef6e96a5add25eee05662d2215
just test -p codex-core --lib -E 'test(multi_agent_v2_wait_agent)'成功标志是 11 tests run: 11 passed。源码仓库的测试入口为 just test;这里遵循该入口。
可选 C:真实模型实验,会消耗你的账号额度
只有明确加 --live 才会运行它。先通过 codex login 登录有 Astra 访问权限的账号,再执行:
python3 reproduce.py --live --output live-results.json脚本显式使用 gpt-6-astra / low。成功条件是完成两次 wait 并返回 DONE。用量可能不同;这是正常现象。模型如果不遵从指定动作,脚本会报错,不会把不一致的行为偷偷计入同一个实验。
8. 看懂结果,不把几个不同的数字混为一谈
模拟服务每次故意报告:输入 1000、其中缓存 900、输出 10、其中推理输出 2。四次请求的汇总应该是 4000、3600、40、8。它只用来检验客户端怎样累计字段,这些数字不是实际消耗或费用。
flowchart LR
A["逻辑上下文<br/>模型可使用哪些内容"] --> B["请求传输<br/>可能只发送增量"]
B --> C["服务端用量<br/>输入、缓存、输出"]
C --> D["订阅扣额<br/>还需要计量规则"]
classDef layer fill:#eef2ff,stroke:#6366f1,color:#1e1b4b
class A,B,C,D layer
这些层次不能直接画等号。WebSocket 客户端 支持复用前缀并只传新增内容;因此长上下文不代表网络每次完整重传。本次请求捕获使用 HTTP/SSE,没有实测 WebSocket 传输字节量。
服务端响应解析 与 逐响应累计代码 也说明,统计时要分清单次用量与累计快照,不能把同一个累计数反复相加。
9. 可以怎么改善?
测试表明,V2 已经有约束短等待的配置。例如下面是一个待评估的五分钟实验起点,不是经过成本优化得出的最佳值,也没有在本次测试中写入全局配置:
[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 300000
default_wait_timeout_ms = 300000
max_wait_timeout_ms = 1500000只提高 default 不足;显式请求短等待时,需要 min 生效。提高最小值也会限制模型主动检查的频率,应结合任务需求评估。正常消息仍能提前唤醒,所以子代理频繁发送无意义消息仍可能带来额外请求。
更完整的改进还包括:消除等待指令冲突,让 UI 自行展示“仍在工作”,并让无新信息的到期检查尽量留在运行时。
不要简单让主代理交完任务就结束:当前 完成通知 的 trigger_turn 为 false。“唤醒正在等待的回合”与“启动已经结束的新回合”不是同一个功能。
10. 证据清单与常见问题
| 文件 | 用途 |
|---|---|
reproduce.py |
七项模拟运行时测试;--live 为单独的真实调用 |
source_probe.py |
下载或读取固定源码,校验哈希,生成并运行六项函数测试 |
source-probe.Cargo.lock |
小型 Rust 测试项目的固定依赖 |
original-results.json |
本文所引用的原始实验脱敏数据 |
publication-retest.json |
发布前使用公开脚本重新测试的结果,和原始测量分开保存 |
RESULTS.md / PROTOCOL.md |
第二轮完整结果、实验方案与正式运行前修订 |
controlled.py / analyze.py |
第二轮 17 次受控运行及结果核验;复用 reproduce.py |
results.json / pilot-summary.json |
第二轮正式数据与调试失败摘要,分开保存 |
SHA256.json |
第二轮实验文件的 SHA-256;自行复现修改结果后哈希会变化 |
LICENSE.txt / NOTICE.txt |
授权与上游归属说明 |
为什么我的毫秒数不一样? 调度、网络栈和进程启动不同。比较行为、次数和合理时序,不追求小数点一致。
为什么 token 数不一样? 模型和上下文可能不同;模拟 token 才是固定夹具。真实测试的成功条件不是固定 token 数。
找不到 Codex / 找不到参数? 先运行 codex --version;指定程序路径,尽量使用 0.153.4。新旧 CLI 的工具或参数可能不同。
缺少锁文件 / 哈希不匹配? 下载全部 Gist 文件;本地源码切换到固定提交。不要跳过哈希校验来声称复现了同一版本。
为什么 both_wait_guidances_present 为 false? 模型目录或提示模板可能变化;它是诊断字段,不是每个环境都必须满足的断言。
能据此说 68% 的额度被浪费吗? 不能。本实验没有订阅计费归因,也没有把原始 token 占比当作费用占比。
到底验证了什么? 第一轮:原生定向测试 11/11;提取函数测试 6/6;模拟运行时场景 7/7;一次真实 Astra / low 测试成功。第二轮:17 次正式运行、7 个成对检查通过,直接比较等待间隔、外层 yield 和输入唤醒。它们不是相互独立的费用实验,不能把测试通过数当作节省额度的证据。
本文借助 AI 整理,并以源码、可执行断言和明确的证据边界为依据。图由 Mermaid 文本生成;如果第三方 Gist 阅读器没有渲染图,请在 GitHub Gist 原页面查看。
如果赚了大钱,你会干什么? 别想了—你不是如果。 —— Dentonshaw