使用LangChain+LangGraph开发AI应用(9) - LangGraph 进阶

学习进度
这一篇给图装上两个进阶器官。持久化执行,图跑一半进程死了,换个进程接着跑。人工协同,图跑到关键步骤停下来,把问题递给人,等一个答复再继续。这两件事共享同一个地基,checkpointer 在每个 super-step 落一个状态快照。
围绕这个地基往深里走,中断、恢复、回放、落盘。示例在 source/hello_persist.py,九个 demo,全部纯函数不依赖模型。完整案例是一条带人工审批的发布流水线 source/release.py,流程跑到审批节点把摘要递给终端,你按 y 或 n,它按你的选择走部署或驳回。
前言用途
agent 跑长任务有两类时刻最要命。一类是意外,进程崩了、网断了、超时了,从头重跑意味着把已经花钱的模型调用全部重来。另一类是主动的,流程走到删库、转账、群发这类不可逆动作前,得停下来等人点头。两类问题的解法是同一套,把状态周期性落盘,停了就从最近的快照接着来。
LangGraph 的答案是 checkpointer。图每推进一个 super-step,状态快照就写进 checkpointer,中断、崩溃、重启之后,带着同一个 thread_id 回来,图从快照处继续。配合 interrupt 函数,任意节点内部都能声明一个暂停点,把任意数据递给外部,恢复时拿回一个答复。审批、确认、填表、改参数,人机协同的所有场景都是这一个动作的变体。
什么时候需要这些。多轮对话要续接状态,长流程要容错,或者流程里有需要人拍板的步骤,就是这一篇的主场。多智能体编排里子图之间的状态恢复,同样压在这套机制上。
原理概念
checkpointer 的存储单位是 checkpoint,一个 thread 里按 super-step 排成一条链。每个 super-step 跑完,图状态的快照落一个 checkpoint,链上每个快照带自己的 config(thread_id 加 checkpoint_id)、元数据(step 步数、writes 来源)和下一步要执行的节点。第 7 篇见过的 get_state 就是取这条链的最新一环,get_state_history 把整条链翻出来。
落盘的节奏也能调。stream 或 invoke 时传 durability 参数,三档可选。"exit" 只在图退出时落盘,性能最好,进程崩了中间状态全丢。"async" 异步落盘,性能和持久性折中,崩溃时有小概率丢最近的快照。"sync" 在下一步开始前同步落盘,最稳,代价是每步多一次写盘。默认行为在 exit 与 sync 之间,生产上按业务能接受丢失多少来选。

