TT Lab
开始
学习 学习路径 课程

AI 智能体 — 是图不是模型

要不要重试,又能花到哪一步

在 TT Lab 中继续学习

一句话总结

重试一行就能开启,但没有确定要重做什么的重试,只是把同样的失败一直重复到上限的装置。而且那种重复,会原样消耗调用预算。

为什么需要它

把智能体写成图之后,总有一天会写下这样的复盘。“支付网关抖动了 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 的话,就是第一次 + 重试两次。而且三次用完仍然失败,最后一个异常会原样向上抛出——重试过的痕迹不会附在异常上。那个痕迹必须自己数出来并留下。

可以重试的失败与不可以重试的失败

这里才是真正难的地方。如果不按种类区分失败,重试就会错向两个极端之一——什么都不重试,或者什么都重试。

标准只有一个。把同一个请求原样再发一次,有没有可能得到不同的答案。

给永久性失败加重试,成本就变成三倍然后结束。这件事不能靠嘴上相信,必须用次数来看。数一数尝试记录,就能清楚看到一次永久性失败积累了三行尝试。这个数字就是向别人解释时的依据。

重试之前必须幂等

要让重试安全,还需要另一个条件。同一个请求发两次,也必须只生效一次。

超时断开的请求尤其危险。只是没收到响应,对方那边可能已经处理了。这时原样再发一次,就会被扣款两次。所以要给每个请求附上一个幂等键,由接收方用这个键来看“是否已经处理过了”。键必须由生成请求的一方确定——如果接收方每次都新生成,就无从知道两个请求是不是同一个请求。

用什么作为键很重要。必须是像订单号那样指向那一件事的值。如果用时间或随机值,每次重试都会成为新的键,幂等性就消失了。

预算连重试也要算

最后是预算。智能体调用的次数,比人预想的多得多。如果不事先确定处理一条要调用几次工具,一条就会把别人的份额也用光。

这里经常被漏掉的是,重试也是调用。假设一条最多尝试三次,把十条放在一个预算里运行,只要前两条抖动一下,后面的条目连尝试的机会都没有。所以预算按“工具主体运行了几次”来数,而不是按“几条”来数,会更准确。

然后还剩下预算耗尽时做什么。答案不是重试。放弃,但要留下结果。如果把异常原样抛上去,调用方只剩堆栈跟踪,什么办成了、什么没办成,谁也不知道。超出预算是失败,但是可预料的失败,所以必须是结果记录中的一行。

本实验使用 Types 参考中的 RetryPolicy 和 Graph API overview 中的节点设置。如何调用工具、如何校验结果,在工具模块中另外讲,这里只看调用之后失败时的情况。看着原因修改参数再次调用,是在图内部做的事,而这里讨论的是把同一个请求原样再发一次的框架一侧的重试——层次不同。

在现场相遇的样子

第一,加了策略,却没有重试。是因为异常属于 ValueError 或 RuntimeError 一系,被默认的 retry_on 筛掉了。看尝试记录只有一行就能知道。

第二,连银行卡被拒也发三次。retry_on 设得太宽了。先被网关一侧的用量限制卡住,真正该重试的条目反而被挤到后面。

第三,同一条被扣了两次款。把超时断开的请求再发了一次,而接收方没有幂等键。这种事故多半是财务那边先发现。

第四,批次的后半部分整个没有运行。前几条的重试把预算吃光了。日志里连“超出预算”都看不到——就是什么记录也没有。

第五,靠调高上限来蒙混。把 max_attempts 调到 10,那天是过去了,但永久性失败的成本变成了 10 倍。该调的不是上限,而是区分的标准。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 /root/work/agbudget/budget.py。先在没有重试的图中复现失败只尝试一次,并用尝试次数确认即使加了 RetryPolicy,默认的 retry_on 也不会重试这次失败。接着明确写出 retry_on 让它重试,用次数看到永久性失败被重复到上限,再加入区分函数把这个数字降回 1。然后用幂等键让同一个请求不被生效两次,统计调用预算、耗尽时放弃但留下结果,最后确认多条共用一个预算时,前面条目的重试会吃掉后面条目的份额。评分器会真正导入你的模块,每次用不同的键、金额和失败计划来运行,并把尝试次数与它另外计算的值对照。