1. LangGraph的工作流执行模型:先把基础逻辑捋清楚
1.1 为什么Agent开发绕不开LangGraph
聊断点恢复和幂等执行之前,得先搞清楚LangGraph到底解决了什么问题。现在做Agent应用,主流方案早就不是简单地把Prompt丢给大模型就完事,而是把LLM调用、工具调用、条件判断、循环重试这些步骤组装成一套有状态的工作流。LangChain早期那种链式调用,写起来舒服,但一遇到复杂的条件分支、循环迭代、人工审核介入,就明显不够用。LangGraph把工作流建模成一张图,节点就是业务步骤,边就是状态流转路径,每一步都打在显式的状态对象上,这就让整个过程可以观测、可以控制、可以恢复。
我见过不少团队在做Agent落地的过程中卡住,问题基本都出在同一个地方:工作流跑一半挂了,要么从头再来,要么状态错乱。比如一个电商客服Agent在处理退款工单,先查询订单、再校验权限、再执行退款、最后发通知,第四步执行到一半,服务重启了,整个流程直接从第一步重新跑一遍,结果用户收到两条退款确认短信。这个场景,靠普通的编排框架很难优雅解决,但LangGraph的checkpoint设计,配合幂等节点设计,是可以从机制上解决的。
1.2 图、状态与检查点:理解三个核心概念
LangGraph里有三个概念必须吃透:图(Graph)、状态(State)、检查点(Checkpoint)。
图负责把节点和边组装起来,定义执行顺序和分支逻辑,这是整个工作流的骨架。在LangGraph里,节点可以是一个函数,也可以是一个ToolNode,甚至是嵌入了LLM调用的AgentNode。边的类型包括普通边、条件边、循环边,这些边决定了某个节点执行完之后下一步去哪个节点。
状态是整个系统的“神经系统”,它像一个全局变量容器,承载了工作流运行中的所有数据。从技术实现看,LangGraph的状态本质上是TypedDict或者Pydantic模型的实例,每个节点执行完毕后会返回一个字典,系统把这个字典与现有状态做合并(merge),形成新的状态。这里的合并逻辑很讲究,默认是简单的字典覆盖,但你可以自定义Reducer函数来控制某些字段的合并方式,后面写幂等设计时我会细讲。
检查点就是断点恢复的地基。LangGraph通过Checkpointer(检查点器)把每一步执行完之后的完整状态快照存到持久化存储里。所谓断点恢复,本质就是“从哪里跌倒,就从哪里的状态快照爬起来继续走”。它不是在节点级别做恢复,而是在状态级别做恢复,这意味着你可以指定从某个节点恢复、带着某个状态片段恢复,甚至恢复之后临时修改状态再继续。
2. 断点恢复的完整方案:从配置到实战
2.1 检查点器的选择与配置
LangGraph支持多种Checkpointer存储,比较常用的是SqliteSaver、PostgresSaver和MemorySaver。三者的定位差异很大,MemorySaver只存在内存里,进程一重启数据就没了,适合本地测试和调试断点逻辑时用;SqliteSaver是单机场景的主力选择,写文件,稳定可靠;PostgresSaver适用于生产环境多实例部署,分布式场景下多个副本可以共享同一个状态存储。
如果只是自己本地跑着玩,压根不需要上Postgres,SqliteSaver足够。不过有个细节要注意:SqliteSaver的依赖包需要单独安装,运行时还得传一个Connection对象进去,不能直接传数据库文件的路径字符串。
import sqlite3 from langgraph.checkpoint.sqlite import SqliteSaver db_path = "./langgraph_demo.db" conn = sqlite3.connect(db_path, check_same_thread=False) checkpointer = SqliteSaver(conn)check_same_thread=False这个参数很多人会漏掉。LangGraph的图执行可能启用异步线程,如果连接禁止跨线程,跑着跑着就抛异常了。先把检查点器创建好,再传给图的编译参数:
from langgraph.graph import StateGraph, START, END graph = workflow.compile(checkpointer=checkpointer)编译之后,每次调用图对象时,都没法用单个参数直接传入了,必须传入一个config字典,其中configurable字典里的thread_id是标识一次完整运行的核心字段。这个thread_id起到的作用,你可以把它类比成快递单号:一次完整的工作流执行,不管中间停了十次八次,只要thread_id相同,它永远能找到上一次停在哪里、状态是什么。
config = {"configurable": {"thread_id": "order-refund-20250317-001"}} result = graph.invoke({"order_id": "A1001", "action": "refund"}, config)2.2 中断机制:给工作流加一个“人工审批闸门”
断点恢复不只是用来处理崩溃场景,另一个核心应用是Human-in-the-loop人工介入。很多时候Agent跑到了关键节点,比如执行退款、发送营销短信、删除数据,不能直接放它过去,需要暂停一下,让真人审批。
LangGraph里最常规的做法就用interrupt函数:
from langgraph.types import interrupt def execute_refund(state): # 已经完成订单查询和权限校验,准备执行退款 refund_amount = state["refund_amount"] decision = interrupt({"question": "确认退款", "amount": refund_amount}) if decision.get("approved"): return {"refund_status": "executed", "refund_amount": refund_amount} else: return {"refund_status": "rejected"}这段代码执行到interrupt时,LangGraph会把当前状态存进检查点,然后直接抛一个中断异常给调用方,工作流停在原地。调用方拿到中断内容后,验证完了,再传一个Command(resume=...)进去,工作流就会从上一步的末尾继续执行,interrupt那一行会返回值,后续逻辑接着跑。
from langgraph.types import Command result = graph.invoke( Command(resume={"approved": True}), config=config )这里值得注意:传给Command的resume参数只能用于恢复,不能用来修改上下文。所以如果审批人想改退款金额,不符合这个模式的逻辑,你得在项目中额外设计一个“状态修正”节点来处理。
2.3 从断点恢复的完整流程演示
用一个综合案例串联一下断点恢复的整个生命周期。假设一个工单质检Agent,处理流程是:解析工单、调用模型打分、生成质检结论、写入数据库。生产环境里大概率会出现的问题就是模型接口超时,导致工作流在第2、3步之间挂掉。
第一轮执行:
graph.invoke({"ticket_id": "TK8821", "content": "..."}, config)假设模型调用那一步因为上游服务不稳定直接抛异常了,工作流终止。这时候先别慌,也不需要从零开始重跑。检查一下当前状态:
state = graph.get_state(config) print(state.next) # 看下一条该执行哪个节点 print(state.values) # 看当前状态里积累了什么如果状态本身是完整的,只是因为临时网络抖动,那直接用graph.invoke(None, config)就能让它从断点处继续执行,前面做过的查询、解析都不会白费。这种操作在LangGraph里有一个专门的叫法:invoke(None)是以“无输入”的方式续跑,框架会从state.next记录的位置接着走。
从状态恢复这条路径看下来,断点恢复帮咱们解决的,本质上就是“不重复劳动”的问题。但这里还得泼一盆冷水:不会重复劳动,不代表不会重复执行。这里的区别很关键,咱们接着看幂等执行这个硬核话题。
3. 幂等执行:Agent生产化必须跨过的坎
3.1 重放机制带来的“副作用重复”问题
断点恢复机制有一个天然的特性:恢复执行的时候,框架没法保证已经执行过的节点不会再次进入。为什么?因为检查点保存的是节点完成后的状态快照,如果上次执行恰好是节点A完成后、节点B开始前挂掉的,那么恢复时会从B节点开始跑。可如果上次执行是节点A执行到一半挂掉的,还没写检查点,那恢复后A节点会重新执行一遍整个函数代码。
看起来好像没问题?两次执行A的起始状态是一样的,体现出来的效果应该也一样。但实际上只要A这个函数里有任何“外部副作用”——比如调用支付接口、发消息、写数据库、调定时任务——一次完整的执行流程中,A的代码就有机会被执行两遍,外部副作用也会跟着发生两遍。
这就牵扯出幂等的真正定义:一个操作执行一次和执行多次,对系统外部造成的最终影响是一致的,那么这个操作就是幂等的。放到Agent场景里就是:不管节点被重放、重跑、重试多少次,都不能让用户多付钱、多收短信、多写入一条重复记录。
3.2 LangGraph中原生幂等能力分析
先从框架能力层面盘点一下LangGraph帮我们做了哪些幂等处理。第一层保障是deduplicate机制。调用图时如果传入了相同的thread_id并带相同的输入,框架会检测到该thread_id下已有相同输入的执行记录,直接返回上次的结果,不重新执行。这个机制对“重复提交”很友好,但它的判断粒度是整个工作流的初始输入,不是节点级别的重复执行。
第二层保障是状态合并时的Reducer。默认情况下,状态里同一个字段如果被多次赋值,后来的值直接覆盖前面的值。如果你给某个字段配置了自定义Reducer,可以通过operator.add把同名字段的值累积到列表,或者用MessageGraph之类的特殊Reducer处理消息序列。这些机制管理的是“状态层面怎么写”,管不了“外部API调用几次”。
所以结论很明确:LangGraph自带的机制解决的是“同一工作流不要重复启动”的问题,节点内部的幂等,必须靠自己在业务函数里设计。下面给出两个我实际验证过的方案。
3.3 基于检查点的幂等节点实现方案
方案A:在节点内部查检查点,判断该步骤是否已经执行过。
实现思路不算复杂:节点函数接收到的state参数本身就是从检查点恢复的,那如果我在状态里维护一个字段,专门记录“哪些节点已经成功执行过了”,那么在节点函数开头检查一下这个字段,命中就直接返回上一次的输出,避免重复执行外部副作用逻辑。
def refund_executor(state): # 状态里维护一个done列表,记录已完成节点 done = state.get("done_nodes", []) if "refund_executor" in done: return {"refund_result": state.get("refund_result")} refund_result = call_payment_api(state["order_id"], state["refund_amount"]) return {"refund_result": refund_result, "done_nodes": done + ["refund_executor"]}这里有一个陷阱:state.get("refund_result")在默认合并逻辑下,恢复到这里的值时是没问题的,但如果refund_result被后续节点修改过,这里返回的就不是节点上次输出的原始值了。所以生产环境我建议单独用一个node_outputs字典字段存每个节点的产物,避免字段被覆盖引发逻辑混乱。
方案B:给外部操作设计业务幂等键,在外部系统层面去重。
这个方案比方案A更保险。你去调一个支付接口、消息接口,支付平台通常都支持商户传一个out_request_no,他们在自己的数据库里以这个字段唯一建索引,重复的请求直接返回原结果。在Agent节点里,这个幂等键常常可以直接复用thread_id加上节点名组合:
import hashlib def send_notification(state): config = state["config"] thread_id = config["configurable"]["thread_id"] idempotency_key = hashlib.md5(f"{thread_id}:send_notification".encode()).hexdigest() response = sms_client.send( phone=state["user_phone"], message=state["notify_content"], idempotency_key=idempotency_key ) return {"notification_id": response.id}这套方案的好处是外部系统自己做了去重,即使我们再怎么重放节点,数据库里也只有一条通知记录。缺点是依赖外部系统支持幂等键,不是所有API都支持。
3.4 幂等与断点组合的注意点和实操建议
把这两个机制组合起来使用时,有几个经验可以明显减少生产环境的踩坑概率:
第一,状态同步点别放太密。不要在每个节点结束都强行加一个interrupt,检查点写得太频繁会拖慢整体性能,同时线程上下文切换成本也高。一般只在关键副作用节点前后、人工审批节点、大规模写入节点前设置检查点。
第二,节点函数的输入输出尽量保持“纯函数”风格。节点内部不要去读环境变量、不要去依赖全局单例,所有数据都从state取,所有结果都通过return返回。只有这样,检查点保存的状态才是完整的、可恢复的。那些偷偷读取外部配置的逻辑,一旦恢复现场时配置变了,整个流程逻辑就失真了。
第三,外部API调用一定要设置超时和重试策略。断点恢复场景里常见的另一个坑就是请求一直卡住,工作流看着像“挂掉”了但实际还占着线程。我建议每个外部调用都配上20秒超时,再加至多2次重试,超过阈值就抛异常,让工作流进入异常分支。
4. 常见问题与排查技巧实录
4.1 断点恢复后状态丢失
我接过一个反馈:工作流执行到中间节点,服务重启后调用graph.invoke(None, config),框架提示找不到对应检查点。排查下来,问题几乎出在thread_id不统一,第一个请求和恢复用的thread_id不一致,等于找错了快递单号。还有一种情况更隐蔽:检查点器的存储没有持久化,有人图省事用了MemorySaver,也没意识到MemorySaver就是纯内存,进程一挂什么都没留下。这两个原因好排查,重点是养成“一个业务请求对应一个thread_id,全程复用”的习惯。
4.2 中断恢复后获取不到最新的用户审批结果
interrupt恢复时大家最容易犯的错误是:以为resume传入的数据会自动合入state里。其实不会,Command(resume=...)只是把数据传回给interrupt的返回值,你需要自己处理状态合并。
def execute_refund(state): decision = interrupt({...}) return {"approval_result": decision}这段代码就是负责把审批结果显式写进state的,一旦漏掉,后续节点就无从得知审批人到底批没批。实践中最稳妥的方式,是在中断节点后面专门加一个小的“解析审批结果”节点,不仅负责合并状态,还能顺带做一层字段校验,防止下游拿着脏数据去调业务接口。
4.3 状态字段合并导致的隐性覆盖
这是状态设计层面的经典问题。假设第一个节点返回{"counter": 1},第二个节点返回{"counter": 2},两者没有关系,但默认合并策略就是后者覆盖前者。如果这个覆盖是预期内的,没问题;但更多时候第二个节点压根不知道状态里还有个counter字段,只是想把自己的计数返回出去,结果把上游数据冲掉了。
解决办法是仔细分析所有节点返回字段的语义,对有累积、追加需求的字段换成Reducer模式:
from operator import add from typing import Annotated from typing_extensions import TypedDict class WorkflowState(TypedDict): tickets: Annotated[list, add] # 自动追加,不覆盖 config: dict4.4 LangGraph与LangChain面试里的高频考察点
后台收到不少读者咨询LangChain和LangGraph面试题目,我挑几个和本文主题最相关的说一说。面试官问“LangGraph和LangChain区别是什么”的时候,重点不是背API,而是说出LangChain解决了“把大模型调用封装成链式管道”的问题,LangGraph则把工作流升级成有状态的图结构,核心优势在分支、循环、人工介入、检查点恢复这些LangChain做起来非常吃力的场景。
问“LangGraph底层怎么实现状态管理”时,重点答出三点:State是TypedDict或Pydantic模型,每个节点返回的部分状态会做合并;Checkpointer通过对State做序列化快照实现持久化;Reducer控制同名字段的合并行为。
问“Agent工具调用在LangGraph里怎么编排”时,建议提到ToolNode和tools_condition,把工具调用作为一个标准节点接入图,然后通过条件边判断是继续调用工具还是进入收尾节点。
5. 写在最后的一点个人经验
这套断点恢复与幂等执行的设计,我从最早在本地用MemorySaver调试,到现在生产环境上跑PostgresSaver,踩过的坑基本都写在上面的章节里了。如果只能记住一句话,那就是:断点恢复解决的是“不从头开始”,幂等设计解决的是“不重复搞事”,两者缺一不可。前者做不好,工作流体验割裂;后者做不好,轻则重复写库,重则重复扣款,属于事故级别的问题。
最后给一个实用建议:新项目上线前,专门做一个“故障演习”——挑三个节点,中途手动杀掉进程重启,看看状态是否还能续上;再挑一个带外部调用的节点,伪造超时重试,看看会不会产生重复副作用。这两项演习跑顺了,Agent工作流的底子才算稳,后面再怎么往上加记忆、加规划模式,都不用太操心稳定性。