Day 25 / 共 60 天 · 阶段 4 Pregel 执行引擎(核心深水区)

重试与超时:让节点失败也能扛住

Day 22 执行器无论快路径还是并行路径,跑任务都调 run_with_retry——今天终于展开它。Agent 节点最常见的失败就是"调 LLM 时网络抖了一下",这种瞬时错误不该让整个图崩掉。RetryPolicytypes.py:416)+ run_with_retry_retry.py:573)就是引擎的"重试保险":指数退避、随机抖动、按异常类型决定重不重试。这是从"能跑"到"生产可用"的关键一课。

📍 你在 60 天里的位置
①入门 D1-6· ②状态 D7-12· ③控制流 D13-18· ④Pregel D19-26· ⑤通道 D27-32· ⑥持久化 D33-40· ⑦中断 D41-46· ⑧函数式 D47-52· ⑨预制件 D53-58· ⑩收官 D59-60
D19 BSP模型 D20 主循环 D21 任务准备 D22 执行器 D23 读写 D24 IO映射 D25 重试超时 D26 调试画图
L01

节点会失败:瞬时错误 vs 确定性错误

🤔 痛点:agent 调 LLM,偶尔报个 500 或超时——难道整个对话就这么崩了? 真实世界里节点会失败,但失败分两类:瞬时错误(网络抖动、限流 429、上游 5xx,重试一下大概率就好)和确定性错误(代码 bug、参数类型错,重试一万遍还是同样的错)。好的框架要能只对前者重试——对后者重试纯属浪费时间还掩盖 bug。今天看 LangGraph 怎么区分并处理。

重试逻辑在 Day 22 的执行链里的位置:runner.tick → run_with_retry → task.proc.invoke(你的节点)。也就是说重试是包在单个节点执行外面的一层循环——节点抛异常,这层决定"再来一次还是放弃"。

💡 本质:重试是"节点级"的,不是"整图级"的 关键理解:重试发生在 单个节点的粒度上。某节点失败重试时,同超步的其他并行节点不受影响;重试成功后,这个超步照常继续。只有当重试也耗尽(超过 max_attempts),异常才向上冒泡到 Day 22 的 _should_stop_others,进而可能中止整个超步。先在最小粒度扛一扛,扛不住才升级。
类比:打电话没打通 给客户打电话占线(瞬时错误)——你会隔一会儿再打,而且越打不通、间隔越长(指数退避),免得一直骚扰。但如果是"号码是空号"(确定性错误),再打多少遍都没用,直接放弃换个号。RetryPolicy 就是这套"重拨规则":重几次、间隔多久、遇到哪种忙音才值得重拨。
L02

RetryPolicy:五个参数定义"怎么重试"

RetryPolicy 是个 NamedTuple(types.py:416),带默认值:

# types.py:416
class RetryPolicy(NamedTuple):
    """Configuration for retrying nodes."""
    initial_interval: float = 0.5    # 第一次重试前等多久(秒)
    backoff_factor: float = 2.0      # 每次重试间隔的放大倍数
    max_interval: float = 128.0      # 间隔上限(再退避也不超过这个)
    max_attempts: int = 3            # 最多尝试几次(含第一次)
    jitter: bool = True              # 是否加随机抖动
    retry_on: (type[Exception] | Sequence[type[Exception]]
               | Callable[[Exception], bool]) = default_retry_on   # 哪些异常才重试
initial_interval=0.5第一次失败后先等 0.5 秒再重试。给瞬时问题(网络抖动)一点恢复时间。
backoff_factor=2.0指数退避的倍数:每失败一次,等待时间翻倍。0.5 → 1 → 2 → 4……越failed 越克制,避免雪崩式重试打垮已经不稳的上游。
max_interval=128退避的天花板。就算翻倍翻到很大,单次等待也不超过 128 秒——防止退避到"几小时后才重试"的荒唐值。
max_attempts=3总共最多试 3 次(1 次正常 + 2 次重试)。用光还失败就真放弃、向上抛。
jitter=True随机抖动:在间隔上加一个 0~1 秒的随机量。为什么要随机?见 L04 的取舍——防"重试风暴同步化"。
retry_on=default_retry_on决定"哪些异常值得重试"的判据。默认是个智能函数(L05/L06),也能传异常类或自定义函数。
注意:节点默认不带 RetryPolicy(除非你在 add_node(..., retry_policy=RetryPolicy()) 显式配)。所以默认情况下节点失败就是直接失败——重试是你主动开启的能力,不是隐式行为。
数据结构:RetryPolicy 的字段各管一件事 RetryPolicy(NamedTuple) initial_interval=0.5 backoff_factor=2.0 max_interval=128 max_attempts=3 jitter=True retry_on=判据 ← 三个管"等多久"(退避曲线) 管"重几次" 管"哪些异常才重" sleep = min(max_interval, initial × factor^(n-1)) + [jitter 0~1s]
图注:三个字段决定退避间隔曲线,max_attempts 决定次数,retry_on 决定异常筛选——各司其职。
L03

