Agent 执行到一半挂了怎么办?LangGraph 断点恢复与防重复执行实战
一个 Agent 已经完成资料检索和报告生成,正准备写入数据库,服务却重启了。重新调用整个工作流,前面的模型费用要再付一次;直接从中间继续,又可能把同一份结果写入两次。
这类问题不能只靠"重试"解决。我们至少要分清三件事:
- 工作流执行到了哪里;
- 恢复时哪些代码会再次运行;
- 数据库写入、发邮件等外部操作怎样避免重复。
本文用一个不调用大模型的最小示例,演示 LangGraph 的持久化检查点、interrupt() 和业务幂等键如何配合。示例面向本地学习,SQLite 既保存检查点,也模拟业务数据库;生产环境仍需根据并发量和基础设施选择持久化方案。
1. 先建立正确的恢复模型
假设工作流包含三个节点:
text
生成报告 → 等待人工审批 → 写入业务数据库
我们希望第一次启动时生成报告并暂停,过一段时间甚至重启进程后,审批人仍能让它继续执行。
LangGraph 中的几个对象各自负责一层问题:
| 对象 | 作用 | 不负责什么 |
|---|---|---|
| state | 保存当前工作流的数据 | 不负责跨进程落盘 |
| checkpointer | 按步骤保存 state 快照 | 不保证外部 API 只调用一次 |
thread_id |
标识同一次工作流实例 | 不是用户 ID,也不是节点 ID |
interrupt() |
暂停执行并等待外部输入 | 不会冻结 Python 调用栈 |
| 业务幂等键 | 识别一次业务操作 | 不保存完整工作流状态 |
官方文档把 checkpointer 定义为线程范围内的图状态快照;Store 则用于跨线程共享的应用数据。两者都叫"持久化",但作用范围不同。LangGraph Persistence
因此,恢复执行需要使用原来的 thread_id。如果每次请求都生成一个新 ID,LangGraph 看到的就是两次不同任务,自然找不到先前进度。
2. 为什么内存检查点不够
教学示例经常这样写:
python
from langgraph.checkpoint.memory import InMemorySaver
graph = builder.compile(checkpointer=InMemorySaver())
它适合单元测试和一次进程内的演示。但进程退出后,内存中的检查点随之消失,无法验证真正的重启恢复。
本例改用 SqliteSaver:
powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install langgraph langgraph-checkpoint-sqlite
SqliteSaver 面向同步、轻量的本地场景;官方参考文档不建议把它直接当成多线程生产方案。生产工作负载可以评估 PostgresSaver 或相应的异步实现。LangGraph Checkpointing Reference
3. 构建一个可以暂停的工作流
先定义状态和三个节点:
python
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. 检查点不能防止数据库重复写入
接下来模拟业务写入。最直接的代码可能是:
python
def unsafe_write(state: ReportState):
connection.execute(
"INSERT INTO reports(content) VALUES (?)",
(state["report"],),
)
如果数据库已经提交成功,而进程在 LangGraph 保存下一份检查点之前崩溃,恢复后这个节点可能再次运行,于是出现两条相同记录。
本例为每次业务操作生成稳定的 operation_id,并把它设置为唯一键:
python
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. 组装图并持久化到文件
完整的建图函数如下:
python
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. 第一次运行:执行到审批点
python
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 恢复
python
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() 前面
节点恢复时会从开头执行,所以这段代码存在重复发送风险:
python
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 回答"执行到了哪里",幂等机制回答"这件事是否已经做过"。