中断的实现比直觉朴素。interrupt 函数被调用时,往运行时抛一个特殊的异常,引擎捕获它,把当前状态落进 checkpointer,然后停住。这个实现决定了三件事。中断可以发生在节点内任意位置、任意条件下,是动态的。恢复时节点从第一行重新执行,interrupt 之前的代码会再跑一遍。interrupt 的恢复值按调用顺序严格对号,一个节点里有几个 interrupt,恢复值就按顺序填进去。
恢复只有一条路,Command(resume=...)。它的值原样成为 interrupt 函数的返回值,图从断点继续。checkpointer 用内存版跑单进程演示,换 SqliteSaver 或 PostgresSaver,快照落进数据库,进程重启、换台机器都能接着审批。官方还有一套静态断点 interrupt_before、interrupt_after,编译图时把节点名钉进去,主要用于调试,人机协同推荐用 interrupt 函数。
checkpointer 是个可替换的接口,官方维护了一排实现。langgraph-checkpoint 是基础包,InMemorySaver 在里面。SqliteSaver 和 PostgresSaver 在单独的包里,要自己装。MongoDB、Azure Cosmos DB 也各有官方包。想自己写一个,实现四个方法就够,put 存快照、put_writes 存中间写入、get_tuple 取快照、list 列快照,快照序列化默认用 JsonPlusSerializer,Pydantic 模型、dataclass、datetime 都能直接存,不支持的对象可以开 pickle 回退。存储里还有个容错设计值得知道,某个 super-step 中途失败时,同批已成功节点的写入会单独存档,恢复执行时已成功的节点不会重跑。
核心用法
九个用法,示例在 source/hello_persist.py,全部纯函数图。共享骨架是一个三节点的审批图,work 干活,approval 里放 interrupt,finish 收尾。
1. 中断闭环
最核心的一条链,暂停、恢复、按答复路由。
| |
| |
| |
第一次 invoke 返回值里多出 __interrupt__ 键,payload 原样躺在里面。恢复值 True 让条件边选了 finish 分支,结论带着「人工通过」。恢复 False 则直接到 END,结果停在 work 的产出上,流程等于被人按了驳回。
用 stream 驱动时呈现方式不同,中断信息在事件流的 stream.interrupts 里,stream.interrupted 为 True。交互循环的写法是固定的三段,发起输入跑一遍,看 interrupted 判断要不要问人,问完把 Command(resume=答复) 作为下一轮输入,直到不再中断。官方把这段循环叫 human-in-the-loop loop, Web 服务里就是「请求进来跑图,发现中断返回审批页面,用户提交后 resume 继续」这个模式。
2. 结构化 payload
interrupt 的参数可以是任意 JSON 值,问题怎么问、选项给什么,自己定义。
| |
| |
payload 递出去,答案递回来,中间隔了多久、隔了多少进程都无所谓,checkpointer 记着呢。
一个节点里放多个 interrupt 也行,恢复值按调用顺序严格对号,第一个 interrupt 拿第一个 resume。配套的纪律是别用条件分支跳过 interrupt 调用,这次执行问了两个问题、下次只问一个,恢复值就对不上号了。并行分支同时中断的场景,恢复值换成 {interrupt.id: 答复} 的映射,每个中断用自己的 id 领答案。还有一条,payload 只放 JSON 能序列化的简单值,函数、类实例传不进去。
3. 节点从头重跑
恢复时节点从第一行重新执行,这是中断机制最容易被忽略的性质。
| |
| |
计数器实锤了重跑。把副作用放在 interrupt 之前,就会执行两次。写节点的时候要么把副作用挪到 interrupt 之后,要么设计成幂等的,upsert 代替 insert。
4. 吞异常坑
用 try/except 包住 interrupt,中断就没了。
| |
| |
图一口气跑完,没人被问到任何问题。interrupt 的暂停机制就是抛异常,except Exception 把它拦下来,引擎收不到信号。要处理业务异常,用具体的异常类型,别用裸的 Exception。
5. 静态断点
编译图时把断点钉死在节点边界上,恢复用 invoke 传 None。
| |
| |
断点停在 b 节点之前,a 的产出已经在状态里。invoke 传 None 意思是不给新输入,从断点继续。断点除了编译时钉死,还能在每次 invoke 时临时指定,app.invoke(inputs, interrupt_before=["b"], config=config),调试时想断哪步断哪步。这种断点在节点边界上,不能带条件,人机协同还是用 interrupt 函数。
6. 状态历史
get_state_history 把线程的整条 checkpoint 链翻出来。
| |
| |
每个 super-step 一条快照,step 是步数,next 是下一步要跑的节点。注意这里只有三条,b 还没执行,step=2 的快照不存在,快照在 super-step 边界落地,没跑完就没有。链长了还可以按条件筛,next(s for s in history if s.next == ("b",)) 找某节点执行前的快照,s.metadata["step"] == 2 按步数找,s.metadata["source"] == "update" 找 update_state 产生的分叉。排查「图停在哪了」,get_state_history 一眼看清。
7. 时间旅行
从历史快照的 config 重新 invoke,之前的步骤直接跳过,之后的重新执行。
| |
| |
快照之前的计算成果都还在,不用重花钱。回放不等于修改,想改历史状态再继续,用 update_state 在快照上分叉出新线程。回放时 interrupt 会重新触发,适合重跑一次带人工确认的流程。
8. SqliteSaver 落盘
内存版进程一退就没了,换 SqliteSaver,快照落进一个 SQLite 文件。
| |
| |
demo 里关掉第一个连接、开第二个新连接模拟换进程,恢复照常。连接要传 check_same_thread=False,引擎在内部线程池里写快照,默认的 sqlite 连接会报跨线程错误。生产换 PostgresSaver,接口不变。
9. response_schema 校验
给 interrupt 配一个 Pydantic 模型,恢复值不符合 schema 直接被拦。
| |
| |
恢复 {"approved": "yes"} 这种类型不对的值,抛 ValidationError,图还停在断点上,换个合法的再按一次就行。校验通过的恢复值直接是 Decision 实例,字段访问不用再解析字典。要做的校验比 schema 复杂怎么办,官方的推荐姿势是把错误信息存进状态,用条件边跳回同一个节点再问一次,每个 interrupt 保持每次执行只调用一次。别在节点里写 while True 循环包 interrupt,恢复是从节点开头重跑的,循环几轮就把之前几轮全部重放,执行次数指数级涨。
完整案例
九招都练完了。收个尾,搭一条真的带人工闸门的流水线,release.py,发布审批。plan 节点把变更描述整理成摘要,approve 节点把摘要递给人工,等一个 Decision,批准走 deploy,驳回走 reject。
| |
| |
–auto-approve 是无人值守模式,跳过终端问答直接批准。去掉这个开关,流程会在审批处停下来,把摘要打在终端上,等你敲 y 或 n,再让你留一句备注。你敲的每个字经 Decision 校验后成为恢复值。交互长这样。
| |
按 n 走的是另一条分支,同一个图,结论变成「已驳回」,备注原样带上。路由写在 approve 节点内部,用上一节的 Command(goto=) 完成,批准与驳回共享同一个中断点。
| |
–db 让快照落进 SQLite 文件。第一次运行发起发布,流程停在审批,你关掉终端下班。第二天再打开,用同样的 thread_id 把 Command(resume) 发过去,图从断点接着跑。审批可以隔夜,状态不会丢。
这套机制还能钻进工具内部。interrupt 在 @tool 装饰的函数里同样可用,模型调用 send_email 这类工具时,函数体里的 interrupt 先暂停,把收件人、主题、正文一起递给人,人可以批准、改参数再放行、或者取消,恢复值决定工具最终怎么执行。给 agent 加「敏感操作人工闸门」就是这一招,模型自己决定调工具,人决定放不放行。
测试在 tests/test_hello_persist.py 十二条加 tests/test_release.py 七条,中断 payload、恢复路由、节点重跑计数、吞异常、普通输入撞断点、静态断点、历史步数、时间旅行、schema 拦截与重试都钉住了。加上前八篇的,全仓一百五十二条,全绿。
源码
https://github.com/yidao620c/xneng-langchain-langgraph