连被拒的那笔也重发了三次
目标
给节点加上 RetryPolicy,让它只重试可以重试的失败。附上幂等键,使同一个请求不被处理两次;统计调用预算,耗尽时不再重试而是放弃,并留下结果。判定全部靠次数——不测量时间或速度。
为什么重要
重试一行就能开启。所以开了就不去确认。然而 RetryPolicy 的默认 retry_on 不会重试 ValueError、TypeError、RuntimeError、OSError 这类异常(这是在本实验环境的 langgraph 0.2.60 中确认过的事实)。我们自己写的异常多半继承这些类,所以即使加了策略,尝试记录里也只会留下一行。既没有错误,也没有警告。
反过来,retry_on 设得宽,就连永久性失败也会重复到上限。一次银行卡被拒,会积累三行尝试,正常条目能用的份额就相应被吃掉。所以需要一个区分“可以重试的失败”的标准——把同一个请求原样再发一次,有没有可能得到不同的答案。
要让重试安全,还有一个条件。超时断开的请求只是没收到响应,对方那边可能已经处理了。原样再发,就会被扣款两次。必须给每个请求附上键,由接收方用这个键来看“是否已经处理过了”。
最后,重试会原样消耗调用预算。前面的条目只要一抖动,后面的条目连尝试的机会都没有。预算按工具主体运行的次数而不是条数来数,耗尽时不抛异常,而是作为结果记录中的一行留下。
评分器不会相信你写下的说明。它会真正导入你的模块,每次用不同的键、金额和失败计划来运行,并把 ATTEMPTS 中积累的行数和账本中生效的金额,与评分器另外计算的值对照。
步骤
- 在 /root/work/agbudget/budget.py 中创建
MAX_ATTEMPTS、TransientError、PermanentError、ATTEMPTS、LEDGER、PLAN、reset()、applied_total()、charge()、State、build_plain()、run_once()。因为没有重试,失败的话尝试只有一次。 - 增加
DEFAULT_RETRY、build_default_retry()、run_default()。确认给节点加上没有写retry_on的RetryPolicy后,尝试仍然只有一次。 - 增加
RETRY_ALL、build_retry_all()、run_retry_all()。明确写出retry_on=(ValueError,),暂时性失败就会重新生效,超出上限时最后一个异常原样抛出。 - 增加
is_retryable(exc)、RETRY_SPLIT、build_split()、run_split(),让永久性失败不再重试。永久性失败的尝试次数要从MAX_ATTEMPTS降到 1。 - 在
charge()中加入幂等键。即使用同一个request_key被调用两次,账本里也只留一行,第二次调用会带着duplicate标记返回当时的那张收据。 - 增加
BudgetExhausted、call_with_budget()、build_budgeted()、run_budgeted()。预算不剩时不调用工具,即使放弃,也要留下outcome为"예산초과"(韩文,意为“超出预算”)的记录。 - 增加
settle_batch(orders, budget),让多条共用一个预算。预算耗尽之后的条目记为skipped。 - 在 /root/work/agbudget/budget_report.json 和 /root/work/agbudget/budget_report.md 中记录测得的次数。
参考
- 执行契约:评分器会把
/root/work/agbudget/budget.py当作 Python 模块导入,直接使用MAX_ATTEMPTS、ATTEMPTS、LEDGER、PLAN、reset、charge、applied_total、TransientError、PermanentError、build_plain、run_once和后面步骤中增加的名称。它不会作为脚本运行,所以可以没有if __name__ == "__main__"。 reset(plan)清空ATTEMPTS和LEDGER,并把PLAN换成plan。plan是{request_key: 남은 실패 횟수}(占位符为剩余失败次数)或{request_key: "permanent"}。评分器每次会放入不同的plan。charge(request_key, amount)每次被调用,都会往ATTEMPTS里追加一行request_key。PLAN是整数 n(n 大于 0)时,抛出TransientError并把 n 减 1;是"permanent"则抛出PermanentError。除此之外,在账本里留下一行{"request_key", "amount", "seq"},并附上duplicate后返回。seq从 1 开始逐个增加。run_once、run_default、run_retry_all、run_split都返回{"ok", "error", "attempts", "receipt", "outcome"}。attempts是len(ATTEMPTS),error在失败时是异常的类名(例如"TransientError"),成功则为空字符串。这些函数不调用reset()——初始化由调用方来做。run_budgeted(request_key, amount, budget)在上面的键之外,还放入budget和applied,outcome是"완료"、"예산초과"、"실패"(韩文,依次意为“完成”“超出预算”“失败”)之一。settle_batch(orders, budget)的orders是[(request_key, amount), ...],返回的值是{"done", "failed", "skipped", "calls", "applied", "budget"}。done、failed、skipped中只按顺序放入request_key。MAX_ATTEMPTS设为 3。它是总尝试次数,不是额外重试次数。- 等待时间请用
initial_interval=0.01, backoff_factor=1.0, jitter=False设得非常小。用默认值(0.5 秒起每次翻倍),实验会变慢。评分器不测量时间,只看次数。 RetryPolicy的retry_on既可以给异常类的元组,也可以给(예외) -> bool(占位符为异常)函数。- 第 8 步的记录所用的批次情形固定如下:
orders = [("A-1", 1500), ("A-2", 2300), ("A-3", 900)],plan = {"A-1": 2},budget = 4。 - 这个 Pod 没有互联网。
pip install无法使用。langgraph 0.2.60 已经装好了(python3 -c "import langgraph")。 - 官方文档:Types 参考 · Graph API overview · Use the graph API
- 常见错误:不写
retry_on就以为会重试、把max_attempts当作额外重试次数、在节点内用try/except吞掉异常导致重试根本不生效、由接收方重新生成幂等键、按条数计算预算。
没有重试,尝试就只有一次
在 /root/work/agbudget/budget.py 中创建 MAX_ATTEMPTS、TransientError、PermanentError、ATTEMPTS、LEDGER、PLAN、reset()、applied_total()、charge()、State、build_plain()、run_once()。build_plain() 返回没有加重试的、已经 compile() 的图。
charge 每次被调用,都先往 ATTEMPTS 追加一行,再看 PLAN——因为失败的尝试也要算。节点不要吞掉失败,要把异常原样抛出。在节点内用 try/except 捕获的话,重试就根本不会生效。run_once 用 try/except 包住 invoke,把异常转成记录。节点名称和状态键重名的话,编译会崩溃,所以要取得不同。
加了策略,为什么只做一次
增加 DEFAULT_RETRY = RetryPolicy(max_attempts=MAX_ATTEMPTS, initial_interval=0.01, backoff_factor=1.0, jitter=False) 和 build_default_retry()、run_default()。retry_on 不写。运行一次暂时性失败,确认尝试仍然是 1 次。
使用 from langgraph.types import RetryPolicy,以 add_node(..., retry=DEFAULT_RETRY) 加上。这一步是有意复现不生效的情形——默认的 retry_on 不会重试 ValueError 一系。评分器会同时检查策略是否真正加在了节点上(compile() 后的图的 nodes["settle"].retry_policy)和尝试次数。所以不能原样返回 build_plain()。
自己写出要重试的内容
增加 RETRY_ALL = RetryPolicy(retry_on=(ValueError,), max_attempts=MAX_ATTEMPTS, ...) 和 build_retry_all()、run_retry_all()。暂时性失败 n 次之后成功,尝试就是 n+1 次,超过 MAX_ATTEMPTS,最后一个异常就原样抛出。
给 retry_on 的是异常类的元组——只给一个类时也别漏掉逗号。max_attempts 是总尝试次数,所以是 3 的话就是第一次加重试两次。上限用完仍然失败,抛出的是没有附带重试标记的最后一个异常。那个痕迹必须数 ATTEMPTS 来亲自留下。
不把永久性失败发三次
创建 is_retryable(exc),并增加 RETRY_SPLIT = RetryPolicy(retry_on=is_retryable, ...)、build_split()、run_split()。永久性失败的尝试次数要从 MAX_ATTEMPTS 降到 1。
retry_on 也可以给 (예외) -> bool(占位符为异常)函数。区分的标准只有一个——把同一个请求原样再发一次,有没有可能得到不同的答案。只把可以重试的种类设为真,不认识的异常设为假。把真作为默认值,永久性失败每次换个新名字出现,都会悄悄漏过去。评分器会把同一个永久性失败分别用 run_retry_all 和 run_split 运行,比较尝试次数。
来两次,也只生效一次
让 charge() 在收到同一个 request_key 两次时,不在账本里新建一行,而是带着为真的 duplicate 返回当时的那张收据。账本的行数和 applied_total() 不能变成两倍。
先用键扫描账本,看是否已经存在。seq 也必须原样保持——如果另外编新号,同一条就会看起来是两条。必须在产生失败的位置之后去查找。键是由调用方确定的值,这一点很重要。评分器会混入两条不同的记录以及其中一条的重复投递,同时检查账本的行数、合计和 seq。
耗尽就放弃,但留下记录
增加 BudgetExhausted、call_with_budget(request_key, amount, budget)、build_budgeted()、run_budgeted(request_key, amount, budget)。len(ATTEMPTS) 达到或超过 budget 时不调用工具,这种情况下返回 outcome 为 "예산초과"(韩文,意为“超出预算”)的记录。
预算不是条数,而是工具主体运行的次数。不要让 is_retryable 把 BudgetExhausted 判为真——因为没有预算而失败,重试也没有用。预算检查必须在调用 charge 之前,这样尝试才不会再多一格。run_budgeted 不要把异常抛到外面,要转成包含 outcome 的记录。
前面条目的重试吃掉后面条目的份额
增加 settle_batch(orders, budget)。多条共用一个预算,预算不剩时,剩下的条目不调用工具,而是放入 skipped。返回值是 {"done", "failed", "skipped", "calls", "applied", "budget"}。
不要对每一条都调用 reset()——预算是整个批次共用的,所以 ATTEMPTS 必须连续。在开始处理一条之前,也先看一次剩余预算,没有的话就根本不要调用 run_budgeted。done、failed、skipped 中只按顺序放入 request_key。评分器会另外计算同样的规则,把列表和 calls 对照。
用测得的次数来记录
在 /root/work/agbudget/budget_report.json 中写入 max_attempts、no_retry_attempts、default_policy_attempts、retry_all_permanent_attempts、split_permanent_attempts、split_transient_attempts、duplicate_delivery_entries、batch_budget、batch_calls、batch_done、batch_skipped、batch_applied,在 /root/work/agbudget/budget_report.md 中用 ## 무엇을 다시 해도 되는가(韩文,意为“什么可以重试”)、## 상한을 어디에 두었나(韩文,意为“上限设在了哪里”)、## 같은 요청이 두 번 와도 안전한 이유(韩文,意为“同一个请求来两次也安全的原因”)、## 예산이 바닥났을 때 무엇을 남기는가(韩文,意为“预算耗尽时留下什么”)四节来写。
数字不要手写,要用实际运行你的模块得到的值来填。split_transient_attempts 是暂时性失败 2 次后成功时的尝试次数,duplicate_delivery_entries 是用同一个键发送两次之后账本里留下的行数。批次要按说明的参考一节所定的 orders、plan、budget 原样运行。评分器会另外计算同样的内容来对照。