Day 52 / 共 60 天 · 阶段 8 函数式 API 与子图(收官)
节点级重试与容错:失败一次,别让整张图崩
真实的 Agent 节点会失败:调 API 遇到 429 限流、网络抖动、偶发超时。这些大多是暂时性的——等一会儿再试就好。RetryPolicy 让单个节点具备"失败自动重试"的能力,还能精确控制重试几次、间隔多久、哪些异常值得重、哪些一试就白搭。今天读透真实的重试循环,也为阶段 8 收官——把"函数式并行 + 子图嵌套 + 缓存 + 重试"这套可靠性工具箱补全。
📍 阶段 8 · 函数式 API 与子图(6 天)你在这里 · 收官
D47 @entrypoint/@task→
D48 func→pregel→
D49 子图基础→
D50 子图隔离/stream→
D51 CachePolicy→
D52 重试容错
🤔 痛点:一个节点偶发失败,整张图前功尽弃
图跑到第 4 个节点,调大模型正好撞上一次 503。默认行为是整张图抛异常、停摆——前三个节点白跑了。可这个 503 其实等 1 秒重试就能成功。你不想为每个节点手写
try/except + 循环 + sleep,而是想声明一句"这个节点失败了自动重试 3 次"。💡 本质:重试 = 在"执行节点"外面套一个"失败就等一会儿再来"的循环
引擎执行每个节点时,不是直接调一次,而是放进一个
run_with_retry 循环里:跑 → 失败 → 判断该不该重试 → 该重就睡一会儿再跑,直到成功或用完次数。类比:打电话占线,你不会挂了就放弃,而是隔一会儿重拨,拨几次不通才作罢——而且第一次隔 1 分钟、第二次隔 2 分钟(越等越久,别骚扰对方)。这就是"指数退避 + 上限"。L01
怎么用:给节点挂 RetryPolicy
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy
from typing import TypedDict
import httpx
class S(TypedDict):
result: str
def call_api(state: S) -> dict:
resp = httpx.get("https://example.com/api")
resp.raise_for_status() # 5xx 会抛 HTTPStatusError
return {"result": resp.text}
g = StateGraph(S)
g.add_node("call", call_api,
retry_policy=RetryPolicy(max_attempts=5, initial_interval=1.0)) # ← 重试
g.add_edge(START, "call"); g.add_edge("call", END)
app = g.compile()
retry_policy=RetryPolicy(...)声明这个节点失败可重试,最多 5 次、首次间隔 1 秒。不给的节点失败即抛、不重试。5xx 自动重默认策略下,服务端 5xx(暂时性)会重试;而 4xx 参数错误(重了也白重)不会——L03 细讲这套判断。同样适用于 @task(
@task(retry_policy=...),D47)和 entrypoint。add_node 的 retry_policy 还能传一个列表(多策略,L06)。L02
RetryPolicy 六字段:一次看全
types.py:416,一个带默认值的 NamedTuple:
# types.py:416
class RetryPolicy(NamedTuple):
initial_interval: float = 0.5 # 首次重试前等多久(秒)
backoff_factor: float = 2.0 # 每次重试间隔的放大倍数
max_interval: float = 128.0 # 间隔的上限(再涨也不超过它)
max_attempts: int = 3 # 最多尝试几次(含第一次)
jitter: bool = True # 是否加随机抖动
retry_on: ... = default_retry_on # 哪些异常触发重试
| 字段 | 默认 | 大白话 |
|---|---|---|
initial_interval | 0.5 | 第一次失败后等 0.5 秒再试 |
backoff_factor | 2.0 | 每多失败一次,等待翻倍(0.5→1→2→4…) |
max_interval | 128 | 等待封顶 128 秒,别无限涨 |
max_attempts | 3 | 总共最多试 3 次(1 次原始 + 2 次重试) |
jitter | True | 间隔加一点随机,避免大家同时重试打爆服务 |
retry_on | default_retry_on | 只有它认可的异常才重试 |
💡 max_attempts 含第一次注意
max_attempts=3 意思是"总共最多执行 3 次",即 1 次首次 + 最多 2 次重试。不是"额外重试 3 次"。这在 L04 的循环里看得很清楚。L03
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,不重 4xxHTTP 5xx 是服务端临时故障(重了可能好);4xx 是请求本身有问题(参数错/没权限,重了照样错),不在 500-599 区间就返回 False。ValueError/TypeError… → False这一长串是确定性代码错误:你传错类型、变量名写错、除以零——重试只会重复同一个错,纯浪费。直接不重、快速失败。兜底 return True不认识的异常保守地重(宁可多试也别漏掉真·暂时性错误)。这是个可讨论的默认,你可自定义 retry_on 收紧。💡 设计取舍①:为什么默认"黑名单"确定性错误,而不是"白名单"可重试错误?白名单(只列该重的)更安全但覆盖不全——世界上暂时性异常千千万,列不完,漏一个就该重的没重。黑名单(列明确不该重的:类型错、语法错这类"重了必然还错"的)把"确定失败"排除掉,其余一律给次机会。LangGraph 选黑名单 + 兜底 True,倾向于"宁可多重试,别放过可恢复的错误"——因为漏重一个网络抖动导致整图失败,比多重一次的代价大。当然这假设了节点可安全重试(幂等),不幂等的节点要自己收紧 retry_on。
L04
run_with_retry:真实的重试循环
引擎执行每个节点都过这个循环 pregel/_retry.py:573。骨架是 while True:
# pregel/_retry.py:600(精简)
while True:
try:
task.writes.clear() # ① 清掉上次失败留下的半成品写
return task.proc.invoke(task.input, config) # ② 跑节点,成功就 return
except GraphBubbleUp: # interrupt 之类的"正常中断" → 不重试,直接抛
raise
except Exception as exc:
if not retry_policy: # 没配策略 → 直接抛
raise
matching_policy = None # ③ 找第一个"认这个异常"的策略(L06)
for policy in retry_policy:
if _should_retry_on(policy, exc):
matching_policy = policy
break
if not matching_policy: # 没有策略认它 → 抛
raise
attempts += 1 # ④ 失败次数 +1
if attempts >= matching_policy.max_attempts: # 试够了 → 放弃
raise
... # ⑤ 算间隔、睡、再循环(L05)
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
task.writes.clear()每次重试前清空上次的写。极重要:上次失败可能已经写了一半通道,不清掉重跑会写重、污染状态。清零保证每次尝试都是干净开始。return proc.invoke(...)成功就直接返回,循环结束。这是唯一的正常出口。except GraphBubbleUp: raiseinterrupt(Day 41)走的是 GraphBubbleUp 这类"控制流异常",它不是失败,绝不能被当错误重试——直接放行。attempts >= max_attempts: raise试够了还不成,把最后一次的异常抛出去,让上层(或错误处理节点)接管。CONFIG_KEY_RESUMING = True重试前标记"恢复中",让子图知道这是重跑,配合 Day 46 的幂等避免子图内部重复副作用。⚠️ 边界:重试依赖节点"可安全重跑"(幂等)因为重试会整个重新执行节点函数。如果节点里有"扣款""发消息"这类副作用,重试会重复扣、重复发。
task.writes.clear() 只能撤销对通道的写,撤销不了已经发生的外部副作用。规避:可重试的节点应做成幂等(带幂等键的请求、可重入的操作),或把副作用收进带自身幂等保护的下游。这也是 L03 默认排除确定性错误的隐含前提——重试的对象应是"重跑无害且可能成功"的操作。L05
退避 + 抖动:间隔怎么算
L04 步骤⑤的等待时间计算 pregel/_retry.py:663:
# pregel/_retry.py:663
interval = matching_policy.initial_interval
interval = min(
matching_policy.max_interval,
interval * (matching_policy.backoff_factor ** (attempts - 1)), # 指数退避
)
sleep_time = (
interval + random.uniform(0, 1) if matching_policy.jitter else interval # 抖动
)
time.sleep(sleep_time)
backoff_factor ** (attempts-1)指数退避:第 1 次重试间隔 = initial×factor⁰、第 2 次 ×factor¹……默认 0.5×2ⁿ:0.5、1、2、4、8…越等越久。min(max_interval, ...)封顶。涨到 128 秒就不再涨——避免重试等到天荒地老。+ random.uniform(0,1)抖动:给间隔加 0~1 秒随机。防"惊群"——大量任务同时失败、若间隔完全一致会在同一刻集体重试,瞬间再次打爆服务。加随机让它们错峰。图注:间隔按 2 的幂增长,直到 max_attempts 用尽或某次成功。
L06
多策略组合:不同异常不同待遇
retry_policy 可以是一个列表——引擎遇到异常时,按顺序找第一个认它的策略 pregel/_retry.py:648:
# pregel/_retry.py:648
matching_policy = None
for policy in retry_policy:
if _should_retry_on(policy, exc): # 这个策略认这个异常吗?
matching_policy = policy
break # 认了就用它,不再往后看
_should_retry_on 支持三种 retry_on 写法 pregel/_retry.py:841:
# pregel/_retry.py:841
def _should_retry_on(retry_policy, exc) -> bool:
if isinstance(retry_policy.retry_on, Sequence):
return isinstance(exc, tuple(retry_policy.retry_on)) # 异常类的列表
elif isinstance(retry_policy.retry_on, type) and issubclass(retry_policy.retry_on, Exception):
return isinstance(exc, retry_policy.retry_on) # 单个异常类
elif callable(retry_policy.retry_on):
return retry_policy.retry_on(exc) # 自定义判断函数
else:
raise TypeError("retry_on must be an Exception class, a list/tuple of them, or a callable")
📝 组合:限流慢慢重,超时快快重
from langgraph.types import RetryPolicy
g.add_node("call", call_api, retry_policy=[
RetryPolicy(retry_on=RateLimitError, initial_interval=10, max_attempts=5), # 限流:等久点
RetryPolicy(retry_on=TimeoutError, initial_interval=0.5, max_attempts=3), # 超时:快重
])
遇到 RateLimitError → 第一条策略认它(间隔 10s,重 5 次);遇到 TimeoutError → 第一条不认、第二条认(间隔 0.5s,重 3 次);其它异常两条都不认 → 直接抛。顺序很重要:靠前的策略优先匹配。图注:遇到异常,从上到下找第一个 _should_retry_on 为真的策略,用它的参数重试。
💡 设计取舍②:为什么用"策略列表 + 首个匹配",而不是一个能处理所有情况的复杂策略?朴素做法是塞一个巨大的 retry_on 回调,里面 if-else 判断各种异常给不同参数。但那样"判断逻辑"和"重试参数"耦合在一坨代码里,难读难复用。列表方案把每种异常的处理拆成独立、可组合、可复用的策略单元——限流策略、超时策略可以在多个节点间共享,新增一种异常处理只需往列表加一条。"首个匹配"借鉴了异常处理链/中间件的经典模式:顺序即优先级,简单可预测。代价是要注意排序(宽泛的策略放后面,否则会截胡特定异常)。
L07
阶段 8 收官 + 今日小结
💡 阶段 8 全景:函数式 + 子图 + 可靠性,串成一条线
- D47-48 函数式 API:
@entrypoint/@task让你用普通函数写图,task 调用运行时派生成 PUSH 任务,结果经 RETURN 通道回到 Future。 - D49-50 子图:图即 Runnable,可当节点。同名字段共享状态,或包一层函数做隔离转换;流式靠命名空间元组下钻。
- D51 缓存:CachePolicy 按输入指纹存/取 writes,命中即跳过执行。
- D52 重试:RetryPolicy 把节点执行套进退避循环,区分暂时性/确定性错误。
👶 重试、缓存、错误处理节点(error_handler)三者怎么配合?
👨🏫 执行顺序上:先查缓存(命中就直接跳过,压根不执行);未命中则进重试循环执行;重试用尽仍失败,才轮到error_handler(Day 的错误处理节点)兜底或整图抛错。可以理解为三道防线:缓存"根本不用跑"、重试"跑挂了再试试"、error_handler"实在不行怎么优雅收场"。它们正交,可按需组合,共同构成节点级的容错体系。
⚠️ 收官提醒:同步节点的超时不可靠RetryPolicy 管重试,TimeoutPolicy(types.py:449)管单次超时。但源码里
run_with_retry 开头有一句:同步任务带 timeout 会直接报 sync_timeout_unsupported(pregel/_retry.py:580)。因为超时靠 asyncio 取消实现,同步阻塞代码(如 time.sleep、CPU 密集)无法被中途取消。要用超时,把节点写成 async。这是"协作式取消"的固有限制,不是 bug。🧠 今天你应该能回答
- RetryPolicy 六字段?(initial_interval/backoff_factor/max_interval/max_attempts/jitter/retry_on)
- max_attempts=3 是重试 3 次吗?(不是,是总共执行最多 3 次 = 1 首次 + 2 重试)
- default_retry_on 的判断逻辑?(连接错/5xx 重;ValueError 等确定性代码错不重;4xx 不重;其它兜底重)
- 重试前为什么 task.writes.clear()?(撤销上次失败留下的半成品写,保证每次尝试干净开始)
- 退避+抖动怎么算?(interval×factor^(n-1) 封顶 max_interval,jitter 加 0~1s 防惊群)
- 多策略列表怎么工作?(按顺序找第一个 _should_retry_on 为真的策略,顺序即优先级)
- 为什么同步节点不能用 timeout?(超时靠 asyncio 取消,同步阻塞代码无法被中途取消)
✋ 10 分钟动手
# 1. 读重试全链路
sed -n '416,435p' libs/langgraph/langgraph/types.py # RetryPolicy 字段
sed -n '1,29p' libs/langgraph/langgraph/_internal/_retry.py # default_retry_on
sed -n '600,682p' libs/langgraph/langgraph/pregel/_retry.py # run_with_retry
sed -n '841,855p' libs/langgraph/langgraph/pregel/_retry.py # _should_retry_on
# 2. 亲眼看退避重试
python - <<'PY'
import time
from langgraph.graph import StateGraph, START, END
from langgraph.types import RetryPolicy
from typing import TypedDict
class S(TypedDict):
n: int
tries = {"n": 0}
def flaky(s):
tries["n"] += 1
print(f" 第 {tries['n']} 次尝试 @ {time.strftime('%H:%M:%S')}")
if tries["n"] < 3:
raise ConnectionError("temporary") # 前两次假装网络挂
return {"n": 42}
g = StateGraph(S)
g.add_node("f", flaky, retry_policy=RetryPolicy(initial_interval=0.5, max_attempts=5))
g.add_edge(START,"f"); g.add_edge("f",END)
print(g.compile().invoke({"n":0})) # 前两次失败重试,第三次成功 → {'n': 42}
PY
明天预告 · Day 53(进入阶段 9):工具箱备齐,回到应用层最常用的预制件——
create_react_agent。一行代码造一个能调工具的 ReAct Agent,它内部是怎样一张图?明天读 prebuilt/chat_agent_executor.py,看模型节点、ToolNode、条件边如何拼成经典的"思考-行动"循环。