Skip to content

人在回路 (Human-in-the-Loop) ​

通过基于中断的人工审核添加审批工作流

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

因为 HITL 构建在 LangGraph 中断(interrupts)和检查点(checkpoints)之上,所以这种暂停是持久的。用户可以刷新页面,审查人员可以从不同的组件中进行响应,智能体仍然会从执行停止的确切位置恢复,而不是重新运行整个流程。

NOTE

本页面使用默认的批量中断模式。关于单次调用的中断(interruptMode: "per_call"),请参阅每次工具调用暂停一次。

中断的工作原理 ​

LangGraph 智能体支持中断(interrupts),即智能体将控制权让渡回客户端的明确暂停点。当智能体触发中断时:

  1. 智能体停止执行并发出中断有效负载(interrupt payload)
  2. useStream Hook 通过 stream.interrupt 暴露该中断
  3. 你的 UI 渲染一个带有批准/拒绝/编辑选项的审查卡片
  4. 用户做出决定
  5. 你的代码使用 resume 命令调用 stream.submit()
  6. 智能体从停止的地方继续执行

前端 SDK 将中断与其余线程状态一同保存,因此你的 UI 可以在任何合适的位置渲染它:在对话记录中内联显示、在审查队列中、在管理员仪表盘中,或在做出决定之前阻止用户进行下一步操作的模态框中。

设置 useStream ​

将 useStream 连接到你的人在回路智能体。当图(graph)触发中断时,Hook 会在 stream.interrupt 上暴露待处理的有效负载。在该值设置期间渲染审批卡片,然后在用户批准、拒绝或编辑操作后,使用 stream.submit(null, { command: { resume: response } }) 恢复运行。

NOTE

代码示例使用 useStream<typeof myAgent> 来获取类型安全的流状态。请参阅针对 Python 或 JavaScript 后端的类型推断。

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 (
    <div>
      {stream.messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      {interrupt && (
        <ApprovalCard
          interrupt={interrupt}
          onRespond={(response) =>
            stream.submit(null, { command: { resume: response } })
          }
        />
      )}
    </div>
  );
}
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 } });
}
`&lt;/script&gt;`

<template>
  <div>
    <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"
    />
  </div>
</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 } });
  }
`&lt;/script&gt;`

<div>
  {#each stream.messages as msg (msg.id)}
    <Message message={msg} />
  {/each}

  {#if stream.interrupt}
    <ApprovalCard interrupt={stream.interrupt} onRespond={handleRespond} />
  {/if}
</div>
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 } });
  }
}

中断有效负载 ​

当智能体暂停时,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 } });

NOTE

当操作被拒绝时,智能体会收到拒绝原因,并可以决定如何继续。如果你省略 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 } });

NOTE

当工具特意作为人类输入的占位符时(例如提示智能体从用户处收集信息的 ask_user 工具),请使用 respond。不要使用 respond 来拒绝提议的操作,因为它会作为成功的工具结果返回给模型。

构建 ApprovalCard ​

以下是审批卡片使用的决策连接逻辑。UI 可以将每个操作拆分为各自独立的卡片,但恢复有效负载是一个单独的 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 (
    <div className="space-y-4">
      {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>
      )}
    </div>
  );
}

自定义中断表单 ​

恢复流程使用了 humanInTheLoopMiddleware,它为工具封装了一个通用的批准 / 拒绝 / 编辑 / 回复卡片。有时仅有一组按钮是不够的:预订航班、批准退款和审核社交动态各自都需要一个不同的表单,带有各自的字段、校验规则和文案。为此,可以从工具内部触发 interrupt(),并让有效负载描述 UI 应该渲染的确切表单。每个工具都可以呈现完全不同的界面。

在中断有效负载中描述表单 ​

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 类型,使表单与有效负载保持同步,根据 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 (
    <div>
      {stream.messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      {card && <InterruptForm card={card} onResolve={handleResolve} />}
    </div>
  );
}

// `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 通过线程的检查点来处理此问题。
  • 记录所有决定。为了便于审计追溯,记录每个批准/拒绝/编辑决定,附带时间戳和做出决定的用户。
  • 周全地设置超时。长时间运行的智能体不应无限期地因等待人工审核而阻塞。可以考虑显示智能体已经等待了多长时间。