并非每个代理操作都应该无人监管。当代理即将发送电子邮件、删除记录、执行金融交易或执行任何不可逆操作时,您需要一个人先审核并批准该操作。人工介入(HITL)模式让您的代理暂停执行,向用户显示待处理操作,并且只有在明确批准后才恢复。

中断的工作原理

LangGraph 代理支持中断,即代理将控制权交回给客户端的显式暂停点。当代理遇到中断时:
  1. 代理停止执行并发送中断负载
  2. useStream 钩子通过 stream.interrupt 公开中断
  3. 您的 UI 渲染包含批准/拒绝/编辑选项的审核卡片
  4. 用户做出决定
  5. 您的代码使用恢复命令调用 stream.submit()
  6. 代理从中断处继续执行

为 HITL 设置 useStream

定义一个与代理状态模式匹配的 TypeScript 接口,并将其作为类型参数传递给 useStream,以便对状态值进行类型安全访问。在下面的示例中,将 typeof myAgent 替换为您的接口名称:
import type { BaseMessage } from "@langchain/core/messages";

interface AgentState {
  messages: BaseMessage[];
}
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>
  );
}

中断负载

当代理暂停时,stream.interrupt 包含一个具有以下结构的 HITLRequest
interface HITLRequest {
  actionRequests: ActionRequest[];
  reviewConfigs: ReviewConfig[];
}

interface ActionRequest {
  action: string;
  args: Record<string, unknown>;
  description?: string;
}

