Skip to content

并非每个智能体操作都应当在无人监督的情况下运行。当智能体即将发送邮件、删除记录、执行金融交易或执行任何不可逆的操作时,你需要人工先审查并批准该操作。人在回路(HITL)模式让你的智能体暂停执行,向用户展示待处理的操作,并且只在获得明确批准后恢复。

由于 HITL 构建在 LangGraph 的中断与检查点之上,暂停是持久化的。用户可以刷新页面,审查者可以从不同的组件回复,智能体仍然会从执行停止的确切位置恢复,而不是重放整个运行过程。

import { PatternEmbed } from "/snippets/pattern-embed.jsx"

中断的工作原理

LangGraph 智能体支持中断,即智能体将控制权交还给客户端的显式暂停点。当智能体遇到中断时:

  1. 智能体停止执行并发出中断 payload
  2. useStream hook 通过 stream.interrupt 展现中断
  3. 你的界面(UI)渲染一张带有批准/拒绝/编辑选项的审查卡片
  4. 用户做出决定
  5. 你的代码使用 resume 命令调用 stream.submit()
  6. 智能体从它停下的位置继续

前端 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 } });
}

恢复流程

在用户做出决定之后,完整的循环如下所示:

  1. 调用 stream.submit(null, { command: { resume: hitlResponse } })
  2. useStream hook 将 resume 命令发送到 LangGraph 后端
  3. 智能体收到 HITLResponse 并继续执行。decisions 中的每一项可以是以下之一:
    • { type: "approve" }:智能体继续执行操作
    • { type: "reject", message }:工具不会被执行,智能体在决定下一步行动之前会收到拒绝消息
    • { type: "edit", editedAction }:智能体使用编辑后的参数运行工具
    • { type: "respond", message }:人类的消息直接作为工具结果返回,而不执行工具
  4. 随着智能体恢复流式输出,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 的形式到达。从你的智能体模块导入 InterruptCardReviewDecision 类型,使表单与 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)中同时解析中断将一条携带卡片的消息提交到状态,方法是使用 useStreamrespond

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 通过线程的检查点处理这一点。
  • 记录所有决定。为了审计追踪,记录每一次批准/拒绝/编辑决定,并带上时间戳与做出决定的用户。
  • 审慎设置超时。长时间运行的智能体不应无限期地阻塞在人工审查上。考虑展示智能体已经等待了多长时间。