对话循环:Agent 的心跳,以及最难的那个「停」

Agent; Agent Loop; ReAct; 停止条件; doom loop 2327 words 12 min read
This post is not yet available in English. Showing the original.

上一篇讲了「一次工具调用」——模型发请求、你的代码执行、结果喂回去、模型给出回答。那是 Agent 的原子。但一次调用几乎不够用。

一个简单问题——「东京现在天气怎么样」——一次调用就够。但一个真实问题——「东京这周末天气适合野餐吗?如果适合,我周六日历有空吗?」——至少需要查天气、查日历、做比较三步,而且可能相互依赖,还可能第四步来澄清。你没法预先算出要几次调用;模型必须在每一步看着已知信息临场决定。

这就是对话循环(agent loop)

,每一轮后加一个决策点——继续,还是停?

真正难的不是循环体。循环体你上一篇已经会写了。真正难的是

一个每个人都踩过的坑

一位同事把一个 Agent 甩给你:「demo 里好好的,一到生产就跑不完。」

你读代码。循环在,模型在发工具调用,结果在回流。但没有任何东西告诉模型什么时候该停,也没有任何东西告诉循环——当模型永远不说「我做完了」时,该怎么办。

你加了一行 if step > 20: break。循环退出了——但现在它是半句话中间退出的。你把 break 挪到模型回复之后。大多数时候干净退出了,但偶尔模型在看似最终答案之后又发了一个工具调用,你悄无声息地漏掉了结果。

你在这上面花了一整天。

修复的关键不是更多代码,而是理解一件事:一个循环有好几种结束方式,它们都得在,而且模型自己的 stop_reason 才是主信号——不是一个行数计数器。

五个阶段

想象一个正在出餐的忙碌厨房。主厨(模型)喊出订单,厨房(你的工具)执行,主厨尝一口回来的菜再喊下一轮——直到菜品装盘送出。副厨(你的循环控制器)不决定菜什么时候做完,主厨决定。但如果主厨哑火了,或者在盘子已经送走之后还在喊订单,副厨需要一个后备计划——预算、计时器、按铃的手——防止厨房滑向混乱。

叫它 ReAct、plan-and-execute、think-act-observe 都行,底下都是同样五个阶段:

  • Observe(观察)
    ——用户消息、系统提示、之前的工具结果、检索到的上下文。实践中,这就是那个不断增长的消息数组。
  • Plan(规划)
    。它返回工具请求、最终答案,或一个问题。这一步你的代码不做决策,模型做。
  • Act(执行)
    。一个或多个——和上一篇同样的分发逻辑,现在放进了循环。
  • Reflect(反思)
    ID 追加到消息数组。模型现在能看到发生了什么。
  • Stop(停止)
    。有就返回,没有就回到 Observe。
stateDiagram-v2
    [*] --> Observe
    Observe --> Plan : 消息就绪
    Plan --> Act : 工具请求
    Plan --> Stop : 最终答案 (stop_reason: end_turn)
    Act --> Reflect : 结果收齐
    Reflect --> Stop : 某个停止条件触发
    Reflect --> Observe : 继续
    Stop --> [*]

循环真正携带的东西

循环不只是在遍历消息数组。轮次之间它还持有:

  • 迄今花的 token——用于预算检查。
  • 步数——用于迭代上限。
  • 最近工具调用的短历史——用于 doom-loop(死循环)检测。
  • 一个 abort token——让用户或系统的其他部分能在循环中途取消。
  • 系统提示——跨轮次保持字节级稳定,让前缀缓存持续命中(下一篇讲为什么)。

你只有在第一次尝试从崩溃中恢复一个循环时,才会意识到循环携带了多少东西。那是「状态与持久化」那一篇的问题。现在只需知道

停止条件是一个谱系,不是一个勾选框

