LangGraph —— 中断恢复 与 超时策略(实战)

LangGraph 实战:FastAPI 实现 Agent 对话中断与恢复(Human-in-the-Loop)

本文通过一个最小可运行 Demo,讲解 LangGraph 中 interrupt() 如何暂停 Agent 流程、Command(resume=...) 如何恢复,以及 __interrupt__ 是如何出现在返回值中的。代码拆分为三个文件,适合快速上手与二次开发。


前言

在构建 AI Agent 时,经常会遇到这样的场景:

  • Agent 准备执行敏感操作(发邮件、扣款、删数据),需要人工审批;
  • 流程执行到一半需要暂停,等用户确认后再继续;
  • 服务重启或用户隔很久再回复,仍能从断点接着跑。

LangGraph 提供了 interrupt() + Command(resume=...) + Checkpointer 的组合,专门解决这类 Human-in-the-Loop(人机协同) 问题。

本文 Demo 不接入 LLM,逻辑写死,专注展示中断与恢复机制,便于理解核心原理。


一、Demo 效果预览

完整交互流程如下:

复制代码
用户:帮我给老板发一封邮件
  ↓
Agent:已理解请求,准备发送邮件
  ↓
⏸ interrupt 暂停 → 页面弹出「批准 / 拒绝」
  ↓
用户点击「批准」
  ↓
Command(resume="yes") 恢复流程
  ↓
Agent:✅ 人工已批准,邮件已发送

二、项目结构

Demo 拆成四个文件,职责清晰:

文件 职责
interrupt_graph.py LangGraph 图定义:State、节点、interrupt()、Checkpointer
interrupt_tasks.py 待办任务索引:超时、过期、僵尸任务扫描
interrupt_api.py FastAPI 接口:/api/chat/api/resume/api/pending
interrupt_index.html 前端页面:对话、待办列表、localStorage 恢复

整体架构:

复制代码
浏览器 (interrupt_index.html)
    │  POST /api/chat
    │  POST /api/resume
    ▼
FastAPI (interrupt_api.py)
    │  graph.invoke(...)
    │  graph.invoke(Command(resume=...))
    ▼
LangGraph (interrupt_graph.py)
    agent → confirm [interrupt] → finish
    Checkpointer (InMemorySaver) 持久化断点

三、环境准备

3.1 依赖安装

bash 复制代码
pip install langgraph langchain-core fastapi uvicorn

3.2 启动服务

bash 复制代码
cd Base/Demo/Langgraph
uvicorn interrupt_api:app --reload --port 8765

浏览器访问:http://127.0.0.1:8765


四、核心原理:三个关键概念

4.1 interrupt() --- 暂停图执行

在节点内调用 interrupt(payload)

  • 图执行立即暂停
  • 当前 State 通过 Checkpointer 落盘
  • payload(任意 JSON 可序列化数据)返回给调用方;
  • 节点内 interrupt() 第一次不会返回,需等 resume 后才有返回值。

4.2 __interrupt__ --- 中断信号从哪来?

重点:__interrupt__ 不是 State 字段,也不是节点 return 出来的。

它是 LangGraph 运行时检测到 interrupt() 后,自动注入到 graph.invoke() 返回值中的:

python 复制代码
result = graph.invoke(input, config)

# 中断时 result 大致如下:
{
    "user_message": "发邮件",
    "agent_reply": "Agent:已理解...",
    "status": "",
    "__interrupt__": [
        Interrupt(
            value={"question": "是否批准?", "preview": "..."},
            id="a8165fbdb5614c118f4bcbb0afc1c2b3"
        )
    ]
}

前端 / API 层读取 result["__interrupt__"][0].value,即可拿到要展示给用户的确认信息。

4.3 Command(resume=...) --- 恢复执行

中断后,用同一个 thread_id 再次 invoke,并传入 resume 值:

python 复制代码
from langgraph.types import Command

graph.invoke(Command(resume="yes"), config)

