LangGraph.js中的审批关卡:使用interrupt()和Command暂停智能体
构建一个最简版的 LangGraph.js 审批机制,该机制会在触发副作用前暂停执行,在终端收集人工决策,并从检查点安全地继续运行。
有些智能体操作的影响太过重大,不能无人监督地执行:代表他人发送邮件、删除记录、批准付款等。对于这类操作,需要让智能体先提出操作建议,然后暂停并等待人类给出同意或拒绝的答复。本示例将用最简化的图结构在 LangGraph.js 中实现这种行为,这样你就能清楚地看到 interrupt()、检查点机制、thread_id 以及 Command({ resume }) 是如何协同工作以暂停当前运行并稍后继续的。
这里刻意没有使用多智能体协调功能,也没有调用大型语言模型。整个流程可以用一句话概括:暂停、等待人类决策、再继续。
何时不应由智能体拥有最终决定权
当您愿意让智能体依据自身判断行事时,自主性便具有价值。许多操作并不符合这一标准,因此人工审核步骤虽会增加些许繁琐,却是必要的。典型的应用场景包括:
- 为用户发送电子邮件或聊天消息
- 修改或删除数据库记录
- 批准付款请求
- 部署代码
- 终止云资源使用
- 升级支持工单
- 发布人工智能生成的内容
在每种情况下,目标都是相同的。智能体依然负责思考并准备行动方案,但在采取任何不可逆的操作之前,它会先表明自己的意图并将最终决策权交给人类。这正是“人在回路中”(Human-in-the-Loop,HITL)在实践中的体现。
interrupt() 与 Command 如何协同工作
简而言之,LangGraph中的HITL运作方式如下:图计算会在执行过程中暂停,等待外部输入,然后再使用该输入继续运行。
这种暂停是通过interrupt()实现的。当某个节点调用它时,LangGraph会停止当前的运行,并通过配置好的检查点保存图的状态,以便日后能够继续同一轮计算。您的应用程序会接收到传递给interrupt()的值,将其展示给用户,收集用户的回复,然后通过携带该回复的Command对象来重新启动图计算。
整个流程如下:
Graph starts
↓
Agent decides to send email
↓
⏸ interrupt()
↓
Human reviews the action
↓
Approve / Reject
↓
Command({ resume: ... })
↓
Graph continues
请记住这个结构;下面的每段代码都对应着其中的一根箭头。
场景:需要审批的邮件
该示例以邮件为例。智能体决定要发送这条消息:
Meeting at 5 PM with Aman
在该消息被发送之前,应有人对其进行审核并决定批准或拒绝。其分支结构如下:
User
↓
Agent decides to send email
↓
⏸ Human approval
↓
┌───────────────┐
│ Approve │ → Send email
│ Reject │ → Stop
└───────────────┘
为确保重点始终放在暂停与继续的功能上,此处并未使用真实的邮件服务提供商。负责“发送”邮件的节点实际上只是将内容输出到终端中。日后即便更换为真正的API,也不会影响控制流程。
逐步构建图表
安装LangGraph并准备导入项
从空的Node.js项目开始,然后添加LangGraph:
npm install @langchain/langgraph
根据所安装的版本,LangGraph.js可能还会将@langchain/core视为依赖项;如果npm发出相关警告或导入失败,请同时添加该包,并查阅当前的安装文档。
用户输入将通过 Node 内置的 readline/promises 模块从终端获取,因此无需额外安装包。这些导入项包括图构建器、状态注解辅助工具、START 和 END 边界标记、interrupt 函数、内存检查指针以及 Command 类,此外还有与读取输入相关的功能模块:
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";
该文件使用 ES 模块语法(import),因此要么为其添加 .mjs 扩展名,要么在 package.json 中设置 "type": "module",同时需使用支持顶层 await 语法的 Node 版本,因为后续的运行代码会在模块层级使用 await。
定义图所承载的状态
在LangGraph中,状态是一个在图中传递的共享对象。每个节点都会从中读取数据并返回部分更新结果。该图仅需两个字段:
message,即代理建议发送的邮件内容decision,即人类用户给出的回复
const StateAnnotation = Annotation.Root({
message: Annotation,
decision: Annotation,
});
Annotation.Root()用于定义状态的结构。由于没有指定还原函数,每个字段都会直接采用最近写入的数值。代理节点会填写message;而在人类用户回复后,审批节点则会填写decision。
编写需要保护的操作
接下来是要实现风险操作的节点。在实际应用中,这一节点会调用邮件API;而在这里它仅负责记录日志:
function sendEmail(state) {
console.log(`\n📧 Email sent: "${state.message}"`);
return {};
}
这个函数的实际功能几乎无关紧要,重要的是它运行的时机:绝不能在人类批准之前执行。其余的逻辑结构都是为了确保这种顺序。注意它会返回一个空对象,这意味着状态不会被改变。
模拟智能体的决策
在真实系统中,这里会是大型语言模型读取用户请求并判断是否需要发送邮件的地方,通常是通过调用工具来实现。在此处添加模型只会分散对HITL机制的注意力,因此使用一个普通函数来扮演智能体的角色,并返回它“选择”的消息即可:
function agent() {
return {
message: "Meeting at 5 PM with Aman",
};
}
可以将这个节点理解为智能体在声明其意图:这就是它想要发送的邮件。即便日后用大型语言模型和工具调用逻辑来替代它,其周围的审批机制基本上保持不变。
使用interrupt()暂停等待人类操作
这是该模式的核心。审批节点会调用interrupt()方法,并传入描述需要做出决策的内容作为参数,随后将返回的值作为新的decision值:
function humanApproval(state) {
const decision = interrupt({
message: state.message,
question: "Do you want to send this email?",
});
return {
decision,
};
}
一旦执行流程到达该调用处
interrupt(...)
整个流程就会暂停。传入的对象会成为中断请求的参数,供调用方应用程序读取。在本例中,该对象为:
{
message: "Meeting at 5 PM with Aman",
question: "Do you want to send this email?"
}
应用程序会将该参数展示给相关人员,等待其回复后再继续执行流程。关键点在于,在恢复执行时所传入的任何值都会成为interrupt()方法的返回值。因此
const decision = interrupt(...);
在得到人工批准后,该行代码实际上会变为如下形式:
const decision = "approve";
该值会被作为decision状态保存下来。随后,路由函数会根据这个值选择下一步操作:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
如果响应为"approve",则会调用sendEmail函数;其他任何响应都会终止运行。将所有非批准响应视为停止信号,是安全机制的合理默认设置:一旦出现意外值,系统会直接终止而非执行相应操作。
添加检查指针并连接图结构
在编译之前还需要一个组件:检查指针。由于运行过程会先停止后再继续,LangGraph必须在中断时刻保存执行状态。没有检查指针的话就无从恢复运行。在演示中,内存中的实现就已经足够了:
const checkpointer = new MemorySaver();
现在注册这三个节点,将START与代理连接,再将代理与审批步骤连接;根据routeAfterApproval在审批步骤处添加条件边,最后使用检查点进行编译:
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,
});
addConditionalEdges的第三个参数会将路由器可能返回的每个值映射到对应的目标节点,这样LangGraph才能正确绘制图表。最终的拓扑结构为:
START
↓
agent
↓
humanApproval
↓
┌──────────────┐
│ │
approve reject
│ │
↓ ↓
sendEmail END
│
↓
END
MemorySaver将检查点存储在进程内存中,这很适合实验使用,但一旦进程退出就毫无用处。对于实际部署而言,应使用由数据库支持的持久化检查点机制,这样即使程序重启,已暂停的运行也能保留下来,并且可以从其他进程或服务器上继续执行——这正是数小时后通过网页界面收到审批请求时的常见情况。如需详细了解检查点在内部的存储方式,请参阅LangGraph的内存检查点机制如何组织及写入数据。
持久化功能中的另一个关键要素是thread_id。它用于标识所涉及的特定检查点运行实例。暂停和恢复操作必须使用相同的thread_id,否则LangGraph将无法找到已保存的运行记录。
从终端运行流程
启动运行并检测中断
readline界面将终端转变为人工审核者:
const rl = readline.createInterface({
input,
output,
});
配置对象在configurable字段下包含thread_id。所有与此次运行相关的操作,无论是初始调用还是恢复执行,都必须传递同一个对象(或至少相同的ID):
const config = {
configurable: {
thread_id: "thread-1",
},
};
以初始状态启动流程图。代理节点将会覆盖空的message字段:
const stream = await graph.stream(
{
message: "",
},
config
);
执行流程从agent节点进入humanApproval节点,在此处interrupt()函数会终止执行。随后流中会生成一个包含__interrupt__键的数据块,其第一个条目的value即为传递给interrupt()的参数,循环会将该参数打印出来供审核者查看:
for await (const chunk of stream) {
if (chunk.__interrupt__) {
const interruptValue =
chunk.__interrupt__[0].value;
console.log(
"\n⏸ Waiting for human approval...\n"
);
console.log(
"The agent wants to send this email:"
);
console.log(`"${interruptValue.message}"`);
console.log(
`\n${interruptValue.question}`
);
}
}
终端输出如下所示。第一行是用户最初的请求内容;上述代码并未打印这一行:
User: Send an email to Aman about the 5 PM meeting
⏸ Waiting for human approval...
The agent wants to send this email:
"Meeting at 5 PM with Aman"
Do you want to send this email?
目前图表处于暂停状态,尚未发送任何邮件。程序正处于检查点处等待中。
请求决策并验证它
现在向审核者提问。循环会不断询问,直到得到两个认可答案之一,同时先对空白字符和大小写进行规范化处理:
let humanAnswer;
while (true) {
humanAnswer = (
await rl.question("\nApprove or reject: ")
)
.trim()
.toLowerCase();
if (
humanAnswer === "approve" ||
humanAnswer === "reject"
) {
break;
}
console.log(
'Please type "approve" or "reject".'
);
}
终端显示“批准或拒绝:”并处于等待状态。输入“approve”即可终止循环,从而使图表可以继续运行。在恢复之前验证输入是值得的,尽管这会增加几行代码:你传回的值正是路由逻辑将会看到的内容。
通过命令继续运行
继续执行意味着再次调用该图结构,但这次不是传入新的输入,而是传递一个Command对象,其resume字段中存储着用户的回答:
await graph.invoke(
new Command({
resume: humanAnswer,
}),
config
);
相同的config会被重复使用,因此thread_id也保持不变,正是通过这个方式LangGraph才能找到被中断的运行进程。resume值作为interrupt()函数的返回值被传递出来。当输入的是approve时,humanApproval函数内的调用
const decision = interrupt(...);
会返回approve。该节点将其作为decision值返回,随后路由器会执行相应操作:
function routeAfterApproval(state) {
if (state.decision === "approve") {
return "sendEmail";
}
return END;
}
由于decision的值等于"approve",控制权就会转移到sendEmail函数,终端也会输出相应内容:
📧 Email sent: "Meeting at 5 PM with Aman"
只有在获得明确批准后,邮件才会被“发送”。完成后,请调用rl.close(),这样readline接口就会释放stdin,进程便可退出。
拒绝时的表现
再次运行该脚本。由于MemorySaver存储在内存中,新的进程会以空的检查点存储开始运行;如果在同一进程中重新运行,则需使用新的thread_id,以避免继续已结束的运行。当提示出现时,
Approve or reject:
答案:
reject
图形会使用该值继续运行。下面的代码片段为便于理解而明确写出了具体数值;在脚本中则直接使用humanAnswer:
await graph.invoke(
new Command({
resume: "reject",
}),
config
);
这次 state.decision 的值为 "reject",因此路由器会返回 END,sendEmail 也永远不会被执行:
Agent wants to send email
↓
⏸ Paused
↓
Human: reject
↓
END
这种区别很重要。在拒绝情况下,图结构不仅会生成不同的消息,还会导致执行副作用的节点根本不会被运行。正因如此,审批步骤才是真正的保护机制,而非仅仅是表面上的处理。
整体情况
将所有部分综合起来,完整的图结构如下所示:
┌─────────────┐
│ START │
└──────┬──────┘
↓
┌─────────────┐
│ Agent │
└──────┬──────┘
↓
┌───────────────────┐
│ Human Approval │
│ │
│ ⏸ interrupt() │
└─────────┬─────────┘
↓
Human decides
/ \
/ \
approve reject
↓ ↓
┌────────────┐ END
│ sendEmail │
└──────┬─────┘
↓
END
简而言之:智能体做出决策,图结构暂停,由人工进行审核,图结构恢复运行,只有到那时操作才会被执行。
重新执行的陷阱:在中断后仍需保留副作用
interrupt()的某种行为会让很多人感到困惑。具体来说,LangGraph不会从interrupt()之后的行继续执行,而是从其第一行重新运行包含该中断指令的节点。第二次执行时的不同之处在于,interrupt()调用会立即返回继续执行的值,而不会暂停。
这对副作用有着直接的影响。同一节点中位于interrupt()之前的任何代码,在图形暂停时会执行一次,而在恢复时又会执行一次。这是应当避免的模式:
function humanApproval(state) {
saveSomethingToDatabase();
const decision = interrupt("Approve?");
return { decision };
}
在这里,对于一次审批操作,saveSomethingToDatabase()会被执行两次。解决办法是从结构上入手:让审批节点不包含任何副作用,将所有实际操作放在后续的节点中,这些节点仅在用户作出回应后才执行。示例就是按照这种方式组织的:
humanApproval
↓
interrupt()
↓
human response
↓
sendEmail
如果确实需要在同一节点中在中断之前执行某些操作,那么应使其具有幂等性(可安全重复执行,例如通过稳定标识符进行插入或更新操作),或者将其移至另一个前置节点中,因为该节点的已完成结果已被检查点保存,不会被重新执行。
底层具体流程
在处理完相关代码后,其生命周期较短:
- 图结构开始执行。
- 代理节点决定要发送电子邮件。
- 执行流程到达
interrupt()函数。 - LangGraph停止当前运行。
- 检查点工具会保存当前状态。
- 应用程序接收到中断相关数据。
- 有人会审核拟执行的操作。
- 该人会做出决策。
- 应用程序使用相同的
thread_id,通过Command函数继续执行图结构。
interrupt()会返回该人的答案。API调用是较简单的部分;真正需要掌握的是暂停与恢复的机制:
Graph
│
▼
Agent decision
│
▼
interrupt()
│
│
┌───┴───┐
│ Human │
└───┬───┘
│
approve/reject
│
▼
resume
│
▼
Continue
一旦理解了这一流程,HITL就不再显得神秘了。它只不过是一个带有检查点的暂停,并在最后要求输入答案而已。
检验自己的实现
在依赖审批机制之前,先进行几项快速测试:
- 批准一次,确认相关操作恰好执行一次。
- 拒绝请求,确认相关操作节点根本不会被执行,而不仅仅是输出结果不同。
- 输入无效答案,确认提示会重新出现,而不会用错误数据继续执行。
- 使用不同的
thread_id继续执行,确认之前的运行不会受到影响。
相同控制点的应用场景
电子邮件仅是一种便捷的演示方式。这种相同的结构适用于任何需要人工监督的操作:消息发送、记录更新或删除、付款审批、部署、云资源拆解、发布生成的内容,或是升级支持请求。操作节点会变化,但其前的控制点则保持不变。如果您想同时了解审批步骤与其他编排模式(如路由和扇出)的情况,这篇关于五种LangGraph模式的概述将它们进行了并列展示。
核心要点
interrupt()会暂停图表并将数据传递给您的应用程序;您恢复运行时传入的值将成为该函数的返回值。Command({ resume: ... })会将用户的回复传回正在暂停的运行过程中。- 暂停操作必须使用检查点;在正式环境中应采用持久性检查点,以便在重启后仍能接收审批结果。
- 暂停和恢复同一次运行时必须使用相同的
thread_id。 - 包含
interrupt()的节点在恢复运行时会从顶部重新执行,因此应将副作用放在后续节点中,或确保这些操作是幂等的。 - 除明确的批准指令外,其他所有请求都应被路由至停止流程,从而使关卡始终处于关闭状态。
要让人类掌控智能体,并不需要复杂的流程。在关键步骤之前设置一个恰当的暂停点,即可让智能体完成大部分工作,而人类则保留最终决策权。
相关阅读
- LangGraph中的审批控制智能体:interrupt()、检查点与存储机制 — 逐步构建LangGraph智能体:明确的ReAct图结构、通过interrupt()实现的人类审批功能,以及借助存储机制实现的跨线程记忆功能,最终形成会先询问用户的收件箱助手。