主题
持久化、中断与恢复
一句话:checkpointer 把图的一次执行变成可按
thread_id恢复的检查点序列;interrupt()在这些检查点上暂停,等人给一个值再继续。
适用版本:LangGraph 1.x。生产用 langgraph-checkpoint-postgres,需要单独安装。
为什么需要
普通函数调用有三个硬限制:进程一挂,执行到一半的状态全丢;需要人审批时只能阻塞线程或轮询数据库,代码很快失控;出了问题只能看日志,无法回到某一步看当时的状态。
LangGraph 的做法是:每执行完一个 super-step 就写一份完整的状态快照(checkpoint),用 thread_id 串起来。恢复时从最新快照继续,回放时从任意快照重新执行。这让 Agent 可以跑几分钟、几小时甚至几天,也可以中途停下来等人。
核心机制
Checkpointer 与 Store 是两套东西
| Checkpointer | Store | |
|---|---|---|
| 存什么 | 图状态快照 | 应用自定义的键值数据 |
| 作用域 | 单个 thread | 跨 thread |
| 用途 | 对话连续性、中断恢复、回放、容错 | 用户偏好、长期事实、共享知识 |
| 访问方式 | config={"configurable": {"thread_id": ...}} | 节点里读写 namespace |
大多数应用两者都用:checkpointer 管当前会话,store 管跨会话记忆。
执行快照与会话账本
| Checkpoint(执行快照) | SessionEvent(会话账本,业务侧维护) | |
|---|---|---|
| 形态 | 每个 super-step 写一份完整状态,可覆盖 | append-only 事件流,只追加不修改 |
| 回答的问题 | 从哪继续执行 | 发生过什么、为什么这样走 |
| 用途 | 恢复、暂停、时间旅行 | 回放、审计、分支分析、跨系统对账 |
| 生命周期 | 可清理、可压缩 | 长期保留,按合规要求归档 |
| 能否替代业务库 | 不能 | 不能 |
Checkpoint 是“机器怎么继续跑”的快照,SessionEvent(业务侧自建的 append-only 事件流)是“业务上发生了什么”的事实源。前者可以删旧留新,后者要求不可变、可追溯;用 checkpoint 当审计日志,会因为清理策略和状态覆盖丢证据;用事件流做恢复,则要自己重建状态机。订单、支付流水仍然在业务库,两者都只是旁路。
thread_id 是游标:同一个 thread_id 再次调用会加载上次的 checkpoint 继续;换一个就是全新会话、空状态。它要稳定且唯一,用 UUID 或“用户 + 会话”组合,长度控制在 255 字符以内(Postgres 列有长度限制)。
一次性执行的生命周期
一次执行只有三个状态:运行、落快照、挂起等人;这张图给出它们之间允许的转移。
每次 super-step 结束就落一次 checkpoint(记录当前 state、下一步节点和 checkpoint_id),所以恢复点最多退一个节点,代价是每一步都要序列化并写库。interrupt() 不保存节点内的局部变量,而是挂起整张图,恢复时从节点开头重跑,因此它之前的写操作会重复。挂起本身不占执行线程,但也没有超时和重试,长期没人审批的 thread 需要业务层监控。
持久化时机:durability
| 模式 | 行为 | 适用 |
|---|---|---|
"sync" | 下一步开始前同步写完 | 资金、审批等不能丢步骤的场景 |
"async" | 异步写,性能好,崩溃时可能丢最后一个 checkpoint | 大多数在线业务 |
"exit" | 只在结束/中断/报错时写 | 长任务、可接受中途丢失重跑 |
用 graph.stream(input, config, durability="sync") 按场景选择。
interrupt:暂停与恢复
python
from langgraph.types import interrupt
def review_node(state: State):
approved = interrupt({"question": "是否批准这笔退款?", "order_id": state["order_id"]})
return {"approved": approved}调用 interrupt() 时:
- 图在当前点挂起,state 写入 checkpoint;挂起值返回给调用方(
invoke在result["__interrupt__"],事件流在stream.interrupts)。 - 图无限期等待,直到用
Command(resume=值)再次调用;恢复时整个节点从头重跑,interrupt()这次直接返回 resume 值。
恢复重跑是最容易踩坑的地方:interrupt() 之前的代码会再执行一遍。所以不要在它前面发消息、扣款、写业务库;不要把它包在 try/except 里,它靠异常机制暂停;不要在节点里条件性地跳过它,恢复时调用顺序必须一致。
时间旅行:回放与分叉
python
# 历史按时间倒序
for snapshot in graph.get_state_history(config):
print(snapshot.config["configurable"]["checkpoint_id"], snapshot.next)
# 从某个历史点分叉(不修改原历史)
fork_config = graph.update_state(snapshot.config, {"approved": True})
graph.invoke(None, fork_config)get_state_history用来定位“哪一步开始错了”。update_state创建新分支,不是回滚;原执行历史保留。invoke(None, fork_config)表示从该检查点继续,不提供新输入。- 回放会重新执行节点,副作用同样要幂等。
回放和分支不默认“像 git 一样免费”。只有只读工具可以直接重放;有外部副作用的工具(发消息、扣款、建工单)必须带幂等键,或缓存首次结果,重跑时直接返回记录值。节点内先写业务库再调工具,会把“重跑”变成“重复写”,写操作应移到节点末尾或配补偿。还要记录每次重放的输入与输出,否则分支后无法解释差异——回放的成本不是框架给的,是幂等设计换来的。
Store:跨会话长期记忆
store.put(namespace, key, value) 写入,store.search(namespace, limit=10) 读取;测试用 InMemoryStore,生产用 PostgresStore、MongoDBStore 或 RedisStore。它支持按向量做语义检索,适合存用户偏好、历史结论、共享知识,但不适合复杂查询和事务。
实战场景
场景一:跨天退款审批的完整链路(interrupt 只是其中一环)
真实系统里 interrupt() 一行背后要配四件事,缺一件这个链路就不成立:
python
# 1. 挂起:图停在 review 节点,返回 __interrupt__ payload
result = app.invoke({"order_id": oid, "reason": user_reason}, cfg)
payload = result["__interrupt__"][0].value # {"question": ..., "order_id": ...}
approval_svc.create(thread_id=cfg["configurable"]["thread_id"],
payload=payload, due_at=now() + timedelta(hours=24)) # 业务审批单 + SLA
# 2. 恢复:审批接口绝不允许裸 Command(resume=True)
def on_approval(approval_id, decision, approver):
rec = approval_svc.claim(approval_id, approver) # 领取即加锁,防并发双批
if not approval_svc.is_still_valid(rec): # 订单状态可能已变(用户撤单)
app.invoke(Command(resume=False), rec.cfg); return "已失效,自动拒绝"
app.invoke(Command(resume=decision), rec.cfg) # 值用枚举,不用 bool 裸传- 审批单表是业务侧的:interrupt 没有超时、队列、提醒(边界节已述),
due_at到期未处理要Command(resume=False)显式关掉,否则 thread 永远挂起; - 恢复前重验前提:从挂起到批准可能过了 8 小时,订单状态、余额、权限都可能变了——resume 值只表达"人批了这个决定",决定是否仍成立必须代码重查;
- 执行顺序的坑:
claim加锁防的是两个审批人同时点批准、两次 resume 交错——第二次 resume 会把已完成图再走一遍,review之后的扣款节点幂等键是最后一道防线。
场景二:checkpoint 表三个月涨了 40G(大 state 的代价)
现象:async durability 下 checkpoint 写入拖慢每个 super-step,表膨胀到无法归档。排查路径:checkpoint_blobs 里单条大小分布 → 定位到某节点把整篇召回文档原文写进了 state。修法按收益排序:
- state 只存 ID 和摘要,原文放对象存储(每步全量快照,10KB 的字段 × 50 步 × 并发会话 = 膨胀主因);
- 长会话按数量或时间归档旧 thread(框架不自动清);
- 1.2+ 用
DeltaChannel存增量——但注意它作用于通道更新方式,节点重跑语义不变,别指望它改变恢复行为。
场景三:时间旅行修数据前先画红线
get_state_history 定位到第 7 步分类错了 → update_state 分叉修正 → invoke(None, fork) 继续。实战红线:分叉只用于"重放是安全的"thread。判断标准是第 7 步之后的节点是否已产生外部副作用——已发通知、已建工单的 thread 修数据会让副作用再执行一遍;这类场景正确做法是带终止原因关掉旧 thread,新开 thread 走补偿流程,而不是让分叉替你把副作用重放一次。
最小上手
python
from typing import TypedDict
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict):
order_id: str
approved: bool
def review(state):
return {"approved": interrupt({"question": "是否批准退款?", "order_id": state["order_id"]})}
b = StateGraph(State); b.add_node("review", review)
b.add_edge(START, "review"); b.add_edge("review", END)
with PostgresSaver.from_conn_string(DB_URI) as cp:
cp.setup() # 首次运行建表
app = b.compile(checkpointer=cp)
cfg = {"configurable": {"thread_id": "order-1001"}}
app.invoke({"order_id": "1001", "approved": False}, cfg) # 挂起,等待审批
print(app.invoke(Command(resume=True), cfg)["approved"]) # True生产动作
- 用 PostgresSaver / AsyncPostgresSaver:
InMemorySaver只用于测试,进程重启即丢;setup()只跑一次,放在部署初始化或迁移脚本里。 - 按业务选 durability:审批和资金流用
sync;普通对话用async;可重跑的长任务用exit。 - 清理 checkpoint:长会话会无限增长,按时间或数量定期归档/删除;也可用
DeltaChannel只存增量(1.2+ beta)。 - 资源与连接池:Postgres 连接数、表膨胀、索引都要纳入常规数据库运维。
- 监控“挂起中的 thread”:审批没人处理会一直占着,超过 SLA 要告警或自动关闭。
- state 不放敏感信息:checkpoint 会落库,密钥、身份证号等应在外部存储,只放引用。
- 升级前验证 schema 兼容:checkpointer 表结构变化、节点重命名都会影响旧 thread 的恢复。
边界
- 不保证 exactly-once 副作用:恢复会重跑节点,外部调用必须幂等或做去重。
- interrupt 不是消息队列:它只是挂起图,没有超时、重试、优先级;这些要在业务层做。
- checkpoint 不是业务事件日志:它是执行快照,不能替代订单流水、审计日志;反过来,事件账本也不能直接当恢复点。
- state 过大会拖慢每一步:大对象放对象存储,state 只留 ID 和摘要。
讲清楚
LangGraph 的持久化核心是 checkpoint:每执行完一步,就把整个图状态写一份快照,用 thread_id 串起来。恢复时从最新快照继续,回放时从任意快照重跑。Checkpoint 是执行快照,append-only 的 SessionEvent 才是会话事实源;两者都不能替代业务库。 interrupt 是建立在这之上的暂停机制:节点里调
interrupt(),图挂起、状态落库,调用方拿到挂起值;人工处理后用Command(resume=值)再调一次,节点从头重跑,interrupt 这次直接返回这个值。所以 interrupt 之前的代码不能有副作用。生产上要选对 durability、用 Postgres 而不是内存、定期清理 checkpoint、监控长期挂起的审批;回放和分支只对只读或幂等操作免费,有外部副作用的工具必须带结果缓存或幂等键。
适用:LangGraph 1.x | 初稿 2026-10