恢复时:

  1. Checkpointer 加载该 thread 的中断点 State;
  2. 从 interrupt 所在节点头部重新执行
  3. 此次 interrupt() 的返回值 = Command(resume=...) 传入的值;
  4. 后续节点继续执行,直到 END;
  5. 返回值中不再出现 __interrupt__

4.4 Checkpointer + thread_id --- 断点靠谁记?

组件 作用
InMemorySaver() 将每步 State 快照存内存(Demo 用;生产换 PostgresSaver)
thread_id 标识一条对话线程,resume 时必须与中断时一致
python 复制代码
config = {"configurable": {"thread_id": "xxx"}}

五、代码详解

5.1 interrupt_graph.py --- 图定义

State 定义
python 复制代码
class State(TypedDict):
    user_message: str
    agent_reply: str
    status: str
三个节点

① agent 节点:模拟 Agent 理解用户意图

python 复制代码
def agent_node(state: State) -> dict:
    msg = state["user_message"]
    return {
        "agent_reply": (
            f"Agent:已理解你的请求「{msg}」。\n"
            "我准备执行敏感操作:向老板发送一封邮件。"
        )
    }

② confirm 节点 :调用 interrupt() 等待人工确认

python 复制代码
def confirm_node(state: State) -> dict:
    decision = interrupt(
        {
            "question": "Agent 请求发送邮件,是否批准?",
            "preview": state["agent_reply"],
        }
    )
    return {"status": "approved" if decision == "yes" else "rejected"}

③ finish 节点:根据人工决策输出最终结果

python 复制代码
def finish_node(state: State) -> dict:
    if state.get("status") == "approved":
        suffix = "\n\n✅ 人工已批准,邮件已发送。"
    else:
        suffix = "\n\n❌ 人工已拒绝,操作已取消。"
    return {"agent_reply": state["agent_reply"] + suffix}
图结构与编译
python 复制代码
workflow = StateGraph(State)
workflow.add_node("agent", agent_node)
workflow.add_node("confirm", confirm_node)
workflow.add_node("finish", finish_node)

workflow.add_edge(START, "agent")
workflow.add_edge("agent", "confirm")
workflow.add_edge("confirm", "finish")
workflow.add_edge("finish", END)

checkpointer = InMemorySaver()
graph = workflow.compile(checkpointer=checkpointer)  # 必须带 checkpointer

注意 :使用 interrupt() 必须 compile(checkpointer=...),否则无法保存断点。

解析返回值
python 复制代码
def parse_result(result: dict) -> dict:
    interrupts = result.get("__interrupt__") or []
    if interrupts:
        return {
            "status": "interrupted",
            "interrupt": interrupts[0].value,
            "agent_reply": result.get("agent_reply", ""),
        }
    return {
        "status": "done",
        "agent_reply": result.get("agent_reply", ""),
        "approval": result.get("status", ""),
    }

5.2 interrupt_api.py --- FastAPI 接口

启动对话(触发中断)
python 复制代码
@app.post("/api/chat")
def chat(req: ChatRequest):
    thread_id = req.thread_id or str(uuid4())
    result = graph.invoke(
        {"user_message": req.message, "agent_reply": "", "status": ""},
        thread_config(thread_id),
    )
    body = parse_result(result)
    body["thread_id"] = thread_id
    return body
恢复流程
python 复制代码
@app.post("/api/resume")
def resume(req: ResumeRequest):
    decision = "yes" if req.decision.lower() in {"yes", "y", "approve", "批准"} else "no"
    result = graph.invoke(Command(resume=decision), thread_config(req.thread_id))
    return parse_result(result)
API 说明
接口 方法 请求体 说明
/api/chat POST { "message": "...", "thread_id": null } 启动流程,可能在 confirm 节点中断
/api/resume POST { "thread_id": "...", "decision": "yes" } 恢复中断的流程

响应示例(中断时):

