LangGraph 恢复 interrupt 后,日志和前置操作又执行了一遍
人审恢复会重跑被中断节点的前置代码。拆分准备与人审节点,核对持久化、业务幂等和权限,避免只看最终状态就漏掉重复操作。
人审页面收到 interrupt() 的问题,用户提交答案后,节点开头的日志却又出现一次。如果前面还创建了审计记录、发送通知或调用外部写接口,这些动作也可能再次发生。
动态 interrupt 恢复时,会从被中断节点的开头重新执行。Command(resume=...) 提供的答案在相应 interrupt() 调用处返回,它并不是把 Python 执行位置直接移动到暂停的下一行。前置代码重跑符合这一契约,不能仅凭重复日志就认定框架出现了恢复故障。
本文处理 Python LangGraph 的普通动态人审节点;这种现象也见于 1.1.2。下面的 StateGraph、interrupt 和 Command 示例按 0.6.7 核对,不使用后续版本的新参数,也不覆盖并行嵌套子图中已完成兄弟节点再次执行的其他问题。
先画清人审前后的动作
如果节点结构是“创建记录 → interrupt → 更新记录”,恢复时创建记录也会再跑。把日志挪到暂停之后,只能减少那条日志,不会改变其他前置代码的执行方式。
可以把不需要重跑的准备工作拆到前一个节点,把人审放在单独节点。下面只展示状态流转,没有发送邮件或写数据库:
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command, interrupt
class State(TypedDict):
work_id: str
payload: str
approved: bool
def prepare(state: State):
return {"payload": f"Review work {state['work_id']}"}
def ask_human(state: State):
answer = interrupt({
"work_id": state["work_id"],
"question": state["payload"],
})
if not isinstance(answer, dict) or type(answer.get("approved")) is not bool:
raise ValueError("approved must be a boolean")
return {"approved": answer["approved"]}
builder = StateGraph(State)
builder.add_node("prepare", prepare)
builder.add_node("ask_human", ask_human)
builder.add_edge(START, "prepare")
builder.add_edge("prepare", "ask_human")
builder.add_edge("ask_human", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "review-example-1"}}
paused = graph.invoke({"work_id": "work-001"}, config)
print(paused.get("__interrupt__"))
finished = graph.invoke(Command(resume={"approved": True}), config)
print(finished["approved"])
这里的 True 只是示例答案。正式服务必须把当前用户的实际决定、身份和对应工作传给恢复入口,不能自动批准。work-001 和会话 ID 是占位值,应用应自行分配并核对访问权限。
正常恢复这个线性图时,重新进入的是 ask_human 节点。prepare 的已完成更新留在 checkpoint 中;ask_human 也要保持确定的中断顺序,不在恢复过程中随意新增或调换多个 interrupt 的位置。
示例的内存 saver 仅限同一进程里的演示。需要跨重启恢复,应换成已配置的持久化 saver,并在服务入口保留同一 thread。如果还没有正确传递运行配置,可先核对 checkpointer 的 thread_id 配置。
拆节点不等于外部操作只会发生一次
如果准备节点本身包含外部写入,节点执行后、checkpoint 保存前发生崩溃,恢复或重试仍可能重复发起该写入。把副作用放到人审之后,也不承诺在崩溃、网络超时和重复请求下恰好执行一次。
对需要防重复的写操作,使用应用已经定义的稳定业务标识。外部系统支持幂等键时,首次执行和重试应使用同一个键;只在当前节点内部临时生成一个新 UUID,会让每次重跑都像一项新任务。系统不支持幂等键时,需要业务数据库的唯一约束或可恢复的执行记录来协调,但仍要处理“远端已成功,本地没来得及记录”的窗口。
对于同一节点的人审前动作,可以改成重复执行不会增加记录的操作,例如以固定业务主键更新待审状态。是否真的幂等应以操作含义为准:更新审计时间、追加历史或递增计数,就算用了相同主键,也可能改变结果。
不要在局部字典里写一个 already_done=True,接着调用 interrupt(),就认为标记已经持久化。节点没有正常返回时,不能把尚未提交的状态更新当作防重凭据。需要标记时,让完成的前置节点返回状态,或按业务事务规则保存独立的执行记录,并核对失败恢复过程。
内部恢复标识不能承担业务判断
__pregel_resuming 属于内部实现细节,可能也用于其他重放或重试路径。用它决定“这次不再收费”或“直接略过权限判断”,会把框架执行状态误当成业务状态。
业务执行时仍应重新核对当前权限、工作内容和决定是否对应。已经批准一项工作,不代表内容改变后仍可沿用旧答案;历史批准也不能自动授权另一项写操作。
排查时分别记录节点进入次数、人审答案和外部操作的业务 ID。重复日志只证明代码再次执行;最终图状态看起来正确,也不能证明外部没有重复记录。验收应覆盖首次暂停、正常同会话恢复、拒绝、无效答案和持久化恢复,核对真实业务记录数及拒绝路径确实没有派发写操作。


