人机交互
实现 Human-in-the-loop (HITL) 工作流,如审批、修改和澄清
📚 学习目标
学完这篇文章后,你将能够:
- 使用
interrupt暂停 Graph 执行 - 使用
Command恢复执行并注入用户反馈 - 实现审批(Approval)和编辑(Editing)模式
前置知识
在开始学习之前,建议先阅读:
- 03-persistence (必须掌握,否则无法实现 HITL)
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
要让“暂停/恢复”可用,你必须满足:
- 编译时传入
checkpointer - 每次调用都带
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 做得更像产品
- 把 interrupt payload 结构化:别只传字符串,传
{type, title, options, data}。 - 给每次 interrupt 一个 id:方便前端做待办列表、方便回放。
- 把关键字段放进 state:例如
draft/status/assignee/approvedAt。 - 为超时准备降级:超过 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 场景下你先记住三点就够了:
resume:把用户输入注入回中断点,继续执行。thread_id:必须和暂停时保持一致,否则无法恢复正确上下文。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
- 用户反馈:实时显示用户输入和系统响应
- 错误处理:妥善处理异常情况,提供友好的错误提示
- 性能优化:避免不必要的重渲染,使用状态管理最佳实践
💡 练习题
-
实战题:构建一个 "敏感操作执行器"。
- 节点 A: 接收用户指令(如 "删除数据库")。
- 节点 B: 检测到敏感词 "删除",触发
interrupt,询问 "你确定吗?"。 - 恢复:
- 如果用户输入 "yes",执行操作。
- 如果用户输入 "no",返回 "操作取消"。
点击查看答案
在审查节点检测敏感词后返回
interrupt({ question: "你确定吗?" }),暂停执行等待人工输入。 恢复时读取Command({ resume: "yes" | "no" }):yes跳转执行节点,no跳转取消节点并输出“操作取消”。
✅ 总结
本章要点:
- Checkpointer 是必须的:没有持久化,暂停后就找不回上下文了。
- interrupt 像是一个异步断点,只有收到
Command才会继续。 - UX 设计:前端需要适配这种暂停/恢复的逻辑,给用户展示待办任务。
下一步:除了保存对话,如何让 AI 记住用户的长期偏好?学习记忆管理。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。