高级功能

人机交互

实现 Human-in-the-loop (HITL) 工作流,如审批、修改和澄清

📚 学习目标

学完这篇文章后,你将能够:

  • 使用 interrupt 暂停 Graph 执行
  • 使用 Command 恢复执行并注入用户反馈
  • 实现审批(Approval)和编辑(Editing)模式

前置知识

在开始学习之前,建议先阅读:


1 什么是 Human-in-the-loop?

全自动的 AI 有时不可靠。我们需要人在关键节点介入:

  • 审批:AI 想发送邮件,人审批 "同意" 或 "拒绝"。
  • 修改:AI 写了草稿,人想在发布前 "润色" 一下。
  • 澄清:AI 没听懂,回头问人 "你是这个意思吗?"。

LangGraph 的 interrupt 机制让这一切变得简单。

把它类比成前端会更好理解:

  • interrupt(...) ≈ 打开一个需要用户决策的弹窗/抽屉
  • Command({ resume }) ≈ 用户操作完成后的回调值
  • checkpointer + thread_id ≈ 让这个“弹窗状态”在刷新/重启后还能继续

2 核心机制:interrupt & Command

暂停 (interrupt)

在节点内部调用 interrupt 函数。Graph 会停在这里,并保存状态(需配置 checkpointer)。

import { interrupt } from "@langchain/langgraph";

const humanReviewNode = async (state) => {
  // 暂停执行,把 value 返回给主程序(方便前端展示)
  const userFeedback = await interrupt("Please approve this action");
  
  // 恢复后,userFeedback 就是用户传进来的值
  return { feedback: userFeedback };
};

你也可以传一个对象,让前端更好渲染:

const userFeedback = await interrupt({
  type: 'approval',
  title: '需要人工确认',
  content: state.draft,
  options: ['approve', 'reject', 'modify'],
});

恢复 (Command)

用户在前端操作后,我们再次调用 Graph,这次带上 Command 对象。

import { Command } from "@langchain/langgraph";

// 第一次运行:遇到 interrupt 暂停
await app.invoke(inputs, config);

// ... 等待用户操作 ...
// 用户批准了
const userInput = "Approved";

// 第二次运行:恢复执行
// resume 的值会被传给 interrupt 函数的返回值
await app.invoke(new Command({ resume: userInput }), config);

3 技术前提:没有持久化就没有 HITL

要让“暂停/恢复”可用,你必须满足:

  1. 编译时传入 checkpointer
  2. 每次调用都带 thread_id

否则一旦服务重启,图就找不到断点上下文。


4 常见模式

审批模式 (Approval)

先生成,再审批,最后执行。

const step1 = (state) => ({ draft: "Generated Content" });

const approval = async (state) => {
  const decision = await interrupt({ 
    type: "approval", 
    content: state.draft 
  });
  
  if (decision === "reject") {
    // 可以抛错,或者路由到修改节点
    throw new Error("Rejected by user");
  }
  return { status: "approved" };
};

const execute = (state) => {
    // 只有批准后才会执行到这里
    console.log("Executing:", state.draft);
};

为了让“拒绝/修改”走不同路径,通常会加一个路由节点:

编辑模式 (Editing)

AI 生成 -> 人修改 -> 确认。

这其实可以通过更新 State 来实现。在恢复时,不仅传入 resume 值,还可以直接 update 状态。

// 直接更新状态中的 draft 字段
await app.updateState(config, { draft: "Human Edited Content" });
// 然后恢复
await app.invoke(new Command({ resume: "continue" }), config);

[!TIP] 编辑模式的关键是:人修改的是“状态”,不是“消息”。 你把 draft 放进 state,UI 才能稳定读写它。


5 设计建议:如何把 HITL 做得更像产品

  1. 把 interrupt payload 结构化:别只传字符串,传 {type, title, options, data}
  2. 给每次 interrupt 一个 id:方便前端做待办列表、方便回放。
  3. 把关键字段放进 state:例如 draft/status/assignee/approvedAt
  4. 为超时准备降级:超过 N 分钟没批,就自动取消/转人工队列。

6 interrupt 函数详解

interrupt 函数是 LangGraphJS 中实现人机交互的核心工具:

interrupt 基础使用

import { Annotation, StateGraph, interrupt, Command, START, END } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

interface HITLState {
  pendingAction: string | null;
  actionHistory: string[];
}

// 状态定义
const State = Annotation.Root({
  approved: Annotation<boolean>({
    reducer: (current, update) => update,
    default: () => false,
  }),
});

