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

https://static.xiongneng.me/langchain-langgraph-cover-20261017190854.png

学习进度

这一篇给图装上两个进阶器官。持久化执行,图跑一半进程死了,换个进程接着跑。人工协同,图跑到关键步骤停下来,把问题递给人,等一个答复再继续。这两件事共享同一个地基,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 之间,生产上按业务能接受丢失多少来选。

https://static.xiongneng.me/interrupt-flow-20261017190835.png

中断的实现比直觉朴素。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. 中断闭环

最核心的一条链,暂停、恢复、按答复路由。

1
2
3
4
5
6
7
8
from langgraph.types import Command, interrupt

def approval(state: State):
    answer = interrupt({"question": "批准吗?", "task": state["task"]})
    return {"approved": answer}

graph.add_conditional_edges(
    "approval", lambda s: "finish" if s["approved"] else END)
1
2
3
result = app.invoke({"task": "删库跑路", ...}, config)
# result["__interrupt__"][0].value -> {'question': '批准吗?', 'task': '删库跑路'}
final = app.invoke(Command(resume=True), config)
1
2
3
== 中断闭环 ==
  中断 payload: {'question': '批准吗?', 'task': '删库跑路'}
  恢复 True(批准): 已处理 发布文档 -> 人工通过

第一次 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 值,问题怎么问、选项给什么,自己定义。

1
picked = interrupt({"options": ["加急", "普通"], "default": "普通"})
1
2
3
== 结构化 payload ==
  发出去的问题: {'options': ['加急', '普通'], 'default': '普通'}
  收回来的答案: 加急

payload 递出去,答案递回来,中间隔了多久、隔了多少进程都无所谓,checkpointer 记着呢。

一个节点里放多个 interrupt 也行,恢复值按调用顺序严格对号,第一个 interrupt 拿第一个 resume。配套的纪律是别用条件分支跳过 interrupt 调用,这次执行问了两个问题、下次只问一个,恢复值就对不上号了。并行分支同时中断的场景,恢复值换成 {interrupt.id: 答复} 的映射,每个中断用自己的 id 领答案。还有一条,payload 只放 JSON 能序列化的简单值,函数、类实例传不进去。

3. 节点从头重跑

恢复时节点从第一行重新执行,这是中断机制最容易被忽略的性质。

1
2
3
4
def flaky(state: State):
    counter["n"] += 1          # interrupt 之前的代码,恢复时会再跑
    answer = interrupt("确认?")
    return {"answer": answer}
1
2
== 节点重跑 ==
  节点函数执行了 2 次(中断一次 + 恢复重跑一次)

计数器实锤了重跑。把副作用放在 interrupt 之前,就会执行两次。写节点的时候要么把副作用挪到 interrupt 之后,要么设计成幂等的,upsert 代替 insert。

4. 吞异常坑

用 try/except 包住 interrupt,中断就没了。

1
2
3
4
5
6
def bad(state: State):
    try:
        answer = interrupt("确认?")
    except Exception:
        answer = "fallback"    # 中断靠异常实现,这里把它吞了
    return {"answer": answer}
1
2
== 吞异常坑 ==
  图没有暂停,直接得到: fallback

图一口气跑完,没人被问到任何问题。interrupt 的暂停机制就是抛异常,except Exception 把它拦下来,引擎收不到信号。要处理业务异常,用具体的异常类型,别用裸的 Exception。

5. 静态断点

编译图时把断点钉死在节点边界上,恢复用 invoke 传 None。

1
2
3
4
5
6
app = g.compile(checkpointer=checkpointer, interrupt_before=["b"])
app.invoke({"n": 2}, config)

snapshot = app.get_state(config)
# snapshot.next == ("b",)
resumed = app.invoke(None, config)
1
2
3
== 静态断点 ==
  停在 ('b',) 前,状态 {'n': 3}
  invoke(None) 续跑: {'n': 30}

断点停在 b 节点之前,a 的产出已经在状态里。invoke 传 None 意思是不给新输入,从断点继续。断点除了编译时钉死,还能在每次 invoke 时临时指定,app.invoke(inputs, interrupt_before=["b"], config=config),调试时想断哪步断哪步。这种断点在节点边界上,不能带条件,人机协同还是用 interrupt 函数。

