人在回路 (Human-in-the-loop)
在敏感工具调用前暂停以等待人工审批、参数修改或人工回复
在现实应用中,智能体可能涉及破坏性操作(如删除数据库记录、覆盖生产文件、发送外部邮件或执行昂贵的 API 调用)。 人在回路(Human-in-the-loop, HITL)使你能够设置安全关卡:智能体在触发目标工具前会自动优雅挂起执行,等待人工审批、人工修改参数或提供额外补充信息后再继续推进。
Deep Agents 深度整合了 LangGraph 的原生中断机制,具有极高的可靠性与状态恢复能力。
基础配置 (Basic configuration)
在调用 createDeepAgent 时,通过 interruptOn 参数指定需要拦截审批的工具名称映射:
graph LR
Agent[Agent] --> Check{Interrupt?}
Check --> |no| Execute[Execute]
Check --> |yes| Human{Human}
Human --> |approve| Execute
Human --> |edit| Execute
Human --> |reject| ToolMessage[ToolMessage]
Execute --> Agent
ToolMessage --> Agent
classDef trigger fill:#F6FFDB,stroke:#6E8900,stroke-width:2px,color:#2E3900
classDef process fill:#E5F4FF,stroke:#006DDD,stroke-width:2px,color:#030710
classDef decision fill:#FDF3FF,stroke:#7E65AE,stroke-width:2px,color:#504B5F
classDef alert fill:#F8E8E6,stroke:#B27D75,stroke-width:2px,color:#634643
class Agent trigger
class Check,Human decision
class Execute process
class ToolMessage process审批决策类型 (Decision types)
当智能体因命中规则而暂停执行时,人工审批者可以提交以下四种决策动作之一:
| 决策动作 (action) | 说明 |
|---|---|
approve | 批准工具调用:按照智能体生成的原始参数正常执行该工具。 |
edit | 修改参数后批准:由人工修改工具入参(如纠正参数中的错误路径),然后以修改后的参数执行。 |
reject | 明确拒绝执行:阻止该工具调用,并可附带说明告知智能体为何被拒绝,由模型重新规划。 |
respond | 替代工具返回结果:人工直接输入拟定的返回值,绕过工具的实际执行。 |
处理中断与恢复执行 (Handle interrupts)
当智能体遇到中断时,其执行会暂停在当前状态,并在状态中包含待处理的中断信息。 使用 Command 对象并附带对应的 resume 数据即可恢复图的执行:
import { tool } from "langchain";
import { createDeepAgent } from "deepagents";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod";
const removeFile = tool(
async ({ path }: { path: string }) => {
return `Deleted ${path}`;
},
{
name: "remove_file",
description: "Delete a file from the filesystem.",
schema: z.object({
path: z.string(),
}),
},
);
const fetchFile = tool(
async ({ path }: { path: string }) => {
return `Contents of ${path}`;
},
{
name: "fetch_file",
description: "Read a file from the filesystem.",
schema: z.object({
path: z.string(),
}),
},
);
const notifyEmail = tool(
async ({
to,
subject,
body,
}: {
to: string;
subject: string;
body: string;
}) => {
return `Sent email to ${to}`;
},
{
name: "notify_email",
description: "Send an email.",
schema: z.object({
to: z.string(),
subject: z.string(),
body: z.string(),
}),
},
);
// Checkpointer is REQUIRED for human-in-the-loop
const checkpointer = new MemorySaver();
const agent = createDeepAgent({
model: "google:gemini-3.6-flash",
tools: [removeFile, fetchFile, notifyEmail],
interruptOn: {
remove_file: true, // Default: approve, edit, reject, respond
fetch_file: false, // No interrupts needed
notify_email: { allowedDecisions: ["approve", "reject"] }, // No editing
},
checkpointer, // Required!
});在流式传输中处理中断 (Handle interrupts with streaming)
在流式模式下,客户端通过监听流事件实时捕获中断事件,弹出审批卡片并在用户交互后提交恢复指令:
const interruptOn = {
// Sensitive operations: allow all options
delete_file: { allowedDecisions: ["approve", "edit", "reject"] },
// Moderate risk: approval or rejection only
write_file: { allowedDecisions: ["approve", "reject"] },
// Must approve (no rejection allowed)
critical_operation: { allowedDecisions: ["approve"] },
};并行多工具调用审批 (Multiple tool calls)
当大语言模型在单轮中同时生成了多个工具调用时,审批机制支持按批次统一处理,或通过单调用模式(per-call mode)逐一单独审批。
拒绝消息反馈 (Rejection messages)
当审批者选择 reject 拒绝操作时,建议附带一段文本反馈(例如“由于当前处于生产封板期,禁止修改配置”)。该理由会作为工具消息回传给模型,引导其生成合规的替代方案。
人工编辑工具入参 (Edit tool arguments)
审批者不仅可以拒绝或放行,还可以在执行前直接修正大模型生成的参数值。修改后的参数同样会通过工具的 Schema 校验。
子智能体中断审批 (Subagent interrupts)
子智能体内部的敏感工具调用同样支持人在回路控制:
1. 工具调用级中断
可以在子智能体规格说明中单独为其指定 interruptOn 规则。
2. 工具调用内部动态中断
工具可以在执行到一半时,根据业务逻辑动态调用 interrupt() 向人工求助(例如请求输入一次性动态验证码 OTP)。
import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";
// Create config with thread_id for state persistence
const config = { configurable: { thread_id: uuid7() } };
// Invoke the agent
let result = await agent.invoke({
messages: [{ role: "user", content: "Delete the file temp.txt" }],
}, config);
// Check if execution was interrupted
if (result.__interrupt__) {
// Extract interrupt information
const interrupts = result.__interrupt__[0].value;
const actionRequests = interrupts.actionRequests;
const reviewConfigs = interrupts.reviewConfigs;
// Create a lookup map from tool name to review config
const configMap = Object.fromEntries(
reviewConfigs.map((cfg) => [cfg.actionName, cfg])
);
// Display the pending actions to the user
for (const action of actionRequests) {
const reviewConfig = configMap[action.name];
console.log(`Tool: ${action.name}`);
console.log(`Arguments: ${JSON.stringify(action.args)}`);
console.log(`Allowed decisions: ${reviewConfig.allowedDecisions}`);
}
// Get user decisions (one per actionRequest, in order)
const decisions = [
{
type: "reject",
message: "User rejected deleting temp.txt. Do not retry deletion.",
}
];
// Resume execution with decisions
result = await agent.invoke(
new Command({ resume: { decisions } }),
config // Must use the same config!
);
}
// Process final result
console.log(result.messages[result.messages.length - 1].content);Handle interrupts with streaming
For real-time UIs, use .stream() instead of .invoke() to display tokens and tool call progress while handling HITL interrupts. Interrupts appear as __interrupt__ entries in the updates stream. After the stream completes, collect human decisions and resume by streaming again with Command(resume=...).
NOTE
For a typical interrupt_on HITL pause, resume with a flat {"decisions": [...]} payload, the same shape as with .invoke(). When multiple interrupts are pending at once (for example, parallel branches), map each interrupt ID to its resume value instead. See Handling multiple interrupts.
import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";
const config = { configurable: { thread_id: uuid7() } };
let streamInput: Record<string, any> | Command = {
messages: [{ role: "user", content: "Delete the file temp.txt" }],
};
while (true) {
let pendingInterrupt: { value: any } | undefined;
for await (const chunk of await agent.stream(
streamInput,
{
streamMode: ["messages", "updates"],
...config,
}
)) {
const [mode, data] = chunk;
if (mode === "messages") {
const [token] = data;
// Display tokens in real time
if (token.text) {
process.stdout.write(token.text);
}
}
if (mode === "updates" && data?.__interrupt__) {
pendingInterrupt = data.__interrupt__[0];
}
}
// If no interrupt occurred, the agent finished
if (!pendingInterrupt) break;
const interruptValue = pendingInterrupt.value;
const actionRequests = interruptValue.actionRequests;
const reviewConfigs = interruptValue.reviewConfigs;
const configMap = Object.fromEntries(
reviewConfigs.map((cfg: any) => [cfg.actionName, cfg])
);
for (const action of actionRequests) {
const review = configMap[action.name];
console.log(`\nTool: ${action.name}`);
console.log(`Arguments: ${JSON.stringify(action.args)}`);
console.log(`Allowed: ${review.allowedDecisions}`);
}
// Collect one decision per actionRequest, in order
const decisions = actionRequests.map(() => ({ type: "approve" }));
streamInput = new Command({ resume: { decisions } });
}
process.stdout.write("\n");TIP
Combine "messages" and "updates" stream modes to show real-time LLM tokens to the user while still catching HITL interrupts. The "messages" mode streams tokens; the "updates" mode delivers interrupt payloads.
Multiple tool calls
When the agent calls multiple tools that require approval, all interrupts are batched together in a single interrupt. You must provide decisions for each one in order.
const config = { configurable: { thread_id: uuid7() } };
let result = await agent.invoke({
messages: [{
role: "user",
content: "Delete temp.txt and send an email to admin@example.com"
}]
}, config);
if (result.__interrupt__) {
const interrupts = result.__interrupt__[0].value;
const actionRequests = interrupts.actionRequests;
// Two tools need approval
console.assert(actionRequests.length === 2);
// Provide decisions in the same order as actionRequests
const decisions = [
{ type: "approve" }, // First tool: delete_file
{
type: "reject",
message: "User rejected this action. Do not retry this tool call.",
} // Second tool: send_email
];
result = await agent.invoke(
new Command({ resume: { decisions } }),
config
);
}Rejection messages
When a reviewer returns a reject decision, Deep Agents skip the tool call and send rejection feedback back to the agent. If you omit message, the default feedback tells the model that the tool was not executed and not to retry the same tool call unless the user asks.
For sensitive or side-effecting tools, pass a domain-specific message with the decision. Be explicit about whether the agent should abandon the action, ask a follow-up question, or try a safer alternative.
const decisions = [
{
type: "reject",
message: "User rejected deleting this file. Do not retry deletion. Ask which file to archive instead.",
},
];Edit tool arguments
When "edit" is in the allowed decisions, you can modify the tool arguments before execution:
if (result.__interrupt__) {
const interrupts = result.__interrupt__[0].value;
const actionRequest = interrupts.actionRequests[0];
// Original args from the agent
console.log(actionRequest.args); // { to: "everyone@company.com", ... }
// User decides to edit the recipient
const decisions = [{
type: "edit",
editedAction: {
name: actionRequest.name, // Must include the tool name
args: { to: "team@company.com", subject: "...", body: "..." }
}
}];
result = await agent.invoke(
new Command({ resume: { decisions } }),
config
);
}Subagent interrupts
When using subagents, you can use interrupts on tool calls and within tool calls.
Interrupts on tool calls
Each subagent can have its own interrupt_on configuration that overrides the main agent's settings:
const agent = createDeepAgent({
tools: [deleteFile, readFile],
interruptOn: {
delete_file: true,
read_file: false,
},
subagents: [{
name: "file-manager",
description: "Manages file operations",
systemPrompt: "You are a file management assistant.",
tools: [deleteFile, readFile],
interruptOn: {
// Override: require approval for reads in this subagent
delete_file: true,
read_file: true, // Different from main agent!
}
}],
checkpointer
});When a subagent triggers an interrupt, the handling is the same—check for __interrupt__ on the result and resume with Command.
When streaming, subagent interrupts also surface as __interrupt__ entries in the parent updates stream. You do not need subgraphs=True for interrupt delivery. Enable subgraphs=True if you also want to stream subagent tokens and progress events. See Handle interrupts with streaming for the full pattern.
Interrupts within tool calls
Subagent tools can call interrupt() directly to pause execution and await approval:
import { createAgent, tool } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";
import { MemorySaver, Command, interrupt } from "@langchain/langgraph";
import { createDeepAgent } from "deepagents";
import { z } from "zod";
const requestApproval = tool(
async ({ actionDescription }: { actionDescription: string }) => {
const approval = interrupt({
type: "approval_request",
action: actionDescription,
message: `Please approve or reject: ${actionDescription}`,
}) as { approved?: boolean; reason?: string };
if (approval.approved) {
return `Action '${actionDescription}' was APPROVED. Proceeding...`;
} else {
return `Action '${actionDescription}' was REJECTED. Reason: ${
approval.reason || "No reason provided"
}`;
}
},
{
name: "request_approval",
description: "Request human approval before proceeding with an action.",
schema: z.object({
actionDescription: z
.string()
.describe("The action that requires approval"),
}),
}
);
async function main() {
const checkpointer = new MemorySaver();
const model = new ChatOpenAI({
model: "gpt-5.4-mini",
maxTokens: 4096,
});
const compiledSubagent = createAgent({
model: model,
tools: [requestApproval],
name: "approval-agent",
});
const parentAgent = await createDeepAgent({
checkpointer: checkpointer,
subagents: [
{
name: "approval-agent",
description: "An agent that can request approvals",
runnable: compiledSubagent as any,
},
],
});
const threadId = "test_interrupt_directly";
const config = { configurable: { thread_id: threadId } };
console.log("Invoking agent - sub-agent will use request_approval tool...");
let result = await parentAgent.invoke(
{
messages: [
new HumanMessage({
content:
"Use the task tool to launch the approval-agent sub-agent. " +
"Tell it to use the request_approval tool to request approval for 'deploying to production'.",
}),
],
},
config
);
if (result.__interrupt__) {
const interruptValue = result.__interrupt__[0].value as {
type?: string;
action?: string;
message?: string;
};
console.log("\nInterrupt received!");
console.log(` Type: ${interruptValue.type}`);
console.log(` Action: ${interruptValue.action}`);
console.log(` Message: ${interruptValue.message}`);
console.log("\nResuming with Command(resume={'approved': true})...");
const result2 = await parentAgent.invoke(
new Command({ resume: { approved: true } }),
config
);
if (!result2.__interrupt__) {
console.log("\nExecution completed!");
// Find the tool response
const toolMsgs = result2.messages?.filter((m) => m.type === "tool") || [];
if (toolMsgs.length > 0) {
const lastToolMsg = toolMsgs[toolMsgs.length - 1];
console.log(` Tool result: ${lastToolMsg.content}`);
}
} else {
console.log("\nAnother interrupt occurred");
}
} else {
console.log(
"\n No interrupt - the model may not have called request_approval"
);
}
}
main().catch(console.error);When run, this produces the following output:
Invoking agent - sub-agent will use request_approval tool...
Interrupt received!
Type: approval_request
Action: deploying to production
Message: Please approve or reject: deploying to production
Resuming with Command(resume={'approved': true})...
Execution completed!
Tool result: Approval for "deploying to production" has been granted. You can proceed with the deployment.Connect these docs to your agent of choice via MCP for real-time answers.