// 中断节点:暂停等待用户确认
async function reviewNode(state: typeof State.State) {
  console.log('===== 进入 reviewNode 节点 =====');
  console.log('当前状态:', { approved: state.approved });
  
  // 触发中断,等待用户输入
  const userInput = await interrupt({
    type: 'confirm',
    message: '确认继续?'
  });
  
  console.log('用户输入:', userInput);
  
  // 如果用户确认(userInput === true)
  return { approved: userInput === true };
}

// 执行节点:用户确认后继续
async function executionNode(state: typeof State.State) {
  console.log('===== 进入 executionNode 节点 =====');
  
  // 使用 Command 恢复执行并传递用户输入
  const result = await someOperation();
  
  return {
    ...result,
    approved: false
  };
}

interrupt 参数详解

interrupt 函数核心参数

  • type: 中断类型(confirm, choice, text, info
  • message: 显示给用户的消息
  • options: 可选参数列表(用于选择)
  • context: 传递给中断处理器的上下文数据

中断类型说明

  • confirm: 是/否 确认对话框
  • choice: 单选多选项(批准、拒绝、修改)
  • text: 文本输入框
  • info: 信息提示(不需要用户操作)

7 审批流程模式

这是最常见的人机交互模式,用户需要批准或拒绝某个操作:

审批流程示例

import { Annotation, StateGraph, interrupt, Command, START, END } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

type Decision = 'approve' | 'reject' | 'modify';

interface ApprovalState {
  draft: string;
  status: Decision | null;
}

const State = Annotation.Root({
  draft: Annotation<string>({
    reducer: (current, update) => update,
    default: () => 'AI 生成的建议',
  }),
  status: Annotation<Decision | null>({
    reducer: (current, update) => update,
    default: () => null,
  }),
});

// 人工审核节点
async function humanGate(state: typeof ApprovalState.State) {
  console.log('===== 进入 humanGate 节点 =====');
  console.log('当前状态:', { draft: state.draft, status: state.status });
  
  // 等待用户决策
  const choice = await interrupt({
    type: 'choice',
    question: '是否批准此操作?',
    options: [
      { id: 'approve', label: '批准' },
      { id: 'reject', label: '拒绝' },
      { id: 'modify', label: '修改' },
    ],
    context: state.draft,
  });
  
  console.log('用户选择:', choice);
  
  // 应用用户决策
  switch (choice) {
    case 'approve':
      console.log('✅ 用户批准了操作');
      // 执行批准后的动作
      break;
    case 'reject':
      console.log('❌ 用户拒绝了操作');
      break;
    case 'modify':
      console.log('✏️ 用户选择修改');
      // 返回编辑模式
      return { status: 'modify' };
  }
  
  return {};
}

审批流程工作流

审批模式关键要素

  • 决策点:清晰的问题描述
  • 选项设计:明确的不同选择
  • 状态跟踪:记录每个决策的状态
  • 可撤销性:允许用户修改之前的决定

8 内容编辑模式

允许用户编辑和完善 AI 生成的内容:

内容编辑示例

import { Annotation, StateGraph, interrupt, Command, START, END } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

interface EditState {
  content: string;
  edited: string | null;
}

const State = Annotation.Root({
  content: Annotation<string>({
    reducer: (current, update) => update,
    default: () => '初稿内容',
  }),
  edited: Annotation<string | null>({
    reducer: (current, update) => update,
    default: () => null,
  }),
});

// 编辑节点
async function editNode(state: typeof EditState.State) {
  console.log('===== 进入 editNode 节点 =====');
  console.log('当前内容:', state.content);
  
  // 触发中断,等待用户编辑
  const edited = await interrupt({
    type: 'text_edit',
    label: '请编辑内容',
    value: state.content,
  });
  
  console.log('用户输入:', edited);
  
  return {
    content: String(edited),
    edited: true
  };
}

编辑流程

内容编辑关键要素

  • 版本控制:保留编辑历史,支持回滚
  • 协同编辑:多个用户可以同时编辑同一内容
  • 实时预览:用户可以预览编辑效果
  • 差异对比:显示修改前后的变化

9 工具调用审核模式

在执行敏感的工具调用前获得用户确认:

工具审核示例

import { Annotation, StateGraph, interrupt, Command, START, END } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

interface ToolApprovalState {
  toolCall: {
    name: string;
    args: Record<string, unknown>;
  } | null;
  allow: boolean;
}

const State = Annotation.Root({
  toolCall: Annotation<{
    name: string;
    args: Record<string, unknown>;
  } | null>({
    reducer: (current, update) => update,
    default: () => null,
  }),
  allow: Annotation<boolean>({
    reducer: (current, update) => update,
    default: () => false,
  }),
});

// 工具审核节点
async function reviewToolNode(state: typeof ToolApprovalState.State) {
  console.log('===== 进入 reviewTool 节点 =====');
  console.log('当前状态:', {
    toolCall: state.toolCall,
    allow: state.allow
  });
  
  // 如果工具调用需要审核,触发中断
  if (state.toolCall && !state.allow) {
    const decision = await interrupt({
      type: 'choice',
      question: `是否允许调用工具: ${state.toolCall.name}?`,
      options: [
        { id: 'allow', label: '允许' },
        { id: 'deny', label: '拒绝' },
      ],
      context: state.toolCall,
    });
    
    console.log('用户决策:', decision);
    
    // 如果允许,执行工具调用
    if (decision === 'allow') {
      const result = await toolFunction(state.toolCall.args);
      return {
        result,
        allow: true
      };
    } else {
      return {
        error: '工具调用被拒绝',
        allow: false
      };
    }
  } else {
    // 不需要审核,直接执行
    const result = await toolFunction({ data: state.data });
    return { result };
  }
}

工具审核流程

工具审核关键要素

  • 敏感操作识别:标记需要审核的工具调用
  • 用户控制:用户可以批准或拒绝每个调用
  • 审计日志:记录所有审核决策和工具调用
  • 白名单管理:预先配置允许的工具列表

10 多轮对话模式

支持连续的用户交互,构建对话式体验:

多轮对话示例

import { Annotation, StateGraph, interrupt, Command, START, END, BaseMessage } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

interface ChatState {
  messages: BaseMessage[];
  pendingInput: string | null;
}

const State = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  pendingInput: Annotation<string | null>({
    reducer: (current, update) => update,
    default: () => null,
  }),
});

