并非所有智能体操作都应该无人监督地运行。当智能体即将发送 电子邮件、删除记录、执行金融交易或执行任何 不可逆操作时,您需要人工先审查并批准该操作。 人在回路(HITL)模式允许您的智能体暂停执行,呈现 待处理操作给用户,并在获得明确批准后才恢复执行。
由于HITL建立在LangGraph中断和检查点之上,暂停是 持久的。用户可以刷新页面,审核员可以从不同的 组件进行回复,而智能体仍会从执行 停止的确切位置恢复,而不是重放整个运行过程。
中断如何工作
LangGraph智能体支持 **中断**,即智能体的显式暂停点 将控制权归还给客户端。当智能体触发中断时:
- 智能体停止执行并发出中断负载
useStream钩子通过以下方式呈现中断stream.interrupt- Your UI renders a review card with approve/reject/edit options
- 用户做出决定
- 您的代码调用
stream.submit()并附带恢复命令 - 智能体从停止处继续
前端SDK将中断与线程状态的其他部分一起保存,因此 您的UI可以在任何有意义的地方呈现它:内联在记录中、 审核队列、管理员仪表板,或在阻止下一个用户 操作直到做出决定为止的模态框中。
设置 useStream
将useStream连接到您的人工介入智能体。当图执行到 中断时,钩子在 stream.interrupt上暴露待处理的负载。在 设置该值时呈现批准卡片,然后用 stream.submit(null, { command: { resume: response } }) 在用户 批准、拒绝或编辑该操作后恢复运行。
const AGENT_URL = "http://localhost:2024";
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
const interrupt = stream.interrupt;
return (
{stream.messages.map((msg) => (
))}
{interrupt && (
stream.submit(null, { command: { resume: response } })
}
/>
)}
);
}
<script setup lang="ts">
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>
</template>
<script lang="ts">
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)}
{/each}
{#if stream.interrupt}
{/if}
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)"
/>
}
`,
})
stream = injectStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "human_in_the_loop",
});
handleRespond(response: HITLResponse) {
this.stream.submit(null, { command: { resume: response } });
}
}
中断负载
当智能体暂停时, stream.interrupt 包含一个带有 的HITLRequest:
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模式支持四种决定类型:
批准
用户确认操作应按原样继续执行:
const response: HITLResponse = {
decisions: [{ type: "approve" }],
};
stream.submit(null, { command: { resume: response } });
拒绝
用户拒绝该操作,可选择提供原因。工具不会被执行:
const response: HITLResponse = {
decisions: [
{
type: "reject",
message: "The email tone is too aggressive. Do not send it.",
},
],
};
stream.submit(null, { command: { resume: response } });
编辑
用户在批准前修改操作的参数:
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 } });
回复
用户为“询问用户”类型的工具提供直接回复。该 message 内容将成为工具结果,工具本身不会被执行:
const response: HITLResponse = {
decisions: [{ type: "respond", message: "Blue." }],
};
stream.submit(null, { command: { resume: response } });
构建 ApprovalCard
以下是由审批卡片使用的决策连接方式。UI可以将每个 操作拆分到各自的卡片中,但恢复载荷是一个单一的 HITLResponse ,每个待处理操作对应一个决策:
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 } });
}
恢复流程
用户做出决策后,完整周期如下:
1. 调用 stream.submit(null, { command: { resume: hitlResponse } }) 2. The useStream hook 向 LangGraph 后端发送恢复命令 3. 代理收到 HITLResponse 并继续执行。中的每个条目可能是: decisions 可能是以下之一: - { type: "approve" }:代理继续执行该操作 - { type: "reject", message }:工具不执行,代理收到拒绝消息后再决定下一步 - { type: "edit", editedAction }:代理使用编辑后的参数运行工具 - { type: "respond", message }:人工消息直接作为工具结果返回,工具本身不执行 4. interrupt 属性重置为 null 当代理恢复流式传输时
处理多个待处理操作
一个中断可能包含多个 actionRequests 当代理想要 同时执行多个操作时。为每个操作渲染一张卡片,并收集所有 决策后再恢复:
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) => (
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,它用以下内容包装一个工具 generic approve / reject / edit / respond card. Sometimes a single set of 仅按钮是不够的:预订航班、批准退款和审核 社交帖子每个都需要一个 *不同的* 表单,有各自的字段、验证和 副本。为此,引发 interrupt() **从工具内部** 并让 有效载荷描述UI应呈现的确切表单。每个工具可以展示一个 完全不同的界面。
在中断有效载荷中描述表单
interrupt() 接受任何JSON可序列化值,这允许你提供一个"卡片" 前端知道如何呈现的内容,例如,表单类型、标题、上下文 人类正在审核的内容,以及要收集的字段。 interrupt() 在其上是泛型的 输入和返回类型(interrupt<I, R>(value: I): R)所以你可以为两者添加类型 你发送的卡片(InterruptCard)和用户解决的值 (ReviewDecision)。导出这些类型以便前端可以导入它们并保持 同步:
name: string;
label: string;
type: "select" | "checkbox" | "textarea" | "currency";
options?: string[];
default?: unknown;
}
/** What the user resolves the interrupt with. */
approved: boolean;
/** Edited / collected form values the tool should act on. */
values?: Record<string, unknown>;
}
/** The form spec ("card") an interrupt hands to the frontend. */
formType: "flight-booking" | "refund-approval" | "content-review";
tool: string;
title: string;
context: Record<string, unknown>;
fields: FormField[];
/** Populated by the frontend when it commits the resolved card to state. */
resolved?: boolean;
decision?: ReviewDecision;
}
const bookFlight = tool(
async ({ origin, destination, date, passengers }) => {
// Pause the tool and hand the frontend a typed form spec; the typed return
// is whatever the UI resolves the interrupt with.
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.`;
}
// Run the real (possibly slow) work with the values the human confirmed.
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 (e.g. "refund-approval", "content-review")以便前端可以切换它并呈现匹配的 form.
为每个工具渲染不同的表单
在客户端,卡片以以下形式到达 stream.interrupt.value。导入 InterruptCard 和 ReviewDecision 类型从您的代理模块,以便表单 和负载保持同步,切换到 formType 以选择正确的表单,然后 传入 fields 到您的输入中:
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) => (
))}
{card && }
);
}
// `InterruptForm` renders a flight / refund / content card based on
// `card.formType`, collects `card.fields`, and calls `onResolve` with the
// user's decision and edited values.
使用以下方式让卡片保持在屏幕上 respond(decision, { update })
当您解决一个普通中断时,卡片会在中断清除的瞬间消失,只有 工具结果返回。这意味着一个丰富的审核卡片 会在运行中途消失。为了让它保持在屏幕上,请解决中断 **并** 提交 一条携带卡片的消息到状态中,在 *同一* 超步骤中使用 useStream's respond:
function handleResolve(decision: ReviewDecision) {
// Snapshot the card with the decision baked in so it renders read-only.
const resolvedCard = { ...card, resolved: true, decision };
const cardMessage = new AIMessage({
content: `Review ${decision.approved ? "approved" : "declined"}.`,
response_metadata: { cards: resolvedCard },
});
// Resume the interrupt AND push the card into state atomically. Maps to
// LangGraph's `Command(resume, update)`: one checkpoint, no extra state write.
stream.respond(decision, { update: { messages: [cardMessage] } });
}
respond(response, { update }) 应用 update **乐观地**: 卡片立即绘制,一旦恢复的运行回显相同的消息,就按ID进行协调 后端从不重新发出卡片,因此它保持渲染 在(可能较慢的)工具运行期间不会闪烁。渲染已解决 的卡片,通过从消息中读取它:
{stream.messages.map((msg) => {
const card = (msg.response_metadata as { cards?: InterruptCard })?.cards;
if (card) return ;
return ;
})}
最佳实践
在实现 HITL 工作流时请牢记以下准则:
- **显示清晰的上下文**。始终显示 *什么* 代理想要做什么以及 *为什么*。包括操作描述和完整参数。 - **使批准成为最简单的路径**。如果操作看起来正确,批准 should be a single click. Reserve multi-step flows for reject/edit. - **验证编辑后的参数**。当用户编辑操作参数时,验证 JSON 结构后再发送。对于格式错误的输入显示内联错误。 - **持久化中断状态**。如果用户刷新页面, 中断应该仍然可见。useStream 通过线程的 checkpoint. - **记录所有决策**. For audit trails, log every approve/reject/edit 带有时间戳和做出决策的用户的决策。 - **深思熟虑地设置超时**。长时间运行的代理不应阻塞 无限期地等待人工审查。考虑显示代理已运行了多长时间 waiting.