LangGraph人机协作实战

在使用LangGraph时,我遇到了一种情况:我不希望代理自行做出最终决定。

LangGraph人机协作实战
梯形图转SCL | AI模型价格对比 | AI工具导航 | ONNX模型库 | Vibe Coding教程 | PLC在线仿真器 | Tripo 3D | Meshy AI | ElevenLabs | KlingAI | ArtSpace | Phot.AI | InVideo

如果代理想要发送电子邮件、删除文件或审批付款呢?我可能想先审查一下。这就是 人机协作(HITL)的基本概念。

在本文中,我将构建一个非常小的示例:代理决定要发送电子邮件,图表暂停,人类审查该操作,然后图表根据该决定恢复

我还创建了一个小型交互式演示,您可以在其中可视化图表、尝试基于终端的批准/拒绝流程,并探索示例背后的完整源代码

没有多代理设置或复杂的工作流。

只是:

暂停 → 人类决定 → 恢复

1、为什么AI代理需要人类?

当我们乐于让自主代理自行做出决策时,它很有用。

但情况并非总是如此。

有些操作非常重要,我们可能希望人类在操作发生之前先审查。

例如:

  • 代表您发送电子邮件或消息
  • 修改或删除数据库记录
  • 审批付款
  • 部署代码
  • 删除云资源
  • 升级客户支持工单
  • 发布AI生成的内容

在这些情况下,我们不一定希望代理拥有最终决定权。

我们希望代理做出决定,向我们展示它想要做什么,然后让人类决定是否应该继续。

这就是人机协作背后的基本思想。

2、HITL实际上意味着什么?

这个想法实际上很简单。

图表暂停执行,等待人类输入,然后根据该输入继续。

在LangGraph中,我们可以使用interrupt()来实现这一点。

当图表到达interrupt()时,当前执行将暂停。LangGraph保存状态,以便稍后可以恢复同一执行。

然后,应用程序可以向人类显示中断信息,收集响应,并使用Command恢复图表。

因此流程如下:

图表开始
    ↓
代理决定发送电子邮件
    ↓
⏸ interrupt()
    ↓
人类审查操作
    ↓
批准 / 拒绝
    ↓
Command({ resume: ... })
    ↓
图表继续

这就是我们将要构建的模式。

3、我们将要构建的示例

让我们使用一个简单的电子邮件示例。

代理将决定要发送此电子邮件:

下午5点与Aman的会议

在实际发送电子邮件之前,我们希望先审查它,然后批准或拒绝它。

完整的流程如下:

用户
  ↓
代理决定发送电子邮件
  ↓
⏸ 人类批准
  ↓
┌───────────────┐
│ 批准          │ → 发送电子邮件
│ 拒绝          │ → 停止
└───────────────┘

对于此示例,我们不会使用真实的电子邮件服务。

sendEmail节点将仅在终端打印一条消息。这使示例保持简单,让我们专注于HITL的工作原理。

3.1 设置项目

让我们从一个小型Node.js项目开始。

安装LangGraph:

npm install @langchain/langgraph

稍后我们还将使用Node.js内置的readline模块从终端获取人类的响应,因此我们不需要为此安装其他包。

我们的导入如下:

import {
  StateGraph,
  Annotation,
  START,
  END,
  interrupt,
  MemorySaver,
  Command,
} from "@langchain/langgraph";

import readline from "node:readline/promises";
import {
  stdin as input,
  stdout as output,
} from "node:process";

现在让我们构建图表。

3.2 定义状态

首先,我们需要定义图表的状态。

我们的图表只需要两个信息:

  • message — 代理想要发送的电子邮件
  • decision — 人类提供的决定
const StateAnnotation = Annotation.Root({
  message: Annotation,
  decision: Annotation,
});

Annotation.Root()定义了在图表中传递的状态形状。

每个节点都可以从状态读取值并返回更新。

在我们的例子中,代理会将电子邮件消息写入message

稍后,人类批准节点会将人类的答案写入decision

3.3 创建操作

接下来,我们需要一个代表我们要保护的操作的东西。

在实际应用中,这可能是调用电子邮件API。

对于我们的示例,我们将保持简单:

function sendEmail(state) {
  console.log(`\n📧 已发送电子邮件:"${state.message}"`);

  return {};
}

这里重要的不是函数做什么。

重要的是它何时运行

我们不希望此节点在人类批准操作之前运行。

3.4 创建代理

通常,这是LLM出场的地方。

模型可以查看用户的请求并决定需要发送电子邮件。

但添加LLM会使此示例不必要地复杂化。

因此,现在我们将使用一个普通函数来表示代理的决定:

function agent() {
  return {
    message: "下午5点与Aman的会议",
  };
}

可以将此节点理解为:

"我已决定要发送此电子邮件。"

在实际应用中,可以将此节点替换为LLM和工具调用逻辑。HITL部分将保持基本相同。

3.5 添加人类批准步骤

现在我们进入重要部分。