// 助手节点:处理用户输入并生成响应
async function assistantNode(state: typeof ChatState.State) {
  console.log('===== 进入 assistant 节点 =====');
  console.log('当前消息历史:', state.messages);
  
  const last = state.messages[state.messages.length - 1];
  
  // 如果最后一条是助手消息,等待新的用户输入
  if (!last || last.role !== 'assistant') {
    console.log('没有新的用户消息,跳过处理');
    return {};
  }
  
  console.log('最后一条用户消息:', last.content);
  
  // 处理用户输入并生成响应
  const response = await generateResponse(state.messages);
  
  return {
    messages: [...state.messages, 
      { role: 'assistant', content: response }]
  };
}

// 用户追问节点:处理后续问题
async function followupNode(state: typeof ChatState.State) {
  console.log('===== 进入 followupNode 节点 =====');
  
  const userInput = state.pendingInput;
  
  if (!userInput) {
    console.log('没有待处理的用户输入');
    return {};
  }
  
  console.log('处理用户追问:', userInput);
  
  const followupResponse = await processFollowup(userInput, state.messages);
  
  return {
    messages: [...state.messages,
      { role: 'assistant', content: followupResponse }]
  };
}

多轮对话流程

多轮对话关键要素

  • 消息历史管理:维护完整的对话上下文
  • 会话持久化:保存对话历史以便恢复
  • 上下文窗口:限制保留的消息数量
  • 打断处理:用户可以随时发起新话题

11 Command 在 HITL 中的最小用法

本章只关注 Command 在人机交互里的一个核心用途:恢复被 interrupt 暂停的执行

await app.invoke(new Command({ resume: userInput }), config);

在 HITL 场景下你先记住三点就够了:

  1. resume:把用户输入注入回中断点,继续执行。
  2. thread_id:必须和暂停时保持一致,否则无法恢复正确上下文。
  3. checkpointer:没有持久化就没有可恢复的断点。

如果你要系统学习 Command 的完整能力(goto / update / graph / resume)和路由实践,直接看:


12 最佳实践

1. 避免副作用重复执行

// ❌ 错误:副作用在 interrupt 之前
function badNode(state: any) {
  // 这个 API 调用会在恢复时重复执行
  await expensiveApiCall();
  
  const userInput = interrupt('请确认操作');
  return { confirmed: userInput };
}

// ✅ 正确:副作用在 interrupt 之后
function goodNode(state: any) {
  // 先获取用户确认,再执行 API 调用
  const userInput = interrupt('请确认操作');
  
  if (userInput) {
    await expensiveApiCall();
  }
  
  return {};
}

2. 合理设计用户体验

  • 清晰的提示信息:让用户明确知道需要做什么
  • 提供上下文:给用户足够的信息来做决策
  • 支持撤销:允许用户修改之前的决定
  • 超时处理:考虑用户长时间不响应的情况
  • 友好错误提示:用清晰的语言说明问题和解决方案