json 复制代码
{
  "status": "interrupted",
  "thread_id": "9042df9c-f64c-4ae8-9117-101bc2b22091",
  "interrupt": {
    "question": "Agent 请求发送邮件,是否批准?",
    "preview": "Agent:已理解你的请求..."
  },
  "agent_reply": "Agent:已理解你的请求..."
}

响应示例(恢复完成后):

json 复制代码
{
  "status": "done",
  "thread_id": "9042df9c-f64c-4ae8-9117-101bc2b22091",
  "agent_reply": "Agent:...\n\n✅ 人工已批准,邮件已发送。",
  "approval": "approved"
}

5.3 interrupt_index.html --- 前端交互

前端核心逻辑:

  1. 发送消息 → 调用 /api/chat,保存返回的 thread_id
  2. status === "interrupted" → 显示「批准 / 拒绝」按钮;
  3. 点击按钮 → 调用 /api/resume,传入 thread_iddecision
  4. 流程完成后重置 thread_id,开始下一轮新对话。

关键代码片段:

javascript 复制代码
// 发送 → 可能中断
const res = await fetch("/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ thread_id: threadId, message }),
}).then(r => r.json());

if (res.status === "interrupted") {
  setWaiting(true, res.interrupt.question);  // 显示批准/拒绝
}

// 恢复
await fetch("/api/resume", {
  method: "POST",
  body: JSON.stringify({ thread_id: threadId, decision: "yes" }),
});

六、完整时序图

复制代码
第一次请求:POST /api/chat
────────────────────────────────────────────────
浏览器          FastAPI              LangGraph
  │               │                     │
  │── message ──→│── graph.invoke ───→│ START
  │               │                     │ → agent ✅
  │               │                     │ → confirm
  │               │                     │   interrupt() ⏸
  │               │                     │   checkpoint 保存
  │               │←─ {__interrupt__}─│
  │←─ interrupted─│                     │

第二次请求:POST /api/resume
────────────────────────────────────────────────
浏览器          FastAPI              LangGraph
  │               │                     │
  │── yes ──────→│── Command(resume) ─→│ checkpoint 恢复
  │               │                     │ → confirm 重跑
  │               │                     │   interrupt() 返回 "yes"
  │               │                     │ → finish ✅
  │               │                     │ → END
  │←── done ──────│←─ 最终结果 ─────────│

七、动手验证

按以下步骤在浏览器中操作:

  1. 输入「帮我给老板发一封邮件」,点击发送
  2. 观察 Agent 回复,页面出现 ⏸ 已中断,等待人工确认
  3. 点击批准拒绝
  4. 观察最终回复(✅ 已发送 / ❌ 已取消)。

也可用 curl 验证:

bash 复制代码
# 1. 启动对话
curl -X POST http://127.0.0.1:8765/api/chat \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user_001", "message": "发邮件给老板"}'

# 2. 查待办
curl "http://127.0.0.1:8765/api/pending?user_id=user_001"

# 3. 恢复流程
curl -X POST http://127.0.0.1:8765/api/resume \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user_001", "thread_id": "你的thread_id", "decision": "yes"}'

八、僵尸任务与超时:产品 + 运维策略(已落实)

核心问题: interrupt() 不会自动超时,若前端丢失或 App 切走,任务可能一直暂停。

解决思路: checkpointer 管图状态,业务层 TaskStore 管待办索引,配合超时与找回机制。

8.1 几种场景与对应方案

场景 风险 Demo 中的方案 生产建议
前端关闭,丢了 thread_id 找不到该续哪条 localStorage 存 thread_id + 待办列表 thread_id 绑 user_id 存 DB
用户切走再回来 不知道有待审批 GET /api/pending?user_id= 拉待办 App 待办中心 + Push 通知
一直没人 resume 僵尸任务 默认 120 秒 TTL,超时标记 expired 24h TTL + cron 清理
resume 时已超时 误操作旧任务 /api/resume 返回 status: expired 同上,提示重新发起
超时后图仍暂停 checkpoint 占资源 后台每 30s 扫描,Command(resume="no") 自动收尾 定时任务 + delete_thread
服务重启 InMemory 丢状态 Demo 局限,重启后无法恢复 PostgresSaver 持久化

