子代理会生子代理,Codex 的账单是怎么失控的

十月中旬 Hacker News 上有个帖子,标题很吓人:OpenAI Codex 的代理失控,在没有授权的情况下花掉 7.8 万美元。发帖的账号注册了大约三个小时,帖子后来被 flag 了。
作者自称 Lorenzo Massaro,来自 Eternal Tech。他说 7 月 10 日从 VS Code 里打开的一个 Codex 任务生成了 826 个子任务,随后收到 162 张发票,合计 79,664.88 美元。父任务跑 GPT-5.5、medium 推理,子任务记录成 GPT-5.6 Sol、ultra。OpenAI 支持只回了一句 credits were consumed。日志他没公开。
数字本身就对不上。帖子里写 2,146 trillion tokens,评论区说 2,130 billion,他自己的单次构建计数器加起来大概 162 billion。他也承认本地计数器不是权威账单。到 10 月 16 日,OpenAI 没有对这个案子发过任何说法。
按证据标准,这事儿采信不了。但 GitHub issue 里查得到的是模式,不是那张账单。
能核实的部分:子代理被复制成同一个贵模型#
issue #32587 在他提到的同一个 alpha 版本上打开,两天后。父代理跑 GPT-5.6 Sol ultra,它会静默地把每一个带工具调用的 multi-agent v2 子代理,都用同样的模型和同样的推理强度生成。
Claude Code 有自己的版本。#68110 至今没有 Anthropic 的回复,用户报告一次会话生成了 905 个代理,在 Bedrock 上 30 分钟花了约 700 美元。同样是自述,同样没有公开日志。
所以真正值得问的问题是:一个编程代理为什么会自己生出几百个子代理?原文按解释力排了六条。前两条决定一次开多少,中间三条决定每个多贵,最后一条把前面五条变成一张发票。
一、V2 子代理不认深度设置#
Codex 源码里 agent_max_depth 上面挂着一句注释:Maximum nesting depth for V1 agent threads. Ignored by V2. 10 月 16 日的代码还是这样,而 agents.max_depth 已经从配置参考里消失了。
issue #35463 里有人报出 4 层、14 层、22 层的 fork 树,其中一棵在不到 50 分钟里长到 1,218 个后代。Claude Code 的 2.1.172 到 2.1.216 也会嵌五层,没有开关可调。
自查就是数深度。Codex 的 spawn 边存在状态库和 ~/.codex/sessions 的会话日志里,Claude Code 写在 ~/.claude/projects/<project>/<session>/subagents/。出现你没要过的一两层以上,就是这条。
修法上两边不对称。Claude Code 设 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1,默认值是 3。Codex 截至 10 月 16 日没有可用的 V2 深度键,只能在 hook 里拦 spawn_agent,或者用 features.multi_agent = false 直接关掉多代理。
二、并发上限不是总数上限#
Claude Code 在同时跑满 20 个时报 Concurrent subagent limit reached,但自 v2.1.224(8 月 7 日)起,官方文档写的是对一次会话里 Claude 能生成的子代理总数没有限制。Codex 的 agents.max_concurrent_threads_per_session 数的也是打开着的线程,不填时文档只说 Codex 会选择默认值,没给数字。
这种现象很好认:任何时刻都没超过上限,一天下来总数几百。修法是把并发调低,同时用 PreToolUse hook 数总数。第三方配置也要翻一遍,issue #35463 里有人追到一个 1000 的值,来自另一个工具的迁移脚本。
三、子代理默认继承父代理的模型#
这条最反直觉。官方子代理文档的原话是,如果你没给子代理配置模型或者 model_reasoning_effort,它就继承父代理的模型和推理强度。
于是「父用顶配,子做琐事」这个设想在账单上不成立。父在 GPT-5.6 Sol ultra,每个只负责读一个文件、跑一次 grep 的子代理也是 Sol ultra。
修法:Codex 设 agents.default_subagent_model 和 agents.default_subagent_reasoning_effort;Claude Code 设 CLAUDE_CODE_SUBAGENT_MODEL=haiku,从 v2.1.257 起还要加 CLAUDE_CODE_SUBAGENT_MODEL_FORCE=1,不然 Claude 还是会把自己的模型传下去。只加 FORCE 不设模型,等于让所有子代理继续跟着主对话走。
四、fork 历史让每个子代理重读一遍上下文#
issue #38989 里,一次跑了 74 个子代理的会话记录到 5.39 亿 tokens。其中带 fork 历史的 30 个占了大约 4.8 亿,不带历史的 44 个合计约 1.53 亿。大头是缓存输入,单价低,但不是免费。
更麻烦的是它污染证据。子代理的日志会重放父代理的历史,直接相加就是重复计数。ccusage 在 issue #950 里修过一个 91 倍的 Codex 子代理会话重复计数。所以本地统计出来的总量,要拿去和 provider 的用量页面对一遍,两个方向都可能错。
修法很简单:任务不需要 fork 历史就别让它带,把需要的文件写进任务描述里。
五、循环:一直等,一直审#
两个代理互相等待到用量上限,或者 review 修、修完再 review,一直不收敛。issue #33276 是前者。#38989 里根代理是被明确告知不要再创建任何子代理之后才停下来的。这说明指令有用,前提是有人在看。
修法就是轮次上限。Claude Code 的子代理定义里可以写 maxTurns,print 模式有 --max-turns,这个参数默认没有限制。任务允许的话,用脚本编排固定数量的代理,比让模型自己决定开几个稳。
六、计费没有硬顶,前面五条才变成钱#
ChatGPT 的积分是自动充值的,上限是「你自己设的月度最高额」。API 的 spend alerts 明确写了不强制,硬性限额的执行也不是瞬时的。Anthropic 的 Custom 档没有月度上限。那 162 张发票走的就是自动充值和积分。
这解释了为什么一个 bug 能变成一张账单。前面五条决定花得多快,这一条决定有没有人踩刹车。
三层防御,每层都会漏#
原文的结论我认为是对的,也是这篇最值钱的地方:预算护栏要放在 harness、hook、provider 三个位置,因为只放一层都绕得过去。
Claude Code 的 --max-budget-usd 只检查客户端的估算,而且只在 print 模式生效。有人用同样的上限,在 Agent SDK 里跑出 $20.95 of $20 这样的日志,因为超支的是已经在飞行中的子代理。Codex 自己的文档写得很直白:把工具 hook 当成有用的护栏,而不是完整的执行边界。还有一篇讲递归 harness 的论文(arXiv 2606.13643)补了另一面:父代理写个脚本,从 shell 里启子代理,就能绕过每轮工具调用的上限,规模直接上几千。
具体的配置,Claude Code 这边是在 settings.json 里关掉嵌套、限制四个并发,再用 hook 挡第 31 次 spawn:
{
"env": {
"CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1",
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "4"
},
"hooks": {
"PreToolUse": [
{ "matcher": "Agent", "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cap-agents.sh" }] }
]
}
}
#!/bin/sh
f="/tmp/agent-count-$(jq -r .session_id)"
n=$(( $(cat "$f" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$f"
[ "$n" -le 30 ] || { echo "Subagent cap of 30 reached for this session" >&2; exit 2; }
chmod +x 之后就能用。退出码 2 会拦下调用,并把提示消息给 Claude 看。Codex 接的是同一类 PreToolUse hook,matcher 写 spawn_agent;它自己的 features.rollout_budget.enabled token 预算还在开发中,默认关闭。走 API key 的流量可以在 LiteLLM 代理上加 per-key 的 max_budget,这个值默认不设,没有数据库的部署还会直接跳过,而且它只看经过它自己的流量。
日志要在出事之前开#
事故只能从当时记录下来的数据里重建。Codex 报告的作者自己说大部分日志似乎被自动删掉了。Claude Code 默认 30 天后清理 transcript,这个值在 cleanupPeriodDays,要调大;OpenTelemetry 也得提前开。
- Claude Code:
CLAUDE_CODE_ENABLE_TELEMETRY=1配OTEL_METRICS_EXPORTER=otlp,会导出claude_code.cost.usage和claude_code.token.usage,能按子代理拆开。这些要写在 shell 或用户设置里,项目级的设置文件打不开。 - Codex:用户配置里加
[otel]段才会导出,会记录按角色区分的multi_agent.spawn。 - 两边都一样:把 provider 的账单级用量导出和本地日志放一起,本地计数器只是估算。
我的看法#
这篇我看得最舒服的不是结论,是它处理那个 7.8 万美元的方式:先说清哪些是自述,哪些能被 issue 复现,哪些数字互相打架。多智能体的成本讨论现在塞满了没法核验的大数,判断一份事故报告值不值得信,我只看两件事,有没有日志,日志能不能和账单对上。两样都没有,剩下的就是叙事。
还有一点原文没强调,我想补上:成本突然飙升的第一嫌疑,通常不是模型变贵了。模型单价是写在价目表上的常量,人不会一夜之间换错。真正会变的是拓扑,一次任务开了多少代理、嵌了几层、每个代理带不带历史。所以监控子代理成本,盯单价没什么用,盯两个数就够,spawn 总数和最大深度。这两个数一异常,账单一定异常。
再补一个更土的原则,我用它代替大部分路由策略:如果你说不清一个子任务的「做完是什么样」,就别把它拆出去。拆得越多,只是更快地拿到一堆模棱两可的中间产物。
参考来源: