让Agent中断后恢复未完成任务:用 LangGraph 与 SQLite 保存执行现场
课程归属:打造能干活的Agent · 第 18 节
本文目标:让资料分析任务在执行中断后,使用同一个task_id恢复已保存的图状态,并在真正生成报告后才标记为completed。
当资料分析 Agent 正在整理输入、等待人工确认或生成报告时,进程可能因为终端关闭、机器重启或异常退出而停止。只把进度放在 Python 变量里,程序一结束就会丢失。本文用 LangGraph 的 SQLite 检查点保存图状态,用业务表记录任务状态,并通过一次可控中断验证恢复流程。案例不调用在线模型,所有资料均为教学模拟数据。
一、先明确:恢复的是执行现场,不只是任务状态
本篇沿用 agent_lab/ 资料分析任务助手。这里有两种不同的持久化数据:
- 业务任务记录:任务 ID、输入、当前状态、结果路径和错误信息,供应用查询与管理。
- LangGraph 检查点:图运行到哪里、状态里已经有哪些字段,供同一个图线程恢复执行。
把任务状态改成 running 并不意味着 Agent 真能接着上次运行;反过来,图状态恢复了,也不代表报告文件一定已经成功写出。因此,恢复后仍要检查最终产物,再决定是否标记完成。
图 1:任务状态与图检查点的分工
图中两个 SQLite 存储职责不同:tasks 是应用自己的业务记录;检查点由 LangGraph 的 SqliteSaver 管理。它们可以放在同一个数据库文件中,但不能把其中一个当成另一个的替代品。
表 1:本案例需要持久化什么
| 数据 | 保存位置 | 用途 | 是否足以独立恢复 |
|---|---|---|---|
task_id、输入、状态 | tasks | 查询任务与判断能否恢复 | 否 |
| 当前图状态、节点进度 | LangGraph 检查点表 | 找回图执行现场 | 与同一 thread_id 配合 |
report.md | output/<task_id>/ | 最终交付物 | 不代表任务记录一定已更新 |
run.log | output/<task_id>/ | 排查执行过程 | 否 |
二、环境与目录
使用 WSL2 Ubuntu 终端,Python 3.11。示例依赖采用 LangGraph 1.2.17 与 langgraph-checkpoint-sqlite 3.1.2 作为文章编写时的版本基线;安装时若解析器提示依赖冲突,应以官方包元数据和实际安装结果为准,不要强行覆盖依赖。版本依据见文末官方发行记录。
表 2:文件职责
| 路径 | 职责 |
|---|---|
agent_lab/main.py | 创建任务、执行、模拟中断与恢复 |
agent_lab/data/input/brief.txt | 教学模拟的资料输入 |
agent_lab/data/agent.db | 业务任务表与 LangGraph 检查点 |
agent_lab/output/<task_id>/report.md | 最终报告 |
agent_lab/output/<task_id>/run.log | 运行记录 |
agent_lab/requirements.txt | Python 依赖约束 |
先创建目录和虚拟环境:
mkdir -p agent_lab/data/input agent_lab/output
cd agent_lab
python3.11 -m venv .venv
source .venv/bin/activate
requirements.txt 内容如下:
langgraph==1.2.17
langgraph-checkpoint-sqlite==3.1.2
安装并检查包版本:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip show langgraph langgraph-checkpoint-sqlite
不同平台的二进制依赖解析可能不同。本文不声称已经在读者的 WSL 环境完成安装;应以 pip show 与后续实际运行结果为准。
创建教学资料 data/input/brief.txt:
资料名称:门店服务反馈(教学模拟)
样本说明:以下内容为虚构示例,不代表真实客户数据。
反馈一:顾客希望缩短高峰时段排队时间。
反馈二:部分顾客认为商品说明不够清楚。
反馈三:顾客认可员工主动介绍替代商品。
分析要求:提炼主要问题、正向反馈和一条可执行建议。
本案例的流程是确定性的,不需要模型密钥;这让我们可以先单独验证恢复机制,再在后续章节接入模型或真实分析工具。
三、执行流程:在可恢复的位置保存检查点
LangGraph 的持久化机制通过 checkpointer 保存图状态;thread_id 用来定位同一条执行线程。官方文档明确区分了内存检查点和持久化检查点:InMemorySaver 只在当前进程内有效,进程重启后不会保留;本例使用基于文件的 SqliteSaver。
图 2:从中断到恢复的时序
关键是 start 与 resume 必须使用相同的任务 ID;程序重启后重新编译图并打开同一个数据库文件,LangGraph 才能找到对应检查点。使用新的 ID 会开启另一条执行线程,不会自动接上旧任务。
表 3:两个 ID 不要混淆
| 字段 | 示例 | 作用 |
|---|---|---|
task_id | task-001 | 业务层唯一任务标识 |
thread_id | task-001 | LangGraph 检查点定位键 |
step_no | 1、2 | 业务步骤序号 |
status | waiting_confirmation | 应用层任务状态 |
本例有意让 thread_id 等于 task_id,减少映射错误。生产系统也可以分开存储,但必须在任务表中保留可靠映射,不能每次恢复时随机生成新线程 ID。
四、完整最小案例
下面的 main.py 包含完整起点:创建业务表、注册图节点、保存任务、触发可控中断、重新恢复并验证报告。tasks 与 task_steps 使用课程约定字段;LangGraph 自己的检查点表由 SqliteSaver 创建和维护,不手工修改其内部结构。
4.1 编写 main.py
from __future__ import annotations
import json
import os
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import TypedDict
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt
ROOT = Path(__file__).resolve().parent
DB_PATH = ROOT / "data" / "agent.db"
INPUT_PATH = ROOT / "data" / "input" / "brief.txt"
OUTPUT_ROOT = ROOT / "output"
# 检查点可能反序列化数据库中保存的数据。按官方建议启用严格模式。
os.environ.setdefault("LANGGRAPH_STRICT_MSGPACK", "true")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def connect_db() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init_db() -> None:
with connect_db() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS tasks (
task_id TEXT PRIMARY KEY,
task_input TEXT NOT NULL,
status TEXT NOT NULL,
current_step INTEGER NOT NULL DEFAULT 0,
max_steps INTEGER NOT NULL DEFAULT 8,
result_json TEXT,
error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS task_steps (
step_id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL REFERENCES tasks(task_id),
step_no INTEGER NOT NULL,
tool_name TEXT NOT NULL,
arguments_json TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
error TEXT,
idempotency_key TEXT UNIQUE,
started_at TEXT,
finished_at TEXT
);
"""
)
def update_task(task_id: str, status: str, step: int | None = None,
result: dict | None = None, error: str | None = None) -> None:
with connect_db() as conn:
fields = ["status = ?", "updated_at = ?", "error = ?"]
values: list[object] = [status, utc_now(), error]
if step is not None:
fields.append("current_step = ?")
values.append(step)
if result is not None:
fields.append("result_json = ?")
values.append(json.dumps(result, ensure_ascii=False))
values.append(task_id)
conn.execute(
f"UPDATE tasks SET {', '.join(fields)} WHERE task_id = ?",
values,
)
def add_step(task_id: str, step_no: int, tool_name: str,
arguments: dict, idempotency_key: str) -> None:
with connect_db() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO task_steps
(task_id, step_no, tool_name, arguments_json, status,
idempotency_key, started_at)
VALUES (?, ?, ?, ?, 'running', ?, ?)
""",
(
task_id, step_no, tool_name,
json.dumps(arguments, ensure_ascii=False),
idempotency_key, utc_now(),
),
)
def finish_step(task_id: str, idempotency_key: str, status: str,
result: dict | None = None, error: str | None = None) -> None:
with connect_db() as conn:
conn.execute(
"""
UPDATE task_steps
SET status = ?, result_json = ?, error = ?, finished_at = ?
WHERE task_id = ? AND idempotency_key = ?
""",
(
status,
json.dumps(result, ensure_ascii=False) if result is not None else None,
error, utc_now(), task_id, idempotency_key,
),
)
class AgentState(TypedDict, total=False):
task_id: str
task_input: str
source_text: str
key_points: list[str]
resume_note: str
report_path: str
def prepare(state: AgentState) -> dict:
task_id = state["task_id"]
add_step(task_id, 1, "read_and_extract", {"input": "brief.txt"}, f"{task_id}:step:1")
if not INPUT_PATH.is_file():
finish_step(task_id, f"{task_id}:step:1", "failed", error="输入文件不存在")
update_task(task_id, "failed", step=1, error="输入文件不存在")
raise FileNotFoundError(f"缺少输入文件:{INPUT_PATH}")
text = INPUT_PATH.read_text(encoding="utf-8")
# 教学规则:提取非空反馈行,实际项目可替换为已验证的分析工具。
points = [
line.strip()
for line in text.splitlines()
if line.startswith("反馈")
]
finish_step(
task_id, f"{task_id}:step:1", "completed",
result={"feedback_count": len(points)},
)
update_task(task_id, "running", step=1)
return {"source_text": text, "key_points": points}
def pause_for_resume(state: AgentState) -> dict:
task_id = state["task_id"]
add_step(
task_id, 2, "resume_gate",
{"reason": "演示进程中断后恢复"},
f"{task_id}:step:2",
)
update_task(task_id, "waiting_confirmation", step=2)
finish_step(
task_id, f"{task_id}:step:2", "waiting_confirmation",
result={"message": "图状态已保存,等待恢复"},
)
# 首次运行在这里暂停;恢复时 Command(resume=...) 的值会作为返回值。
answer = interrupt({
"task_id": task_id,
"message": "模拟执行中断。关闭程序后,运行 resume 命令继续。",
})
if answer != "continue":
finish_step(task_id, f"{task_id}:step:2", "failed", error="恢复指令不正确")
update_task(task_id, "failed", step=2, error="恢复指令不正确")
raise ValueError("恢复参数必须为 continue")
finish_step(
task_id, f"{task_id}:step:2", "completed",
result={"resume_note": str(answer)},
)
update_task(task_id, "running", step=2)
return {"resume_note": str(answer)}
def write_report(state: AgentState) -> dict:
task_id = state["task_id"]
add_step(
task_id, 3, "write_report",
{"output": "report.md"},
f"{task_id}:step:3",
)
out_dir = OUTPUT_ROOT / task_id
out_dir.mkdir(parents=True, exist_ok=True)
positives = [p for p in state.get("key_points", []) if "认可" in p]
concerns = [p for p in state.get("key_points", []) if "希望" in p or "认为" in p]
report = (
"# 门店服务反馈分析(教学模拟)\\n\\n"
f"- 任务 ID:`{task_id}`\\n"
f"- 样本反馈数:{len(state.get('key_points', []))}\\n\\n"
"## 主要问题\\n"
+ "".join(f"- {item}\\n" for item in concerns)
+ "\\n## 正向反馈\\n"
+ "".join(f"- {item}\\n" for item in positives)
+ "\\n## 建议\\n"
"- 将高峰排队时间作为优先观察项,并补充记录时段与等待时长。\\n"
"- 为商品说明增加简短、易理解的介绍。\\n"
"\\n> 本报告由固定教学规则生成,不代表真实业务结论。\\n"
)
report_path = out_dir / "report.md"
report_path.write_text(report, encoding="utf-8")
(out_dir / "run.log").write_text(
f"{utc_now()} task={task_id} report_written={report_path.relative_to(ROOT)}\\n",
encoding="utf-8",
)
# 只有报告存在且非空,才允许业务任务进入 completed。
if not report_path.is_file() or report_path.stat().st_size == 0:
finish_step(task_id, f"{task_id}:step:3", "failed", error="报告文件为空")
update_task(task_id, "failed", step=3, error="报告文件为空")
raise RuntimeError("报告验收失败:文件不存在或为空")
result = {
"task_id": task_id,
"status": "completed",
"report_path": str(report_path.relative_to(ROOT)),
"summary": {
"feedback_count": len(state.get("key_points", [])),
"positive_count": len(positives),
"concern_count": len(concerns),
},
"warnings": ["教学模拟数据;结论需结合真实资料复核"],
}
finish_step(
task_id, f"{task_id}:step:3", "completed",
result={"report_path": result["report_path"]},
)
update_task(task_id, "completed", step=3, result=result)
return {"report_path": result["report_path"]}
def build_graph(checkpointer):
builder = StateGraph(AgentState)
builder.add_node("prepare", prepare)
builder.add_node("pause_for_resume", pause_for_resume)
builder.add_node("write_report", write_report)
builder.add_edge(START, "prepare")
builder.add_edge("prepare", "pause_for_resume")
builder.add_edge("pause_for_resume", "write_report")
builder.add_edge("write_report", END)
return builder.compile(checkpointer=checkpointer)
def create_task(task_id: str) -> None:
if not INPUT_PATH.is_file():
raise FileNotFoundError(f"请先创建教学资料:{INPUT_PATH}")
with connect_db() as conn:
conn.execute(
"""
INSERT INTO tasks
(task_id, task_input, status, current_step, max_steps,
created_at, updated_at)
VALUES (?, ?, 'pending', 0, 8, ?, ?)
""",
(task_id, "分析 data/input/brief.txt 并生成报告", utc_now(), utc_now()),
)
def get_task(task_id: str):
with connect_db() as conn:
return conn.execute(
"SELECT * FROM tasks WHERE task_id = ?", (task_id,)
).fetchone()
def main() -> None:
init_db()
if len(sys.argv) != 3 or sys.argv[1] not in {"start", "resume"}:
print("用法:python main.py start <task_id> | python main.py resume <task_id>")
raise SystemExit(2)
action, task_id = sys.argv[1], sys.argv[2]
config = {"configurable": {"thread_id": task_id}}
with SqliteSaver.from_conn_string(str(DB_PATH)) as checkpointer:
graph = build_graph(checkpointer)
if action == "start":
if get_task(task_id):
raise SystemExit(f"任务 {task_id} 已存在;恢复请使用 resume,不要重复 start。")
create_task(task_id)
update_task(task_id, "running", step=0)
result = graph.invoke(
{"task_id": task_id, "task_input": "分析门店服务反馈"},
config=config,
)
if result.get("__interrupt__"):
print(json.dumps(result["__interrupt__"], ensure_ascii=False, default=str))
print(f"任务已暂停。请退出进程后执行:python main.py resume {task_id}")
return
print(json.dumps(result, ensure_ascii=False, default=str))
else:
task = get_task(task_id)
if task is None:
raise SystemExit(f"找不到任务 {task_id},请先执行 start。")
if task["status"] == "completed":
print(f"任务已完成,报告:{task['result_json']}")
return
if task["status"] != "waiting_confirmation":
raise SystemExit(
f"任务当前状态为 {task['status']},只有 waiting_confirmation 可按本示例恢复。"
)
result = graph.invoke(Command(resume="continue"), config=config)
row = get_task(task_id)
print(f"恢复调用已返回;任务状态:{row['status']}")
print(f"任务结果:{row['result_json']}")
if row["status"] != "completed":
print(f"图返回值:{json.dumps(result, ensure_ascii=False, default=str)}")
if __name__ == "__main__":
main()
4.2 运行第一次:让任务在中途暂停
在 agent_lab/ 目录运行:
python main.py start task-001
程序读取教学资料、保存第一步结果,然后在 interrupt() 处暂停。终端会输出中断信息和下一条恢复命令。此时业务任务状态应该为 waiting_confirmation,但报告尚未生成。
这里的暂停是受控中断,不是通过杀进程模拟崩溃。 它可以稳定验证检查点保存与恢复机制;真实崩溃后的恢复还需要在生产部署中补充异常处理、启动扫描和状态对账。
4.3 关闭进程,再恢复任务
第一次命令返回后,进程已经结束。再次执行:
python main.py resume task-001
恢复命令重新打开相同的 data/agent.db,用同一个 thread_id=task-001 调用 Command(resume="continue")。LangGraph 从检查点继续,执行报告生成节点;程序检查 report.md 存在且非空后,再把业务状态更新为 completed。
图 3:恢复前后状态变化
这张图的重点是完成状态的判定位置:任务不能因为恢复调用没有抛异常就直接标记成功。报告文件是本例的交付物,业务层应检查它的存在性与基本内容,再更新最终状态。
五、验证数据库与输出文件
先查看输出目录:
find output/task-001 -maxdepth 1 -type f -print
cat output/task-001/report.md
cat output/task-001/run.log
预期至少包含 report.md 和 run.log。报告内容应有主要问题、正向反馈和建议;具体内容由固定教学规则从 brief.txt 提取,不能当作真实业务分析结果。
查看任务状态和步骤记录:
python - <<'PY'
import sqlite3
from pathlib import Path
db = Path("data/agent.db")
con = sqlite3.connect(db)
con.row_factory = sqlite3.Row
task = con.execute(
"SELECT task_id, status, current_step, result_json, error "
"FROM tasks WHERE task_id = ?",
("task-001",),
).fetchone()
print(dict(task) if task else "task not found")
steps = con.execute(
"SELECT step_no, tool_name, status, error "
"FROM task_steps WHERE task_id = ? ORDER BY step_no",
("task-001",),
).fetchall()
for row in steps:
print(dict(row))
con.close()
PY
表 4:验收标准
| 检查项 | 成功判据 | 失败时说明 |
|---|---|---|
| 首次运行 | 任务进入 waiting_confirmation | 先检查输入文件、依赖和 SQLite 写入权限 |
| 恢复定位 | resume task-001 找到原任务 | 检查是否用了相同数据库文件与 task_id |
| 报告交付 | report.md 存在且非空 | 不应将任务标记为完成 |
| 业务状态 | tasks.status 为 completed | 检查图是否走到写报告节点 |
| 步骤记录 | task_steps 能看到步骤状态 | 检查步骤写入逻辑和数据库事务 |
六、异常与边界情况
1. 使用了新的任务 ID
如果首次运行的是 task-001,恢复时却执行 python main.py resume task-002,程序会提示找不到任务。即使新 ID 能创建新的图线程,它也不会继承旧线程的检查点。
2. 任务已完成后再次恢复
本例检测到 completed 后会返回已有结果,而不会再次进入图执行。这是业务层的防重复保护。更复杂的系统还应考虑重复触发、并发恢复和外部副作用的幂等性。
3. 报告写入成功,但业务状态更新失败
文件系统与 SQLite 不是一个跨资源原子事务。极端情况下,报告已写出,但进程在更新 tasks 前退出,数据库仍可能显示 running。生产系统需要增加启动时的对账策略:检查已有报告和步骤记录,决定是补记完成、重新执行安全步骤,还是转入人工排查。不要简单地删除数据库重来,因为这会丢失检查点与审计记录。
4. 工具节点有外部副作用
检查点不等于“恰好执行一次”。如果节点在调用外部系统后、保存新检查点前失败,恢复时可能再次调用该系统。涉及发邮件、写业务数据库、下单等操作时,应使用幂等键、事务或外部系统的去重机制;对需要用户确认的动作,应由真实交互层确认,不能仅靠模型返回一个布尔值。
表 5:常见问题定位
| 现象 | 可能原因 | 建议处理 |
|---|---|---|
| 恢复后像新任务 | thread_id 改了 | 绑定 task_id 与 thread_id |
| 找不到检查点 | 数据库路径不同或文件被替换 | 使用绝对路径定位同一 agent.db |
状态停留在 running | 程序异常退出,业务状态未收尾 | 启动时扫描并对账,记录异常原因 |
| 重复生成或重复写入 | 节点副作用不是幂等的 | 为外部操作设计幂等键 |
| 数据库含敏感内容 | 检查点保存了输入或中间状态 | 限制文件权限、控制保留期限并评估脱敏 |
七、从教学案例走向可用的任务恢复
本例使用 SQLite 文件适合本地开发、教学和轻量级单机任务。若多个进程或多台机器同时运行、数据库文件位于网络共享盘,或者任务量持续增长,应评估更合适的持久化服务与并发控制方案。SQLite 检查点也应有备份、保留期限和访问权限管理;不要把它当成不需要运维的“永久记忆”。
另外,interrupt() 在恢复时会从被中断的节点重新执行该节点,因此中断前不要在同一个节点里先执行不可重复的外部副作用,再等待恢复。把“准备数据”“等待确认”“执行副作用”拆成清晰节点,并让副作用具备幂等性,通常更容易安全恢复。
最终验收不是“控制台说恢复成功”,而是三项证据同时成立:同一任务 ID 被重新加载、报告文件实际存在且非空、业务任务记录处于 completed。这三项将图状态、交付物与应用状态串成了可检查的闭环。
参考资料
- LangGraph 官方文档:Persistence —— 检查点、线程状态及持久化机制。
- LangGraph 官方文档:Interrupts ——
interrupt()、Command(resume=...)和thread_id的恢复方式。 - LangGraph 官方仓库:SQLite Checkpoint README ——
SqliteSaver的导入、使用和安全建议。 - LangGraph 官方发行记录 —— 核对 LangGraph 版本。
- LangGraph SQLite 检查点发行记录
本文基于LangGraph与SQLite实现Agent任务中断后的执行现场恢复,重点区分业务状态与图检查点的持久化职责。通过可控中断验证检查点保存、thread_id一致性、报告文件存在性校验等关键机制,并覆盖边界异常如重复恢复、外部副作用幂等性、状态与产物不一致等问题,为生产级可恢复Agent提供最小可行方案。
6

被折叠的 条评论
为什么被折叠?



