Python 现代开发学习笔记

(十三)等待确认并从同一个任务恢复

1. 在写入前暂停

interrupt 从 langgraph.types 导入。调用时把准备执行的动作交给用户,图保存暂停信息并返回;下次对同一线程提交 Command(resume=...) 才继续。

# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def approve(state: RunState) -> dict:
    # 恢复时会从这个函数开头重走;这里先展示动作,不执行写入。
    decision = interrupt({"action": "save_plan", "operation_id": state["operation_id"],
                          "question": state["question"], "minutes": state["minutes"]})
    if type(decision) is not bool:
        raise ValueError("确认结果需要是布尔值")
    return {"approved": decision, "status": "approved" if decision else "rejected"}

第一次走到 decision = interrupt(...),本次节点调用被中断,decision 还没有得到值,函数也没有执行后面的 return。图把暂停信息交给外层调用者,graph.invoke 可以结束,命令行进程也可以退出。等待确认期间,保留下来的是检查点数据。

这与第八章的 await 有不同的执行方式:await 在运行中的事件循环里暂停一个协程,稍后继续原协程;这里重开图任务时,会重新调用暂停节点,从函数开头执行,走到对应 interrupt 才取得恢复值。把数据库写入放在 interrupt 前,就会在提出确认前发生,并可能在重新进入时再发生。因此 approve 只展示动作和接收决定,save 安排在它后面。暂停与恢复

2. 逐行观察暂停节点的重新进入

先用内存检查点观察函数执行位置。短例在进入 approve 时追加一条记录,拿到决定以后再追加一条。这里记录的是执行轨迹,正式计划仍要由确认后的节点保存。

"""用进入记录核对 interrupt 恢复时从函数开头重新执行。"""
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class State(TypedDict, total=False):
    minutes: int
    approved: bool


entered = []  # 这里只记录函数执行顺序,正式计划不在这里写入。


def approve(state: State):
    entered.append("进入 approve")
    decision = interrupt({"minutes": state["minutes"]})
    entered.append(f"拿到决定:{decision}")
    return {"approved": decision}


builder = StateGraph(State)
builder.add_node("approve", approve)
builder.add_edge(START, "approve")
builder.add_edge("approve", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "reentry"}}
first = graph.invoke({"minutes": 35}, config)
assert first["__interrupt__"][0].value == {"minutes": 35}
assert entered == ["进入 approve"]
print("暂停:", entered)
# 同一进程中先看清重入;持久化重开见 checkpoint_walk,跨进程恢复用 CLI 核对。
second = graph.invoke(Command(resume=True), config)
assert second["approved"] is True
assert entered == ["进入 approve", "进入 approve", "拿到决定:True"]
print("恢复:", entered)

第一次输出只有 ["进入 approve"]。若恢复是直接从 interrupt 后一行继续,第二次结果就应只有两项;实际会得到 ["进入 approve", "进入 approve", "拿到决定:True"]。同一个函数的第一行执行了两次,而中断后的记录只追加了一次。

对照两次节点调用的执行行,第一次在interrupt处结束,恢复从函数第一行重新进入后取得True

3. 重新打开持久化任务

再把内存检查点换成 SQLite。可运行的 checkpoint_walk.py 将打开与重新打开写成两个函数。它使用 context_for 创建当前用户、索引和存储器,使用 run_config 得到线程配置;演示中的业务操作号固定为 op-demo-1,便于沿图追踪。CLI 则自动生成操作号。

先看第一次执行:

# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def pause_and_close(root: Path):
    config = run_config("alice", "walk")
    context = context_for(root, "alice")
    # 演示把随机编号换成固定文字,便于沿图追踪同一项业务。
    initial = {"question": QUESTION, "write_plan": True, "minutes": 35,
               "operation_id": "op-demo-1", "hits": [], "approved": False, "status": "new"}
    with SqliteSaver.from_conn_string(str(root / "checkpoints.sqlite3")) as saver:
        graph = build_graph(saver)
        updates = list(graph.stream(initial, config, context=context, stream_mode="updates"))
        snapshot = describe(graph, config)
        assert [next(iter(item)) for item in updates] == ["retrieve", "draft", "check", "__interrupt__"]
        assert snapshot["state"]["status"] == "answered"
        assert snapshot["next"] == ["approve"] and snapshot["pending"]
        assert "plan_id" not in snapshot["state"] and context.plans.list("alice") == []
    return updates, snapshot