interface ReviewConfig {
  allowedDecisions: ("approve" | "reject" | "edit")[];
}
属性描述
actionRequests代理想要执行的待处理操作数组
actionRequests[].action操作名称(如 "send_email""delete_record"
actionRequests[].args操作的结构化参数
actionRequests[].description操作内容的可选人类可读描述
reviewConfigs每个操作的配置,控制允许哪些决定
reviewConfigs[].allowedDecisions显示哪些按钮:"approve""reject""edit"

决定类型

HITL 模式支持三种决定类型:

批准

用户确认操作应按原样继续:
const response: HITLResponse = {
  decision: "approve",
};

stream.submit(null, { command: { resume: response } });

拒绝

用户拒绝操作并提供可选原因:
const response: HITLResponse = {
  decision: "reject",
  reason: "The email tone is too aggressive. Please revise.",
};

stream.submit(null, { command: { resume: response } });
当操作被拒绝时,代理会收到拒绝原因,并可以决定如何继续。它可能会改写、提出澄清问题或完全放弃该操作。

编辑

用户在批准前修改操作的参数:
const response: HITLResponse = {
  decision: "edit",
  args: {
    ...originalArgs,
    subject: "Updated subject line",
    body: "Revised email body with softer language.",
  },
};

stream.submit(null, { command: { resume: response } });

构建 ApprovalCard

这是一个处理所有三种决定类型的完整审批卡片组件:
function ApprovalCard({
  interrupt,
  onRespond,
}: {
  interrupt: { value: HITLRequest };
  onRespond: (response: HITLResponse) => void;
}) {
  const request = interrupt.value;
  const [editedArgs, setEditedArgs] = useState(
    request.actionRequests[0]?.args ?? {}
  );
  const [rejectReason, setRejectReason] = useState("");
  const [mode, setMode] = useState<"review" | "edit" | "reject">("review");

  const action = request.actionRequests[0];
  const config = request.reviewConfigs[0];

  if (!action || !config) return null;

  return (
    <div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
      <h3 className="font-semibold text-amber-800">Action Review Required</h3>
      <p className="mt-1 text-sm text-amber-700">
        {action.description ?? `The agent wants to perform: ${action.action}`}
      </p>

      <div className="mt-3 rounded bg-white p-3 font-mono text-sm">
        <pre>{JSON.stringify(action.args, null, 2)}</pre>
      </div>

      {mode === "review" && (
        <div className="mt-4 flex gap-2">
          {config.allowedDecisions.includes("approve") && (
            <button
              className="rounded bg-green-600 px-4 py-2 text-white"
              onClick={() => onRespond({ decision: "approve" })}
            >
              Approve
            </button>
          )}
          {config.allowedDecisions.includes("reject") && (
            <button
              className="rounded bg-red-600 px-4 py-2 text-white"
              onClick={() => setMode("reject")}
            >
              Reject
            </button>
          )}
          {config.allowedDecisions.includes("edit") && (
            <button
              className="rounded bg-blue-600 px-4 py-2 text-white"
              onClick={() => setMode("edit")}
            >
              Edit
            </button>
          )}
        </div>
      )}

      {mode === "reject" && (
        <div className="mt-4 space-y-2">
          <textarea
            className="w-full rounded border p-2"
            placeholder="Reason for rejection..."
            value={rejectReason}
            onChange={(e) => setRejectReason(e.target.value)}
          />
          <button
            className="rounded bg-red-600 px-4 py-2 text-white"
            onClick={() =>
              onRespond({ decision: "reject", reason: rejectReason })
            }
          >
            Confirm Rejection
          </button>
        </div>
      )}

      {mode === "edit" && (
        <div className="mt-4 space-y-2">
          <textarea
            className="w-full rounded border p-2 font-mono text-sm"
            value={JSON.stringify(editedArgs, null, 2)}
            onChange={(e) => {
              try {
                setEditedArgs(JSON.parse(e.target.value));
              } catch {
                // allow invalid JSON while editing
              }
            }}
          />
          <button
            className="rounded bg-blue-600 px-4 py-2 text-white"
            onClick={() =>
              onRespond({ decision: "edit", args: editedArgs })
            }
          >
            Submit Edits
          </button>
        </div>
      )}
    </div>
  );
}

恢复流程

在用户做出决定后,完整周期如下:
  1. 调用 stream.submit(null, { command: { resume: hitlResponse } })
  2. useStream 钩子将恢复命令发送到 LangGraph 后端
  3. 代理接收 HITLResponse 并继续执行
  4. 如果批准,工具使用原始(或编辑后的)参数运行
  5. 如果拒绝,代理收到原因并决定下一步
  6. 当代理恢复流式传输时,interrupt 属性重置为 null
您可以在单个代理运行中链接多个 HITL 检查点。例如,代理可能先请求搜索批准,然后在发送带有结果的电子邮件之前再次请求批准。每个中断独立处理。

常见使用场景

使用场景操作审核配置
发送电子邮件send_email["approve", "reject", "edit"]
数据库写入update_record["approve", "reject"]
金融交易transfer_funds["approve", "reject"]
文件删除delete_files["approve", "reject"]
外部服务 API 调用call_api["approve", "reject", "edit"]

处理多个待处理操作

当代理想要一次执行多个操作时,中断可以包含多个 actionRequests。为每个操作渲染一个卡片,并在恢复前收集所有决定:
function MultiActionReview({
  interrupt,
  onRespond,
}: {
  interrupt: { value: HITLRequest };
  onRespond: (responses: HITLResponse[]) => void;
}) {
  const [decisions, setDecisions] = useState<Record<number, HITLResponse>>({});
  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(
              request.actionRequests.map((_, i) => decisions[i])
            )
          }
        >
          Submit All Decisions
        </button>
      )}
    </div>
  );
}

最佳实践

实现 HITL 工作流程时牢记以下指南:
  • 显示清晰的上下文。始终显示代理想要做什么以及为什么。包括操作描述和完整参数。
  • 让批准成为最简单的方式。如果操作看起来正确,批准应该是单次点击。将多步骤流程保留给拒绝/编辑。
  • 验证编辑后的参数。当用户编辑操作参数时,在发送前验证 JSON 结构。为格式错误的输入显示内联错误。
  • 持久化中断状态。如果用户刷新页面,中断应该仍然可见。useStream 通过线程的检查点处理此问题。
  • 记录所有决定。对于审计追踪,记录每个批准/拒绝/编辑决定以及时间戳和做出决定的用户。
  • 深思熟虑地设置超时。长时间运行的代理不应无限期地阻塞人工审核。考虑显示代理等待了多长时间。