8.2 interrupt_tasks.py --- 待办任务层

python 复制代码
# interrupt 时注册待办
task_store.register(thread_id, user_id, question, preview)

# 用户回来拉列表
task_store.list_pending(user_id)

# resume 前检查
if task_store.is_expired(thread_id):
    return {"status": "expired", ...}

环境变量 INTERRUPT_TTL_SECONDS=120(Demo 默认 2 分钟,便于观察)。

8.3 策略 1:待办列表找回 thread_id

http 复制代码
GET /api/pending?user_id=user_abc123

响应:

json 复制代码
{
  "pending": [{
    "thread_id": "9042df9c-...",
    "question": "Agent 请求发送邮件,是否批准?",
    "remaining_seconds": 85,
    "status": "pending"
  }]
}

前端关闭再打开,从待办点「继续审批」即可恢复。

8.4 策略 2:localStorage 持久化

javascript 复制代码
localStorage.setItem("interrupt_demo_user_id", userId);
localStorage.setItem("interrupt_demo_thread_id", threadId);

页面加载时自动检测未完成 thread,提示继续审批。

8.5 策略 3:resume 前超时拦截

python 复制代码
if task_store.is_expired(thread_id):
    return {"status": "expired", "message": "审批已超时,请重新发起"}

8.6 策略 4:后台自动过期(防僵尸)

python 复制代码
async def _expire_loop():
    while True:
        await asyncio.sleep(30)
        for task in task_store.expire_stale():
            graph.invoke(Command(resume="no"), thread_config(task.thread_id))

超时任务自动以「拒绝」收尾,释放 checkpoint 断点。

8.7 验证步骤

  1. 发消息触发 interrupt,关闭浏览器
  2. 重新打开 → 待办列表仍有该任务
  3. 点「继续审批」→ 批准/拒绝
  4. 或等待 2 分钟 → 待办消失,resume 返回 expired

九、常见问题 FAQ

Q1:__interrupt__ 是谁写的?

LangGraph 运行时。节点里调用 interrupt() 后,框架自动将其包装进返回值,无需手动构造。

Q2:resume 时为什么要用同一个 thread_id?

Checkpointer 按 thread_id 索引断点。换 id 会找不到中断前的 State,流程无法续接。

Q3:resume 后 confirm 节点会重新执行吗?

会。 节点从头部 重新跑,interrupt() 之前的代码也会再执行一次。因此 interrupt 前的逻辑应是幂等的。

Q4:interrupt 和 checkpointer 压缩是一回事吗?

不是。interrupt流程暂停 ;checkpointer 是状态持久化。本文 Demo 不涉及上下文压缩。

Q5:前端丢了,interrupt 是不是永远不会响应?

不会永远挂着不管。Demo 已实现:待办列表找回、localStorage、超时拦截、后台自动 resume="no"。生产还需 PostgresSaver + Push 通知。

Q6:生产环境还要注意什么?

  • Checkpointer 换 PostgresSaver,支持跨重启、多实例;
  • 中断可能持续数小时/数天,需考虑 thread 过期清理;
  • 敏感操作应记录审批日志,不只依赖 Agent 内存。

十、总结

概念 一句话
interrupt(payload) 在节点内暂停图,把 payload 交给调用方
__interrupt__ LangGraph 自动注入的中断信号,告知「图在等什么」
Command(resume=value) 把人工输入传回节点,继续执行
checkpointer 保存断点 State,resume 时恢复
thread_id 对话线程标识,中断与恢复必须一致

本 Demo 用三个文件实现了 LangGraph 最经典的人机协同模式:Agent 执行 → 人工审批 → 恢复继续。在此基础上可扩展接入 LLM、多步 interrupt、审批链路等更复杂场景。


参考资料


标签: LangGraph FastAPI AI Agent Human-in-the-Loop interrupt Checkpointer Python