Claude Code 报 Error: Reached max turns (1):无头护栏掐断时,文件可能已经写了
这是 Claude Code 报错词族的第 3 篇。前两篇分别是 auto mode 不可用和 MCP 连不上(Failed to connect),这次换一条新报错:无头模式 claude -p 的两道护栏被触发。
Error: Reached max turns (1)
Error: Exceeded USD budget (0.01)
如果你用的是 --output-format json,看到的就不是这两行字,而是 "subtype": "error_max_turns"、"terminal_reason": "max_turns",外加一个不存在的 result 字段。

问题背景
把 Claude Code 放进 CI、cron 或批处理脚本时,给它套上限是常规操作:
--max-turns N:限制轮数,防止模型在工具调用里兜圈子--max-budget-usd X:限制花费,防止一次任务烧穿预算
官方 CLI 参考(2026-09-25 抓取)对这两个参数的描述都只有一句话:
--max-turns:「Limit the number of agentic turns (print mode only). Exits with an error when the limit is reached. No limit by default.」--max-budget-usd:「Maximum dollar amount to spend on API calls before stopping (print mode only).」
文档没回答的恰恰是写脚本最关心的几件事:
- 报错打到哪条流?
- 退出码是多少?
- json 里长什么样?
- 被掐断时,模型已经做了的事算不算数?
- 「限 1 轮」到底是限什么?
这篇就把这些实测出来。
问题分析
所有实验都用同一个三步任务:读 data.txt 第 3 行的数字(42),乘以 2 写进 out.txt,再回复结果。正确完成时 out.txt 为 84,至少需要 3 次模型调用:Read → Write → 回复。这样一来,在不同位置掐断的效果看得很清楚。
先说结论。实测下来,这两道护栏有 4 个反直觉的地方:
- 报错打在 stdout,不在 stderr。
2>/dev/null过滤不掉它。它还不带换行,拼进日志会和下一行粘在一起。 - json 模式没有
result键。jq -r .result打出的是四个字母null,jq 自己的退出码是 0,下游脚本会把字符串"null"当成模型的回答继续往下传。这是这组报错最隐蔽的失败面。 - 报失败 ≠ 没产生副作用。 第 N 次调用发出的工具调用照常执行。
--max-turns 2报错时,文件已经写好了。 - 预算是事后检查。 要等一次 API 调用返回才比较累计花费,所以最多会超出一次调用的钱。超线的那一次调用可能恰好已经把任务做完了,可你拿到的仍是 exit 1。
另外,--max-turns 在 2.1.280 的 claude --help 里查不到,文档里却有。--max-budget-usd 在 help 里有,并注明「only works with --print」。
技术方案与选型
下游脚本要判断一次 claude -p 有没有成功,有这么几种做法:
| 方案 | 结论 | 理由 |
|---|---|---|
| 只看退出码 | 用(判成败够了) | 两天 22 个 json run 里,claude exit 1 与判定 FAIL 一一对应 |
--output-format json + 判 is_error 与 subtype |
用(要知道原因时) | terminal_reason 能区分 max_turns / budget_exhausted / api_error |
text 模式匹配 stdout 里的 Error: 字符串 |
排除 | 报错与正常回答走同一条流,不带换行,模型的正常回答里也可能出现 Error: |
jq -r .result 取值后判空 |
排除 | 失败时打印字面量 null,jq exit 0,[ -n "$r" ] 照样为真 |
只看 subtype |
排除 | 网关 503 时 subtype 是 success,但 is_error 是 true(见下文) |
靠 --max-turns 约束交互模式 |
排除 | 交互模式不执行该参数(E6 实测,1 次) |
被掐断后 --resume 续跑来省钱 |
不作为省钱手段 | 09-25 续跑比重跑贵约 32%,09-23 两者持平 |
最终用的判定脚本如下。它跑遍了两天全部 22 个 json run,结论与 claude 的退出码完全一致:
#!/bin/bash
f="$1"
if ! jq -e . "$f" >/dev/null 2>&1; then echo "FAIL no-json"; exit 1; fi
read -r is_error subtype reason < <(jq -r '[(.is_error|tostring), (.subtype // "none"), (.terminal_reason // "none")] | @tsv' "$f")
if [ "$is_error" = "false" ] && [ "$subtype" = "success" ]; then
echo "OK $(jq -r '.result' "$f")"; exit 0
fi
echo "FAIL subtype=$subtype terminal_reason=$reason errors=$(jq -c '.errors // .result' "$f")"; exit 1
退出码判断不了的两件事,要靠别的手段:
- 为什么失败:读
terminal_reason - 磁盘上已经发生了什么:自己检查产物和临时文件(见「实践效果」)
实测过程
- 环境:macOS(Mac mini M4)。被测
claude -p一律--model haiku(Haiku 4.5),--allowedTools Read,Write。 - 隔离:每次都带独立的
CLAUDE_CONFIG_DIR和< /dev/null,并加--debug-file留日志,逐次数真实发出的/v1/messages请求。 - 数据来源:两天两个版本。
- 2026-09-23,Claude Code 2.1.270:第一轮实验。
- 2026-09-25,Claude Code 2.1.280:读回 09-23 的原始日志后补跑复测。
- 成本口径:全文成本都是 CLI 标价(
total_cost_usd或 debug 里的[engine] cost=),不是实际账单。
版本漂移本身就是读数的一部分:隔了一个版本,报错字节、字段、N 的语义和费用阶梯都没变。下表每行都标了采集日期。
复现命令($TASK 为上面的三步任务):
claude -p "$TASK" --model haiku --allowedTools Read,Write --max-turns 1 < /dev/null; echo " [exit $?]"
claude -p "$TASK" --model haiku --allowedTools Read,Write --max-turns 1 < /dev/null 2>/dev/null | xxd
claude -p "$TASK" --model haiku --allowedTools Read,Write --max-turns 1 --output-format json < /dev/null | jq -r .result
claude -p "$TASK" --model haiku --allowedTools Read,Write --max-budget-usd 0.01 < /dev/null; echo " [exit $?]"
实践效果
报错原文与字节形态
| 采集日期 / 版本 | 模式 | 输出 | 字节 | 流 | exit |
|---|---|---|---|---|---|
| 09-23 / 2.1.270 | text, --max-turns 1 |
Error: Reached max turns (1) |
28,无换行 | stdout | 1 |
| 09-25 / 2.1.280 | text, --max-turns 1 |
Error: Reached max turns (1) |
28,无换行,stderr 0 字节 | stdout | 1 |
| 09-25 / 2.1.280 | text, --max-budget-usd 0.01 |
Error: Exceeded USD budget (0.01) |
33,无换行,stderr 0 字节 | stdout | 1 |
| 09-25 / 2.1.280 | json, --max-turns 1 |
subtype: error_max_turns、terminal_reason: max_turns、errors: ["Reached maximum number of turns (1)"]、无 result 键 |
- | stdout | 1 |
| 09-25 / 2.1.280 | json, --max-budget-usd 0.01 |
subtype: error_max_budget_usd、terminal_reason: budget_exhausted、errors: ["Reached maximum budget ($0.01)"]、无 result 键 |
- | stdout | 1 |
注意 text 与 json 用的是两套措辞:text 是 Reached max turns,json 的 errors 是 Reached maximum number of turns。按字符串 grep 时,这两种都要覆盖。
jq -r .result 对被掐断的 run 输出 null(xxd:6e75 6c6c 0a),jq 退出码 0。换成 jq -e -r .result 时,jq 退出码变成 1。jq -r 'has("result")' 输出 false:这个键根本不存在,不是值为 null。
--max-turns N 到底限的是什么