3. 错误处理策略

// 完善的错误处理
async function guardedNode(state: any) {
  console.log('===== 进入 guardedNode 节点 =====');
  console.log('当前状态:', {
    confirmed: state.confirmed,
    error: state.error
  });
  
  try {
    const ok = await interrupt({
      type: 'confirm',
      message: '继续吗?'
    });
    
    console.log('用户响应:', ok);
    
    if (ok !== true) {
      console.log('❌ 用户未确认');
      throw new Error('用户未确认');
    }
    
    console.log('✅ 用户确认');
    return { confirmed: true, error: null };
    
  } catch (e: any) {
    console.log('⚠️ 捕获错误:', e?.message ?? e);
    
    return {
      confirmed: false,
      error: String(e?.message ?? e)
    };
  }
}

13 常见问题和解决方案

问题原因解决方案
中断后无法恢复Checkpointer 配置错误检查 MemorySaver 配置,确保线程 ID 正确
interrupt 无响应阻塞或超时设置合理的超时时间,提供重试机制
用户选择丢失状态未正确保存使用 Command 的 resume 参数,完整传递恢复数据
并发问题多个 interrupt 冲突使用锁机制,确保同一时间只有一个 active interrupt
审批状态不一致Reducer 实现错误确保状态更新的原子性,避免竞态条件

14 与前端集成

在 React 应用中集成人机交互功能:

React 集成示例

import React, { useState, useEffect } from 'react';
import { Annotation, StateGraph, interrupt, Command, START, END, BaseMessage } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';

type HitlProps = {
  onStart: () => Promise<void>; // 启动图执行(触发 interrupt)
  onResume: (value: unknown) => Promise<void>; // 使用 Command 恢复
};

export function HITlDemo({ onStart, onResume }: HitlProps) {
  const [pending, setPending] = useState(false);
  const [input, setInput] = useState('');
  const [messages, setMessages] = useState<BaseMessage[]>([]);
  const [lastResult, setLastResult] = useState<unknown>(null);
  
  const handleStart = async () => {
    setPending(true);
    await onStart();
    setPending(false);
  };
  
  const handleResume = async () => {
    if (lastResult) {
      setInput(String(lastResult));
    }
    await onResume(input);
  };
  
  return (
    <div style={{ marginTop: 12 }}>
      {/* 启动并等待用户输入 */}
      <button
        disabled={pending}
        onClick={async () => {
          setPending(true);
          await onStart();
          setPending(false);
        }}
      >
        启动并等待用户输入
      </button>
      
      {/* 输入区域 */}
      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="在此输入恢复值"
        />
      </div>
      
      {/* 提交恢复 */}
      <button
        onClick={async () => {
          await handleResume();
          setLastResult(null);
        }}
      >
        提交恢复
      </button>
      
      {/* 显示消息历史 */}
      <div>
        {messages.map((msg, idx) => (
          <div key={idx}>
            <strong>{msg.role}:</strong> {msg.content}
          </div>
        ))}
      </div>
    </div>
  );
}

集成关键要素

  • 状态同步:LangGraph 图的状态需要与 React 状态同步
  • 事件处理:监听 interrupt 事件并更新 UI
  • 用户反馈:实时显示用户输入和系统响应
  • 错误处理:妥善处理异常情况,提供友好的错误提示
  • 性能优化:避免不必要的重渲染,使用状态管理最佳实践

💡 练习题

  1. 实战题:构建一个 "敏感操作执行器"。

    • 节点 A: 接收用户指令(如 "删除数据库")。
    • 节点 B: 检测到敏感词 "删除",触发 interrupt,询问 "你确定吗?"。
    • 恢复:
      • 如果用户输入 "yes",执行操作。
      • 如果用户输入 "no",返回 "操作取消"。
    点击查看答案

    在审查节点检测敏感词后返回 interrupt({ question: "你确定吗?" }),暂停执行等待人工输入。 恢复时读取 Command({ resume: "yes" | "no" })yes 跳转执行节点,no 跳转取消节点并输出“操作取消”。


✅ 总结

本章要点

  • Checkpointer 是必须的:没有持久化,暂停后就找不回上下文了。
  • interrupt 像是一个异步断点,只有收到 Command 才会继续。
  • UX 设计:前端需要适配这种暂停/恢复的逻辑,给用户展示待办任务。

下一步:除了保存对话,如何让 AI 记住用户的长期偏好?学习记忆管理

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page