每个生产级循环都用好几个停止条件,从最软到最硬分层叠放:

  • 模型驱动的停止(model-driven stop)
    ,且 finish reason 是 end_turn(OpenAI 形状的 API 上是 stop)。这是主信号——模型认为自己做完了。
  • 显式 final_answer 工具
    final_answer(text) 工具,让它成为模型提交结果的唯一合法途径。这强制模型有意识地收尾,防止在答案已存在后还漂移出额外调用,还给你一个干净的、可记录的标准输出。
  • 宽限调用(grace call)
    ,提示里写「你还剩一轮,收尾吧」。模型通常干净地收场。没有它,硬上限会拦腰砍断思考。OpenClaw 是这个模式最清晰的参考。
  • 步数上限(step cap)
    ——通常 10-50,长时运行的助手系统里有时到 ~90。这是安全网,不是主停止。如果你的循环大多数时候停在这里,说明上游出了问题。
  • Token 或成本上限
    token 或累计成本越过阈值就退出,返回已产出的部分并标注为「不完整」。

线上的形状:

// 最小循环——是形状,不是你的最终代码。
for (let step = 0; step < MAX_STEPS && totalTokens < TOKEN_BUDGET; step++) {
  const response = await llm.complete({ messages, tools });
  totalTokens += response.usage.totalTokens;

  // 模型驱动停止,或显式 final_answer。
  if (isFinalAnswer(response)) return finalize(response);

  const results = await executeTools(response.toolCalls);
  messages.push(...toToolResultMessages(results));
}
// 循环因上限退出——返回部分结果,标注清楚。
return partialResult(messages);

注意 stop_reason 的判断在最前面,行数计数只是兜底的循环条件。这个顺序本身就是设计:优先听模型的,行数只是防失控的护栏。

藏在循环里的失败模式

Doom Loop(死循环)

模型陷入「读文件 → 发现不对 → 再读同一个文件 → 还是不对 → 再读……」的重复。它不会自己意识到自己在原地打转。

对策是保留一段最近工具调用的短历史,检测「相同调用 + 相同参数」的重复。连续重复 N 次(常见是 3)就打断——注入一条提示让它换策略,或者直接升级到人类介入。Goose 的 Repetition Inspector 就是干这个的。

静默漏结果

模型在看似最终答案之后又发了一个工具调用。如果你的停止判断放错位置,你会把这次调用连同它的结果一起漏掉,而模型还在等那个永远不回来的结果。

final_answer 工具直接消除了这个模式

,「答案之后又有调用」这件事在协议上就不可能发生。

半句话退出

硬上限拦腰砍断。宽限调用是解药——给模型一个「知道自己要收尾」的最后机会,而不是让计数器在它思考到一半时掐断。

步边界

这一篇最该带走的一句话:每一轮循环之间的那个「步边界」(step boundary),是几乎所有生产能力最终的挂载点。

  • 持久化
    ,崩溃后从这里恢复。
  • 可观测性
    ,记录每一步的 token、耗时、决策。
  • 权限
    ,决定是否放行。
  • 审批(human-in-the-loop)
    ,等人类确认。
  • 压缩
    ,超了就压缩。

你现在不需要实现这些。你只需要知道

「持久化」「可观测」「权限」「压缩」时,它们全都挂在同一个地方——循环每一轮之间的那个缝隙。把这个缝隙留干净、留清楚,后面所有能力才有地方安放。

真实项目怎么停

  • OpenClaw 的宽限调用是最清晰的参考
    ,而不是硬砍。
  • Goose 的 Repetition Inspector 做 doom-loop 检测,是「循环携带最近调用历史」的实证。
  • 长时运行的助手系统(如 Hermes)把步数上限放到 ~90,但依然靠 stop_reason 做主停止——上限只是那个几乎不该被触发的安全网。

一次工具调用是原子。这一篇把它放进了带停止条件、带失败检测、能把多次调用串成多步工作的循环里。这就是聊天机器人结束、Agent 开始的那条边界。

下一篇讲这个循环真正的燃料

、上下文,以及那个决定成本的隐藏变量——缓存。


本文主要参考

ch02(the agent loop,五阶段 / 停止条件谱系 / 步边界)· agent开发系列(Goose Repetition Inspector、OpenClaw 宽限调用、执行层)。原理部分译自英文课程,保留关键英文术语。