run_with_retry:一个 while True 包住节点

run_with_retry_retry.py:573)主体就是个 while True,每轮尝试跑一次节点:

# _retry.py:573
def run_with_retry(task, retry_policy, configurable=None) -> None:
    retry_policy = task.retry_policy or retry_policy   # 579 节点自带优先,否则用图级默认
    attempts = 0
    config = ...
    while True:                                        # 600 重试循环
        try:
            task.writes.clear()                        # 615 关键!清掉上次失败残留的写入
            return task.proc.invoke(task.input, config)  # 617 跑你的节点,成功就 return 退出
        except ParentCommand as exc:                   # 618 跨图 Command,特殊处理(不重试)
            ...
        except GraphBubbleUp:                          # 632 中断/续跑信号 → 直接抛,不重试
            raise
        except asyncio.CancelledError as exc:
            raise NodeCancelledError(task.name) from exc  # 640 同步节点被取消 → 转成错误
        except Exception as exc:                       # 641 普通异常 → 进重试判定(L04)
            ...
task.retry_policy or retry_policy优先用节点自己的策略(add_node(retry_policy=...)),没有才用图级默认。细粒度覆盖粗粒度——不同节点可有不同重试脾气(LLM 节点重试、纯计算节点不重试)。
task.writes.clear()每次重试前清空写入。为什么重要?上一次失败前节点可能已经写了半截东西进 task.writes,不清就会和这次的写入叠加、产生脏数据。清空保证"每次尝试都是干净的从头来"。这是重试幂等的一个前提。
return task.proc.invoke(...)真正跑节点(Day 23 那根 read→bound→write 链)。成功就 return 直接跳出 while——只有抛异常才会走到下面的 except 继续循环。
⚠️ 边界/坑:重试会重跑整个节点函数——副作用要自己保证幂等 task.writes.clear() 只能清掉"对图状态的写入",清不掉你节点里的外部副作用。如果你的节点在失败前已经"给用户扣了一次款"或"发了一条 MQ 消息",重试会再执行一遍——扣两次款。引擎无法回滚你的外部操作。所以带副作用的节点必须自己做幂等(用幂等键、先查后写)。"引擎重试安全"仅限于状态写入,不含你的 IO。
L04

匹配策略 + 退避抖动:等多久再重试

捕获普通异常后,决定重不重试、等多久(_retry.py:641-674):

# _retry.py:641
except Exception as exc:
    if not retry_policy:
        raise                                          # 645 压根没配策略 → 不重试,直接抛
    matching_policy = None
    for policy in retry_policy:                        # 649 可能有多个策略,逐个看谁匹配
        if _should_retry_on(policy, exc):              # 650 这个异常该按这个策略重试吗(L05)
            matching_policy = policy
            break
    if not matching_policy:
        raise                                          # 655 没有策略认领这个异常 → 抛
    attempts += 1                                      # 658 失败次数 +1
    if attempts >= matching_policy.max_attempts:
        raise                                          # 660 用光重试次数 → 放弃
    interval = matching_policy.initial_interval        # 663
    interval = min(matching_policy.max_interval,       # 665 退避:初始 × 倍数^(失败次数-1),封顶
                   interval * (matching_policy.backoff_factor ** (attempts - 1)))
    sleep_time = (interval + random.uniform(0, 1)      # 671 抖动:加 0~1 秒随机
                  if matching_policy.jitter else interval)
    time.sleep(sleep_time)                             # 674 睡一会儿,然后 while 回去重试
