云计算百科
云计算领域专业知识百科平台

任务做完了它却不收工?Claude Code 的 Stop 钩子为什么会一直停不下来

文章目录

  • 📖 介绍 📖
  • 🖥️ 演示环境 🖥️
  • 📒 为什么收不了工:Stop 钩子的循环机制与止损写法 📒
    • 🧭 先把退出码的「常识」放一放
    • 🚪 循环的第一种成因:意外的 exit 2
    • 🔁 循环的第二种成因:忘了 stop_hook_active
    • 🛡️ 第三道保险:平台自带的 8 次上限
    • 🧯 已经在循环了,怎么现场止损
    • 📋 本文的实测边界
  • ⚓ 相关链接 ⚓

📖 介绍 📖

你给 Claude Code 配了一个 Stop 钩子:每次它宣布任务完成,就自动跑一遍测试,测试不过就把它打回去继续干。逻辑听上去很完美。

然后某一天你发现不对劲了。测试明明全绿,它却还在跑;跑完一遍又一遍,日志像瀑布一样刷屏,token 一路烧,你按 Ctrl+C 都不知道该拦哪一次。它不是卡住了——它是收不了工。

这类问题最迷惑人的地方在于:钩子本身一行错都没有。脚本退出码正确、日志干净、单独执行也正常。真正的症结藏在 Claude Code 对「Stop」这个事件的特殊约定里——在那个时刻,某个退出码的含义和你在其他任何地方养成的直觉完全相反。

官方文档其实把这套约定写得清清楚楚,但散落在长文的不同角落,很多人折腾完环境才找到它们。下文按「为什么会循环、循环为什么会自己断、怎么写出不会锁死自己的钩子」的顺序展开,方案锚定官方文档原文整理,供大家参考。

封面图

🖥️ 演示环境 🖥️

  • 🖥️ 工具:Claude Code,Stop / SubagentStop 钩子
  • 📁 配置位置:~/.claude/settings.json 的 hooks 配置段
  • 🌐 网络:本地环境

📢 注意: 本文内容为个人学习笔记,仅供参考。附:读者须知

📒 为什么收不了工:Stop 钩子的循环机制与止损写法 📒

🧭 先把退出码的「常识」放一放

写 shell 脚本的人都有肌肉记忆:退出码 0 是成功,非 0 是失败,1 是最普通的错误。绝大多数时候这个直觉也没错——Claude Code 的钩子大部分事件确实遵循这套约定,退出码 1 只是「非阻断性错误」,动作照常继续。

但 Stop 事件不一样。它处理的不是一次工具调用,而是「模型宣布任务结束」这个时刻本身。此时退出码 2 的含义被重新定义了:

退出码 2 = 我不允许你停下来,继续干活。

这是官方文档的原话逻辑。你在 Stop 钩子里 exit 2,Claude 就会被送回工作状态,并且它无法自行修复一个不运行的钩子——也就是说,只要你的钩子持续以 2 退出,它就会一直被送回去。无限循环的机制就这么简单:不是 bug,是约定被用反了。

反过来说,这个设计本身是合理的:Stop 钩子的典型用途就是「任务结束时强制跑测试,没过就打回去」。exit 2 正是「打回去」的合法通道。问题出在两个地方——一是脚本里的意外错误被误当成了「打回去」,二是「打回去」之后没有一个让循环体面结束的条件。下面逐个拆。

🚪 循环的第一种成因:意外的 exit 2

最常见的翻车姿势,是把 Stop 钩子写成了一串普通的检查命令:

{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "bash -c 'npm run lint && npm run test'"
}]
}]
}
}

想得很美:lint 和测试都过了就正常结束。但注意——npm run test 如果测试失败,它自己的退出码可能是 1;而任何非 0 非 2 的退出码在 Stop 事件上属于「非阻断性错误」,循环不会发生,这一点是安全的。

真正危险的是另一类:你的检查脚本或它调用的工具里,存在恰好以 2 退出的路径。比如某个 linter 用退出码 2 表示「发现了 lint 错误」,某个自定义脚本用 2 表示「警告」。这些语义在它们自己的世界里完全正确,但挂在 Stop 钩子上,每一个 2 都变成了一次「不许收工」。于是任务做完 → 钩子跑 lint → lint 报错退出 2 → 模型被送回去 → 模型无事可做宣布完成 → 钩子又跑 lint → ……测试重跑、日志刷屏,就是这么来的。

还有一个隐蔽变体:钩子脚本自己崩了。如果脚本内部因为环境差异(路径不对、解释器缺失)在某条分支上以 2 退出,效果完全相同。

处置思路:Stop 钩子里的命令链要显式管理退出码,别让任何子命令的退出码裸奔到钩子边界上。最朴素的写法是「只有你明确想打回去时才 exit 2,其余一切情况一律归零」:

#!/bin/bash
input=$(cat) # 钩子的 JSON 输入从 stdin 进来

# 跑检查,无论结果如何都不让它的退出码直接决定钩子的命运
if npm run test > /tmp/stop-hook-test.log 2>&1; then
exit 0 # 全绿:放行,允许收工
else
# 测试挂了:打回去,并把失败摘要通过 stderr 喂给模型
tail -20 /tmp/stop-hook-test.log >&2
exit 2
fi