我们需要图表在发送电子邮件之前停止,并等待人类决定。

我们可以使用interrupt()来实现:

function humanApproval(state) {
  const decision = interrupt({
    message: state.message,
    question: "您要发送此电子邮件吗?",
  });

  return {
    decision,
  };
}

当LangGraph到达:

interrupt(...)

时,图表暂停。

我们传递给interrupt()的值成为应用程序可以接收的中断负载。

在我们的示例中,该负载包含:

{
  "message": "下午5点与Aman的会议",
  "question": "您要发送此电子邮件吗?"
}

然后,我们的应用程序可以向人类显示该信息。

人类做出决定后,我们恢复图表。

恢复时提供的响应成为interrupt()的返回值。

因此,如果人类批准,那么:

const decision = interrupt(...);

最终将给我们:

const decision = "approve";

我们将该值存储在状态中。

现在我们需要根据该答案决定图表的去向:

function routeAfterApproval(state) {
  if (state.decision === "approve") {
    return "sendEmail";
  }

  return END;
}

如果人类批准,我们将转到sendEmail

如果他们拒绝,图表结束。

3.6 添加检查点

在编译图表之前,我们还有一个重要部分:检查点。

为什么?

因为我们的图表将暂停并在稍后恢复。

interrupt()暂停图表时,LangGraph需要保存执行状态,以便稍后可以继续该执行。

对于这个小示例,我们将使用MemorySaver

const checkpointer = new MemorySaver();

现在让我们将所有内容连接在一起:

const graph = new StateGraph(StateAnnotation)
  .addNode("agent", agent)
  .addNode("humanApproval", humanApproval)
  .addNode("sendEmail", sendEmail)

  .addEdge(START, "agent")
  .addEdge("agent", "humanApproval")

  .addConditionalEdges(
    "humanApproval",
    routeAfterApproval,
    {
      sendEmail: "sendEmail",
      [END]: END,
    }
  )

  .compile({
    checkpointer,
  });

图表现在如下所示:

START
  ↓
agent
  ↓
humanApproval
  ↓
 ┌──────────────┐
 │              │
approve       reject
 │              │
 ↓              ↓
sendEmail      END
 │
 ↓
END

MemorySaver将检查点保存在内存中,这对于这个小示例来说是完美的。

在生产应用中,您通常会使用持久性检查点,以便状态可以在进程重启等情况下幸存。

还有一个重要细节:thread_id

thread_id告诉LangGraph我们正在处理哪个检查点执行。

当我们暂停并在稍后恢复图表时,我们使用相同的thread_id

3.7 运行图表

现在让我们实际运行它。

首先,我们将创建一个小型readline接口,以便终端可以充当我们的人类审查者:

const rl = readline.createInterface({
  input,
  output,
});

接下来,我们将创建配置:

const config = {
  configurable: {
    thread_id: "thread-1",
  },
};

现在我们可以启动图表:

const stream = await graph.stream(
  {
    message: "",
  },
  config
);

图表开始运行。

它通过agent节点,然后到达humanApproval

此时,interrupt()暂停图表。

我们可以在流中检查中断:

for await (const chunk of stream) {
  if (chunk.__interrupt__) {
    const interruptValue =
      chunk.__interrupt__[0].value;

    console.log(
      "\n⏸ 等待人类批准...\n"
    );

    console.log(
      "代理想要发送此电子邮件:"
    );

    console.log(`"${interruptValue.message}"`);

    console.log(
      `\n${interruptValue.question}`
    );
  }
}

现在终端将显示类似以下内容:

用户:给Aman发送关于下午5点会议的电子邮件

⏸ 等待人类批准...

代理想要发送此电子邮件:
"下午5点与Aman的会议"

您要发送此电子邮件吗?

此时,图表已暂停

尚未发送任何内容。

现在让我们实际询问人类他们想要做什么:

let humanAnswer;

while (true) {
  humanAnswer = (
    await rl.question("\n批准或拒绝:")
  )
    .trim()
    .toLowerCase();

  if (
    humanAnswer === "approve" ||
    humanAnswer === "reject"
  ) {
    break;
  }

  console.log(
    '请输入"approve"或"reject"。'
  );
}

终端现在将等待我们:批准或拒绝:

如果我们输入:approve

我们可以恢复图表。

3.8 恢复图表

要恢复中断的图表,我们使用Command

await graph.invoke(
  new Command({
    resume: humanAnswer,
  }),
  config
);

注意我们使用的是相同的config,这意味着我们使用的是相同的thread_id

这就是LangGraph知道我们想要恢复哪个中断执行的方式。

我们通过resume提供的值成为interrupt()的返回值。

因此,如果我们输入:approve

那么在humanApproval内部:

const decision = interrupt(...);

将返回:approve

然后图表到达我们的路由函数:

function routeAfterApproval(state) {
  if (state.decision === "approve") {
    return "sendEmail";
  }

  return END;
}

因为决定是"approve",所以图表移动到sendEmail

