夜雨聆风学习资料网

ARTICLE · 1233126

【LangGraph源码精读】第06期|节点失败了怎么办?LangGraph的重试和部分失败恢复

【LangGraph源码精读】第06期|节点失败了怎么办?LangGraph的重试和部分失败恢复

【LangGraph源码精读】第06期|节点失败了怎么办?LangGraph的重试和部分失败恢复

30篇源码精读系列·第06篇(执行引擎层收官篇)。你的Agent跑到第5个节点崩了,前面4个节点的结果全丢了?不会。LangGraph的pending writes机制让已成功节点的结果立即持久化,恢复时跳过它们只重跑失败的节点。但这背后有一个你必须知道的代价:恢复不是从崩溃的那行代码继续,而是从整个节点的开头重跑。这篇把失败重试、部分恢复、优雅关闭、幂等性一次讲透。


一、开头:你的Agent崩了,前面的工作全白做了?

这是一个经典场景。

你写了一个5步Agent:搜索→分析→生成→审核→发布。跑到"生成"节点时,LLM API超时了,整个Agent崩溃。你以为前面"搜索"和"分析"的结果都丢了,需要从头重跑——但实际上没有。

LangGraph的pending writes机制让每个节点完成后立即持久化结果。 同Super-step中,A节点成功了,B节点失败了,A的结果已经写入checkpoint_writes表。恢复时A不会重跑,只重跑B。

但这里有一个反直觉的点:恢复不是从B节点崩溃的那行代码继续,而是从B节点的整个函数开头重跑。 如果B节点内部有3个步骤,第1步已经执行了(比如调用了一个外部API产生了副作用),恢复时第1步会再执行一次。这就是为什么"节点必须幂等"不是建议,是硬性要求。

这篇讲LangGraph的完整容错体系:pending writes部分恢复、两层持久化、三种持久化模式、重试源码、error_handler补偿、drain优雅关闭、恢复重放机制、幂等性要求。 搞懂这些,你的Agent才能真正"生产可用"。


二、失败的三种类型

在深入机制之前,先分清LangGraph中三种不同的失败。

失败类型
说明
恢复方式
节点异常
节点函数抛出异常(API超时、代码bug)
retry_policy重试 → error_handler补偿
系统崩溃
进程被杀、Pod被驱逐、机器宕机
从最后一个checkpoint恢复,pending writes跳过已成功节点
人为中断
interrupt_before/after、drain请求
保存checkpoint,后续用Command恢复

这三种失败的恢复机制完全不同。 节点异常是"软件层面"的,重试和补偿就能解决;系统崩溃是"硬件层面"的,需要持久化和恢复;人为中断是"控制层面"的,需要协作式停止。


三、pending writes:部分失败恢复的核心

pending writes是LangGraph容错体系中最精妙的设计。

3.1 什么是pending writes?

回顾执行流程:节点执行完后,commit()把结果写入task.writes缓冲区,然后调用loop.put_writes(task_id, writes)。

传统做法
LangGraph做法
所有节点执行完后统一保存
每个节点完成后立即保存自己的writes
一个节点失败,同Super-step全部重跑
已成功节点的writes已持久化,恢复时跳过
恢复粒度是Super-step
恢复粒度是节点级

关键:put_writes是在节点完成时立即调用的,不是等整个Super-step结束。 这意味着即使同Super-step的其他节点还在跑,已完成节点的结果已经安全地写入了持久化存储。

3.2 pending writes的存储结构

Checkpointer有两张核心表:

表
粒度
存什么
写入时机
checkpoints
Super-step级
完整状态快照(channel_values、channel_versions、versions_seen)
after_tick时
writes
节点(task)级
(task_id, channel, value)元组
每个节点完成时立即

writes表是pending writes恢复的基础。 恢复时,LangGraph读取writes表中当前checkpoint下所有已成功task的writes,把它们重新挂到对应的task上,标记为"已完成",然后只执行还没有writes的task。

3.3 一个具体例子

假设Super-step有3个并行节点:A(搜索)、B(分析)、C(生成)。

时间
发生了什么
writes表
T1
A完成,put_writes(A, {messages: [...]})
A: messages=[...]
T2
B完成,put_writes(B, {analysis: "..."})
A: messages, B: analysis
T3
C执行中,进程崩溃
A: messages, B: analysis(C无writes)
T4
恢复,tick()读取writes表
A和B的writes重新挂回,标记已完成
T5
只执行C
C完成,after_tick合并所有writes

A和B没有重跑,只重跑了C。 这就是"部分失败恢复"——不是整个Super-step重跑,而是只重跑失败的节点。


四、两层持久化:put_writes和put

LangGraph的持久化分两层,对应不同的恢复粒度。

