LangGraph断点恢复与幂等执行实战

2 阅读8分钟

Agent 执行到一半挂了怎么办?LangGraph 断点恢复与防重复执行实战

一个 Agent 已经完成资料检索和报告生成,正准备写入数据库,服务却重启了。重新调用整个工作流,前面的模型费用要再付一次;直接从中间继续,又可能把同一份结果写入两次。

这类问题不能只靠“重试”解决。我们至少要分清三件事:

  1. 工作流执行到了哪里;
  2. 恢复时哪些代码会再次运行;
  3. 数据库写入、发邮件等外部操作怎样避免重复。

本文用一个不调用大模型的最小示例,演示 LangGraph 的持久化检查点、interrupt() 和业务幂等键如何配合。示例面向本地学习,SQLite 既保存检查点,也模拟业务数据库;生产环境仍需根据并发量和基础设施选择持久化方案。

1. 先建立正确的恢复模型

假设工作流包含三个节点:

生成报告 → 等待人工审批 → 写入业务数据库

我们希望第一次启动时生成报告并暂停,过一段时间甚至重启进程后,审批人仍能让它继续执行。

LangGraph 中的几个对象各自负责一层问题:

对象作用不负责什么
state保存当前工作流的数据不负责跨进程落盘
checkpointer按步骤保存 state 快照不保证外部 API 只调用一次
thread_id标识同一次工作流实例不是用户 ID,也不是节点 ID
interrupt()暂停执行并等待外部输入不会冻结 Python 调用栈
业务幂等键识别一次业务操作不保存完整工作流状态

官方文档把 checkpointer 定义为线程范围内的图状态快照;Store 则用于跨线程共享的应用数据。两者都叫“持久化”,但作用范围不同。LangGraph Persistence

因此,恢复执行需要使用原来的 thread_id。如果每次请求都生成一个新 ID,LangGraph 看到的就是两次不同任务,自然找不到先前进度。

2. 为什么内存检查点不够

教学示例经常这样写:

from langgraph.checkpoint.memory import InMemorySaver

graph = builder.compile(checkpointer=InMemorySaver())

它适合单元测试和一次进程内的演示。但进程退出后,内存中的检查点随之消失,无法验证真正的重启恢复。

本例改用 SqliteSaver:

python -m venv .venv
.\.venv\Scripts\python.exe -m pip install langgraph langgraph-checkpoint-sqlite

SqliteSaver 面向同步、轻量的本地场景;官方参考文档不建议把它直接当成多线程生产方案。生产工作负载可以评估 PostgresSaver 或相应的异步实现。LangGraph Checkpointing Reference

3. 构建一个可以暂停的工作流

先定义状态和三个节点:

from typing import TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt


class ReportState(TypedDict, total=False):
    operation_id: str
    topic: str
    report: str
    approved: bool
    write_status: str


def generate_report(state: ReportState):
    # 用确定性文本代替真实模型调用,便于观察恢复行为
    report = f"关于 {state['topic']} 的待审核报告"
    print("[generate] 生成报告")
    return {"report": report}


def request_approval(state: ReportState):
    decision = interrupt(
        {
            "kind": "report_approval",
            "operation_id": state["operation_id"],
            "preview": state["report"],
            "question": "是否批准写入?",
        }
    )
    return {"approved": decision.get("approved") is True}


def route_after_approval(state: ReportState):
    return "write_report" if state.get("approved") else END

第一次执行到 interrupt() 时,运行时保存状态并返回中断信息。外部系统可以把待审批内容展示到页面上,稍后用 Command(resume=...) 提交决定。

这里有一个容易误解的细节:恢复并不是从 interrupt() 的下一行继续运行。LangGraph 会从当前节点开头重新执行,再把 Command(resume=...) 中的值作为这次 interrupt() 的返回值。因此,放在 interrupt() 前面的代码也会再次运行。LangGraph Interrupts

这也是我把“生成报告”和“请求审批”拆成两个节点的原因。恢复审批节点时,不需要重新生成报告。

4. 检查点不能防止数据库重复写入

接下来模拟业务写入。最直接的代码可能是:

def unsafe_write(state: ReportState):
    connection.execute(
        "INSERT INTO reports(content) VALUES (?)",
        (state["report"],),
    )

如果数据库已经提交成功,而进程在 LangGraph 保存下一份检查点之前崩溃,恢复后这个节点可能再次运行,于是出现两条相同记录。

本例为每次业务操作生成稳定的 operation_id,并把它设置为唯一键:

import sqlite3


