为什么你的 Agent 明明任务没做完,还是停止调用工具了?
深入 Claude Code 源码,拆解四层防护设计,让你的 Agent Loop 不再「莫名其妙」罢工。
你有没有遇到过这种场景?
你让 Agent 帮你重构一个模块,把分散在 15 个文件里的代码逐个迁移。Agent 干得不错——它先用 Glob 定位了所有文件,再逐个 Read,然后开始 Edit。
改完第一个文件后,它停下来了,发来一条消息:
"我已经修改了第一个文件,其他文件也需要我修改吗?"
你盯着屏幕,血压上升:废话,我说的是「所有文件」,你问我?
或者更诡异的:
Agent 调了一个命令,返回空输出。然后它冷静地告诉你:
"好的,已完成。"
什么都没做,但语气笃定得像刚写完整个代码库。
这不是幻觉,这是工程问题。 我也曾经在这些坑里反复摔跤。直到我扒了一遍 Claude Code 源码,才发现在 Agent Loop 的「提前停止」这件事上,有一套精密到令人发指的多层防护体系。
今天就把这套体系拆给你看。
第一层:API 协议层 — 空结果触发的「幽灵停止」
这是最隐蔽的一个 bug,也是 Claude Code 源码里藏得最深的一句修复:
// 任何返回空内容的 tool_result 都会被替换为:
{
content: `(${toolName} completed with no output)`
}
发生了什么?
Claude API 底层协议用 \n\nHuman: 作为对话轮次的停止标记。当工具返回空内容时,连续的空白 tool_result 块在某些情况下会被解析器误判为对话结束信号。模型不是「不想干活」,它是被协议层面的静默信号给「卡住」了。
Claude Code 的解决方案简单到令人意外:永远不让模型看到空的 tool_result。任何空结果都会被注入一个包含工具名的占位文本——"(Bash completed with no output)"——确保没有空内容能穿透到模型的视野。
这也是你实现 Agent Loop 时,工具返回空字符串就莫名其妙停止的根因。
第二层:模型层 — 不调用工具≠任务完成
协议层的 bug 修了,还有更难缠的:模型确实会主动决定不调用工具。
大多数 Agent Loop 的实现是这样的:
if hasToolCalls → execute → continue
else → break // ❌ 这就停了
Claude Code 的做法截然不同——它把模型「不调用工具」当成一个可恢复的信号,进入一棵精心设计的决策树:
上下文过长? → 先 collapse drain retry(自动压缩历史对话后重试)→ 不行就 reactiveCompact(实时削减上下文窗口)→ 都用尽了才真正终止。
输出被截断? → 追加 recovery 消息("Continue from where you left off"),让模型接着来。但有次数限制,耗尽了再进入下一级。
弹尽粮绝了? → 检查有没有 blocking errors → 有就追加到 messages 让模型看到 → 还不够就检查 token budget → 还有余量就发 nudge 消息推一把 → 只有所有手段都用尽了,才允许终止。
这棵树的设计哲学是:你不想干活,我推你干;你干不了,我给你创造条件干;只有山穷水尽了,我才放你走。
第三层:提示词层 — 心理暗示 + 威慑
工程手段能修 bug,但治不了模型的「主观意愿」。Claude Code 在系统提示词里植入了精妙的护栏:
「不要轻易放弃」:
如果一个方法失败了,先诊断原因再切换策略——读错误、检查假设、精准修复。不要盲目重试同一种做法,但也不要因为一次失败就放弃整个可行方案。
这条指令的精妙在于:它给模型构建了一个「诊断→修复」的认知框架,而不是「失败→停止」的本能反应。
「停下也没用」:
Token 预算是一个硬性下限,不是建议。如果你提前停止,系统会自动继续推进。
这甚至不像是给模型的指令,更像是一种心理威慑。当模型知道「停下也会被 nudge 回来」,它的策略就从「投机取巧」变成了「老实干完」。
第四层:工程层 — 硬限制与容错
最后一层是基础设施的保护:
- maxTurns / budget 超限 —— 真正的资源性停止,合理终止。
- 权限拒绝 —— 记录但继续循环,不会因为一个工具的权限问题拉垮整个 Agent Loop。
- 压缩触发 —— 压缩成功后直接进入下一轮,不调用模型,避免压缩后的「空窗期」浪费。
Claude Code 对每类终止原因都做了语义化分类:max_turns、prompt_too_long、stop_hook_prevented、completed……让每一次停止都能被追溯和优化。
四层防护总览
一层兜一层,层层递进:
改造示例:从「一刀切」到分级决策树
把你现在可能写的:
// ❌ 常见写法:模型不调工具就直接停
while (true) {
const response = await model.generate(messages)
if (!response.hasToolCalls) break // 一刀切
await executeTools(response.toolCalls)
}
替换为分级决策:
// ✅ 分级决策树:只有山穷水尽才停
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(...formatErrors()) // 让模型看到错误再试
continue
}
if (tokenBudgetRemaining > 0) {
messages.push({ role: "user", content: "Is there more work to do?" }) // nudge
continue
}
break // 只有到这里才真正终止
}
核心原则就两条:
- 模型不调工具 ≠ 任务完成——先救,别急着停
- 每种停止原因都有对应的恢复手段——压缩 / 追加 / nudge,用尽了再放弃
回过头来检查你自己的 Agent Loop
| 检查项 | 你做了吗? |
|---|---|
| 空 tool_result 注入了占位文本? | |
| 模型返回纯文本时,有多级恢复而非直接 break? | |
| 提示词里有「别轻易放弃」的 explicit instruction? | |
| 上下文过长能自动压缩+重试? | |
| 单个工具失败不会终止整个 Loop? | |
| 能区分「模型主动停」和「资源耗尽」? |
最关键的一条:把那个简单粗暴的 if (!hasToolCalls) break 替换成一个分级决策树。模型不调用工具,不一定是做完了——很可能只是卡住了。