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)
恢复时:
- Checkpointer 加载该 thread 的中断点 State;
- 从 interrupt 所在节点头部重新执行;
- 此次
interrupt()的返回值 =Command(resume=...)传入的值; - 后续节点继续执行,直到 END;
- 返回值中不再出现
__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 --- 前端交互
前端核心逻辑:
- 发送消息 → 调用
/api/chat,保存返回的thread_id; - 若
status === "interrupted"→ 显示「批准 / 拒绝」按钮; - 点击按钮 → 调用
/api/resume,传入thread_id和decision; - 流程完成后重置
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 ──────│←─ 最终结果 ─────────│
七、动手验证
按以下步骤在浏览器中操作:
- 输入「帮我给老板发一封邮件」,点击发送;
- 观察 Agent 回复,页面出现 ⏸ 已中断,等待人工确认;
- 点击批准 或拒绝;
- 观察最终回复(✅ 已发送 / ❌ 已取消)。
也可用 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 验证步骤
- 发消息触发 interrupt,关闭浏览器
- 重新打开 → 待办列表仍有该任务
- 点「继续审批」→ 批准/拒绝
- 或等待 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、审批链路等更复杂场景。
参考资料
标签:
LangGraphFastAPIAI AgentHuman-in-the-LoopinterruptCheckpointerPython