Agent 循环何时该停?拆解运行时的五层终止语义

作者:问题终结者2026.11.07 00:28浏览量:0

简介:Agent 的“结束”从来不是一个布尔值。本文从一次生产环境的幂等性两难切入,把循环的停止点拆解为模型流、工具批次、Turn、Driver 空闲与长期目标五层,并给出终止原因的细分枚举、状态机示意与工程落地建议,帮助你把重试、审计与恢复逻辑建立在正确的语义边界上。

一、引子:一个 504 引出的两难

把 Agent 接入工单系统,让它自主领取任务、执行并回报结果,是当前最常见的落地形态之一。设想这样一个场景:工单要求升级某个 SDK,Agent 改完代码、跑绿测试,随后调用接口创建 Pull Request。接口等待三十秒后返回 504。

此时页面上看不到 PR,但服务端可能已经创建成功,只是响应没能送回来。让 Agent 重试,明天可能多出一个内容完全相同的 PR;就此收工,又可能漏掉真正的失败。这是典型的“非幂等写操作 + 不确定超时”组合:一次超时把系统推入“结果未知”状态,停止决策直接变成了业务决策。

再往前一步:即使 PR 建好了,CI 还在排队。模型这一轮宣称“已经完成”,到底算不算完成?这类问题一旦进入生产环境,答案就必须由运行时的结构给出,而不是靠提示词碰运气。

二、while (hasToolCalls) 为什么不够用

朴素实现里,Agent 循环往往长这样:只要模型还在发起工具调用就继续跑,工具不调了就退出。这个结构在演示环境够用,进生产后会接连失守:

  • 工具已经返回,模型可能还要再看一次结果才能决定下一步,工具停下来不等于轮次停下来;
  • 用户在命令执行中途发来新指令(steering),本轮必须被重新拉起;
  • 并行工具之间出现分歧:一个宣布可以收口,另一个留下了需要模型解释的输出;
  • Agent 回到 idle 后,长期目标仍可能通过调度把它重新唤醒。

换句话说,“结束”不是一个单一时刻,而是要在几个不同的位置分别确认。与其追问循环有多少行,不如追问两件事:到底谁说了算,什么时候可以接着跑。

三、把“停”拆成五层

参考成熟 Agent 运行时的通用分层,一次运行中的停顿点大致有五层,它们是嵌套关系,外层结束不蕴含内层语义。

第一层:模型流结束。模型输出流关闭,只说明本次请求收到了结束信号——可能是正常结束,也可能撞上输出上限、触发错误,或被调用方取消。它对应常见的 finish_reason 字段,含义是“这次补全结束了”,而不是“任务结束了”。

第二层:工具批次结束。模型在上一轮发起的一批工具调用全部返回。若在此时退出循环,等于替模型做了“结果不重要”的假设,切断了它基于结果继续推理的机会。

第三层:Turn 结束。一个 Turn 真正结束要同时满足三个条件:工具结果已交回模型、本轮没有待处理的回复、输入队列里没有新消息。用户中途塞进来的 steering,或工具通过回调补进来的上下文,都会把这一轮重新拉起来。

第四层:Driver Activity 结束。当前 Agent 暂时没有下一条可执行的 Turn,回到 idle。这一层只回答“这个 Agent 实例现在忙不忙”,不关心任何业务目标是否达成。

第五层:长期 Goal 结束。持久化的目标状态变为 complete、blocked 或 paused,才算目标级终止。Agent 此刻 idle,不代表目标已完成——调度器下一次巡检时,仍可能通过 follow-up 机制把它叫回来继续推进。

层级 结束意味着什么 不意味着什么
模型流 本次补全收到结束信号 工具已执行、任务完成
工具批次 上一批工具调用全部返回 模型已确认结果、Turn 结束
Turn 结果已回灌、无待处理消息 Agent 空闲、目标完成
Driver 暂无下一条 Turn,回到 idle 目标已完成
Goal 持久状态进入终态 ——(这是最外层的终止)

四、终止原因:一个不能偷懒的枚举

