关于 langgraph-human-in-the-loop
面向使用 LangGraph 的开发者,说明如何通过 interrupt 暂停图执行并收集人工输入,再用 Command 恢复。涵盖检查点、线程标识、并行中断及恢复时节点重跑的注意事项。
主要功能
- 01暂停图执行并呈现数据
- 02用 Command 提交恢复值
- 03配置检查点与 thread ID
- 04处理并行中断和输入校验
- 05避免恢复时重复副作用
适用场景
- 01等待人工审批后继续图流程
- 02收集并校验表单输入
- 03并行分支集中提交人工决策
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ langchain-ai/ langchain-skills --skill langgraph-human-in-the-loop安装后在 Claude Code 中输入/langgraph-human-in-the-loop确认已加载。
详细介绍
interrupt(value)— 暂停执行,并向调用方展示一个值Command(resume=value)— 恢复执行,并将该值传回interrupt()- 检查点保存器 — 暂停期间保存状态所必需
- 线程 ID — 标识要恢复的暂停执行所必需
要求
要使中断生效,必须满足以下三项要求:
- 检查点保存器 — 使用
checkpointer=InMemorySaver()(开发环境)或PostgresSaver(生产环境)进行编译 - 线程 ID — 在每次
invoke/stream调用中传入{"configurable": {"thread_id": "..."}} - 可序列化为 JSON 的负载 — 传递给
interrupt()的值必须可序列化为 JSON
基本中断与恢复
interrupt(value) 会暂停图。该值会显示在结果的 __interrupt__ 下。Command(resume=value) 会恢复执行,恢复值将成为 interrupt() 的返回值。
重要提示:图恢复执行时,节点会从开头重新运行——interrupt() 之前的所有代码都会再次运行。
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
class State(TypedDict):
approved: bool
def approval_node(state: State):
# Pause and ask for approval
approved = interrupt("Do you approve this action?")
# When resumed, Command(resume=...) returns that value here
return {"approved": approved}
checkpointer = InMemorySaver()
graph = (
StateGraph(State)
.add_node("approval", approval_node)
.add_edge(START, "approval")
.add_edge("approval", END)
.compile(checkpointer=checkpointer)
)
config = {"configurable": {"thread_id": "thread-1"}}
# Initial run — hits interrupt and pauses
result = graph.invoke({"approved": False}, config)
print(result["__interrupt__"])
# [Interrupt(value='Do you approve this action?')]
# Resume with the human's response
result = graph.invoke(Command(resume=True), config)
print(result["approved"]) # True
暂停执行以供人工审核,然后使用 Command 恢复执行。
import { interrupt, Command, MemorySaver, StateGraph, StateSchema, START, END } from "@langchain/langgraph";
import { z } from "zod";
const State = new StateSchema({
approved: z.boolean().default(false),
});
const approvalNode = async (state: typeof State.State) => {
// Pause and ask for approval
const approved = interrupt("Do you approve this action?");
// When resumed, Command({ resume }) returns that value here
return { approved };
};
const checkpointer = new MemorySaver();
const graph = new StateGraph(State)
.addNode("approval", approvalNode)
.addEdge(START, "approval")
.addEdge("approval", END)
.compile({ checkpointer });
const config = { configurable: { thread_id: "thread-1" } };
// Initial run — hits interrupt and pauses
let result = await graph.invoke({ approved: false }, config);
console.log(result.__interrupt__);
// [{ value: 'Do you approve this action?', ... }]
// Resume with the human's response
result = await graph.invoke(new Command({ resume: true }), config);
console.log(result.approved); // true
审批工作流
一种常见模式:通过中断展示草稿,然后根据人工决策进行路由。
中断以供人工审核,然后根据决策路由至发送或结束。from langgraph.types import interrupt, Command
from langgraph.graph import StateGraph, START, END
from typing import Literal
from typing_extensions import TypedDict
class EmailAgentState(TypedDict):
email_content: str
draft_response: str
classification: dict
def human_review(state: EmailAgentState) -> Command[Literal["send_reply", "__end__"]]:
"""Pause for human review using interrupt and route based on decision."""
classification = state.get("classification", {})
# interrupt() must come first — any code before it will re-run on resume
human_decision = interrupt({
"email_id": state.get("email_content", ""),
"draft_response": state.get("draft_response", ""),
"urgency": classification.get("urgency"),
"action": "Please review and approve/edit this response"
})
# Process the human's decision
if human_decision.get("approved"):
return Command(
update={"draft_response": human_decision.get("edited_response", state.get("draft_response", ""))},
goto="send_reply"
)
else:
# Rejection — human will handle directly
return Command(update={}, goto=END)
中断以供人工审核,然后根据决策路由至发送或结束。
import { interrupt, Command, END, GraphNode } from "@langchain/langgraph";
const humanReview: GraphNode<typeof EmailAgentState> = async (state) => {
const classification = state.classification!;
// interrupt() must come first — any code before it will re-run on resume
const humanDecision = interrupt({
emailId: state.emailContent,
draftResponse: state.responseText,
urgency: classification.urgency,
action: "Please review and approve/edit this response",
});
// Process the human's decision
if (humanDecision.approved) {
return new Command({
update: { responseText: humanDecision.editedResponse || state.responseText },
goto: "sendReply",
});
} else {
return new Command({ update: {}, goto: END });
}
};
验证循环
在循环中使用 interrupt() 验证人工输入;如果输入无效,则重新提示。
from langgraph.types import interrupt
def get_age_node(state):
prompt = "What is your age?"
while True:
answer = interrupt(prompt)
# Validate the input
if isinstance(answer, int) and answer > 0:
break
else:
# Invalid input — ask again with a more specific prompt
prompt = f"'{answer}' is not a valid age. Please enter a positive number."
return {"age": answer}
每次调用 Command(resume=...) 都会提供下一个答案。如果答案无效,循环会再次中断并给出更明确的提示。
config = {"configurable": {"thread_id": "form-1"}}
first = graph.invoke({"age": None}, config)
# __interrupt__: "What is your age?"
retry = graph.invoke(Command(resume="thirty"), config)
# __interrupt__: "'thirty' is not a valid age..."
final = graph.invoke(Command(resume=30), config)
print(final["age"]) # 30
在循环中验证人工输入,直到输入有效为止,并在此期间重新提示。
import { interrupt } from "@langchain/langgraph";
const getAgeNode = (state: typeof State.State) => {
let prompt = "What is your age?";
while (true) {
const answer = interrupt(prompt);
// Validate the input
if (typeof answer === "number" && answer > 0) {
return { age: answer };
} else {
// Invalid input — ask again with a more specific prompt
prompt = `'${answer}' is not a valid age. Please enter a positive number.`;
}
}
};
多个中断
当并行分支分别调用 interrupt() 时,可在一次调用中通过将每个中断 ID 映射到其恢复值来恢复所有分支。
from typing import Annotated, TypedDict
import operator
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict):
vals: Annotated[list[str], operator.add]
def node_a(state):
answer = interrupt("question_a")
return {"vals": [f"a:{answer}"]}
def node_b(state):
answer = interrupt("question_b")
return {"vals": [f"b:{answer}"]}
graph = (
StateGraph(State)
.add_node("a", node_a)
.add_node("b", node_b)
.add_edge(START, "a")
.add_edge(START, "b")
.add_edge("a", END)
.add_edge("b", END)
.compile(checkpointer=InMemorySaver())
)
config = {"configurable": {"thread_id": "1"}}
# Both parallel nodes hit interrupt() and pause
result = graph.invoke({"vals": []}, config)
# result["__interrupt__"] contains both Interrupt objects with IDs
# Resume all pending interrupts at once using a map of id -> value
resume_map = {
i.id: f"answer for {i.value}"
for i in result["__interrupt__"]
}
result = graph.invoke(Command(resume=resume_map), config)
# result["vals"] = ["a:answer for question_a", "b:answer for question_b"]
通过将中断 ID 映射到相应的值来恢复多个并行中断。
import { Command, END, MemorySaver, START, StateGraph, interrupt, isInterrupted, INTERRUPT, Annotation } from "@langchain/langgraph";
const State = Annotation.Root({
vals: Annotation<string[]>({
reducer: (left, right) => left.concat(Array.isArray(right) ? right : [right]),
default: () => [],
}),
});
function nodeA(_state: typeof State.State) {
const answer = interrupt("question_a") as string;
return { vals: [`a:${answer}`] };
}
function nodeB(_state: typeof State.State) {
const answer = interrupt("question_b") as string;
return { vals: [`b:${answer}`] };
}
const graph = new StateGraph(State)
.addNode("a", nodeA)
.addNode("b", nodeB)
.addEdge(START, "a")
.addEdge(START, "b")
.addEdge("a", END)
.addEdge("b", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "1" } };
const interruptedResult = await graph.invoke({ vals: [] }, config);
// Resume all pending interrupts at once
const resumeMap: Record<string, string> = {};
if (isInterrupted(interruptedResult)) {
for (const i of interruptedResult[INTERRUPT]) {
if (i.id != null) {
resumeMap[i.id] = `answer for ${i.value}`;
}
}
}
const result = await graph.invoke(new Command({ resume: resumeMap }), config);
// result.vals = ["a:answer for question_a", "b:answer for question_b"]
用户可修复的错误使用 interrupt() 暂停执行并收集缺失数据——本技能介绍的就是这种模式。有关完整的四层错误处理策略(RetryPolicy、Command 错误循环等),请参阅 fundamentals 技能。
中断前的副作用必须具备幂等性
图恢复执行时,节点会从开头重新运行——interrupt() 之前的所有代码都会再次运行。在子图中,父节点和子图节点都会重新执行。
应该:
- 在
interrupt()之前使用 upsert(而非 insert)操作 - 使用创建前检查模式
- 尽可能将副作用放在
interrupt()之后 - 将副作用拆分到独立节点中
不应该:
- 在
interrupt()之前创建新记录——每次恢复都会产生重复记录 - 在
interrupt()之前向列表追加内容——每次恢复都会产生重复条目
# GOOD: Upsert is idempotent — safe before interrupt
def node_a(state: State):
db.upsert_user(user_id=state["user_id"], status="pending_approval")
approved = interrupt("Approve this change?")
return {"approved": approved}
# GOOD: Side effect AFTER interrupt — only runs once
def node_a(state: State):
approved = interrupt("Approve this change?")
if approved:
db.create_audit_log(user_id=state["user_id"], action="approved")
return {"approved": approved}
# BAD: Insert creates duplicates on each resume!
def node_a(state: State):
audit_id = db.create_audit_log({ # Runs again on resume!
"user_id": state["user_id"],
"action": "pending_approval",
})
approved = interrupt("Approve this change?")
return {"approved": approved}
中断前的幂等操作与非幂等操作(错误示例)。
// GOOD: Upsert is idempotent — safe before interrupt
const nodeA = async (state: typeof State.State) => {
await db.upsertUser({ userId: state.userId, status: "pending_approval" });
const approved = interrupt("Approve this change?");
return { approved };
};
// GOOD: Side effect AFTER interrupt — only runs once
const nodeA = async (state: typeof State.State) => {
const approved = interrupt("Approve this change?");
if (approved) {
await db.createAuditLog({ userId: state.userId, action: "approved" });
}
return { approved };
};
// BAD: Insert creates duplicates on each resume!
const nodeA = async (state: typeof State.State) => {
await db.createAuditLog({ // Runs again on resume!
userId: state.userId,
action: "pending_approval",
});
const approved = interrupt("Approve this change?");
return { approved };
};
### 恢复时重新执行子图
当子图包含 interrupt() 时,恢复操作会重新执行父节点(调用子图的节点)和子图节点(调用 interrupt() 的节点):
def node_in_parent_graph(state: State):
some_code() # <-- Re-executes on resume
subgraph_result = subgraph.invoke(some_input)
# ...
def node_in_subgraph(state: State):
some_other_code() # <-- Also re-executes on resume
result = interrupt("What's your name?")
# ...
async function nodeInParentGraph(state: State) {
someCode(); // <-- Re-executes on resume
const subgraphResult = await subgraph.invoke(someInput);
// ...
}
async function nodeInSubgraph(state: State) {
someOtherCode(); // <-- Also re-executes on resume
const result = interrupt("What's your name?");
// ...
}
Command(resume) 警告
Command(resume=...) 是唯一适合作为 invoke()/stream() 输入的 Command 模式。不要将 Command(update=...) 作为输入传入——它会从最新检查点恢复,导致图看起来卡住。完整的反模式说明请参阅 fundamentals 技能。
修复
interrupt 功能需要检查点保存器。# WRONG
graph = builder.compile()
# CORRECT
graph = builder.compile(checkpointer=InMemorySaver())
interrupt 功能需要检查点保存器。
// WRONG
const graph = builder.compile();
// CORRECT
const graph = builder.compile({ checkpointer: new MemorySaver() });
使用 Command 从中断处恢复(普通字典会重新启动图)。
# WRONG
graph.invoke({"resume_data": "approve"}, config)
# CORRECT
graph.invoke(Command(resume="approve"), config)
使用 Command 从中断处恢复(普通对象会重新启动图)。
// WRONG
await graph.invoke({ resumeData: "approve" }, config);
// CORRECT
await graph.invoke(new Command({ resume: "approve" }), config);
### 不应执行的操作
- 不使用检查点保存器就使用中断——这会失败
- 恢复时不使用相同的 thread_id——这会创建新线程,而不是恢复原线程
- 将
Command(update=...)作为 invoke 输入传入——图看起来会卡住(请使用普通字典) - 在
interrupt()之前执行非幂等副作用——恢复时会产生重复操作 - 假设
interrupt()之前的代码只运行一次——每次恢复都会重新运行