重试与超时:让节点失败也能扛住
Day 22 执行器无论快路径还是并行路径,跑任务都调 run_with_retry——今天终于展开它。Agent 节点最常见的失败就是"调 LLM 时网络抖了一下",这种瞬时错误不该让整个图崩掉。RetryPolicy(types.py:416)+ run_with_retry(_retry.py:573)就是引擎的"重试保险":指数退避、随机抖动、按异常类型决定重不重试。这是从"能跑"到"生产可用"的关键一课。
节点会失败:瞬时错误 vs 确定性错误
重试逻辑在 Day 22 的执行链里的位置:runner.tick → run_with_retry → task.proc.invoke(你的节点)。也就是说重试是包在单个节点执行外面的一层循环——节点抛异常,这层决定"再来一次还是放弃"。
_should_stop_others,进而可能中止整个超步。先在最小粒度扛一扛,扛不住才升级。RetryPolicy 就是这套"重拨规则":重几次、间隔多久、遇到哪种忙音才值得重拨。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),也能传异常类或自定义函数。add_node(..., retry_policy=RetryPolicy()) 显式配)。所以默认情况下节点失败就是直接失败——重试是你主动开启的能力,不是隐式行为。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。匹配策略 + 退避抖动:等多久再重试
捕获普通异常后,决定重不重试、等多久(_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 回到顶端再试一次。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)):节点抛 ConnectionError → isinstance(exc, (ConnectionError, TimeoutError)) 为 True → 重试;抛 ValueError → 不在元组里 → False → 立即放弃向上抛。你能精确控制"只对哪些异常宽容"。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(兜底)不认识的异常默认重试——保守假设"可能是瞬时的"。这是"宁可多重一次也别漏掉可恢复错误"的取向。TypeError(真 bug)会被悄悄重试 3 次然后才报错——不但拖慢反馈、还让人误以为是"偶发问题"而非"必现 bug"。LangGraph 的选择是把"几乎不可能靠重试解决"的异常类型硬编码进黑名单,让 bug 立即暴露、让重试只作用于真正的瞬时错误。代价是这个名单是"经验性"的、可能不完美(比如某些 OSError 其实是瞬时的),但对绝大多数场景,"让 bug 快速失败"比"对 bug 也宽容"更有价值。超时与重试怎么配合
超时有两个层次,别混淆:
① 超步级墙钟超时(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))。
今日小结 + 动手 + 明日预告
🧠 今天你应该能回答
- 重试是什么粒度的?(单个节点级,同超步其他节点不受影响;耗尽才向上冒泡)
- 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
"
debug.py(stream_mode="debug" 每步吐 task/checkpoint 事件)和 _draw.py(把图画成 Mermaid——而且它是靠"空跑一遍 Pregel 循环"来发现边的!)。学会给引擎装上"仪表盘",也为阶段 6 的时间旅行埋下伏笔。