def init_business_db():
    with sqlite3.connect("business.sqlite") as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS reports (
                operation_id TEXT PRIMARY KEY,
                topic TEXT NOT NULL,
                content TEXT NOT NULL
            )
            """
        )


def write_report(state: ReportState):
    with sqlite3.connect("business.sqlite") as connection:
        cursor = connection.execute(
            """
            INSERT OR IGNORE INTO reports(operation_id, topic, content)
            VALUES (?, ?, ?)
            """,
            (
                state["operation_id"],
                state["topic"],
                state["report"],
            ),
        )

    status = "inserted" if cursor.rowcount == 1 else "already_exists"
    print(f"[write] {status}")
    return {"write_status": status}

同一个 operation_id 无论重试多少次,表中最多只有一条记录。这里真正阻止重复写入的是数据库唯一约束,LangGraph 的检查点负责减少不必要的节点重跑。

INSERT OR IGNORE 适合这个只写一次的示例。如果同一个 ID 携带了不同内容,实际系统不应静默忽略,可以同时保存内容哈希,发现冲突时报警。调用第三方支付、邮件或工单 API 时,也要把稳定的幂等键传给对方;本地先记一条日志,无法证明远端操作一定没有执行。

5. 组装图并持久化到文件

完整的建图函数如下:

import sqlite3

from langgraph.checkpoint.sqlite import SqliteSaver


def build_graph():
    builder = StateGraph(ReportState)
    builder.add_node("generate_report", generate_report)
    builder.add_node("request_approval", request_approval)
    builder.add_node("write_report", write_report)

    builder.add_edge(START, "generate_report")
    builder.add_edge("generate_report", "request_approval")
    builder.add_conditional_edges("request_approval", route_after_approval)
    builder.add_edge("write_report", END)

    checkpoint_connection = sqlite3.connect(
        "checkpoints.sqlite",
        check_same_thread=False,
    )
    checkpointer = SqliteSaver(checkpoint_connection)
    return builder.compile(checkpointer=checkpointer), checkpoint_connection

数据库连接要覆盖图的使用周期。示例把连接随图一起返回,是为了让调用方明确关闭它;在 Web 服务中,更适合在应用生命周期内统一创建和释放资源。

6. 第一次运行:执行到审批点

from uuid import uuid4


init_business_db()
graph, checkpoint_connection = build_graph()

operation_id = uuid4().hex
config = {"configurable": {"thread_id": operation_id}}

result = graph.invoke(
    {
        "operation_id": operation_id,
        "topic": "LangGraph 断点恢复",
    },
    config=config,
)

print(result["__interrupt__"])
checkpoint_connection.close()
print("保存这个 operation_id:", operation_id)

这时可以退出 Python 进程。checkpoints.sqlite 保存图状态,operation_id 同时承担本例的线程标识和业务幂等键。

实际服务里不一定要复用同一个值:thread_id 可以描述一次工作流实例,operation_id 描述一次外部操作。这里合并只是为了缩短示例;如果一个工作流会执行多次外部写入,应为每次写入生成各自稳定的业务键。

7. 第二次运行:用同一个 ID 恢复

from langgraph.types import Command


saved_id = "替换为第一次输出的 operation_id"
config = {"configurable": {"thread_id": saved_id}}

graph, checkpoint_connection = build_graph()
result = graph.invoke(
    Command(resume={"approved": True}),
    config=config,
)

print(result["write_status"])
checkpoint_connection.close()

恢复时传入的是 Command(resume=...),不是一份新的初始 state。并且必须继续使用原来的 thread_id。

第一次成功写入会返回 inserted。如果因为故障导致写节点被再次调度,相同业务键会得到 already_exists,表中不会新增第二条记录。

拒绝审批时传入 {"approved": False},条件边会直接走向 END。报告和审批过程仍留在检查点中,但不会执行写节点。

8. 三个常见误区

误区一:有 checkpointer 就是 exactly-once

检查点保存工作流状态,不可能替所有外部系统提供事务保证。一次网络超时可能意味着“请求没有送达”,也可能意味着“远端已经成功,只是响应丢了”。恢复后是否重试,需要幂等键、状态查询或补偿机制共同判断。

更准确的说法是:LangGraph 提供可恢复的执行状态;外部副作用仍要由业务层保证安全重放。

误区二:把副作用写在 interrupt() 前面

节点恢复时会从开头执行,所以这段代码存在重复发送风险:

def bad_node(state):
    send_email(state["report"])
    approved = interrupt("继续吗?")
    return {"approved": approved}

更稳妥的结构是把审批节点与发送节点分开,并让发送接口接受幂等键。官方文档也建议把副作用放在中断之后,或拆到独立节点,并确保可重试操作具备幂等性。Interrupt rules: side effects

误区三:把 thread_id 当成登录用户 ID

同一用户可以发起多个并行任务。如果全部使用同一个 thread_id,不同任务的检查点可能混进同一条线程。更常见的做法是为每次工作流执行生成任务 ID,再把用户 ID 作为业务字段保存。

9. 上生产前还要补什么

这个最小案例解决了“本地进程重启后继续审批”和“本地数据库避免重复插入”。生产系统还需要逐项回答:

  • 检查点保存在哪里,是否支持多实例并发;
  • API 如何鉴权,谁有权恢复某个 thread_id;
  • 审批时读取的业务数据是否已经过期;
  • 外部调用是否支持幂等键,失败后怎样查询真实状态;
  • 检查点和业务记录保留多久,是否包含敏感信息;
  • 代码或图结构升级后,旧检查点怎样兼容或迁移。

检查点里的 state 也可能包含用户输入、模型回答和工具结果。官方参考资料提醒,持久化数据需要考虑序列化安全;敏感状态还应结合访问控制与静态加密,而不是因为数据库文件在服务器上就默认安全。SQLite Checkpoint Reference

Agent 的可靠性经常卡在最后一公里:模型已经给出正确结果,系统却无法安全地暂停、恢复和提交。把图状态恢复与业务副作用分开设计后,问题会清晰很多:checkpointer 回答“执行到了哪里”,幂等机制回答“这件事是否已经做过”。