跳到正文

持久化、中断与恢复 ​

一句话:checkpointer 把图的一次执行变成可按 thread_id 恢复的检查点序列;interrupt() 在这些检查点上暂停,等人给一个值再继续。

适用版本:LangGraph 1.x。生产用 langgraph-checkpoint-postgres,需要单独安装。

为什么需要 ​

普通函数调用有三个硬限制:进程一挂,执行到一半的状态全丢;需要人审批时只能阻塞线程或轮询数据库,代码很快失控;出了问题只能看日志,无法回到某一步看当时的状态。

LangGraph 的做法是:每执行完一个 super-step 就写一份完整的状态快照(checkpoint),用 thread_id 串起来。恢复时从最新快照继续,回放时从任意快照重新执行。这让 Agent 可以跑几分钟、几小时甚至几天,也可以中途停下来等人。

核心机制 ​

Checkpointer 与 Store 是两套东西 ​

CheckpointerStore
存什么图状态快照应用自定义的键值数据
作用域单个 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() 时:

  1. 图在当前点挂起,state 写入 checkpoint;挂起值返回给调用方(invoke 在 result["__interrupt__"],事件流在 stream.interrupts)。
  2. 图无限期等待,直到用 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。修法按收益排序:

  1. state 只存 ID 和摘要,原文放对象存储(每步全量快照,10KB 的字段 × 50 步 × 并发会话 = 膨胀主因);
  2. 长会话按数量或时间归档旧 thread(框架不自动清);
  3. 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

把 AI 工程讲到既懂也会用