| 采集日期 / 版本 | --max-turns | exit | subtype | num_turns | 实际模型调用 | out.txt | 花费 $ |
|---|---|---|---|---|---|---|---|
| 09-23 / 2.1.270 | 1 | 1 | error_max_turns | 2 | - | 无 | 0.0459 / 0.0063 |
| 09-23 / 2.1.270 | 2 | 1 | error_max_turns | 3 | - | 84 | 0.0132 / 0.0131 |
| 09-23 / 2.1.270 | 3 | 0 | success | 3 | - | 84 | 0.0194 |
| 09-23 / 2.1.270 | 4 | 0 | success | 3 | - | 84 | 0.0194 |
| 09-23 / 2.1.270 | 6 | 0 | success | 3 | - | 84 | 0.0195 |
| 09-25 / 2.1.280 | 1 | 1 | error_max_turns | 2 | 1 | 无 | 0.0063 / 0.0063 |
| 09-25 / 2.1.280 | 2 | 1 | error_max_turns | 3 | 2 | 84 | 0.0134 / 0.0131 |
| 09-25 / 2.1.280 | 3 | 0 | success | 3 | 3 | 84 | 0.0191 / 0.0194 |
(09-23 没开 --debug-file,所以没有逐次请求数。09-25 的调用次数来自 debug 日志里 [API REQUEST] /v1/messages 的条数。)
几点读数:
- N 就是允许的模型调用次数。mt1 恰好 1 次请求,mt2 恰好 2 次,mt3 恰好 3 次。
- 被掐断时
num_turns报的是 N+1,成功时报的是实际调用数。拿num_turns判断「跑了几轮」会差一。 - 第 N 次调用发出的工具照样执行。mt2 的第 2 次调用发出 Write,文件原子写入完成,
out.txt= 84,然后才报error_max_turns、exit 1。只看退出码就去「回滚」的脚本,会以为什么都没发生。 - 两个版本的费用阶梯几乎一样:约 $0.0063 / $0.013 / $0.019(热缓存)。
被掐断后续跑