层级
方法
写入时机
恢复粒度
作用
第一层
put_writes
每个节点完成时
节点级
pending writes恢复,跳过已成功节点
第二层
put
after_tick时(Super-step边界)
Super-step级
完整状态快照,时间旅行、中断恢复

4.1 为什么需要两层?

如果只有put(Super-step级)
如果只有put_writes(节点级)
同Super-step一个节点失败,全部重跑
无法获取完整状态快照,时间旅行和中断恢复不可用
无法实现节点级部分恢复
每次恢复需要重放所有writes重建状态,效率低

两层持久化是"粒度"和"完整性"的平衡。 put_writes提供细粒度的部分恢复,put提供完整的状态快照用于时间旅行和快速恢复。

4.2 恢复时的tick()逻辑

恢复场景下,tick()做了一件关键的事:

步骤
做什么
1
从checkpointer读取当前checkpoint的writes表
2
把已成功持久化的普通writes重新挂到对应task
3
跳过ERROR、INTERRUPT、RESUME等控制类写入
4
prepare_next_tasks时,已有writes的task标记为"缓存命中",不执行
5
只执行没有writes的task(失败的或未执行的)

这就是为什么恢复时已成功节点不重跑。 它们的writes已经在writes表里了,tick()把它们当作"缓存命中"直接复用。


五、三种持久化模式

LangGraph支持三种持久化模式,在性能和数据安全之间权衡。

模式
写入时机
性能
崩溃恢复能力
适用场景
exit
只在graph退出时(成功/失败/中断)
最好
无法从系统崩溃恢复(中间状态没存)
短任务、测试、不需要恢复的场景
async
异步写入,和下一步执行重叠
好
小概率丢失(崩溃时正在写的checkpoint可能不完整)
大多数生产场景,平衡性能和安全
sync
同步写入,写完才开始下一步
有开销
最安全,每个checkpoint都完整写入
金融、支付等不能丢状态的场景

5.1 怎么选?

你的场景
推荐模式
原因
快速原型、单元测试
exit
不需要持久化,性能最好
普通生产Agent
async
性能和安全的平衡点,崩溃概率低
涉及支付、订单、库存
sync
不能丢任何状态,宁可慢一点
长运行任务(小时级)
sync
崩溃代价太大,必须每个Super-step都保存

注意:exit模式下pending writes也不会立即保存。 因为put_writes也是持久化操作,exit模式下会延迟到退出时统一写入。所以exit模式下系统崩溃会丢失当前Super-step的所有节点结果。


六、重试的源码实现:run_with_retry

重试是节点级容错的第一道防线。

6.1 run_with_retry的执行流程

步骤
做什么
1
执行节点函数
2
如果成功,返回结果
3
如果失败,检查retry_on判断是否可重试
4
可重试且未超过max_attempts,等待backoff时间后重试
5
不可重试或超过max_attempts,抛出异常
6
异常被error_handler捕获(如果配置了),否则冒泡

6.2 退避策略

重试次数
等待时间(默认参数)
说明
第1次重试
0.5秒 + 随机抖动
initial_interval=0.5
第2次重试
1.0秒 + 随机抖动
backoff_factor=2
第3次重试
2.0秒 + 随机抖动
指数退避
第4次重试
4.0秒 + 随机抖动
直到max_interval=128秒

指数退避+随机抖动是分布式系统的标准做法。 指数退避避免频繁重试打垮下游服务,随机抖动避免"惊群效应"(多个Agent同时重试导致流量尖峰)。

6.3 重试的关键语义

语义
说明
重试单位是task attempt
单个节点的一次尝试,不是整个graph
同Super-step其他节点不受影响
A重试时,已成功的B不会重跑
超时后重试,超时时钟重置
每次新尝试的run_timeout从0开始计时
重试前清除上一次的writes
失败尝试的部分结果不保留
NodeTimeoutError默认可重试
超时通常是暂时的,重试可能成功

七、error_handler和Saga补偿

重试解决不了所有问题。有些错误重试也没用(支付失败、库存不足、参数错误),这时候需要error_handler做补偿。

7.1 error_handler的执行时机

条件
结果
节点成功
正常返回,不触发handler
节点失败,retry_policy重试成功
不触发handler
节点失败,重试耗尽
触发error_handler
节点失败,无retry_policy
立即触发error_handler
error_handler自身失败
异常冒泡,整个Super-step失败

7.2 Saga补偿模式

error_handler最经典的用法是Saga模式——分布式事务中的补偿机制。

步骤
正常流程
失败补偿
1
扣减库存(reserve_inventory)
-
2
支付(charge_payment)
支付失败 → error_handler触发
3
生成订单(create_order)
handler回滚库存(Command(goto="release_inventory"))

error_handler可以返回Command(update=..., goto=...),同时更新状态和路由到补偿节点。 这比"整个graph失败回滚"灵活得多——你可以精确控制失败后做什么、跳到哪里。