我们应该看到:

📧 已发送电子邮件:"下午5点与Aman的会议"

就这样。电子邮件操作仅在人类批准后才发生。

4、当我们拒绝时会发生什么?

让我们再次尝试相同的操作。

这次,当终端询问:

批准或拒绝:

我们输入:

reject

图表通过以下方式恢复:

await graph.invoke(
  new Command({
    resume: "reject",
  }),
  config
);

现在state.decision包含"reject"

我们的路由函数将图表直接发送到END

因此sendEmail节点永远不会运行。

流程是:

代理想要发送电子邮件
        ↓
   ⏸ 已暂停
        ↓
人类:拒绝
        ↓
      END

这里重要的是图表不仅仅是返回不同的消息。

实际的操作节点从未执行。

这就是人类批准有用的原因。

5、将示例整合在一起

至此,完整的流程如下:

                 ┌─────────────┐
                 │    START    │
                 └──────┬──────┘
                        ↓
                 ┌─────────────┐
                 │    Agent    │
                 └──────┬──────┘
                        ↓
              ┌───────────────────┐
              │  人类批准          │
              │                   │
              │   ⏸ interrupt()   │
              └─────────┬─────────┘
                        ↓
                 人类决定
                   /       \
                  /         \
             approve       reject
                ↓             ↓
         ┌────────────┐      END
         │ sendEmail  │
         └──────┬─────┘
                ↓
               END

整个HITL工作流基本上是:

代理决定 → 暂停 → 人类审查 → 恢复 → 操作

6、关于interrupt()的一个重要事项

关于中断有一个值得了解的细节。

当图表恢复时,LangGraph会从头重新进入包含interrupt()的节点。

它不会简单地从interrupt()之后的确切JavaScript行继续。

然而,在恢复的执行上,interrupt()调用通过Command({ resume: ... })提供的值返回。

因此,我们需要小心在interrupt()之前放置副作用。

例如,避免这样做:

function humanApproval(state) {
  saveSomethingToDatabase();

  const decision = interrupt("批准?");

  return { decision };
}

当图表恢复时,该代码可以再次运行。

对于具有真实副作用的操作,最好将它们保留在人类批准步骤之后。

这正是我们在这里所做的:

humanApproval
      ↓
interrupt()
      ↓
人类响应
      ↓
sendEmail

实际的电子邮件操作仅在批准后发生。

7、底层发生了什么?

如果我们剥离代码,过程实际上非常简单。

  1. 图表开始执行。
  2. 代理决定要发送电子邮件。
  3. 图表到达interrupt()
  4. LangGraph暂停执行。
  5. 当前状态被检查点保存。
  6. 应用程序接收中断信息。
  7. 人类审查操作。
  8. 人类提供决定。
  9. 应用程序使用Command恢复图表。
  10. interrupt()返回人类的响应。
  11. 图表根据该响应继续。

因此重要的部分不是代码。

而是暂停和恢复模式。

        图表
          │
          ▼
    代理决定
          │
          ▼
      interrupt()
          │
          │
      ┌───┴───┐
      │ 人类  │
      └───┬───┘
          │
     approve/reject
          │
          ▼
       恢复
          │
          ▼
      继续

一旦您理解了这个流程,HITL就不再神秘了。

8、自己尝试HITL流程

我还创建了一个小型交互式演示,以使HITL流程更易于理解。

它包括:

  • 图表可视化 — 查看工作流在人类批准处暂停并跟踪批准/拒绝路径。
  • 终端模拟器 — 通过选择approvereject与工作流交互。
  • 完整源代码 — 探索演示背后的完整实现。

您可以在此处尝试演示:

[尝试交互式HITL演示 → https://nayan-kunwar.github.io/hitl-langGraph-interactive-demo/ ]

该演示是一个托管在GitHub Pages上的独立HTML页面,旨在补充本文讨论的LangGraph实现。

9、我们可以在哪里使用HITL?

电子邮件示例只是演示该模式的一种简单方式。

当代理即将执行需要人类监督的操作时,可以使用相同的方法。

例如:

  • 发送电子邮件或消息
  • 更新或删除数据库记录
  • 审批付款
  • 部署代码
  • 删除云资源
  • 发布AI生成的内容
  • 升级客户支持请求

实际操作会改变,但模式几乎保持不变:

代理决定 → 暂停 → 人类审查 → 恢复 → 操作

10、结束语

重要的部分基本上是:

  • interrupt()暂停图表
  • Command({ resume: ... })提供人类的响应
  • 检查点保存图表状态
  • thread_id让LangGraph知道要恢复哪个执行

这就是真正的思想。

您不需要庞大的工作流来为代理添加人类控制。有时您只需要在重要操作之前进行一次适当放置的暂停。

代理仍然可以完成大部分工作,但当重要事情即将发生时,人类可以拥有最终决定权。


原文链接:Human-in-the-Loop in LangGraph: A Practical Example

汇智网翻译整理,转载请标明出处