这段的关键不止是分支,还有 >&2:退出码 2 附带的消息走 stderr,Claude 会把它当作「为什么被打回来」的反馈读进去。消息具体、可执行,模型才有办法针对性修复;只 exit 2 不说话,模型只能瞎猜,猜不出来就更容易原地打转。

🔁 循环的第二种成因:忘了 stop_hook_active

就算退出码管住了,还有一层更隐蔽的坑:你的钩子在等你做一件永远不会发生的事。

设想钩子的逻辑是「检查某个部署状态,没部署成功就 exit 2」。如果这个部署根本不依赖模型的行为——比如它在等一个外部服务的回调,而模型对此无能为力——那么无论被打回去多少次,那个条件都不会自己变真。模型每一轮都被要求「继续干活」,但它没有任何可干的事,于是宣布完成,又被 2 打回来。这就是纯粹的死锁。

官方为此在 Stop 事件的输入里专门给了一个字段:stop_hook_active。它的语义一句话就能说清——当这次收工尝试本身就是被 Stop 钩子打回来的续场,这个值为 true。官方文档的建议原话是:检查这个值(或者读 transcript),避免阻塞在一个永远不会解决的条件上。

用法也就一行:

#!/bin/bash
input=$(cat)
active=$(echo "$input" | jq -r '.stop_hook_active')

if [ "$active" = "true" ]; then
# 已经因为本钩子续跑过至少一轮:无论条件如何,这次放行
# 否则就是「等一个永远不会来的条件」,死循环
exit 0
fi

# ……正常的首轮检查逻辑,从这里开始……

把这段放在所有检查逻辑之前,等于给钩子装了一个「只咬一次」的保险:首轮不过可以打回去,但绝不会咬第二轮。绝大多数「Stop 钩子停不下来」的帖子,缺的就是这五行。

这个字段在 SubagentStop 事件里同样存在,语义一致;子代理结束时的钩子同样建议先查它再决定要不要拦。

🛡️ 第三道保险:平台自带的 8 次上限

就算前两道都失守了,循环也不会真的无限。官方在 Stop 钩子上加了一层硬保护:连续 8 次续跑之后,下一次阻断会被平台直接推翻,回合强制结束。这个计数在模型每次实际调用工具后重置——也就是说,只要模型在续跑期间真的干了活,计数就会清零重新算;只有模型反复「宣布完成→被打回→无事可做→再宣布完成」这种空转,计数才会一路涨到顶。

两个由此而来的实用结论:

  • 发现循环时不用慌。它最多空转 8 轮就会自己断,烧的是 token 和时间,不是死机。但 8 轮 token 也不便宜,所以这层保护是兜底,不是借口。
  • 这个上限可以调。环境变量 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP 控制它。如果你确实需要更严格的收工门禁,可以调高;但在钩子写对 stop_hook_active 检查之前调高它,等于把兜底拆了——顺序别搞反。
  • 顺带一提,Stop 钩子还有一种更温和的介入方式:不 exit 2,而是输出带 additionalContext 的 JSON,给模型留一句「收工前记得跑测试」的提示。这条路和 exit 2 受同一套保护(stop_hook_active 与 8 次上限都生效),但界面上不会显示成吓人的「Stop hook error」。如果你的本意只是提醒而不是强制,用它会体验更好。

    🧯 已经在循环了,怎么现场止损

    钩子改配置需要重启会话才生效,而循环中的会话恰恰最不想让你等。两个不用改配置的止损手段:

    • 拆环境变量:从当前会话所在终端启动 Claude Code 时带上 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=1,把续跑上限压到 1,循环最多再空转一轮就断。
    • 让条件为真:如果钩子在等某个外部状态(比如部署回调),直接手动把状态做真,下一轮检查通过、exit 0,循环自然结束。

    止损之后,再回头按本文的顺序改脚本:先管住意外 exit 2,再补 stop_hook_active 检查。

    📋 本文的实测边界

    结论全部锚定官方 hooks 参考文档(2026 年 10 月版本)的原文条款,并交叉核对了两个公开 issue 的现象描述:一个是「exit 2 的阻断反馈在界面上显示为 Stop hook error 而非正常反馈」的显示层问题,一个是某记忆插件因后台 worker 不可达而触发 exit 2 无限重试的真实案例——后者正是「第三方组件的意外 exit 2 引发循环」的活标本。

    需要说明:撰写环境的机器上没有部署 Claude Code 命令行,本文没有实跑复现,所有行为条款均直接引自官方文档原文而非推断。hooks 的具体行为可能随版本调整,使用前请以官方文档当前版本为准。

    ⚠️ 说明: 本文基于 Claude Code 官方文档 2026 年 10 月的版本整理。钩子的退出码语义、输入字段与续跑上限等行为后续都可能随版本调整,请前往官方文档查看最新内容并自行核实,一切以官方当前版本为准;本文所述仅代表整理时点的情况,仅供参考。

    ⚓ 相关链接 ⚓

    • XiaoqiangClub
    • Claude Code 官方文档:Hooks 参考
    • Claude Code 官方文档:环境变量(含续跑上限)
    • Claude Code 官方文档:钩子排错与调试日志
    • 插件装上了却不生效?Claude Code 插件排错的四阶段反查表
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 任务做完了它却不收工?Claude Code 的 Stop 钩子为什么会一直停不下来
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!