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、底层发生了什么?
如果我们剥离代码,过程实际上非常简单。
- 图表开始执行。
- 代理决定要发送电子邮件。
- 图表到达
interrupt()。 - LangGraph暂停执行。
- 当前状态被检查点保存。
- 应用程序接收中断信息。
- 人类审查操作。
- 人类提供决定。
- 应用程序使用
Command恢复图表。 interrupt()返回人类的响应。- 图表根据该响应继续。
因此重要的部分不是代码。
而是暂停和恢复模式。
图表
│
▼
代理决定
│
▼
interrupt()
│
│
┌───┴───┐
│ 人类 │
└───┬───┘
│
approve/reject
│
▼
恢复
│
▼
继续
一旦您理解了这个流程,HITL就不再神秘了。
8、自己尝试HITL流程
我还创建了一个小型交互式演示,以使HITL流程更易于理解。
它包括:
- 图表可视化 — 查看工作流在人类批准处暂停并跟踪批准/拒绝路径。
- 终端模拟器 — 通过选择
approve或reject与工作流交互。 - 完整源代码 — 探索演示背后的完整实现。
您可以在此处尝试演示:
[尝试交互式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
汇智网翻译整理,转载请标明出处