interval * backoff_factor ** (attempts-1)指数退避公式。第 1 次重试 0.5 × 2^0 = 0.5s,第 2 次 0.5 × 2^1 = 1s,第 3 次 2s……用 min(max_interval, ...) 封顶。
+ random.uniform(0, 1)抖动:在算好的间隔上再加一个 0~1 秒的随机数,打散重试时刻。
time.sleep(sleep_time)同步版用 time.sleep 阻塞当前线程等待(异步版用 await asyncio.sleep_retry.py:830)。睡醒后 while True 回到顶端再试一次。
控制流:指数退避重试(initial=0.5, factor=2, attempts=3) 试① 跑节点 试② 跑节点 试③ 跑节点 仍失败→抛出 睡 ~0.5s 睡 ~1.0s 用光次数 任一次成功 → return,立即跳出循环 每次实际睡眠 = 退避间隔 + 0~1s 随机抖动
图注:最多 3 次尝试,间隔按 0.5→1→2 指数增长并加抖动;任一次成功即退出。
🎯 设计取舍①:为什么要加随机抖动 jitter?没它不是更可预测吗? 想象一个上游服务挂了 1 秒,此刻有 1000 个节点同时失败。如果没抖动,它们会在完全相同的时刻(都等 0.5s)一起重试——1000 个请求同一瞬间砸向刚缓过来的上游,直接把它再打挂,形成"重试风暴/惊群"。抖动让每个重试时刻散开在 0.5~1.5s 之间,把冲击摊平。可预测性在这里是缺点,随机性才是解药——这是分布式系统里公认的最佳实践,LangGraph 默认开启。
L05

retry_on:这个异常该不该重试

"该不该重试"由 _should_retry_on_retry.py:841)判定,支持三种写法:

# _retry.py:841
def _should_retry_on(retry_policy, exc) -> bool:
    if isinstance(retry_policy.retry_on, Sequence):       # 843 ① 传了异常类的列表/元组
        return isinstance(exc, tuple(retry_policy.retry_on))
    elif isinstance(retry_policy.retry_on, type) and issubclass(
        retry_policy.retry_on, Exception):                 # 845 ② 传了单个异常类
        return isinstance(exc, retry_policy.retry_on)
    elif callable(retry_policy.retry_on):                  # 849 ③ 传了自定义函数
        return retry_policy.retry_on(exc)
    else:
        raise TypeError("retry_on must be an Exception class, a list/tuple ..., or a callable")
Sequence → isinstance(exc, tuple(...))写法①:retry_on=[ConnectionError, TimeoutError],异常是其中任一类型就重试。最常用。
单个 type → isinstance写法②:retry_on=ConnectionError,只重这一类。
callable → retry_on(exc)写法③:传个函数,你自己判断(比如"HTTP 状态码是 5xx 才重")。默认的 default_retry_on 就是这种——下一讲细看。
📝 真实值走一遍 RetryPolicy(retry_on=(ConnectionError, TimeoutError)):节点抛 ConnectionErrorisinstance(exc, (ConnectionError, TimeoutError)) 为 True → 重试;抛 ValueError → 不在元组里 → False → 立即放弃向上抛。你能精确控制"只对哪些异常宽容"。
L06

default_retry_on:默认哪些不该重试

默认判据 default_retry_on_internal/_retry.py:1)体现了"哪些错重试有意义"的经验:

# _internal/_retry.py:1
def default_retry_on(exc: Exception) -> bool:
    import httpx, requests
    if isinstance(exc, ConnectionError):
        return True                                    # 连接错误 → 重(瞬时)
    if isinstance(exc, httpx.HTTPStatusError):
        return 500 <= exc.response.status_code < 600   # 5xx 才重(服务端问题)
    if isinstance(exc, requests.HTTPError):
        return 500 <= exc.response.status_code < 600 if exc.response else True
    if isinstance(exc, (                               # ↓ 这些一律不重(确定性错误)
        ValueError, TypeError, ArithmeticError, ImportError, LookupError,
        NameError, SyntaxError, RuntimeError, ReferenceError,
        StopIteration, StopAsyncIteration, OSError,
    )):
        return False
    return True                                        # 其余未知异常 → 默认重试
ConnectionError → True网络连不上,典型瞬时错误,重。
5xx → True,4xx → False只重服务端错误(5xx)。4xx(如 400 参数错、401 没权限)是你请求本身的问题,重试也是同样的错——不重。这个区分极其关键。
ValueError/TypeError/... → False一长串代码级错误明确不重:类型错、名字错、语法错、查找错……这些是 bug,重试只会掩盖问题、浪费时间。
return True(兜底)不认识的异常默认重试——保守假设"可能是瞬时的"。这是"宁可多重一次也别漏掉可恢复错误"的取向。
🎯 设计取舍②:为什么明确把 ValueError/TypeError 列进"不重试"黑名单? 本可以简单点:默认全重试,让用户自己配 retry_on 排除。但那样新手一开重试,代码里的 TypeError(真 bug)会被悄悄重试 3 次然后才报错——不但拖慢反馈、还让人误以为是"偶发问题"而非"必现 bug"。LangGraph 的选择是把"几乎不可能靠重试解决"的异常类型硬编码进黑名单,让 bug 立即暴露、让重试只作用于真正的瞬时错误。代价是这个名单是"经验性"的、可能不完美(比如某些 OSError 其实是瞬时的),但对绝大多数场景,"让 bug 快速失败"比"对 bug 也宽容"更有价值。
L07