如果只记录“成功/失败”两种结束,审计与重试都会失真。至少应区分以下几类:

  1. TurnEndReason:
  2. COMPLETED # 模型给出最终答复,无待续事项
  3. BLOCKED # 依赖外部条件,暂时无法推进
  4. MAX_TOKENS # 撞上上下文或输出上限,本轮被截断
  5. ABORTED # 运行时收到取消指令,可执行清理
  6. ERROR # 本轮执行中发生错误
  7. INTERRUPTED # 进程中断后由恢复层补记,无清理机会

最值得展开的是 aborted 与 interrupted 的区别:

  • aborted 表示运行时主动收到取消信号——用户按了停止键,或上游下发了取消指令。此时运行时仍有机会做清理:回滚半完成状态、释放锁、写入审计日志。
  • interrupted 则是进程突然消失(崩溃、被杀、机器断电)后,恢复层在重启时给尚未闭合的 Turn 补上的状态。它是一种“事后追认”,事发时没有任何清理机会。

混淆两者的代价非常具体。做副作用审计时:aborted 的 Turn,你知道取消前执行到哪一步;interrupted 的 Turn,只能靠持久化日志反推。决定是否重试时:aborted 通常意味着“有人不想要这个结果”,盲目重试违背用户意图;interrupted 则往往值得在对账后安全续跑。blocked 与 max-tokens 也各有含义:blocked 表示依赖外部条件暂时无法推进,适合挂起等待;max-tokens 表示本轮被截断,续跑前需要考虑压缩上下文或分段输出。

五、谁按下停止键:四种决策主体

把五层结束点再抽象一步,会发现停止的决策权分散在四类主体手里:

  1. 模型:给出最终答复、不再发起工具调用,是 Turn 自然收敛的主因;
  2. 运行时:token 上限、错误处理、超时与取消,是硬性边界的执行者;
  3. 用户:steering 注入新输入会把 Turn 拉长,显式取消则产生 aborted;
  4. 调度器:在 Goal 层面决定是否再次唤醒一个 idle 的 Agent。

设计运行时时,一个有用的检验标准是:任何一处终止,都能回答“是这四类主体中的哪一个、基于什么信号做出的决定”。答不出来,说明状态记录有缺口。

六、工程落地:状态机示意与五条建议

核心循环的示意如下(伪代码):

  1. loop:
  2. resp = call_model(messages) # 第一层:模型流
  3. if resp.finish_reason == "length":
  4. return MAX_TOKENS
  5. if not resp.tool_calls:
  6. return COMPLETED # 模型不再发起工具
  7. results = run_all(resp.tool_calls) # 第二层:等本批工具全部返回
  8. append(messages, results) # 结果回灌给模型
  9. if has_pending_input(): # 第三层:steering/工具注入的新消息
  10. continue # Turn 被重新拉起
  11. continue # 否则模型需再看一眼结果
  12. # 第四层:循环退出且无新 Turn → Driver 回到 idle
  13. # 第五层:Goal 由持久状态决定,调度器巡检时可再次唤醒

配套五条实践建议:

  1. 分层记录,别合并语义。流结束原因、Turn 结束原因、目标状态分别持久化,落在同一张事件表里并用 turn_id 关联,事后才可能精确还原一次运行的完整轨迹。
  2. 对非幂等副作用强制携带幂等键。创建 PR、发送通知这类操作,由客户端生成唯一请求 ID、服务端做去重,504 之后的重试才是安全的,开头那个两难也就不复存在。
  3. 按终止原因制定重试策略。max-tokens 与 error 在修正输入后可重试;aborted 要尊重取消意图,默认不重试;interrupted 需要恢复层先做状态对账,再决定续跑还是标记失败。
  4. idle 不等于完成。调度器巡检长期目标时,以持久状态为准,而不是以 Agent 是否在忙为准,避免“看起来闲着就当作做完了”的误判。
  5. steering 与工具注入统一入队。所有“半路插进来的输入”走同一条消息队列,Turn 的续跑判断就简化为“队列是否为空”,逻辑清晰且不易漏。

七、结语

Agent 循环的难点从来不是“怎么跑起来”,而是“由谁、在哪个语义层级、以什么理由停下来”。模型流、工具批次、Turn、Driver、Goal 五层各管一段,彼此之间不能互相替代;终止原因的细分,则直接决定了审计与重试的正确性。当你的 Agent 开始在生产环境自主执行写操作时,把这些边界写清楚,比堆更多的提示词更能决定系统的可靠性。