Agent Loop 的终止条件、超时与错误处理

循环一直在运行,不代表任务一直在进展。它可能重复同一个调用,也可能等不到外部结果。下面分别处理完成、出错、超时和用户取消,确保每种停止都有清楚的原因,也不会留下继续工作的子任务。

1. 终止条件必须由运行时强制

常见停止来源:

  • 模型返回 final answer;
  • 任务成功条件被验证器满足;
  • 达到最大步骤;
  • 达到 wall-clock deadline;
  • token、金额或工具调用预算耗尽;
  • 用户取消;
  • 不可恢复错误;
  • 连续重复动作触发循环检测。

只在 prompt 中写“最多调用 10 次”不够。模型可能忽略,网络重试也可能让真实次数与模型认知不同。

2. 多层超时

  • 整次运行超时:限制任务最长时间。
  • 单轮模型超时:控制 provider 卡住。
  • 单工具超时:不同工具配置不同上限。
  • 外部请求超时:连接、首字节、读取分别设置。
  • 人工确认超时:超时后保持待处理或明确取消。

超时结果应包含发生层级、已用时间、是否产生部分副作用以及是否适合重试。

3. 重试分类

错误 默认策略
临时网络错误、限流 指数退避 + jitter,尊重 Retry-After
非法参数 返回字段错误,由模型修正
权限错误 停止该动作或切换到允许的只读路径
空结果 改写查询、扩大范围,但限制次数
业务冲突 重新读取最新状态,再决定
非幂等动作结果未知 先查询状态,避免直接重复
模型格式漂移 结构化重试一次,再失败则记录

4. 循环检测

对最近动作生成指纹:

Text
fingerprint = hash(tool_name + canonical_json(arguments) + relevant_state)

如果相同指纹连续出现,且观察没有新信息,可:

  1. 向模型加入“动作重复且状态未变化”的观察;
  2. 要求明确提出不同策略;
  3. 超过阈值后停止并输出失败记录。

还要检测语义循环,例如搜索词只做轻微改写但结果集合相同。

5. 部分成功

长任务可能完成 8/10 个子项。状态应保存:

  • 已完成项和验证证据;
  • 未完成项及原因;
  • 可安全重试的下一步;
  • 已产生副作用;
  • 检查点版本。

最终响应要区分 complete、partial、failed,而不是把部分结果包装成完整成功。

6. 故障注入测试

主动模拟:

  • provider 429/500;
  • 工具返回慢;
  • 结果 JSON 损坏;
  • 数据库冲突;
  • 进程在工具执行后、状态提交前崩溃;
  • 用户中途取消;
  • 相同动作重复。

验证预算、幂等、恢复和 trace 是否仍然正确。

7. 错误分类决定恢复策略

错误类别 示例 默认策略
参数错误 schema 不匹配、必填项缺失 把字段级错误作为观察交给模型修正
暂时性基础设施错误 429、连接重置、短暂 5xx 指数退避、抖动、总重试预算
权限错误 认证过期、路径越界、审批缺失 刷新凭据、申请审批或结束,不盲目重试
业务冲突 版本冲突、库存变化 重新观察最新状态后重规划
语义失败 结果不满足成功标准 回到计划或 evaluator,限制修复轮次
取消/截止时间 用户取消、deadline 传播取消信号,写 checkpoint 和停止原因
flowchart TD E[工具或模型错误] --> C{错误分类} C -->|retryable 且幂等| R[退避后重试] C -->|需要新环境状态| O[重新 Observe] C -->|需要人工决定| H[保存状态并暂停] C -->|不可继续| S[结构化停止] R --> B{总预算仍充足} B -->|是| X[再次执行] B -->|否| S

8. 三层预算

一次调用有 attempt_timeout,一个步骤有 step_deadline,整个任务有 run_deadline。重试次数不是单独的无限计数器,而应消耗统一时间与成本预算。外层取消要传给模型请求、HTTP、浏览器和子进程;仅设置 Promise.race 而未中止底层操作会留下后台副作用。

9. 防止“看似结束”

最终文本出现“已完成”不构成成功。Runner 应检查必需产物、测试结果、提交状态或业务记录,并把未通过项作为 observation。最终返回同时包含 status、stop_reason、checks 和 evidence,让上层能区分完成、部分完成、取消与资源耗尽。

10. 用一次卡住的调用理解“停止”

假设模型建议读取一个 HTTP 地址,运行总预算是 10 秒。只在 while 开头检查时间,会出现这种情况:第 1 秒发起请求,后面一直等待,没有机会回到循环判断。因此需要把剩余预算传给请求适配器,并让适配器支持连接、读取及总时限。

调用方停止等待、运行时取消任务、远端停止工作,是三个不同结果。JavaScript 的 Promise.race 只决定调用方先收到哪个结果,不会自动杀掉另一个 Promise;Python 的 asyncio.wait_for 会请求取消,但要等待取消处理,实际耗时可能超过给定值。吞掉取消或执行阻塞代码的协程也会破坏这个预期。对不能合作取消的本地执行,通常要使用受控子进程,并分别处理终止、等待退出和资源回收。

练习可按三步做:先让假工具等待 100 毫秒并成功;再让它按取消信号退出;最后让它故意忽略信号。检查系统分别记录“完成”“已取消”“仍需回收/结果未知”,而不是三种情况都返回一个笼统 timeout。如果工具做的是付款,最后一种情况必须查询付款状态,不能为了让循环继续就再付一次。

参见 Python asyncio 超时与取消。