超时与重试怎么配合

超时有两个层次,别混淆:

① 超步级墙钟超时(Day 20/22):step_timeout 通过 runner.tick(timeout=...)整个超步设上限,在 concurrent.futures.wait 那层生效(_runner.py:286)。到点还没跑完就停等。

② 节点级超时(TimeoutPolicy):给单次节点调用设超时。同步节点不支持——run_with_retry 开头就守卫(_retry.py:580-583):

# _retry.py:580
if task.timeout is not None:
    # 同步节点无 asyncio 上下文,编译期已拦截,这里是运行期兜底
    raise sync_timeout_unsupported(task.name)

异步节点才有节点级超时,用一个"看门狗"协程实现(_retry.py:417):

# _retry.py:417
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
    await asyncio.sleep(run_timeout_s)     # 睡够超时时长
    raise asyncio.TimeoutError             # 到点就抛超时——和节点协程赛跑,谁先完成算谁
看门狗协程思路:起一个"睡 N 秒就抛 TimeoutError"的协程,和你的节点协程一起跑。节点先完成就正常返回;看门狗先醒就把节点判超时。这就是异步超时的经典实现。
超时后交给重试节点级超时抛出的 NodeTimeoutError,会被 run_with_retry 的 except 捕获,再走一遍 retry_on 判定——超时也能被重试策略接管。所以"超时 + 重试"能组合:单次太慢就掐掉重来。

👶 小白:同步节点为什么不支持超时?我 time.sleep(100) 卡住了咋办?

👨‍🏫 老师:因为同步代码跑在线程里,Python 没法安全地从外部打断一个正在跑的线程(没有可靠的线程 kill)。异步能超时是因为 asyncio 可以在 await 点让出、被取消。所以卡死的同步节点,超步级 step_timeout 能让主循环"不再等它"(wait 超时返回),但那个线程本身还在后台空转——这也是为什么生产环境重 IO 的节点建议写成 async:才能真正掐断。同步节点要防卡死,只能靠你自己在节点里给底层库设超时(比如 requests.get(timeout=5))。

L08

今日小结 + 动手 + 明日预告

🧠 今天你应该能回答

  • 重试是什么粒度的?(单个节点级,同超步其他节点不受影响;耗尽才向上冒泡)
  • RetryPolicy 五个参数?(initial_interval / backoff_factor / max_interval / max_attempts / jitter + retry_on)
  • 每次重试前为什么 task.writes.clear()?(清掉上次失败残留写入,保证干净重来)
  • 为什么要加 jitter 抖动?(打散重试时刻,防"重试风暴/惊群"打垮上游)
  • default_retry_on 的原则?(5xx/连接错重试;4xx 和 ValueError/TypeError 等 bug 不重)
  • 为什么要把代码错列进不重试黑名单?(让 bug 快速失败、不被悄悄掩盖)
  • 节点级超时的限制?(仅异步节点支持;同步节点靠 step_timeout 不再等它,但线程仍在后台)
  • 重试能保证幂等吗?(只保证状态写入幂等,外部副作用要你自己做幂等)

✋ 10 分钟动手

# 1. 读 run_with_retry 主循环
sed -n '573,682p' libs/langgraph/langgraph/pregel/_retry.py

# 2. 读 retry_on 判定 + 默认判据
sed -n '841,854p' libs/langgraph/langgraph/pregel/_retry.py
cat libs/langgraph/langgraph/_internal/_retry.py

# 3. 亲眼看重试:前两次抛错、第三次成功
python -c "
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy
from typing import TypedDict
class S(TypedDict): n: int
calls = {'c': 0}
def flaky(s):
    calls['c'] += 1
    print('attempt', calls['c'])
    if calls['c'] < 3: raise ConnectionError('boom')  # 前两次假装网络错
    return {'n': 42}
g = StateGraph(S)
g.add_node('flaky', flaky, retry_policy=RetryPolicy(initial_interval=0.1, max_attempts=5))
g.add_edge(START,'flaky'); g.add_edge('flaky',END)
print(g.compile().invoke({'n':0}))   # 观察 attempt 1/2/3,最终 n=42
"
明天预告 · Day 26:阶段 4 收官。前面所有机制都跑起来了,可怎么看清它在跑什么?明天进 debug.pystream_mode="debug" 每步吐 task/checkpoint 事件)和 _draw.py(把图画成 Mermaid——而且它是靠"空跑一遍 Pregel 循环"来发现边的!)。学会给引擎装上"仪表盘",也为阶段 6 的时间旅行埋下伏笔。
← Day 24 输入输出映射 Day 26 · 调试与画图 →