7.3 error_handler的限制

限制
说明
每个节点最多一个handler
不能给一个节点配多个error_handler
handler不能捕获自己
handler自身失败会冒泡,不会递归捕获
handler结果不缓存
cache_policy不适用于error_handler节点
handler是graph task
handler执行时也会走完整的Pregel流程

八、drain优雅关闭:v1.2的生产级能力

drain是v1.2新增的功能,解决"部署时怎么不丢正在运行的任务"的问题。

8.1 为什么需要drain?

传统做法
drain做法
收到SIGTERM直接杀进程
收到SIGTERM发drain信号,协作式停止
正在运行的Super-step结果丢失
当前Super-step跑完,保存checkpoint后停止
恢复时从头重跑
从checkpoint恢复,已完成工作不丢

8.2 drain的协作式语义

drain是协作式的,在Super-step之间生效,永远不抢占正在运行的工作:

场景
行为
节点执行中收到drain
节点跑完,下一个Super-step生效,抛出GraphDrained
节点重试中收到drain
重试循环跑完(成功或耗尽),然后生效
graph在同一tick自然结束
正常返回,检查control.drain_requested区分
还有更多Super-step
抛出GraphDrained(reason),checkpoint已保存
子图请求drain
GraphDrained冒泡到父图,父图在下一个Super-step边界停止

核心原则:drain不会打断正在做的事,只会在"安全点"(Super-step边界)停止。 这保证了停止时状态一定是一致的。

8.3 节点内感知drain

节点可以通过runtime参数检查drain状态,提前调整行为:

场景
节点行为
runtime.drain_requested为True
跳过昂贵计算,返回最小结果
runtime.drain_reason
获取drain原因(如"sigterm")

这对长任务特别有用。 如果节点内部有一个10步的循环,第3步时收到drain,可以提前结束循环返回部分结果,而不是硬跑完10步才停止。

8.4 SIGTERM处理的标准模式

生产环境中处理SIGTERM的标准做法:

步骤
做什么
1
创建RunControl对象
2
注册SIGTERM信号处理器,调用control.request_drain("sigterm")
3
graph.invoke(inputs, config, control=control)
4
捕获GraphDrained,记录日志
5
进程退出,下次启动时用相同thread_id恢复

九、恢复的起点和重放机制

这是最容易被误解的一点。

9.1 恢复不是从崩溃的那行代码继续

你以为的
实际的
从节点崩溃的那行代码继续执行
从整个节点函数的开头重跑
节点内部已执行的步骤不重复
节点内部所有步骤重新执行
副作用只产生一次
副作用可能重复(除非幂等)

恢复的起点是节点的开头,不是崩溃的代码行。 因为LangGraph不保存节点内部的执行状态(Python的调用栈、局部变量),只保存节点的输入输出。恢复时只能重新调用节点函数,传入恢复后的state。

9.2 不同API的恢复起点

API
恢复起点
StateGraph(Graph API)
从停止的node的开头开始
子图调用
从父节点开头开始,子图内从停止的node开始
Functional API
从entrypoint的开头开始

9.3 重放机制

恢复时,LangGraph会重放从起点到停止点的所有步骤:

步骤
说明
1
加载最后一个checkpoint的完整状态
2
从恢复起点开始重新执行节点
3
已成功节点的writes从writes表恢复,不重跑
4
失败/未执行的节点重新执行
5
直到graph完成或再次失败

重放不是"从头开始",而是"从checkpoint的状态开始,跳过已成功节点"。 已成功节点的结果从writes表直接复用,只有失败的节点会重跑。


十、幂等性:恢复不重复副作用

因为恢复时节点会从开头重跑,所以节点必须幂等。

10.1 什么是幂等?

操作
幂等?
说明
读取数据
是
读多少次都一样
计算(纯函数)
是
相同输入相同输出
调用LLM
基本是
相同prompt可能输出不同,但不产生外部副作用
发送邮件
否
重跑会发两封邮件
扣减库存
否
重跑会扣两次
创建订单
否
重跑会创建两个订单
写入文件
否
重跑会覆盖或追加

10.2 怎么保证幂等?

方案
说明
适用场景
幂等键
每次操作带唯一ID,下游检查是否已处理
API调用、支付、订单
先查后写
执行前检查是否已执行过
数据库写入、文件创建
@task包装
用Functional API的@task装饰副作用操作
节点内多个副作用步骤
拆分节点
把副作用操作拆成独立节点,节点级恢复
复杂工作流
接受重复
副作用无害时(如日志)
非关键操作

@task是Functional API提供的幂等机制。 被@task装饰的函数,其结果会被持久化;恢复时如果task已成功执行,直接返回持久化的结果,不重复执行。这解决了"节点内部多个副作用步骤"的幂等问题。


十一、常见坑:容错的5个隐蔽问题

