Agent 任务没做完就停了?我扒了 Claude Code 源码,找到了 4 层原因
从一个让人抓狂的 Bug 出发,深入 Claude Code 源码,拆解 Agent Loop 提前停止背后的四层防护体系,附可直接落地的分级决策树改造方案。
凌晨两点,Agent 跟我说「已完成」
那天我让 Agent 帮我重构一个模块------把分散在 15 个文件里的代码逐个迁移。
Agent 开局很猛:Glob 定位文件、Read 逐个读取、Edit 开始改第一个。
然后它停下来,发来一条消息:
"我已经修改了第一个文件,其他文件也需要我修改吗?"
我盯着屏幕,血压上升。废话,我说的是「所有文件」。
更诡异的是另一次:Agent 调了一个命令,返回空输出,然后冷静地告诉我:
"好的,已完成。"
什么都没做,语气笃定得像刚写完整个代码库。
这不是玄学,这是工程问题。我花了一个周末扒了 Claude Code 的源码,发现 Agent Loop「提前停止」这件事背后,藏着一套精密的四层防护体系------每一层都有坑,每一层也都有解法。
今天把这套体系拆给你看,顺便给你一个可以直接抄的改造方案。
第一层:API 协议层 ------ 空结果触发的「幽灵停止」
这是最隐蔽的 Bug,也是 Claude Code 源码里藏得最深的一行修复。
先说现象:工具明明执行了,但 Agent 突然停止,既没报错,也没解释。
根因在协议层 。Claude API 用 \n\nHuman: 作为对话轮次的停止标记。当工具返回空内容时,连续的空白 tool_result 块在某些情况下会被解析器误判为对话结束信号。
模型不是「不想干活」,它是被协议层面的静默信号给卡住了。
Claude Code 的修复简单到令人意外:
ini
// 任何返回空内容的 tool_result 都会被替换为占位文本
if (!toolResult.content || toolResult.content === '') {
toolResult.content = `(${toolName} completed with no output)`
}
永远不让模型看到空的 tool_result。 任何空结果都注入一个包含工具名的占位文本,确保没有空内容能穿透到模型视野。
✅ 你的 Agent 要做的:工具返回空字符串时,主动注入占位文本,而不是直接透传。
第二层:模型层 ------ 不调用工具 ≠ 任务完成
协议层的坑填了,还有更难缠的:模型确实会主动决定不调用工具。
大多数 Agent Loop 的实现长这样:
arduino
if (response.hasToolCalls) {
await executeTools(response.toolCalls)
} else {
break // ❌ 一刀切,停了
}
Claude Code 的做法截然不同------它把「模型不调用工具」当成一个可恢复的信号,进入一棵精心设计的决策树:
css
模型不调用工具
├─ 上下文过长(413)?
│ ├─ collapse drain retry(压缩历史后重试)
│ └─ reactiveCompact(实时削减上下文)→ 用尽才终止
├─ 输出被截断?
│ └─ 追加 recovery 消息("Continue from where you left off")→ 有次数限制
└─ 弹尽粮绝?
├─ 有 blocking errors → 追加到 messages 让模型看到
├─ token budget 还有余量 → 发 nudge 消息推一把
└─ 所有手段用尽 → 才允许终止
这棵树的设计哲学只有一句话:你不想干活,我推你干;你干不了,我给你创造条件干;只有山穷水尽了,我才放你走。
第三层:提示词层 ------ 心理暗示 + 威慑
工程手段能修 Bug,但治不了模型的「主观意愿」。Claude Code 在系统提示词里植入了两条精妙的护栏。
护栏一:「别轻易放弃」
如果一个方法失败了,先诊断原因再切换策略------读错误、检查假设、精准修复。不要盲目重试同一种做法,但也不要因为一次失败就放弃整个可行方案。
这条指令给模型构建了一个「诊断 → 修复」的认知框架,而不是「失败 → 停止」的本能反应。
护栏二:「停了也没用」
Token 预算是硬性下限,不是建议。如果你提前停止,系统会自动继续推进。
这甚至不像是给模型的指令,更像是一种心理威慑。当模型知道「停下也会被 nudge 回来」,它的策略就从「投机取巧」变成了「老实干完」。
✅ 你的 Agent 要做的:在系统提示词里明确告诉模型「不调用工具不等于完成」,并说明系统会继续推进。
第四层:工程层 ------ 硬限制与容错
最后一层是基础设施的保护,也是最容易被忽视的一层。
| 场景 | Claude Code 的处理 |
|---|---|
| maxTurns / budget 超限 | 硬终止,合理退出 |
| 单个工具权限被拒绝 | 记录错误,继续循环,不拉垮整个 Loop |
| 上下文压缩触发 | 压缩成功后直接进入下一轮,跳过模型调用 |
| 停止原因 | 语义化分类:max_turns / prompt_too_long / completed / stop_hook_prevented |
最关键的设计:单个工具失败不终止整个 Loop。这听起来理所当然,但大多数自己实现的 Agent 都会在这里翻车------一个工具抛异常,整个循环就崩了。
四层防护全景图
落地改造:把「一刀切」换成分级决策树
改造前(常见写法) :
csharp
// ❌ 模型不调工具就直接停
while (true) {
const response = await model.generate(messages)
if (!response.hasToolCalls) break
await executeTools(response.toolCalls)
}
改造后(分级决策树) :
kotlin
// ✅ 只有山穷水尽才停
let recoveryAttempts = 0
const MAX_RECOVERY = 3
while (true) {
const response = await model.generate(messages)
// 正常路径:有工具调用,执行后继续
if (response.hasToolCalls) {
await executeTools(response.toolCalls)
recoveryAttempts = 0 // 干活了就重置计数
continue
}
// 恢复路径一:上下文过长
if (contextTooLong(messages)) {
messages = await compressContext(messages)
continue
}
// 恢复路径二:输出被截断
if (response.truncated && recoveryAttempts < MAX_RECOVERY) {
messages.push({ role: 'user', content: 'Continue from where you left off.' })
recoveryAttempts++
continue
}
// 恢复路径三:有未处理的错误
if (hasUnresolvedErrors(messages)) {
messages.push(...formatBlockingErrors())
continue
}
// 恢复路径四:budget 还有余量,nudge 一下
if (tokenBudgetRemaining > 0) {
messages.push({ role: 'user', content: 'Is there more work to do? If yes, continue.' })
continue
}
// 所有手段用尽,才真正终止
break
}
两条核心原则:
- 模型不调工具 ≠ 任务完成 ------ 先救,别急着停
- 每种停止原因都有对应的恢复手段 ------ 压缩 / 追加 / nudge,用尽了再放弃
对照检查:你的 Agent Loop 做到了几条?
| 检查项 | 做了吗? |
|---|---|
| 空 tool_result 注入了占位文本? | ☐ |
| 模型返回纯文本时,有多级恢复而非直接 break? | ☐ |
| 系统提示词里有「别轻易放弃」的明确指令? | ☐ |
| 上下文过长能自动压缩 + 重试? | ☐ |
| 单个工具失败不会终止整个 Loop? | ☐ |
| 能区分「模型主动停」和「资源耗尽」? | ☐ |
最关键的一条 :把那个简单粗暴的 if (!hasToolCalls) break 替换成分级决策树。模型不调用工具,不一定是做完了------很可能只是卡住了。
写在最后
扒完这段源码,我最大的感受是:Claude Code 对「停止」这件事的态度,比大多数 Agent 框架严肃得多。
它不把「模型不调工具」当成终止信号,而是当成一个需要诊断的异常。每一种停止原因都有对应的恢复手段,每一次真正的终止都有语义化的分类。
这种设计哲学值得借鉴:Agent 的鲁棒性,不是靠模型更聪明,而是靠工程层把每一个可能的失败路径都想清楚。
你在实现 Agent Loop 时,遇到过哪些奇怪的「莫名停止」?欢迎留言分享,说不定我们踩的是同一个坑。
如果这篇文章对你有帮助,点个赞收藏一下~后续还会继续扒 Claude Code 源码,下一篇准备聊聊上下文压缩的实现细节。