别再让 Agent 裸重试:先做错误分级和副作用确认,再同时启用有限重试、熔断与任务终止三层止损机制。只要这三层都由编排器代码强制执行,即使模型反复生成相同的工具调用,任务也会在次数、时限、预算或状态条件触发后结束,而不是持续消耗配额。掘金热榜《为什么你写的 Agent 一报错就死循环?很多人都漏掉了这层“错误自救与熔断”机制》讨论的,正是这层经常被遗漏的稳定性设计。
为什么同一个报错会被反复执行?
排查这类问题,先对齐日志里的三个字段:工具名、规范化后的参数、错误签名。如果连续几次调用的这三项完全相同,而下游服务状态也没有变化,那么后续尝试并没有获得新信息,继续重试通常只会复制故障。
循环一般来自三个位置:框架或 SDK 捕获异常后自动重试;模型拿到工具报错,把错误当成一次普通 Observation,再次选择相同工具;任务编排器没有进度判断,失败后仍把原步骤推回队列。固定间隔重试会把局部故障放大成请求风暴,多个定时任务还可能同时重试,进一步挤占下游容量。
上下文膨胀也会放大问题。错误日志、调用历史和中间结果不断进入对话,Agent 一边重复同一步,一边让上下文越来越长,最后既丢失最初目标,也更难判断任务是否已经推进。
更关键的是,裸错误字符串只告诉模型“失败了”,没有告诉它应该换参数、换接口、走降级还是立即停止。Observation 如果只有一句 timeout,模型能做出的选择往往只有“再试一次”。它至少应包含错误类型、状态码或错误码、最近尝试记录、上下游状态,以及允许执行的下一步动作。
所以,最先要做的并非修改 Prompt,而是让编排器拥有否决权:同一调用连续没有进展时,由代码终止该调用路径,而不是等待模型自行意识到应该停。
哪些错误能重试,哪些应该马上停?
判断顺序应当是“先校验响应,再分类错误”,不能只看 HTTP 状态码。HTTP 200 只说明传输完成,不代表业务结果有效。body 为空、缺少必填字段、输出无法解析,都可能属于应用层失败;但 results=[] 究竟是故障还是合法的零结果,必须由具体工具的响应契约决定,不能一律重试。
| 观察到的信号 | 错误判断 | 默认处理 |
|---|---|---|
| 连接建立失败、读取超时、网络波动 | 可能是瞬时故障 | 在调用预算内做有上限的指数退避;相同错误连续出现时触发无进展检测 |
| HTTP 429 | 可能是临时限流,也可能是配额耗尽 | 前者允许有限退避重试;后者直接上报,避免继续消耗请求 |
| HTTP 500、502、503、504 | 通常可能是下游暂时不可用 | 有限重试,同时检查错误是否持续发生在同一依赖上 |
| HTTP 401,token 已过期 | 凭据可刷新 | 刷新凭据后执行一次受限重试;刷新失败则停止 |
| 权限不足、配额或计费耗尽 | 原请求不会自行恢复 | 不重试,转为告警或人工处理 |
| 参数错误、业务规则拒绝 | 语义错误 | 修正输入或进入业务失败分支,不原样重放 |
| HTTP 200 但 body 为空或结构不完整 | 伪成功或应用层异常 | 按工具契约校验;允许有限重试,但相同无效结果重复出现时停止 |
| 写操作超时,结果未知 | 可能已经产生副作用 | 先查询执行状态或确认幂等性,确认前禁止重放 |
这些是分类原则,不是所有服务的统一错误码含义。具体 API 的 429、配额和 5xx 语义,仍应优先查对应服务的官方文档。错误分级的目的,是把可以恢复的瞬时故障与不会因重复调用而消失的永久错误分开。
可以把一次调用的处理逻辑写成下面的语言无关伪代码:
validate_response
→ classify_error
→ verify_side_effect
→ retryable AND attempts_available AND breaker_closed
→ exponential_backoff_retry
→ otherwise
→ fallback / terminate / escalate
其中 validate_response 负责发现 HTTP 200 下的无效结果,verify_side_effect 负责确认写操作是否已经执行。两者不能因为错误信息里出现了 timeout 就被省略。
重试、熔断和终止条件分别管什么?
三者控制范围不同,不能互相替代。
| 控制层 | 控制范围 | 主要条件 | 触发后的行为 |
|---|---|---|---|
| 有限重试 | 单次工具调用 | 最大尝试次数、指数退避、错误是否可重试 | 延迟后再次调用,或把失败交给下一层 |
| 熔断器 | 某个下游依赖 | 连续失败、失败比例等阈值 | OPEN 时快速失败,不再访问该依赖;经过恢复窗口后进入 HALF_OPEN 探测 |
| 任务终止 | 整条 Agent 任务 | 总尝试数、时限、Token 或费用预算、无进展、取消信号、副作用未知 | 输出最终原因,停止自主执行并进入降级或人工处理 |
最大重试次数只约束一次调用。十个任务各重试三次,仍然可能同时向下游发出三十次请求。熔断器按依赖隔离故障,可以阻止多个定时任务反复踩同一个服务,但它不负责判断整条任务是否应该结束。终止层依据总尝试数、时限、Token 或费用预算、无进展、取消信号和副作用未知,判断整条任务是否结束。
熔断器通常有三种状态。CLOSED 表示正常放行;达到失败阈值后转为 OPEN,直接拒绝该依赖的调用;恢复窗口结束后转为 HALF_OPEN,只允许少量探测请求,成功则关闭熔断,失败则重新打开。阈值和恢复窗口没有通用值,需要结合下游 SLA、并发量和调用代价设置。
终止条件则应在任务状态中明确记录。示例状态名可以是:
SUCCEEDED
NON_RETRYABLE
RETRY_EXHAUSTED
CIRCUIT_OPEN
BUDGET_EXHAUSTED
NO_PROGRESS
SIDE_EFFECT_UNKNOWN
CANCELLED
NEEDS_HUMAN
这些名称只是建议的状态协议,不是某个框架的标准枚举。其中 CIRCUIT_OPEN 只结束当前依赖路径,任务仍可尝试缓存、降级或其他依赖;只有不存在安全降级路径时,才进入 NEEDS_HUMAN。硬上限必须由编排器代码执行,不能寄希望于模型或 Prompt 自觉遵守。
配置时,最好把不同层级的预算拆开,而不是只设置一个 max_retries:
call_max_attempts
call_base_delay
call_max_delay
breaker_failure_threshold
breaker_open_duration
task_max_total_calls
task_deadline
task_max_tokens
task_max_cost
no_progress_limit
超时后直接重试,会不会把副作用执行两遍?
会。超时只说明客户端没有收到响应,不代表服务端没有执行。写请求如果已经落库、扣款或发送消息,原样重试就可能产生两次副作用。
这类操作应先确认接口是否支持幂等键。支持时,可以在原操作状态未知的前提下复用同一个幂等键发起查询或重试;不支持时,应优先使用请求 ID 查询执行状态。两种条件都不具备,就只能进入对账或人工处理,不能继续盲目重放。
write_timeout
→ if idempotency_supported:
retry_with_same_idempotency_key
→ else if execution_status_queryable:
query_status
→ else:
SIDE_EFFECT_UNKNOWN
→ escalate
查询到执行成功时,应返回成功;明确失败且操作可重试时,才重新发起;状态仍未知时,则保留 SIDE_EFFECT_UNKNOWN,转人工或按业务规则补偿。补偿和回滚是另一套流程,不能与“重新调用一次”混为一谈。
所有止损机制触发后,还能怎么恢复?
自动恢复链应当按“降级或缓存、告警、人工接管、回滚、复盘”的顺序逐级收口,但不是每次故障都必须执行全部步骤。
降级的前提是业务允许。例如实时数据源不可用时可以读取缓存,但必须明确标记数据可能过期;如果缓存也不可用,就应进入告警而不是继续调用故障依赖。人工接管需要保留任务状态、已完成步骤和未完成目标,让人能够从断点继续,而不是重新执行整个流程。
如果失败操作已经产生副作用,应根据业务规则决定是否回滚或补偿。故障结束后记录工具名、错误分类、尝试历史、每次等待时间、熔断状态、剩余预算和最终终止原因。日志里只有一行 TimeoutError,无法判断是重试过多、熔断误开,还是任务预算已经耗尽。
怎么验证它真的会停,而不是换个地方继续循环?
测试环境里分别模拟持续返回 429、连接超时和 HTTP 200 下的无效响应,断言应针对配置值,而不是把“重试三次”写死。语言无关的测试断言可以写成:
test always_429:
assert tool_calls <= configured_call_limit
assert delays follow configured_exponential_backoff
assert final_reason in {RETRY_EXHAUSTED, CIRCUIT_OPEN}
test write_timeout_without_status_query:
assert tool_calls == 1
assert final_reason == SIDE_EFFECT_UNKNOWN
test http_200_missing_required_field:
assert response_validation_failed == true
assert final_reason != SUCCEEDED
test empty_results_allowed_by_contract:
assert final_reason == SUCCEEDED
test breaker:
assert state_sequence contains CLOSED -> OPEN -> HALF_OPEN
assert tool_calls == 0 while OPEN
test task_budget:
assert total_tool_calls <= configured_task_budget
assert cancellation_is_respected == true
assert final_reason_persisted == true
还要验证任务级总预算,而不是只统计单个工具。否则 Agent 可能避开同一个工具,换另一个工具继续循环;熔断器也可能只覆盖当前依赖,遗漏其他故障路径。测试最终应证明三件事:调用次数不会越界,有副作用的调用不会重复执行,任务最终状态能够落库并解释为什么停止。
重试次数、熔断阈值和任务预算没有通用的“设置成 3 次”答案,它们取决于下游 SLA、调用是否幂等、并发任务数量、限流规则和可承受费用。参考资料中的“最多重试 3 次”只是一个特定 SubAgent 修复案例的约束,并非通用默认。 这三层机制能保证失败是有限的,却不能保证任务一定成功;尤其是副作用结果未知、下游长期不可用等场景,最终仍需要业务对账和人工接管。














暂无评论内容