第 18 章 错误处理、重试、超时与兜底
生产环境下的 Agent 系统运行在不可靠的基础设施与不确定的模型行为之上:LLM API 会返回 5xx、会触发速率限制(429)、会超时而不返回任何数据;模型输出可能偏离工具 schema 约定(JSON 解析失败、缺字段);工具可能收到非法参数、在运行时抛异常;数据库连接会断开、网络会出现分区;依赖库版本升级会引入不兼容变更。若不做系统化错误处理,一次瞬时网络抖动即可让整个任务链路失败,严重时造成数据不一致(工具执行了副作用但最终结果没保存)、长任务长时间阻塞(不重试也不失败),甚至因重试风暴打垮下游服务。LangGraph 在节点/工具/全局三个层级提供错误处理原语:RetryPolicy 处理可重试的瞬时错误、NodeSpec.timeout 处理节点级超时、ToolNode 提供工具错误钩子、全局条件路由与 catch 节点处理业务错误与兜底;幂等性与死循环熔断则是架构层必须满足的硬性约束。
18.1 错误分类与传播机制
Agent 系统中的错误按性质与处理策略可分为三类,分类决定了后续的重试/兜底/告警策略:
- 瞬时错误(Transient Errors):网络波动、API 5xx(500/502/503/504)、速率限制(HTTP 429)、连接超时、连接池耗尽、临时 DNS 失败、短时数据库连接失败。特点:相同入参稍后重试通常能成功;适合指数退避+抖动重试。
- 业务错误(Business Errors):模型输出格式错误(JSON 解析失败、缺关键字段、tool_calls 格式不对)、工具参数不合法(Pydantic 校验失败、枚举值越界)、LLM 拒答(content_policy 安全拦截)、业务规则校验失败(金额超限、权限不足、数据不存在)、工具返回业务错误码(余额不足、库存不够)。特点:相同入参重试无意义,需要改进入参(给 LLM 反馈错误让其修正)、或走人工兜底、或返回友好错误给用户。
- 系统错误(System Errors):内存溢出(OOM)、磁盘满、数据库连接彻底丢失、依赖缺失、代码 Bug(TypeError、KeyError、AttributeError)、版本不兼容、配置缺失。特点:重试大概率仍然失败,需要告警、快速失败、人工介入。
LangGraph 的错误传播遵循 Pregel 超步原子性语义:节点函数抛出未捕获异常时,Pregel 运行时捕获该异常并标记本超步失败;该节点所在超步中已经成功执行完成的兄弟节点其返回值会通过 checkpointer.put_writes 记录到 pending_writes 区,但不会合并到 Channel(即不更新 State),保证失败原子性——要么超步内所有节点写入都成功合并、要么全部不合并(all-or-nothing 语义)。随后异常沿调用栈上抛到 invoke/stream 调用方,由用户 try/except 捕获。checkpointer 会记录该错误状态(包含 error 信息与失败时的 Channel 快照),下次用同一 thread_id invoke 不会自动重试,必须由调用方决定是 resume、update_state 后重做、还是放弃。Stream 模式下异常会从异步迭代器中 throw 出来,异步消费者需要 try/except 包裹 async for 循环。
18.2 RetryPolicy 与节点级超时
RetryPolicy 是 LangGraph 内置的节点级自动重试机制,可在 add_node 或 compile 时指定,定义在 langgraph.errors 模块。
from langgraph.errors import RetryPolicy
graph_builder.add_node(
"llm_node",
llm_node_fn,
retry=RetryPolicy(
max_attempts=3, # 最大尝试次数(含首次执行),默认 3;设为 1 表示不重试
initial_interval=1.0, # 首次重试前等待秒数,默认 1.0
backoff_factor=2.0, # 指数退避乘数,默认 2.0(1s → 2s → 4s)
jitter=True, # 是否加入随机抖动,默认 True(防雪崩)
retry_on=( # 指定可重试异常类型或谓词函数;默认所有 Exception
ConnectionError, TimeoutError,
lambda e: hasattr(e, "status_code") and e.status_code in {429, 500, 502, 503, 504},
),
reschedule_on=False, # True 时每次重试作为新超步重新调度;False 时节点级原地重试
),
timeout=60, # 节点级超时秒数
)| 参数 | 默认值 | 说明 |
|---|---|---|
| max_attempts | 3 | 含首次执行;超过此次数异常直接上抛 |
| initial_interval | 1.0 | 首次退避等待秒数 |
| backoff_factor | 2.0 | 指数退避乘数:第 i 次等待 interval = initial * factor^(i-1) |
| jitter | True | True 时在 [interval/2, interval] 区间均匀随机抖动,避免多个并发请求在同一时刻重试形成"重试雪崩" |
| retry_on | 所有 Exception | 可传异常类型元组,或 (e)->bool 谓词(根据异常属性动态判断);谓词返回 False 的异常直接上抛不重试 |
| reschedule_on | False | True 时每次重试作为新超步(经历 Channel 状态刷新、Checkpoint 写入、Barrier 重新同步),适合重试前需要更新 State 或让兄弟节点重新执行的复杂场景;False 时在节点执行循环内原地重试(更轻量,但 State 视角不变) |
重试发生在节点级别:重试只重新执行本节点,不重新执行前驱节点,不影响已完成的兄弟节点,也不触发新的 Checkpoint(除非 reschedule_on=True)。默认 retry_on 为所有 Exception,生产中必须显式收窄为可重试的瞬时异常,否则业务错误(如 Pydantic 校验失败、TypeError 逻辑 Bug)也会被无意义重试 3 次,平白增加延迟与 token 消耗。
节点级超时。LangGraph 1.2.x 在 NodeSpec 中支持 timeout 参数指定单节点最大执行秒数(Python 浮点秒),超时后节点任务被取消并抛出 TimeoutError(在节点内部 raise,可被 try/except 或 RetryPolicy 捕获)。TimeoutError 应在 retry_on 中显式列出,配合重试策略处理。
graph_builder.add_node(
"llm_node", llm_node_fn,
timeout=30,
retry=RetryPolicy(max_attempts=3, retry_on=(TimeoutError, ConnectionError)),
)实现细节:async 节点的超时通过 asyncio.wait_for 实现,能真正取消底层协程与正在进行的 HTTP 请求(前提是底层 HTTP client 支持取消);同步节点通过 asyncio.to_thread + wait_for 调度到线程池,超时能取消 wait_for 等待但不一定能强行中断线程内正在执行的阻塞代码(Python GIL 限制,线程无法被外部 kill),因此建议所有长耗时节点都写成 async,或把阻塞调用放在 asyncio.to_thread 中并让阻塞代码本身检查取消标志。
工具级错误处理。ToolNode(langgraph.prebuilt.ToolNode)构造时接受 handle_tool_errors 参数:传 True 表示捕获工具抛出的所有异常,封装为 ToolMessage(name=工具名、content=错误字符串、status="error")返回给 LLM,LLM 可根据错误信息调整参数再次调用;传 False(默认)表示异常直接上抛,走图级错误处理;传自定义 ToolExceptionHandler(签名 (e, tool_call, config) -> ToolMessage)可实现结构化错误回复、错误上报打点、敏感信息脱敏(错误信息中可能含 API Key 等不能暴露给 LLM 的内容)。生产中建议 handle_tool_errors=True,让 LLM 具备自我修正参数的能力,但要对敏感异常类型做脱敏处理。
18.3 全局兜底、断路器、幂等性与降级
全局兜底有三种常用实现模式,按兜底层级从外到内:
- try/except 包裹调用方:在最外层 invoke/stream 调用处 catch Exception(或具体的 GraphRecursionError/GraphInterrupt 以外的异常),记录日志+打点、返回友好错误响应、触发告警。这是最后一道防线,兜底所有未被节点级处理的异常。
- State 错误字段 + catch 节点:在 State 中增加
error: str | None与error_detail: dict | None字段,节点内部 try/except 出错时返回{"error": "llm_timeout", "error_detail": {...}},条件路由检测到 error 非空时跳转到统一 catch 节点;catch 节点根据错误类型决定降级回复、转人工、或(对可重试错误)清空 error 字段后回到原节点重试。 - 条件分支兜底:条件路由 path 函数对所有可能返回值提供映射(含未知值兜底到 catch 节点),避免出现
ValueError: Branch 'b' returns unknown destination 'x'。
断路器模式(Circuit Breaker):当下游服务(LLM、数据库、关键工具)连续失败 N 次(如 5 分钟内 10 次 LLM 调用均 5xx)后,断路器暂时"打开",后续请求直接快速失败不调用下游(返回兜底错误或降级),等待一个恢复期(如 30s)后半开试探放行少量请求,试探成功则关闭断路器恢复正常,试探失败则再次打开。断路器能有效防止下游故障扩散为整个集群雪崩。LangGraph 不内置断路器,应在节点代码层或 LLM/工具客户端层通过 pybreaker 库或自定义实现(基于内存计数器+时间窗),并结合分布式场景下的 Redis 计数做集群级熔断。
幂等性设计是重试机制能够安全生效的前提。RetryPolicy 自动重试、interrupt-resume 节点重跑、Checkpoint 故障恢复 pending_sends 重放,都会导致节点或工具被重复执行。所有带副作用的工具函数(写数据库、发邮件、调用外部支付 API、发 Slack 消息、生成订单)必须幂等:写库前先按唯一 request_id 查询是否已执行;发消息使用外部 API 的 Idempotency-Key 头;创建操作先查后写;节点内部可将"已执行副作用"的标记写入 State(如 payment_done: True),重试时读到标记则跳过实际执行。request_id 从 config["configurable"]["request_id"] 或 metadata 中透传,由调用入口(HTTP 网关)生成并全局唯一。
降级策略。主模型不可用时切换备用模型(gpt-4o → gpt-4o-mini → 本地开源 Qwen/Llama,按成本/可用性排序),主工具不可用时切换备用工具(付费搜索 API 失败降级到免费搜索、搜索引擎失败降级到本地知识库),主流程失败时切换到简化流程(不做复杂研究直接给出保守回复、转人工客服)。实现方式:(1) 节点内 try/except 捕获主调用异常,失败后 await 备用调用;(2) 更结构化的方式是在 State 中维护 fallback_level: int,条件路由根据 fallback_level 选择对应模型/工具节点;(3) 降级事件必须写入 State(degraded: True, degraded_reason: str)并打点告警,事后分析降级率。
死循环熔断。recursion_limit(config 中传入,默认 25)是图级全局保护,防止图进入无限循环后一直消耗 token。但业务层还必须设置领域上限:max_tool_calls(单次任务最多工具调用次数,如 15)、max_llm_calls(最多 LLM 调用次数,如 10)、max_duration_sec(任务最长执行时长,如 120s)。这些计数器放在 State 中(每次工具/LLM 调用前判断并累加),超限直接跳转到兜底节点返回"抱歉,我尝试了多次但未能解决,请转人工"。recursion_limit 作为最后防线,业务层上限应早于 recursion_limit 触发。
完整示例:带重试、超时、降级、错误记录的健壮 ReAct Agent。
# langgraph==1.2.x
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
from langgraph.errors import RetryPolicy
from langchain_core.messages import BaseMessage, SystemMessage
from langchain_openai import ChatOpenAI
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
tool_call_count: int
llm_call_count: int
error: str | None
tools = [] # 填入业务工具
tool_node = ToolNode(tools, handle_tool_errors=True)
def make_llm(model: str = "gpt-4o-mini"):
return ChatOpenAI(
model=model, timeout=30, max_retries=0, # SDK 重试关闭,由 RetryPolicy 统一控制
)
def agent_node(s: AgentState):
if s.get("tool_call_count", 0) >= 10 or s.get("llm_call_count", 0) >= 10:
return {"error": "exceeded_max_calls", "messages": []}
sys = SystemMessage("你是助手。工具报错时请阅读错误信息并修正参数重试,连续失败则直接向用户说明。")
try:
resp = make_llm().bind_tools(tools).invoke([sys] + s["messages"])
except Exception as e:
# 尝试降级到小模型
try:
resp = ChatOpenAI(model="gpt-4o-mini", timeout=20).bind_tools(tools).invoke(
[sys] + s["messages"] + [SystemMessage(f"主模型失败,降级模型:{type(e).__name__}")])
except Exception as e2:
return {"error": f"llm_failed: {type(e).__name__}/{type(e2).__name__}",
"llm_call_count": s.get("llm_call_count",0)+1, "messages": []}
tc_count = sum(1 for m in s["messages"] if getattr(m, "tool_calls", None))
return {"messages": [resp],
"tool_call_count": tc_count,
"llm_call_count": s.get("llm_call_count", 0)+1}
def route(s: AgentState):
if s.get("error"):
return "catch"
return tools_condition(s)
def catch_node(s: AgentState):
err = s.get("error", "未知错误")
return {"messages": [{"role": "assistant",
"content": f"抱歉,执行遇到问题:{err},已为您转接人工处理。"}],
"error": None}
g = StateGraph(AgentState)
g.add_node("agent", agent_node,
retry=RetryPolicy(max_attempts=3, initial_interval=1, backoff_factor=2, jitter=True,
retry_on=(ConnectionError, TimeoutError)),
timeout=60)
g.add_node("tools", tool_node)
g.add_node("catch", catch_node)
g.add_edge(START, "agent")
g.add_conditional_edges("agent", route, {"tools": "tools", END: END, "catch": "catch"})
g.add_edge("tools", "agent")
g.add_edge("catch", END)
graph = g.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "demo-1"}, "recursion_limit": 30}
r = graph.invoke({"messages": [("user", "北京今天天气如何?")], "tool_call_count": 0,
"llm_call_count": 0}, config=cfg)
print(r["messages"][-1].content[:200])本章小结
Agent 错误按性质分为瞬时错误(可重试)、业务错误(需改进入参或转人工)、系统错误(需告警与人工介入)三类。LangGraph 在节点抛出未捕获异常时按超步原子失败(已完成兄弟节点写入不合并),异常上抛、pending_writes 落盘但不合并 Channel。RetryPolicy 在节点级提供 max_attempts/initial_interval/backoff_factor/jitter/retry_on/reschedule_on 参数,以指数退避+抖动+异常白名单的方式重试;NodeSpec.timeout 提供单节点超时(async 通过 wait_for 可真正取消);ToolNode.handle_tool_errors 封装工具异常为 ToolMessage 供 LLM 自我修正;全局兜底通过最外层 try/except、State.error 字段+catch 节点、条件分支兜底三种方式分层实现。断路器(连续失败快速失败)必须在客户端层实现防止雪崩。所有副作用工具必须幂等(request_id 去重、Idempotency-Key)。降级策略通过 try/except 备用调用或 State.fallback_level 条件路由实现。死循环防护靠 recursion_limit 与业务层 max_tool_calls/max_llm_calls/max_duration 多重上限。
常见错误
- 过度重试导致雪崩。现象:下游服务短暂 5xx,所有并发请求同时重试产生 3-5 倍流量,直接把下游打垮导致全站不可用。原因:max_attempts 过大、未启用 jitter、retry_on 过宽(对 4xx 业务错误也重试)、多个层级重复重试(SDK+LangGraph+HTTP Client 叠加)。修复:max_attempts 不超过 3,jitter=True,retry_on 只包含 429/5xx/网络类异常;关闭 LLM/HTTP SDK 自带重试(max_retries=0),由 LangGraph RetryPolicy 统一控制;前端入口侧做请求限流与并发上限。
- 不幂等工具在重试/恢复时产生重复数据。现象:重试后用户收到两封相同邮件、数据库出现重复订单、外部 API 重复扣款。原因:副作用工具未做幂等保护,被重试/节点重跑/pending_sends 重放触发多次执行。修复:所有写操作工具接收 request_id 参数(从 config["configurable"]["request_id"] 透传),执行前先按 request_id 查库是否已处理;调用外部支付/邮件 API 时使用 Idempotency-Key 头;数据库写入用 UPSERT(INSERT ON CONFLICT)而非 plain INSERT。
- timeout 设置过短中断正常长请求。现象:偶发 TimeoutError 触发重试,token 消耗翻倍、但请求本身在更高延迟下本可成功;超时重试可能导致副作用重复。原因:timeout 未按节点类型区分,用统一小值(如 5s)处理所有节点;未考虑 P99 长尾。修复:按节点类型分别设置 timeout(简单对话 10-15s、RAG 检索 20-30s、长文生成 60-120s、批量任务 120-300s);基于监控 P99 延迟定期调整;对纯副作用节点(支付、发件)timeout 要长于下游 SLA 并配合幂等。
- 未捕获 LLM 输出格式错误导致 KeyError/JSONDecodeError。现象:偶发 KeyError: 'tool_calls' 或 json.JSONDecodeError,整轮任务失败触发 RetryPolicy 重试 3 次仍失败。原因:LLM 偶尔不按约定输出结构化字段,代码直接下标访问。修复:使用 with_structured_output 或 bind_tools(LangChain 提供自动解析兜底);ToolNode.handle_tool_errors=True;节点内对关键字段做
.get()判空,解析失败时追加错误反馈消息("请以 JSON 格式输出 tool_calls")写回 messages,让 LLM 在下一轮自我修正,不要直接抛未处理异常。 - 错误处理节点(catch_node)本身又抛异常。现象:catch 节点内格式化错误信息时再次抛异常(如
e.status_code在非 HTTP 异常上 AttributeError),外层 try/except 也无法优雅处理,用户看到 500。原因:兜底代码未做自身防御,假设异常对象有固定结构。修复:catch 节点代码必须极简、防御式(用getattr(e, "status_code", "N/A"))、只做字符串拼接与错误标记,不再调用任何可能失败的下游(不调 LLM、不写库),最多写日志和打点。
生产建议
- 在统一的 Agent 工厂/基类中配置默认 RetryPolicy(max_attempts=3,指数退避+jitter,retry_on 限定为网络/限流/5xx),禁止业务图使用默认"所有 Exception 都重试";
- LLM/HTTP 客户端统一关闭 SDK 层自带重试(max_retries=0),避免双重退避(SDK 重试+RetryPolicy 重试导致重试次数乘积放大);
- 所有副作用工具统一从 config 读取 configurable.request_id,写入数据库作为幂等键,CI 检查工具函数必须接收 request_id 参数;
- 错误监控:invoke/stream 外层 catch 所有异常,按 error_type、model、tool_name、node_name 维度打点(Prometheus/OpenTelemetry),配置 5xx 率、超时率、重试率、降级率的告警阈值;
- 断路器部署在 LLM Client 与关键工具层(连续 10 次 5xx 打开 30s,半开试探 3 次),保护整体服务不被下游故障拖垮;
- 降级链预先在配置中定义(primary → secondary → tertiary → static fallback message),降级事件写 State 字段(degraded/degraded_reason)与日志,便于事后分析降级触发频率与原因;
- 每次 invoke 入口在 metadata 中注入 deadline 绝对时间戳(time.time() + timeout_sla),长任务节点检查 deadline 自动放弃并返回超时错误,避免整体调用超过 SLA;
- catch 节点接入人工工单系统:达到最大重试或致命错误时,自动创建工单(携带 thread_id、最后状态、错误快照),人工修复后通过 thread_id resume。