坑
现象
根因
解法
恢复后副作用重复
支付扣了两次钱
节点不幂等,恢复时重跑整个节点
加幂等键,或用@task包装
exit模式崩溃全丢
进程被杀后无法恢复
exit模式只在退出时持久化
生产用async或sync模式
pending writes没生效
恢复时已成功节点还是重跑了
checkpointer配置错误,或writes表损坏
检查checkpointer的put_writes实现
drain没反应
发了drain信号但graph还在跑
drain是协作式的,节点在跑就等它跑完
配合timeout做硬上限
error_handler死循环
handler失败后又触发自己
handler路由回了原节点,原节点又失败
handler不要路由回可能失败的原节点

十二、面试关联:容错的5道高频题

面试题
答题要点
考察深度
LangGraph怎么实现部分失败恢复?
pending writes机制:每个节点完成后立即put_writes,恢复时从writes表恢复已成功节点,只重跑失败的
核心机制
恢复是从崩溃的代码行继续吗?
不是。从节点函数开头重跑,因为不保存调用栈和局部变量。所以节点必须幂等
恢复语义
三种持久化模式有什么区别?
exit只在退出时存,async异步存,sync同步存;安全性递增,性能递减
工程权衡
drain和直接杀进程有什么区别?
drain是协作式的,在Super-step边界停止并保存checkpoint;杀进程丢失当前Super-step结果
生产运维
retry和error_handler什么关系?
先重试,重试耗尽后才触发error_handler;handler可以返回Command做Saga补偿
错误处理流程

十三、本期小结

失败重试和部分恢复,核心就6句话:

  1. 1. pending writes是部分失败恢复的核心。 每个节点完成后立即put_writes持久化结果,同Super-step中一个节点失败不影响已成功节点。恢复时从writes表恢复已成功节点的结果,只重跑失败的节点。恢复粒度是节点级,不是Super-step级。
  2. 2. 两层持久化对应不同恢复粒度。 put_writes是节点级,在节点完成时立即写入,用于部分恢复;put是Super-step级,在after_tick时写入完整快照,用于时间旅行和快速恢复。两层配合实现"细粒度恢复+完整快照"。
  3. 3. 三种持久化模式在性能和安全间权衡。 exit只在退出时存(性能最好但崩溃全丢),async异步存(平衡点),sync同步存(最安全但有开销)。生产场景默认async,涉及支付订单用sync。
  4. 4. 重试是节点级的,不影响同Super-step其他节点。 run_with_retry用指数退避+随机抖动,默认重试网络异常和5xx,不重试代码错误。重试前清除失败尝试的writes,超时后重试时钟重置。重试耗尽后才触发error_handler。
  5. 5. drain是协作式优雅关闭,在Super-step边界生效。 收到drain信号后,当前节点和重试循环跑完,下一个Super-step抛出GraphDrained并保存checkpoint。节点内可以通过runtime.drain_requested提前调整行为。drain保证停止时状态一致,可恢复。
  6. 6. 恢复不是从崩溃的代码行继续,而是从节点开头重跑。 LangGraph不保存调用栈和局部变量,只保存节点输入输出。所以节点必须幂等——副作用操作用幂等键、先查后写、@task包装或拆分节点。不幂等的节点在恢复时会产生重复副作用。

十四、执行引擎层收官 + 下一期预告

执行引擎层6篇(01-06)全部完成:

期数
主题
核心收获
01
Pregel执行引擎
BSP三阶段、PregelLoop、一次Super-step 7步
02
节点调度源码
tick→runner.tick→after_tick、prepare_next_tasks、commit()
03
Channel系统
5种Channel、O(N²)问题、DeltaChannel
04
Reducer和版本号
add_messages三功能、结合律、versions_seen、终止判断
05
并发执行器
Sync/Async、超时、重试、慢节点
06
失败重试和部分恢复
pending writes、两层持久化、drain、幂等性

下一期(源码07),进入用户API/编译层第一篇:

【LangGraph源码精读】第07期|add_node/add_edge做了什么?我读源码发现3个隐藏校验

从StateGraph的add_node到add_edge,从节点注册到边的校验,搞懂你写的每一行builder代码在底层触发了什么。

你在生产中遇到过Agent崩溃恢复的问题吗?评论区聊聊,我优先写你最关心的场景。


本文为《LangGraph源码精读30讲》系列第06篇。内容基于LangGraph v1.2.11源码结构梳理,核心文件位于libs/langgraph/langgraph/pregel/目录下的_loop.py(pending writes恢复、drain处理)、_runner.py(run_with_retry)、_retry.py(重试逻辑),以及libs/checkpoint/langgraph/checkpoint/base.py(BaseCheckpointSaver的put/put_writes接口)。drain和per-node timeout为v1.2新增功能。版本更新后部分细节可能变化。如有错漏欢迎指正。

相关学习资料