Agent Loop 的终止条件、超时与错误处理
循环一直在运行,不代表任务一直在进展。它可能重复同一个调用,也可能等不到外部结果。下面分别处理完成、出错、超时和用户取消,确保每种停止都有清楚的原因,也不会留下继续工作的子任务。
1. 终止条件必须由运行时强制
常见停止来源:
- 模型返回 final answer;
- 任务成功条件被验证器满足;
- 达到最大步骤;
- 达到 wall-clock deadline;
- token、金额或工具调用预算耗尽;
- 用户取消;
- 不可恢复错误;
- 连续重复动作触发循环检测。
只在 prompt 中写“最多调用 10 次”不够。模型可能忽略,网络重试也可能让真实次数与模型认知不同。
2. 多层超时
- 整次运行超时:限制任务最长时间。
- 单轮模型超时:控制 provider 卡住。
- 单工具超时:不同工具配置不同上限。
- 外部请求超时:连接、首字节、读取分别设置。
- 人工确认超时:超时后保持待处理或明确取消。
超时结果应包含发生层级、已用时间、是否产生部分副作用以及是否适合重试。
3. 重试分类
| 错误 | 默认策略 |
|---|---|
| 临时网络错误、限流 | 指数退避 + jitter,尊重 Retry-After |
| 非法参数 | 返回字段错误,由模型修正 |
| 权限错误 | 停止该动作或切换到允许的只读路径 |
| 空结果 | 改写查询、扩大范围,但限制次数 |
| 业务冲突 | 重新读取最新状态,再决定 |
| 非幂等动作结果未知 | 先查询状态,避免直接重复 |
| 模型格式漂移 | 结构化重试一次,再失败则记录 |
4. 循环检测
对最近动作生成指纹:
fingerprint = hash(tool_name + canonical_json(arguments) + relevant_state)
如果相同指纹连续出现,且观察没有新信息,可:
- 向模型加入“动作重复且状态未变化”的观察;
- 要求明确提出不同策略;
- 超过阈值后停止并输出失败记录。
还要检测语义循环,例如搜索词只做轻微改写但结果集合相同。
5. 部分成功
长任务可能完成 8/10 个子项。状态应保存:
- 已完成项和验证证据;
- 未完成项及原因;
- 可安全重试的下一步;
- 已产生副作用;
- 检查点版本。
最终响应要区分 complete、partial、failed,而不是把部分结果包装成完整成功。
6. 故障注入测试
主动模拟:
- provider 429/500;
- 工具返回慢;
- 结果 JSON 损坏;
- 数据库冲突;
- 进程在工具执行后、状态提交前崩溃;
- 用户中途取消;
- 相同动作重复。
验证预算、幂等、恢复和 trace 是否仍然正确。
7. 错误分类决定恢复策略
| 错误类别 | 示例 | 默认策略 |
|---|---|---|
| 参数错误 | schema 不匹配、必填项缺失 | 把字段级错误作为观察交给模型修正 |
| 暂时性基础设施错误 | 429、连接重置、短暂 5xx | 指数退避、抖动、总重试预算 |
| 权限错误 | 认证过期、路径越界、审批缺失 | 刷新凭据、申请审批或结束,不盲目重试 |
| 业务冲突 | 版本冲突、库存变化 | 重新观察最新状态后重规划 |
| 语义失败 | 结果不满足成功标准 | 回到计划或 evaluator,限制修复轮次 |
| 取消/截止时间 | 用户取消、deadline | 传播取消信号,写 checkpoint 和停止原因 |
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。如果工具做的是付款,最后一种情况必须查询付款状态,不能为了让循环继续就再付一次。