对话循环:Agent 的心跳,以及最难的那个「停」
上一篇讲了「一次工具调用」——模型发请求、你的代码执行、结果喂回去、模型给出回答。那是 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 宽限调用、执行层)。原理部分译自英文课程,保留关键英文术语。