mcpField
L
langgraph-human-in-the-loop

langgraph-human-in-the-loop

github.com/langchain-aiAgent 工作流1.3k17.4k 次安装3 天前更新
代码仓库

关于 langgraph-human-in-the-loop

面向使用 LangGraph 的开发者,说明如何通过 interrupt 暂停图执行并收集人工输入,再用 Command 恢复。涵盖检查点、线程标识、并行中断及恢复时节点重跑的注意事项。

主要功能

  1. 01暂停图执行并呈现数据
  2. 02用 Command 提交恢复值
  3. 03配置检查点与 thread ID
  4. 04处理并行中断和输入校验
  5. 05避免恢复时重复副作用

适用场景

  1. 01等待人工审批后继续图流程
  2. 02收集并校验表单输入
  3. 03并行分支集中提交人工决策

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/langchain-ai/langchain-skills --skill langgraph-human-in-the-loop

安装后在 Claude Code 中输入/langgraph-human-in-the-loop确认已加载。

详细介绍

LangGraph 的人在回路模式可让你暂停图执行、向用户展示数据,并根据用户输入恢复执行:
  • interrupt(value) — 暂停执行,并向调用方展示一个值
  • Command(resume=value) — 恢复执行,并将该值传回 interrupt()
  • 检查点保存器 — 暂停期间保存状态所必需
  • 线程 ID — 标识要恢复的暂停执行所必需

要求

要使中断生效,必须满足以下三项要求:

  1. 检查点保存器 — 使用 checkpointer=InMemorySaver()(开发环境)或 PostgresSaver(生产环境)进行编译
  2. 线程 ID — 在每次 invoke/stream 调用中传入 {"configurable": {"thread_id": "..."}}
  3. 可序列化为 JSON 的负载 — 传递给 interrupt() 的值必须可序列化为 JSON

基本中断与恢复

interrupt(value) 会暂停图。该值会显示在结果的 __interrupt__ 下。Command(resume=value) 会恢复执行,恢复值将成为 interrupt() 的返回值。

重要提示:图恢复执行时,节点会从开头重新运行——interrupt() 之前的所有代码都会再次运行。

暂停执行以供人工审核,然后使用 Command 恢复执行。
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 映射到其恢复值来恢复所有分支。

通过将中断 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() 之前的代码只运行一次——每次恢复都会重新运行

评论

支持 Markdown