从被掐断的 session 执行 --resume <session_id>,发一句 Continue.,能接着完成任务。续跑时模型只派发了 Write,Read 的结果已经在会话里了。但续跑不省钱:
| 采集日期 / 版本 | 掐断 | 续跑 | 合计 | 对照:一次跑完 |
|---|---|---|---|---|
| 09-23 / 2.1.270 | $0.0063 | $0.0132 | $0.0195 | $0.0194 |
| 09-25 / 2.1.280 | $0.0063 | $0.0193 | $0.0256 | $0.0194(贵约 32%) |
| 09-25 / 2.1.280(截图这次,新目录冷缓存) | $0.0389 | $0.0521 | $0.0910 | - |
两天的结论不一致(一次持平,一次贵 32%),所以只能说「续跑不省钱」,不能说「续跑一定更贵」。
--max-budget-usd 是事后检查

| 采集日期 / 版本 | --max-budget-usd | exit | subtype | stop_reason | 实际花费 $ | out.txt | 临时文件残留 |
|---|---|---|---|---|---|---|---|
| 09-23 / 2.1.270 | 0.01 | 1 | error_max_budget_usd | tool_use | 0.0387 | 无 | 无 |
| 09-25 / 2.1.280 | 0.01 | 1 | error_max_budget_usd | tool_use | 0.0131 | 无 | out.txt.tmp.5755.cddea0807d29(2 字节 84) |
| 09-23 / 2.1.270 | 0.05 | 1 | error_max_budget_usd | end_turn | 0.0517 | 84 | 无 |
| 09-23 / 2.1.270 | 0.10 | 0 | success | end_turn | 0.0522 | 84 | 无 |
(0.05 和 0.10 两档只有 09-23 的数据,09-25 没有复测。)
0.05 这一档最能说明问题:
- 顶层
usage只覆盖前两次调用,按单价核算为 $0.0454,没超线,于是放行了第三次调用 - 第三次调用返回时模型已经给出最终答复(
stop_reason: end_turn),out.txt也写好了 - 累计 $0.0517 超线,报
error_max_budget_usd、exit 1,result被丢弃 - 任务做完了,调用方拿到的却是失败
「前两次 $0.0454」是用 modelUsage 合计减去顶层 usage 推出来的,当天没有 debug 日志可逐次对账,属于推断。
09-25 的 0.01 档暴露了另一个问题:
- 第 1 次调用约 $0.0063,没超线,放行
- 第 2 次调用发出了 Write,debug 里能看到
Writing to temp file ... out.txt.tmp.5755.cddea0807d29 - 累计 $0.0131 超线,进程退出,没有后续的 rename
- 工作目录里留下一个 2 字节的
out.txt.tmp.5755.cddea0807d29,正式的out.txt不存在
同一天录截图时又复现了一次,残片是 0 字节的 out.txt.tmp.9000.4ffc01d2af2b。2 次都复现了。批处理脚本要记得清理 *.tmp.*,而且不能假设残片内容完整。
还有一个反直觉的费用读数:
--max-budget-usd 0.01冷启动时第一次调用就花了 $0.0389(09-25 text 模式),是--max-turns 1热缓存($0.0063)的 6 倍- 带预算参数的 run 都有约 28k 的 cache creation,和 max-turns 组不共享缓存
- 原因未验证
费用聚合
只统计 json run,数据来自 modelUsage:
| 口径 | 被掐断 run 数 | 平均 | 最低 | 最高 |
|---|---|---|---|---|
| 09-23 / 2.1.270 | 8 | $0.0235 | $0.0063 | $0.0517 |
| 09-25 / 2.1.280 | 5 | $0.0104 | $0.0063 | $0.0134 |
| 合计 | 13 | $0.0185 | $0.0063 | $0.0517 |
两天合计的输入侧,cache read 占 94.8%,cache creation 占 5.2%,裸 input 一共只有 396 token。花费多少主要看缓存是冷是热,和掐在第几轮关系不大。
最便宜的失败是热缓存下的 --max-turns 1,$0.0063,两天各测了两次,数字稳定。
另外三个读数
交互模式不执行 --max-turns(已验证,1 次)。 在 tmux 里用 claude --max-turns 2 启动交互界面,输入同一个任务:
- debug 里有 3 次
repl_main_thread请求,外加 1 次generate_session_title - 任务完整跑完,
out.txt= 84,界面没有任何报错 - 花费 $0.0871
- 这和文档里的「print mode only」一致
网关 503 时 subtype 是 success。 09-25 有一次 run 连续 11 次 API error (attempt k/11): 503 后放弃:
- exit 1,
is_error: true,terminal_reason: api_error,但subtype是success result里是报错文本API Error: 503 No available accounts ...- 只判
subtype == "success"的脚本会把这次当成成功,所以上面的判定脚本同时检查了is_error
慢不一定是护栏的问题。 09-23 那次 --max-turns 4 墙钟 157.17s,而 --max-turns 6 只用 6.65s:
- 两次的 token 与花费几乎相同,
ttft_ms151119,慢在首 token 之前 - 09-25 复现出同样的特征(ttft 117384,debug 显示 8 次 503 重试后成功)
- 所以 09-23 这次大概率是网关退避。当天没留 debug 日志,只能标推断
- json 结果不暴露重试次数,text 模式的 stderr 也什么都不打
踩坑
1. zsh 不对无引号变量分词,循环静默建出了带空格的目录。 09-23 那轮我用了这样一段循环:
for r in "E1-mt2-a 2" "E1-mt2-b 2" "E1-mt3 3" "E1-mt4 4" "E1-mt6 6"; do set -- $r; ./run.sh "$1" --output-format json --max-turns "$2"; done
在 bash 里,set -- $r 会把 $r 拆成两个参数。zsh 默认不拆:$1 成了 "E1-mt2-a 2",$2 是空串。结果:
- 建出了
runs/E1-mt2-a 2等 5 个带空格的目录 --max-turns ""被静默接受,而且等于不限轮。5 次全部 exit 0,success、num_turns3,没有任何警告
这 5 个无效样本已隔离到 _invalid_zsh_nosplit/,不计入任何结论。修法是在 zsh 里写 set -- ${=r},或者干脆用两个显式数组;只写 set -- 修不好,出问题的恰恰是它。
还有一个副作用值得单独记:护栏参数拿到空值时不报错。脚本里拼 --max-turns "$N" 之前,先断言 $N 非空。
2. 第一轮实验被 SIGTERM 掐掉了,没留下读数文档。 09-23 那轮外层进程以 exit 143 结束(stageA.exit = STAGE_A_EXIT=143):
- 各 run 目录的原始数据是完整的,但汇总的读数文档没写出来
- 本轮的做法:先把那一轮每个 run 的
stdout、exitcode、walltime_s、side_effect全部读回核对(那个目录全程只读),再在新版本 2.1.280 上补跑关键组 - 所以本文的表格混用了两天两个版本的数据,每行都标了日期
- 09-23 的读数都能在 09-25 上复现:字节形态、N 的语义、费用阶梯一致
- 只有 0.05 和 0.10 两档预算、
--max-turns 4/6和 stream-json 这几组只有 09-23 的数据,没有复测
3. 无头模式别忘了 < /dev/null。 这是同一个「静默失效」家族的老坑(另见 Hooks 的 stdin 陷阱)。09-25 实测对比如下:
| stdin | 启动到发出首个请求 | 墙钟 | stderr |
|---|---|---|---|
< /dev/null |
0.165s | 3.21s | 0 字节 |
| 打开但没有数据的管道 | 3.206s(3 次测到 3.205~3.219s) | 6.12s | 157 字节警告 |
stderr 里的警告原文:
Warning: no stdin data received in 3s, proceeding without it. If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.
不会一直挂住,只是每次白等 3 秒。
4. CLAUDE_CONFIG_DIR 只隔离用户级配置。 实验在仓库里的 tmp/ 子目录下跑,交互模式的信任对话框仍然提示加载了仓库根的 .claude/settings.local.json(预批准 22 项工具权限)。要做干净的对照,得把工作目录放到仓库之外。
同为 claude -p 生命周期上的坑,还有一篇 No conversation found to continue:-p 跑出来的会话,在交互模式里用 --continue 找不到。