【打造能干活的Agent】让Agent中断后恢复未完成任务

本文基于LangGraph与SQLite实现Agent任务中断后的执行现场恢复,重点区分业务状态与图检查点的持久化职责。通过可控中断验证检查点保存、thread_id一致性、报告文件存在性校验等关键机制,并覆盖边界异常如重复恢复、外部副作用幂等性、状态与产物不一致等问题,为生产级可恢复Agent提供最小可行方案。

让Agent中断后恢复未完成任务:用 LangGraph 与 SQLite 保存执行现场

课程归属:打造能干活的Agent · 第 18 节
本文目标:让资料分析任务在执行中断后,使用同一个 task_id 恢复已保存的图状态,并在真正生成报告后才标记为 completed。

当资料分析 Agent 正在整理输入、等待人工确认或生成报告时,进程可能因为终端关闭、机器重启或异常退出而停止。只把进度放在 Python 变量里,程序一结束就会丢失。本文用 LangGraph 的 SQLite 检查点保存图状态,用业务表记录任务状态,并通过一次可控中断验证恢复流程。案例不调用在线模型,所有资料均为教学模拟数据。

一、先明确:恢复的是执行现场,不只是任务状态

本篇沿用 agent_lab/ 资料分析任务助手。这里有两种不同的持久化数据:

  • 业务任务记录:任务 ID、输入、当前状态、结果路径和错误信息,供应用查询与管理。
  • LangGraph 检查点:图运行到哪里、状态里已经有哪些字段,供同一个图线程恢复执行。

把任务状态改成 running 并不意味着 Agent 真能接着上次运行;反过来,图状态恢复了,也不代表报告文件一定已经成功写出。因此,恢复后仍要检查最终产物,再决定是否标记完成。

图 1:任务状态与图检查点的分工

是

提交资料分析任务

业务数据库:tasks

LangGraph 执行图

SQLite 检查点

是否发生中断

保存 waiting_confirmation

进程退出或等待恢复

用相同 task_id 恢复

继续未完成节点

写入报告并检查文件

更新任务为 completed

图中两个 SQLite 存储职责不同:tasks 是应用自己的业务记录;检查点由 LangGraph 的 SqliteSaver 管理。它们可以放在同一个数据库文件中,但不能把其中一个当成另一个的替代品。

表 1:本案例需要持久化什么

数据保存位置用途是否足以独立恢复
task_id、输入、状态tasks查询任务与判断能否恢复否
当前图状态、节点进度LangGraph 检查点表找回图执行现场与同一 thread_id 配合
report.mdoutput/<task_id>/最终交付物不代表任务记录一定已更新
run.logoutput/<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.txtPython 依赖约束

先创建目录和虚拟环境:

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:从中断到恢复的时序

"SQLite" "LangGraph" "任务程序" "运行者" "SQLite" "LangGraph" "任务程序" "运行者" 关闭进程,数据仍在磁盘 start task-001 创建任务记录 invoke + thread_id 保存图检查点 返回中断信息 状态更新为 waiting_confirmation resume task-001 Command(resume) + 相同 thread_id 读取检查点 继续执行并返回结果 检查报告后更新 completed

关键是 start 与 resume 必须使用相同的任务 ID;程序重启后重新编译图并打开同一个数据库文件,LangGraph 才能找到对应检查点。使用新的 ID 会开启另一条执行线程,不会自动接上旧任务。

表 3:两个 ID 不要混淆

字段示例作用
task_idtask-001业务层唯一任务标识
thread_idtask-001LangGraph 检查点定位键
step_no1、2业务步骤序号
statuswaiting_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:恢复前后状态变化

是

否

pending

running:读取资料

waiting_confirmation:已保存检查点

重新启动并读取同一 thread_id

running:继续生成报告

报告存在且非空

completed

failed

这张图的重点是完成状态的判定位置:任务不能因为恢复调用没有抛异常就直接标记成功。报告文件是本例的交付物,业务层应检查它的存在性与基本内容,再更新最终状态。

五、验证数据库与输出文件

先查看输出目录:

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。这三项将图状态、交付物与应用状态串成了可检查的闭环。

参考资料

  1. LangGraph 官方文档:Persistence —— 检查点、线程状态及持久化机制。
  2. LangGraph 官方文档:Interrupts —— interrupt()、Command(resume=...) 和 thread_id 的恢复方式。
  3. LangGraph 官方仓库:SQLite Checkpoint README —— SqliteSaver 的导入、使用和安全建议。
  4. LangGraph 官方发行记录 —— 核对 LangGraph 版本。
  5. LangGraph SQLite 检查点发行记录
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值