停止条件、超时与失败边界
学习目标
- 为 Agent Loop 设计可判定的成功、失败、暂停和预算停止条件。
- 区分 MaxSteps、RetryBudget、Action Timeout 和 Run Deadline。
- 把可恢复错误与不可恢复错误转换成稳定的 StopReason 和用户可读结果。
前置知识
- 已阅读本章前五篇,理解 Goal、Action、Observation、RunState 和 RunStatus。
- 已了解 Tool 的副作用需要由 Runtime 控制;本文不依赖在线模型或外部服务。
为什么必须显式停止
Goal Reached
Goal Reached 表示 Success Criteria 已被 Observation 证实。它是最优先的正常停止路径;模型说“完成了”不够,必须有状态或工具结果支持。
MaxSteps
MaxSteps 限制一次 Run 最多允许多少个可计费或可产生副作用的执行步骤。它是防止循环失控的硬边界,不是让模型自行决定何时停的提示。
RetryBudget
RetryBudget 限制同一个失败动作可以重试多少次。它应按动作或错误类别计数,不能把每次全局循环都当作新重试,否则一个坏工具会消耗所有预算或造成重试风暴。
Action Timeout
Action Timeout 限制单次 Model 调用或 Tool 执行的等待时间。它释放当前等待,但不必然撤销已经发给外部服务的副作用;取消能力和外部幂等性要另行设计。
Run Deadline
Run Deadline 是整个 Run 的绝对截止时间。即使每个动作各自没有超时,只要达到 Deadline,Runtime 也应拒绝开始下一步并返回 timeout。
Awaiting Approval
等待用户审批不是失败,也不是成功。Runtime 应进入 waiting 状态,保存待审批 Action 和过期时间,收到明确批准或拒绝后再恢复;不能让模型继续猜测审批结果。
阅读提示:审批等待只有收到明确批准才回到检查链;拒绝和过期分别进入终态。其他停止分支仍由 Runtime 根据可验证的 Observation、硬边界和预算决定。
错误的恢复边界
可恢复错误
可恢复错误是通过重试、补充输入或换用受限动作,有合理机会继续的错误,例如临时网络超时、缺少非敏感字段或工具返回暂时不可用。恢复策略必须有预算和退避,不能无限重试。
不可恢复错误
不可恢复错误意味着当前 Run 不应继续执行,例如动作违反硬约束、工具不存在、权限明确拒绝、输出无法解析且已达到修复预算。Runtime 应保存原因并返回失败,而不是反复把同一个输入送给 Model。
不确定结果
网络连接中断时,外部动作可能已经发生,也可能没有发生。此时不能简单当作失败后重试;应进入 unknown 或 waiting,先通过查询、幂等键或人工确认判断真实状态。
FailureBoundary
FailureBoundary 是决定“当前错误能否继续留在本次 Run 内处理”的边界。它把底层异常转换成三类结果:可以在预算内恢复、必须暂停等待外部确认、或立即结束并交给上层处理。边界的所有者应是 Runtime 或业务 Workflow,而不是 Model。
失败转换
底层异常应转换成包含来源、类别、是否可恢复和安全提示的结构化错误。对用户显示简洁原因,对日志保留诊断上下文;不要把凭据、完整隐私数据或内部堆栈直接交给 Model。
常见失控模式
空回答循环
Model 连续返回空文本或缺少动作时,Runtime 应计入解析失败预算,并在超限后以 empty_output 停止。把空结果当作“继续问一次”会产生没有进展的循环。
重复动作循环
相同输入反复触发同一个 Action,且 Observation 没有变化时,应识别为 no_progress 或 loop_detected。仅增加 MaxSteps 只能延后故障,并不会让系统取得新信息。
工具结果不一致
工具声称成功,但随后查询不到预期状态,说明 Postcondition 未满足或外部状态最终一致。Runtime 应将其标为 inconsistent_result,避免继续根据成功假设执行下一步。
错误重试风暴
多个层都实现重试时,单次调用可能被指数放大。RetryBudget 应有明确的所有者;子工具已经重试过的错误要携带 attempt 信息,父 Run 不应无条件重新开始整条流程。
一个带预算的本地运行器
下面的代码用预先定义的动作序列模拟成功、重复无进展和预算耗尽,展示 StopReason 如何由 Runtime 判定;它不访问网络,也不执行危险操作。
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RunResult:
status: str
stop_reason: str
output: str | None = None
def run_with_budget(observations: list[str], max_steps: int = 4, retry_budget: int = 1) -> RunResult:
previous = None
retries = 0
for step_index, observation in enumerate(observations, start=1):
if step_index > max_steps:
return RunResult("failed", "max_steps")
if not observation:
retries += 1
if retries > retry_budget:
return RunResult("failed", "empty_output")
continue
if observation == previous:
return RunResult("failed", "loop_detected")
previous = observation
if observation.startswith("done:"):
# 成功条件:Observation 明确给出可交付结果。
return RunResult("completed", "goal_reached", observation[5:])
# 输出契约:没有成功条件且没有其他错误时,不伪造最终答案。
return RunResult("failed", "max_steps")
print(run_with_budget(["working", "done:report ready"]))
print(run_with_budget(["working", "working"]))
print(run_with_budget(["", "", "done:late"], retry_budget=1))
# 输出:RunResult(status='completed', stop_reason='goal_reached', output='report ready')
# 输出:RunResult(status='failed', stop_reason='loop_detected', output=None)
# 输出:RunResult(status='failed', stop_reason='empty_output', output=None)示例中的 observations 代表已经经过工具或模型边界的结果;真实 Runtime 还要用单调时钟检查 Deadline,并把异常转换成同一类 RunResult。成功、失败和预算耗尽都有可见的原因。
StopReason 的稳定分类
StopReason 应使用有限、可枚举的分类,例如 goal_reached、max_steps、retry_exhausted、timeout、cancelled、awaiting_approval、tool_error、constraint_rejected 和 unknown_outcome。展示文案可以变化,分类不应依赖自然语言匹配。
用户提示与内部诊断
对用户可说“在截止时间内未能确认部署状态,请稍后查询”;对日志应同时记录工具名、动作摘要、耗时和请求标识。两者的详细程度不同,但都不能捏造成功。
超时后的副作用
超时只证明本地等待到达上限,不能证明远程动作被撤销。对付款、发布、删除等动作,要提供查询状态、幂等键或人工确认路径;没有这些能力时,应优先进入 unknown/waiting,而不是自动重试。
取消与失败的区别
用户主动取消和工具自然失败是不同的 StopReason。取消应尽快阻止新的 Action,并把当前任务标为 cancelled;已经发出的外部请求是否停止,要由执行器和服务协议决定。第 07 篇会专门讨论取消与异步执行。
源码阅读时寻找失败边界
找预算的真正拥有者
搜索 max_steps、timeout、retry 或 deadline 等字段,确认它们是在 Model 调用前、Tool 调用前还是整个 Run 层检查。多个层各自计数时,计算总预算和重试倍增风险。
找异常转换点
查看工具异常是否被转换成结构化结果、是否保留 cause、是否会触发下一轮 Model。若异常直接被 catch 后返回空字符串,后续很容易形成空回答循环。
找成功判定
确认成功是由工具返回、状态查询、schema 校验还是模型文本触发。模型文本可以作为候选信号,但需要可验证的 Observation 或后置条件支持。
找最终出口
任何 return、raise、event 或 callback 都可能是运行出口。检查它是否写入 RunStatus、StopReason 和最终结果引用,避免用户看到错误但状态仍显示 running。
与主线项目的对应关系
smolagents
smolagents 的执行器通常需要限制迭代、解析失败和工具错误。阅读时重点看何时把模型输出视为最终结果、何时继续、何时抛出执行异常;具体配置名不要当作跨版本契约。
OpenAI Agents SDK
OpenAI Agents SDK 的 Runner、guardrail 和工具执行共同影响停止。源码阅读要区分模型完成、工具错误、转交和外部取消,并确认事件中是否能找到对应的 StopReason。
LangGraph
LangGraph 的图边可以通过条件限制循环,节点和执行器可承载超时、重试与中断。图上存在一条边不代表它必然安全;节点仍需处理外部副作用和不确定结果。
DeepSeek Harness
DeepSeek Harness 的 Driver、Hook 和事件机制可能加入审批、取消或失败传播。阅读时优先确认父 Run 是否会等待插件结果,以及插件失败是否被错误地降级成继续运行。
易混点
- 超时不是撤销:本地停止等待不等于远端副作用被回滚。
- MaxSteps 不是 RetryBudget:前者限制总动作次数,后者限制同一失败路径的重试次数。
- 取消不是失败:用户取消应保留 cancelled 语义,不要伪装成 tool_error。
- 未知结果不是失败成功二选一:外部动作状态未知时应查询、等待或人工确认。
- 模型说完成不等于 Goal Reached:成功必须对应可验证的 Observation 或 Postcondition。
课后小问
远程发布请求超时后,为什么不能立即再发一次?
答案:超时只说明本地没有及时收到结果,远程发布可能已经成功,第二次请求可能造成重复副作用。
解析:应先用请求标识查询状态,或依赖幂等键保证重复请求安全;无法确认时进入 unknown/waiting 比猜测失败更可靠。
为什么空回答需要单独的停止原因?
答案:空回答通常是解析或模型输出问题,和工具失败、用户取消的修复路径不同。
解析:结构化 StopReason 让监控知道应检查模型响应、提示或解析器,而不是盲目增加工具重试。
本节小结
- Agent 必须同时定义成功、预算耗尽、超时、取消、等待和失败出口。
- MaxSteps、RetryBudget、Action Timeout 和 Run Deadline 属于不同层级的限制。
- 可恢复错误要有有限预算,不确定结果要查询或等待,硬约束失败不能自动放宽。
- StopReason 是程序、日志和用户提示之间的稳定契约。
快速回顾
- 能说明 Goal Reached、MaxSteps、RetryBudget、Action Timeout 和 Run Deadline 的区别。
- 能判断超时后何时重试、何时进入 unknown/waiting。
- 能为解析失败、工具异常、循环无进展和用户取消选择不同 StopReason。
- 下一篇阅读 同步、异步、取消与流式执行,把时间边界扩展到任务调度和实时输出。
阿源的知识库