要不要重试,又能花到哪一步
一句话总结
重试一行就能开启,但没有确定要重做什么的重试,只是把同样的失败一直重复到上限的装置。而且那种重复,会原样消耗调用预算。
为什么需要它
把智能体写成图之后,总有一天会写下这样的复盘。“支付网关抖动了 30 秒左右,这 30 秒里进来的条目全都以失败告终。”
于是开启重试。在 LangGraph 中,给一个节点加一行就行。
graph.add_node("settle", settle, retry=RetryPolicy(max_attempts=3))
然后下周又写同样的复盘。是两种情况之一。
- 重试没生效。加了策略,但尝试记录里仍然只有一行。
- 重试太过生效。连被银行卡拒绝的条目也各重发了三次,这期间,正常条目可用的调用份额消失了。有的条目被扣了两次款。
这三件事——要重做什么、让同一个请求不被处理两次、最多花多少——分开学每一件都很容易,但不把它们绑在一起,就一定会漏掉一件。
默认值重试得比想象的少
把 RetryPolicy 的参数原样写出来是这样。这是在本实验环境的 langgraph 0.2.60 中亲自确认的值。
RetryPolicy(initial_interval=0.5, backoff_factor=2.0, max_interval=128.0,
max_attempts=3, jitter=True, retry_on=<기본 함수>)
值得注意的是最后的 retry_on。打开默认函数的主体,是这样的。
def default_retry_on(exc):
if isinstance(exc, ConnectionError):
return True
if isinstance(exc, (ValueError, TypeError, ArithmeticError, ImportError,
LookupError, NameError, SyntaxError, RuntimeError,
ReferenceError, StopIteration, StopAsyncIteration, OSError)):
return False
...
return True
也就是说,默认策略不会重试 ValueError。RuntimeError、OSError 也一样。会重试的是 ConnectionError 和以 5xx 结束的 HTTP 响应。想一想,这是合理的默认值——ValueError 或 TypeError 多半是我们自己的代码写错了,这种东西重试一百遍也是同样的结果。
问题在于,我们自己写的异常多半继承 ValueError 或 RuntimeError。如果把“网关暂时不可用”写成 class GatewayBusy(RuntimeError),即使加了策略,一次也不会重试。既没有错误,也没有警告。所以如果只相信“已经加了策略”就放过,几周后又会写同样的复盘。
修复的方法是自己写出 retry_on。给异常类的元组,或者给一个函数。
RetryPolicy(retry_on=(GatewayBusy,), max_attempts=3)
RetryPolicy(retry_on=lambda exc: isinstance(exc, GatewayBusy), max_attempts=3)
max_attempts 是总尝试次数,不是额外重试次数。是 3 的话,就是第一次 + 重试两次。而且三次用完仍然失败,最后一个异常会原样向上抛出——重试过的痕迹不会附在异常上。那个痕迹必须自己数出来并留下。
可以重试的失败与不可以重试的失败
这里才是真正难的地方。如果不按种类区分失败,重试就会错向两个极端之一——什么都不重试,或者什么都重试。
标准只有一个。把同一个请求原样再发一次,有没有可能得到不同的答案。
- 有 → 连接断了、超时断开了、收到了 5xx、触发了用量限制。重试。
- 没有 → 参数错了、账户不存在、审批被拒绝、没有权限。不重试。
给永久性失败加重试,成本就变成三倍然后结束。这件事不能靠嘴上相信,必须用次数来看。数一数尝试记录,就能清楚看到一次永久性失败积累了三行尝试。这个数字就是向别人解释时的依据。
重试之前必须幂等
要让重试安全,还需要另一个条件。同一个请求发两次,也必须只生效一次。
超时断开的请求尤其危险。只是没收到响应,对方那边可能已经处理了。这时原样再发一次,就会被扣款两次。所以要给每个请求附上一个幂等键,由接收方用这个键来看“是否已经处理过了”。键必须由生成请求的一方确定——如果接收方每次都新生成,就无从知道两个请求是不是同一个请求。
用什么作为键很重要。必须是像订单号那样指向那一件事的值。如果用时间或随机值,每次重试都会成为新的键,幂等性就消失了。
预算连重试也要算
最后是预算。智能体调用的次数,比人预想的多得多。如果不事先确定处理一条要调用几次工具,一条就会把别人的份额也用光。
这里经常被漏掉的是,重试也是调用。假设一条最多尝试三次,把十条放在一个预算里运行,只要前两条抖动一下,后面的条目连尝试的机会都没有。所以预算按“工具主体运行了几次”来数,而不是按“几条”来数,会更准确。
然后还剩下预算耗尽时做什么。答案不是重试。放弃,但要留下结果。如果把异常原样抛上去,调用方只剩堆栈跟踪,什么办成了、什么没办成,谁也不知道。超出预算是失败,但是可预料的失败,所以必须是结果记录中的一行。
本实验使用 Types 参考中的 RetryPolicy 和 Graph API overview 中的节点设置。如何调用工具、如何校验结果,在工具模块中另外讲,这里只看调用之后失败时的情况。看着原因修改参数再次调用,是在图内部做的事,而这里讨论的是把同一个请求原样再发一次的框架一侧的重试——层次不同。
在现场相遇的样子
第一,加了策略,却没有重试。是因为异常属于 ValueError 或 RuntimeError 一系,被默认的 retry_on 筛掉了。看尝试记录只有一行就能知道。
第二,连银行卡被拒也发三次。retry_on 设得太宽了。先被网关一侧的用量限制卡住,真正该重试的条目反而被挤到后面。
第三,同一条被扣了两次款。把超时断开的请求再发了一次,而接收方没有幂等键。这种事故多半是财务那边先发现。
第四,批次的后半部分整个没有运行。前几条的重试把预算吃光了。日志里连“超出预算”都看不到——就是什么记录也没有。
第五,靠调高上限来蒙混。把 max_attempts 调到 10,那天是过去了,但永久性失败的成本变成了 10 倍。该调的不是上限,而是区分的标准。
实际工作中真正重要的事
- 自己写出
retry_on。默认值多半不会重试我们自己写的异常。 - 只重试可以重试的失败。标准只有一个:“把同一个请求原样再发一次,能不能得到不同的答案”。
- 重试之前附上幂等键。键由生成请求的一方确定,必须是指向那一件事的值。
- 预算连重试也要算。不是条数,而是工具主体运行的次数。
- 即使放弃,也要留下结果。超出预算是可预料的失败,可预料的失败必须是记录中的一行。
下一项实验要做什么
一步步扩展 /root/work/agbudget/budget.py。先在没有重试的图中复现失败只尝试一次,并用尝试次数确认即使加了 RetryPolicy,默认的 retry_on 也不会重试这次失败。接着明确写出 retry_on 让它重试,用次数看到永久性失败被重复到上限,再加入区分函数把这个数字降回 1。然后用幂等键让同一个请求不被生效两次,统计调用预算、耗尽时放弃但留下结果,最后确认多条共用一个预算时,前面条目的重试会吃掉后面条目的份额。评分器会真正导入你的模块,每次用不同的键、金额和失败计划来运行,并把尝试次数与它另外计算的值对照。