工具大全
亲手实测作者:Coocon2026年9月25日9 次阅读约 10 分钟阅读

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 -p 的两个护栏报错,xxd 证明报错在 stdout、不带换行

问题背景

把 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 个反直觉的地方:

  1. 报错打在 stdout,不在 stderr。 2>/dev/null 过滤不掉它。它还不带换行,拼进日志会和下一行粘在一起。
  2. json 模式没有 result 键。 jq -r .result 打出的是四个字母 null,jq 自己的退出码是 0,下游脚本会把字符串 "null" 当成模型的回答继续往下传。这是这组报错最隐蔽的失败面。
  3. 报失败 ≠ 没产生副作用。 第 N 次调用发出的工具调用照常执行。--max-turns 2 报错时,文件已经写好了。
  4. 预算是事后检查。 要等一次 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 1/2/3/4/6 在两个版本上的扫描结果

采集日期 / 版本 --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(热缓存)。

被掐断后续跑

--max-turns 1 掐断后用 --resume 续跑,out.txt 最终为 84

从被掐断的 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 各档位读数:0.05 的预算花了 0.0517

采集日期 / 版本 --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_ms 151119,慢在首 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_turns 3,没有任何警告

这 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 找不到。

相关文章

Claude Code MCP server Failed to connect 怎么排查:CONNECTION_CLOSED、connection timed out after 30000ms、ENOENT 逐条实测

Claude Code MCP server Failed to connect 怎么排查:CONNECTION_CLOSED、connection timed out after 30000ms、ENOENT 逐条实测

claude mcp list 显示 ✘ Failed to connect,但「连不上」可能是命令不存在、PATH 缺 node、server 启动即退出、不回握手、端口没服务或配置写错。本文用自写 stub 在 Claude Code 2.1.280 上逐条复现:mcp list 失败也返回 0;CONNECTION_CLOSED 背后可能是两种完全不同的原因,真因只在 --debug-file 里;一个不回握手的 server 会让 claude -p 墙钟从 4.63s 变成 36.21s,而 duration_ms 只从 4323.5 涨到 5930。

mcpclaude-code+4
pitfalls2026年9月24日8 min
27

DeepSeek 标称 1M、Claude Code 只认 200K:两个都测了,真正卡住你的是第三个数

DeepSeek 标称 1M 上下文,而 Claude Code 接上去之后对同一个模型报告 contextWindow 200000。我把两边都测了。DeepSeek 的真实上限是 1,048,576——字面的 2 的 20 次方,不是一百万整——而且这个额度**含**你的 max_tokens 输出预算,用一组对照实验坐实。针插在第 0 位、上下文 1,039,744 token 时仍被准确捞出。Claude Code 则在远低于此处就本地拒绝,25 毫秒、零 API 调用,而且它卡的**根本不是 token**:闸门在约 48 万字符。喂高熵文本,478,000 字符顺利通过、真实 token 高达 309,567——比它刚刚自称的 20 万窗口还多 55%。而日常使用里这三个数你一个都碰不到,因为 Bash 输出超过整 30,000 字符就压根不进上下文。

claude-code长上下文+5
hands-on2026年9月20日6 min
103

拿 DeepSeek 当 Claude Code 后端:功能全通,但成本显示虚高 38 倍

DeepSeek 提供 Anthropic 格式端点,三个环境变量就能让 Claude Code 转去调它。我在真机上把整条链跑了一遍:本地工具(Read / Write / Bash / Glob / Edit / 子代理)全部正常且副作用真实发生,所以结论是能用。但没人测过的那部分是——Claude Code 按 Claude Sonnet 的价目给 DeepSeek 的 token 计费。十次相同调用,Claude Code 报告花了 $1.71,DeepSeek 账户实际只扣了 ¥0.32(≈$0.045),虚高 38 倍,这是余额差不是价目表推算。另外:官方文档关于未知模型名的说法是错的(会 400 而不是回落),v4-pro 默认返回 thinking 块导致小 max_tokens 看起来是空响应,以及一个看着像 DeepSeek 的锅其实不是的失败。

claude-codedeepseek+5
hands-on2026年9月20日7 min
95

服务、端口、证书全正常,VPN 却断了 4 小时:Tailscale 接管 DNS 后把翻墙机的解析清空了

一台跑 sing-box(VLESS-REALITY + Hysteria2)的洛杉矶 VPS,装上 Tailscale 第二天 VPN 全断。systemctl、端口、证书全部正常,根因藏在 /etc/resolv.conf:Tailscale 默认接管 DNS,tailnet 后台没配全局 nameserver,dhclient 续租时 resolv.conf 被读成空文件,tailscaled 从此对所有公网域名回 SERVFAIL,REALITY 握手连 www.apple.com 都解析不了。本文给出完整时间线、每一步的证据命令、三种修法,以及让 AI 助手(Claude Code)以后不再踩这个坑该写进 CLAUDE.md 的几条规则。

claude-code故障排查+8
pitfalls2026年9月17日5 min
134