6. 状态历史

get_state_history 把线程的整条 checkpoint 链翻出来。

1
2
history = sorted(
    app.get_state_history(config), key=lambda s: s.metadata.get("step", 0))
1
2
3
4
== 状态历史 ==
  step=-1 next=('__start__',) values={}
  step=0 next=('a',) values={'n': 2}
  step=1 next=('b',) values={'n': 3}

每个 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,之前的步骤直接跳过,之后的重新执行。

1
2
3
history = list(app.get_state_history(config))
step1 = next(s for s in history if s.metadata.get("step") == 1)
replayed = app.invoke(None, step1.config)
1
2
== 时间旅行 ==
  从 step=1 的 checkpoint 回放: {'n': 30}

快照之前的计算成果都还在,不用重花钱。回放不等于修改,想改历史状态再继续,用 update_state 在快照上分叉出新线程。回放时 interrupt 会重新触发,适合重跑一次带人工确认的流程。

8. SqliteSaver 落盘

内存版进程一退就没了,换 SqliteSaver,快照落进一个 SQLite 文件。

1
2
3
4
5
6
7
8
9
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

conn = sqlite3.connect("release_state.db", check_same_thread=False)
app = build_approval_graph(SqliteSaver(conn))

# 进程 A 发起,中断退出
# 进程 B 打开同一个文件接着审批
final = app.invoke(Command(resume=True), config)
1
2
3
== SqliteSaver 落盘 ==
  第一段连接中断: True
  新连接恢复: 已处理 发布上线 -> 人工通过

demo 里关掉第一个连接、开第二个新连接模拟换进程,恢复照常。连接要传 check_same_thread=False,引擎在内部线程池里写快照,默认的 sqlite 连接会报跨线程错误。生产换 PostgresSaver,接口不变。

9. response_schema 校验

给 interrupt 配一个 Pydantic 模型,恢复值不符合 schema 直接被拦。

1
2
3
4
5
class Decision(BaseModel):
    approved: bool
    reason: str

answer = interrupt("审批", response_schema=Decision)
1
2
3
== response_schema ==
  非法恢复值被拦下: ValidationError
  合法恢复值: True(没问题)

恢复 {"approved": "yes"} 这种类型不对的值,抛 ValidationError,图还停在断点上,换个合法的再按一次就行。校验通过的恢复值直接是 Decision 实例,字段访问不用再解析字典。要做的校验比 schema 复杂怎么办,官方的推荐姿势是把错误信息存进状态,用条件边跳回同一个节点再问一次,每个 interrupt 保持每次执行只调用一次。别在节点里写 while True 循环包 interrupt,恢复是从节点开头重跑的,循环几轮就把之前几轮全部重放,执行次数指数级涨。

完整案例

九招都练完了。收个尾,搭一条真的带人工闸门的流水线,release.py,发布审批。plan 节点把变更描述整理成摘要,approve 节点把摘要递给人工,等一个 Decision,批准走 deploy,驳回走 reject。

1
uv run python release.py "把搜索阈值从 0.8 调到 0.75" --auto-approve
1
2
3
变更摘要: 变更「把搜索阈值从 0.8 调到 0.75」已通过静态检查,涉及 1 个配置文件,风险等级低。

结论: 已发布。审批备注:无人值守演示,自动批准

–auto-approve 是无人值守模式,跳过终端问答直接批准。去掉这个开关,流程会在审批处停下来,把摘要打在终端上,等你敲 y 或 n,再让你留一句备注。你敲的每个字经 Decision 校验后成为恢复值。交互长这样。

1
2
3
4
5
变更摘要: 变更「把搜索阈值从 0.8 调到 0.75」已通过静态检查,涉及 1 个配置文件,风险等级低。
批准发布?(y/n) y
审批备注(可空): 低风险配置变更

结论: 已发布。审批备注:低风险配置变更

按 n 走的是另一条分支,同一个图,结论变成「已驳回」,备注原样带上。路由写在 approve 节点内部,用上一节的 Command(goto=) 完成,批准与驳回共享同一个中断点。

1
uv run python release.py "把搜索阈值从 0.8 调到 0.75" --db release_state.db

–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

参考