为什么你的 Agent 明明任务没做完,还是停止调用工具了?

0 阅读6分钟

为什么你的 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
elsebreak  // ❌ 这就停了

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_turnsprompt_too_longstop_hook_preventedcompleted……让每一次停止都能被追溯和优化。


四层防护总览

一层兜一层,层层递进:


改造示例:从「一刀切」到分级决策树

把你现在可能写的:

// ❌ 常见写法:模型不调工具就直接停
while (true) {
  const response = await model.generate(messages)
  if (!response.hasToolCalls) break  // 一刀切
  await executeTools(response.toolCalls)
}

替换为分级决策:

// ✅ 分级决策树:只有山穷水尽才停
let recoveryAttempts = 0
const MAX_RECOVERY = 3while (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  // 只有到这里才真正终止
}

核心原则就两条:

  1. 模型不调工具 ≠ 任务完成——先救,别急着停
  2. 每种停止原因都有对应的恢复手段——压缩 / 追加 / nudge,用尽了再放弃

回过头来检查你自己的 Agent Loop

检查项你做了吗?
空 tool_result 注入了占位文本?
模型返回纯文本时,有多级恢复而非直接 break?
提示词里有「别轻易放弃」的 explicit instruction?
上下文过长能自动压缩+重试?
单个工具失败不会终止整个 Loop?
能区分「模型主动停」和「资源耗尽」?

最关键的一条:把那个简单粗暴的 if (!hasToolCalls) break 替换成一个分级决策树。模型不调用工具,不一定是做完了——很可能只是卡住了。