这次 stream 依次输出 retrieve、draft、check 的字段更新,最后输出 __interrupt__。这里的双下划线名称是图返回的暂停信息;完整服务的 describe 函数从检查点任务中提取其 value,整理成更便于 CLI 和 HTTP 使用的 pending 列表。pending 是服务输出字段,不是 RunState 中的另一个 status。

暂停时逐项检查:

查看位置 实际内容 对执行位置的解释
state.status answered 上一个完成的节点是 check
state.approved False approve 尚未拿到决定并返回更新
pending save_plan、35 分钟、op-demo-1 与问题 等待确认的完整动作
next ["approve"] 恢复要重新进入 approve
state.plan_id 字段尚未出现 save 没有完成
计划表 0 行 业务写入尚未发生

因此,判断任务是否正在等待用户,要看 pending;status 保留最近一次成功节点写回的业务状态。接着关闭连接,重新建立图和依赖,再使用同一个线程配置:

# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def reopen_and_approve(root: Path):
    # 重新建立图、连接与上下文,再按同一个 thread_id 读取检查点。
    config = run_config("alice", "walk")
    context = context_for(root, "alice")
    with SqliteSaver.from_conn_string(str(root / "checkpoints.sqlite3")) as saver:
        graph = build_graph(saver)
        before = describe(graph, config)
        assert before["pending"][0]["operation_id"] == "op-demo-1"
        updates = list(graph.stream(Command(resume=True), config, context=context, stream_mode="updates"))
        after = describe(graph, config)
        assert [next(iter(item)) for item in updates] == ["approve", "save"]
        assert after["state"]["status"] == "saved" and after["next"] == []
        assert after["pending"] == [] and after["state"]["plan_id"] == 1
    assert len(context.plans.list("alice")) == 1
    return updates, after

恢复时的 updates 只有 approve 和 save,没有 retrieve、draft、check。approve 将 approved 改成 True,save 返回 plan_id=1、status=saved,最后 next 与 pending 都为空。问题、证据、回答和 operation_id 继续沿用检查点中的值。配套测试还会启动两次独立 Python 进程执行 ask 和 approve,核对进程结束后确实能继续同一任务。

4. 区分新任务、确认与失败重试

项目的 resume 先检查当前任务确实存在待确认项,再调用图:

# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def resume(graph, context: RunContext, thread: str, approved: bool) -> dict:
    config = run_config(context.owner, thread)
    if not describe(graph, config)["pending"]:
        raise ValueError("这个任务没有等待确认的动作")
    graph.invoke(Command(resume=approved), config=config, context=context)
    return describe(graph, config)

Command 同样从 langgraph.types 导入。恢复值在这里要求是布尔值,True 批准、False 拒绝。身份、动作内容和 operation_id 都从已保存的状态读取;确认请求不重新指定另一份要保存的内容。

要做的操作 本次传入图的输入 使用的线程
开始新问题 包含 question 等字段的新状态字典 新任务编号
回答待确认动作 Command(resume=True) 或 False 原来正在等待的线程
继续执行失败节点 None,从已有检查点继续 原来失败的线程
新状态开始新任务,Command向原中断交付决定,None从原失败节点继续执行

重复使用已完成的 thread_id 提交新问题,会让两项业务混在同一状态里。项目给新任务分配新的显示编号;批准、查看和重试继续使用原编号。聊天型 Agent 可以选择在同一线程追加新消息,具体取决于图怎样解释输入。

笔记正文与全部题解 · Ctrl / ⌘ K