外观
并非每个智能体操作都应当在无人监督的情况下运行。当智能体即将发送邮件、删除记录、执行金融交易或执行任何不可逆的操作时,你需要人工先审查并批准该操作。人在回路(HITL)模式让你的智能体暂停执行,向用户展示待处理的操作,并且只在获得明确批准后恢复。
由于 HITL 构建在 LangGraph 的中断与检查点之上,暂停是持久化的。用户可以刷新页面,审查者可以从不同的组件回复,智能体仍然会从执行停止的确切位置恢复,而不是重放整个运行过程。
import { PatternEmbed } from "/snippets/pattern-embed.jsx"
中断的工作原理
LangGraph 智能体支持中断,即智能体将控制权交还给客户端的显式暂停点。当智能体遇到中断时:
- 智能体停止执行并发出中断 payload
useStreamhook 通过stream.interrupt展现中断- 你的界面(UI)渲染一张带有批准/拒绝/编辑选项的审查卡片
- 用户做出决定
- 你的代码使用 resume 命令调用
stream.submit() - 智能体从它停下的位置继续
前端 SDK 将中断与线程状态的其余部分一起保留,因此你的界面(UI)可以在任何有意义的地方渲染它:在对话记录的同一行内、在审查队列中、在管理面板中,或者在一个在做出决定之前阻止用户下一步操作的模态框中。
设置 useStream
将 useStream 连接到你的人在回路智能体。当图遇到中断时,hook 会在 stream.interrupt 上展现待处理的 payload。在该值被设置时渲染一张审批卡片,然后在用户批准、拒绝或编辑操作之后,使用 stream.submit(null, { command: { resume: response } }) 恢复运行。
INFO
The code examples use useStream<typeof myAgent> for type-safe stream state. See Type inference for Python or JavaScript backends.
tsx
import { useStream } from "@langchain/react";
const AGENT_URL = "http://localhost:2024";
export function Chat() {
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
const interrupt = stream.interrupt;
return (
{stream.messages.map((msg) => (
<Message key={msg.id} message={msg} />
))}
{interrupt && (
<ApprovalCard
interrupt={interrupt}
onRespond={(response) =>
stream.submit(null, { command: { resume: response } })
}
/>
)}
);
}vue
<script setup lang="ts">
import { useStream } from "@langchain/vue";
const AGENT_URL = "http://localhost:2024";
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
function handleRespond(response: HITLResponse) {
stream.submit(null, { command: { resume: response } });
}
</script>
<template>
<Message
v-for="msg in stream.messages.value"
:key="msg.id"
:message="msg"
/>
<ApprovalCard
v-if="stream.interrupt.value"
:interrupt="stream.interrupt.value"
@respond="handleRespond"
/>
</template>svelte
<script lang="ts">
import { useStream } from "@langchain/svelte";
const AGENT_URL = "http://localhost:2024";
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
function handleRespond(response: HITLResponse) {
stream.submit(null, { command: { resume: response } });
}
</script>
{#each stream.messages as msg (msg.id)}
<Message message={msg} />
{/each}
{#if stream.interrupt}
<ApprovalCard interrupt={stream.interrupt} onRespond={handleRespond} />
{/if}ts
import { Component } from "@angular/core";
import { injectStream } from "@langchain/angular";
import type { HITLResponse } from "langchain";
const AGENT_URL = "http://localhost:2024";
@Component({
selector: "app-chat",
template: `
@for (msg of stream.messages(); track msg.id) {
<app-message [message]="msg" />
}
@if (stream.interrupt()) {
<app-approval-card
[interrupt]="stream.interrupt()"
(respond)="handleRespond($event)"
/>
}
`,
})
export class ChatComponent {
stream = injectStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
handleRespond(response: HITLResponse) {
this.stream.submit(null, { command: { resume: response } });
}
}中断 payload
当智能体暂停时,stream.interrupt 包含一个结构如下的 HITLRequest:
ts
interface HITLRequest {
actionRequests: ActionRequest[];
reviewConfigs: ReviewConfig[];
}
interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
interface ReviewConfig {
allowedDecisions: ("approve" | "reject" | "edit" | "respond")[];
}| 属性 | 描述 |
|---|---|
actionRequests | 智能体想要执行的待处理操作数组 |
actionRequests[].name | 操作名称(例如 "send_email"、"delete_record") |
actionRequests[].args | 操作的传结构化参数 |
actionRequests[].description | 操作内容的可选的人类可读描述 |
reviewConfigs | 控制允许哪些决定的逐操作配置 |
reviewConfigs[].allowedDecisions | 要显示哪些按钮:"approve"、"reject"、"edit"、"respond" |
决定类型
HITL 模式支持四种决定类型:
批准(Approve)
用户确认操作应按原样进行:
ts
const response: HITLResponse = {
decisions: [{ type: "approve" }],
};
stream.submit(null, { command: { resume: response } });拒绝(Reject)
用户拒绝该操作,并可以附上可选原因。工具不会被执行:
ts
const response: HITLResponse = {
decisions: [
{
type: "reject",
message: "The email tone is too aggressive. Do not send it.",
},
],
};
stream.submit(null, { command: { resume: response } });INFO
当一个操作被拒绝时,智能体会收到拒绝原因,并且可以决定如何进行。如果你省略 message,后端会使用一条默认消息,告诉模型该工具未被执行,并且除非用户要求,否则不要重试同一个工具调用。对于会产生副作用的工具,请传入一条明确的消息,告诉智能体是放弃该操作、提出后续问题,还是尝试更安全的替代方案。
编辑(Edit)
用户在批准之前修改操作的参数:
ts
const response: HITLResponse = {
decisions: [
{
type: "edit",
editedAction: {
name: actionRequest.name,
args: {
...actionRequest.args,
subject: "Updated subject line",
body: "Revised email body with softer language.",
},
},
},
],
};
stream.submit(null, { command: { resume: response } });回复(Respond)
用户为"ask user"类型的工具提供直接回复。message 会成为工具结果,而工具本身不会被执行:
ts
const response: HITLResponse = {
decisions: [{ type: "respond", message: "Blue." }],
};
stream.submit(null, { command: { resume: response } });INFO
当工具本身是有意作为人类输入的占位符时,例如一个提示智能体从用户那里收集信息的 ask_user 工具,请使用 respond。不要使用 respond 来拒绝某个拟议的操作,因为它会作为成功的工具结果返回给模型。
构建 ApprovalCard
以下是审批卡片使用的决定接线。界面(UI)可以将每个操作拆分成自己的卡片,但 resume payload 是一个单一的 HITLResponse,每个待处理操作对应一个决定:
tsx
async function approveAll() {
const resume: HITLResponse = {
decisions: actionRequests.map(() => ({ type: "approve" })),
};
await stream.submit(null, { command: { resume } });
}
async function rejectOne(index: number, message: string) {
const resume: HITLResponse = {
decisions: actionRequests.map((_, i) =>
i === index
? { type: "reject", message }
: { type: "reject", message: "Rejected along with other actions" },
),
};
await stream.submit(null, { command: { resume } });
}
async function editOne(index: number, editedArgs: Record<string, unknown>) {
const originalAction = actionRequests[index];
const resume: HITLResponse = {
decisions: actionRequests.map((_, i) =>
i === index
? {
type: "edit",
editedAction: { name: originalAction.name, args: editedArgs },
}
: { type: "approve" },
),
};
await stream.submit(null, { command: { resume } });
}恢复流程
在用户做出决定之后,完整的循环如下所示:
- 调用
stream.submit(null, { command: { resume: hitlResponse } }) useStreamhook 将 resume 命令发送到 LangGraph 后端- 智能体收到
HITLResponse并继续执行。decisions中的每一项可以是以下之一:{ type: "approve" }:智能体继续执行操作{ type: "reject", message }:工具不会被执行,智能体在决定下一步行动之前会收到拒绝消息{ type: "edit", editedAction }:智能体使用编辑后的参数运行工具{ type: "respond", message }:人类的消息直接作为工具结果返回,而不执行工具
- 随着智能体恢复流式输出,
interrupt属性重置为null
TIP
你可以在单次智能体运行中串联多个 HITL 检查点。例如,智能体可能会先请求批准进行搜索,然后在发送包含结果的邮件之前再次请求。每个中断都会被独立处理。
处理多个待处理操作
当智能体想要一次执行多个操作时,一个中断可以包含多个 actionRequests。为每个操作渲染一张卡片,并在恢复之前收集所有决定:
tsx
function MultiActionReview({
interrupt,
onRespond,
}: {
interrupt: { value: HITLRequest };
onRespond: (response: HITLResponse) => void;
}) {
const [decisions, setDecisions] = useState<Record<number, HITLResponse["decisions"][number]>>({});
const request = interrupt.value;
const allDecided =
Object.keys(decisions).length === request.actionRequests.length;
return (
{request.actionRequests.map((action, i) => (
<SingleActionCard
key={i}
action={action}
config={request.reviewConfigs[i]}
onDecide={(response) =>
setDecisions((prev) => ({ ...prev, [i]: response }))
}
/>
))}
{allDecided && (
<button
className="rounded bg-green-600 px-4 py-2 text-white"
onClick={() =>
onRespond({
decisions: request.actionRequests.map((_, i) => decisions[i]),
})
}
>
Submit All Decisions
</button>
)}
);
}自定义中断表单
恢复流程 使用 humanInTheLoopMiddleware,它用一个通用的批准 / 拒绝 / 编辑 / 回复卡片包裹工具。有时一组按钮是不够的:预订航班、批准退款和审查社交帖子各自需要一个不同的表单,具有各自的字段、验证和文案。为此,在工具内部触发 interrupt(),让 payload 描述界面(UI)应该渲染的确切表单。每个工具都可以呈现出完全不同的界面。
在中断 payload 中描述表单
interrupt() 接受任何可 JSON 序列化的值,这允许你提供一个前端知道如何渲染的"卡片",例如表单类型、标题、人类正在审查的上下文,以及要收集的字段。interrupt() 在其输入与返回类型上是泛型的(interrupt<I, R>(value: I): R),因此你可以同时为发送的卡片(InterruptCard)和用户解析出的值(ReviewDecision)指定类型。导出这些类型,以便前端可以导入它们并保持同步:
ts
import { createAgent, tool } from "langchain";
import { interrupt } from "@langchain/langgraph";
import { z } from "zod";
export interface FormField {
name: string;
label: string;
type: "select" | "checkbox" | "textarea" | "currency";
options?: string[];
default?: unknown;
}
/** 用户用来解析中断的值。 */
export interface ReviewDecision {
approved: boolean;
/** 工具应依据的已编辑 / 已收集的表单值。 */
values?: Record<string, unknown>;
}
/** 中断传递给前端的表单规范("卡片")。 */
export interface InterruptCard {
formType: "flight-booking" | "refund-approval" | "content-review";
tool: string;
title: string;
context: Record<string, unknown>;
fields: FormField[];
/** 当前端将已解析的卡片提交到状态时由前端填充。 */
resolved?: boolean;
decision?: ReviewDecision;
}
const bookFlight = tool(
async ({ origin, destination, date, passengers }) => {
// 暂停工具并将类型化的表单规范交给前端;类型化的返回值
// 就是 UI 解析中断时得到的值。
const decision = interrupt<InterruptCard, ReviewDecision>({
formType: "flight-booking",
tool: "book_flight",
title: "Confirm flight booking",
context: { origin, destination, date, passengers },
fields: [
{
name: "seatClass",
label: "Seat class",
type: "select",
options: ["Economy", "Premium Economy", "Business"],
default: "Economy",
},
{ name: "insurance", label: "Add trip insurance", type: "checkbox", default: false },
],
});
if (!decision.approved) {
return `Booking cancelled. No flight from ${origin} to ${destination} was reserved.`;
}
// 使用人工确认的值执行真实(可能较慢)的工作。
const seatClass = String(decision.values?.seatClass ?? "Economy");
return `Flight booked from ${origin} to ${destination} in ${seatClass}.`;
},
{
name: "book_flight",
description: "Book a flight. Requires human confirmation of trip details.",
schema: z.object({
origin: z.string(),
destination: z.string(),
date: z.string(),
passengers: z.number().int().min(1),
}),
},
);为每个工具赋予不同的 formType(例如 "refund-approval"、"content-review"),以便前端可以基于它进行切换并渲染匹配的表单。
为每个工具渲染不同的表单
在客户端,卡片以 stream.interrupt.value 的形式到达。从你的智能体模块导入 InterruptCard 和 ReviewDecision 类型,使表单与 payload 保持同步,根据 formType 切换以选择正确的表单,并将 fields 送入你的输入组件:
tsx
import { useStream } from "@langchain/react";
import type { InterruptCard, ReviewDecision } from "./agent";
function Chat() {
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "hitl_interrupt_forms",
});
const card = stream.interrupt?.value as InterruptCard | undefined;
return (
{stream.messages.map((msg) => (
<Message key={msg.id} message={msg} />
))}
{card && <InterruptForm card={card} onResolve={handleResolve} />}
);
}
// `InterruptForm` 根据 `card.formType` 渲染航班 / 退款 / 内容卡片,
// 收集 `card.fields`,并使用用户的决定和编辑后的值
// 调用 `onResolve`。使用 respond(decision, { update }) 让卡片保持显示
当你解析一个普通的中断时,卡片会在中断清除的那一刻消失,只有工具结果会回来。这意味着一张丰富的审查卡片会在运行中途消失。要让它保持显示,在同一个超步(superstep)中同时解析中断并将一条携带卡片的消息提交到状态,方法是使用 useStream 的 respond:
tsx
import { AIMessage } from "langchain";
function handleResolve(decision: ReviewDecision) {
// 将卡片与决定一起快照,使其以只读方式渲染。
const resolvedCard = { ...card, resolved: true, decision };
const cardMessage = new AIMessage({
content: `Review ${decision.approved ? "approved" : "declined"}.`,
response_metadata: { cards: resolvedCard },
});
// 原子地同时恢复中断并将卡片推入状态。对应
// LangGraph 的 `Command(resume, update)`:一次检查点写入,无额外状态写入。
stream.respond(decision, { update: { messages: [cardMessage] } });
}respond(response, { update }) 会乐观地应用 update:卡片立即渲染,并且一旦恢复的运行回显同一条消息,就按 ID 进行对账。后端绝不会重新发出这张卡片,因此在(可能较慢的)工具运行期间,它可以无闪烁地保持渲染。通过从消息中读回已解析的卡片来渲染它:
tsx
{stream.messages.map((msg) => {
const card = (msg.response_metadata as { cards?: InterruptCard })?.cards;
if (card) return <InterruptForm key={msg.id} card={card} readOnly />;
return <Message key={msg.id} message={msg} />;
})}TIP
由于已解析的卡片存在于消息历史中,它可以经受住刷新,并且每个读取该线程的组件都能看到它,人类做出的决定也会成为持久化记录的一部分,而不仅仅是瞬态的界面(UI)状态。
最佳实践
在实现 HITL 工作流时,请牢记以下准则:
- 展示清晰的上下文。始终展示智能体想要做什么以及为什么。包含操作描述与完整参数。
- 让批准成为最轻松的路径。如果操作看起来正确,批准应该只需一次点击。把多步流程留给拒绝/编辑。
- 校验编辑后的参数。当用户编辑操作参数时,在发送之前校验 JSON 结构。对格式错误的输入显示行内错误。
- 持久化中断状态。如果用户刷新页面,中断应该仍然可见。
useStream通过线程的检查点处理这一点。 - 记录所有决定。为了审计追踪,记录每一次批准/拒绝/编辑决定,并带上时间戳与做出决定的用户。
- 审慎设置超时。长时间运行的智能体不应无限期地阻塞在人工审查上。考虑展示智能体已经等待了多长时间。