5. 从零开始搭建一个 AI Agent —— 人机协作与中断恢复

人机协作与中断恢复:让 Agent 学会"请示汇报"

前四篇文章,我们让 Agent 学会了算数、操作文件、记忆知识、委派任务。 但有一个关键问题始终悬而未决:Agent 真的可以随意执行危险操作吗?

想象一下:你说"帮我清理一下临时文件",Agent 理解成 rm -rf / ------ 这不是段子,是真实会发生的事。

这一篇,我们要给 Agent 装上"刹车系统":关键操作前暂停,等人类确认后再继续。 这就是 Human-in-the-loop(人机协作)的核心思想。


目录

  1. [回顾:前四篇文章结束时 Agent 能做什么?](#回顾:前四篇文章结束时 Agent 能做什么? "#1-%E5%9B%9E%E9%A1%BE%E5%89%8D%E5%9B%9B%E7%AF%87%E6%96%87%E7%AB%A0%E7%BB%93%E6%9D%9F%E6%97%B6-agent-%E8%83%BD%E5%81%9A%E4%BB%80%E4%B9%88")
  2. [为什么需要 Human-in-the-loop?](#为什么需要 Human-in-the-loop? "#2-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81-human-in-the-loop")
  3. interruptOn:声明哪些工具需要审批
  4. 中断处理:检测、展示、等待、恢复
  5. 三种决策:批准、编辑和拒绝
  6. [Event Streaming:实时事件流(Beta)](#Event Streaming:实时事件流(Beta) "#6-event-streaming%E5%AE%9E%E6%97%B6%E4%BA%8B%E4%BB%B6%E6%B5%81beta")
  7. SSE:流式输出的底层协议
  8. 三个新测试:验证人机协作能力
  9. 回顾与展望

1. 回顾:前四篇文章结束时 Agent 能做什么?

阶段 能力 新增工具/机制 文章链接
第一篇 计算器、报时、流式输出、多轮对话、Extended Thinking calculatorget_current_time langChain + TypeScript 实战
第二篇 文件读写、搜索、执行 shell 命令 lsread_filewrite_fileedit_fileglobgrepexecute LocalShellBackend 与命令执行
第三篇 持久记忆、按需加载领域知识、长对话自动压缩 Memory (AGENTS.md)、Skills (SKILL.md)、Summarization Memory Skills 与上下文工程
第四篇 任务分解、子 Agent 委派、并行执行 taskwrite_todos、Subagents 任务委派与子 Agent 并行处理

到了第五篇,我们要解决一个安全性问题

Agent 可以读写文件、执行 shell 命令 ------ 但谁来监督它?


2. 为什么需要 Human-in-the-loop?

2.1 一个真实的灾难场景

假设你对 Agent 说:

"帮我清理一下 workspace 目录下的临时文件。"

Agent 的理解可能是:

text 复制代码
[用户] 清理临时文件
   ↓
[Agent 思考] 临时文件通常是 .tmp 后缀
   ↓
[Agent 执行] execute("find . -name '*.tmp' -delete")
   ↓
[Agent 执行] execute("rm -rf /tmp/*")  ← 等等,这删的是系统临时目录!
   ↓
[系统] 💥 崩溃

问题在哪?

  1. Agent 没有"危险操作"的概念 ------ 它只知道"用户让我清理文件",不知道 rm -rf /tmp/* 会删掉系统的临时文件
  2. 没有审批机制 ------ Agent 决定做什么就做什么,没有人在关键时刻说"等等,这不对"
  3. 不可逆操作 ------ 文件删除后无法恢复,错误一旦发生就来不及了

2.2 解法:Human-in-the-loop

Human-in-the-loop(HITL,人机协作)的核心思想很简单:

关键操作前暂停,等人类确认后再继续。

text 复制代码
[用户] 清理临时文件
   ↓
[Agent 思考] 我需要执行 shell 命令
   ↓
[Agent 准备执行] execute("find . -name '*.tmp' -delete")
   ↓
[🛑 中断] 检测到 execute 工具调用,暂停执行
   ↓
[展示给用户] "Agent 想要执行:find . -name '*.tmp' -delete,是否批准?"
   ↓
[用户审批] ✅ 批准 / ❌ 拒绝 / ✏️ 编辑
   ↓
[Agent 继续] 根据用户决策执行(或取消)操作

核心变化:

  • Agent 不再是"自动驾驶" ------ 它在执行危险操作前必须"请示"
  • 人类保持控制权 ------ 关键决策由人来做
  • 可审计、可追溯 ------ 每个操作都有明确的审批记录

2.3 类比

模式 类比 优点 缺点
无 HITL 自动驾驶(L5) 快、无需人工干预 容易出事故、不可逆
有 HITL 辅助驾驶(L2-L3) 安全、人类保持控制 慢、需要人工确认

在生产环境中,安全永远比速度重要。宁可慢一点,也不能让 Agent 随意执行危险操作。


3. interruptOn:声明哪些工具需要审批

3.1 配置 interruptOn

createDeepAgent 提供了一个 interruptOn 参数,用于声明哪些工具需要人工审批:

typescript 复制代码
// src/agents.ts --- createDeepAgent 配置(只展示新增部分)
export const agent = createDeepAgent({
  model,
  tools: [calculatorTool, getCurrentTimeTool],
  backend,
  memory: ['/AGENTS.md'],
  skills: ['/skills/'],
  subagents,
  
  // ↓↓↓ 新增:interruptOn 配置 ↓↓↓
  // 声明哪些工具需要人工审批
  // - execute: 危险操作 --- shell 命令可能删除文件、修改系统配置
  // - write_file: 写文件 --- 防止 Agent 意外创建不该创建的文件
  // 未配置的工具(calculator、read_file 等)自动放行,不打断
  // ⚠️ 需要 checkpointer(已有 MemorySaver),否则无法在中断后恢复
  interruptOn: {
    execute: {
      allowedDecisions: ['approve', 'reject'] as const,
      description: '⚠️ Shell 命令即将执行,请确认后审批',
    },
    write_file: {
      allowedDecisions: ['approve', 'edit', 'reject'] as const,
      description: '📝 文件写入操作,请审批',
    },
  },
  
  systemPrompt: '...',
  checkpointer: new MemorySaver()  // ⚠️ 必须有 checkpointer,否则无法恢复
});

3.2 interruptOn 配置说明

interruptOn 是一个对象,key 是工具名,value 是配置:

typescript 复制代码
interruptOn: {
  [toolName]: boolean | InterruptOnConfig
}
  • true --- 启用审批,允许 approve/edit/reject 三种决策
  • false --- 自动放行(默认行为)
  • InterruptOnConfig --- 详细配置

InterruptOnConfig 的字段:

字段 类型 说明
allowedDecisions ('approve' | 'edit' | 'reject')[] 允许的决策类型
description string | DescriptionFactory 审批提示文案(静态或动态生成)
when (request) => boolean 条件判断:什么情况下触发中断

3.3 三种决策类型

决策 含义 适用场景
approve 批准,按原样执行 大多数场景
edit 编辑,修改参数后执行 需要微调参数时(如修改文件路径)
reject 拒绝,不执行 操作危险或不合适时

3.4 示例:不同工具的审批策略

typescript 复制代码
interruptOn: {
  // execute:只允许批准或拒绝(不允许编辑命令)
  execute: {
    allowedDecisions: ['approve', 'reject'],
    description: '⚠️ Shell 命令即将执行',
  },
  
  // write_file:允许批准、编辑、拒绝(可以修改文件内容)
  write_file: {
    allowedDecisions: ['approve', 'edit', 'reject'],
    description: '📝 文件写入操作',
  },
  
  // 动态描述:根据工具调用生成审批提示
  send_email: {
    allowedDecisions: ['approve', 'reject'],
    description: (toolCall, state, runtime) => {
      const { to, subject } = toolCall.args;
      return `📧 即将发送邮件给 ${to},主题:${subject}`;
    },
  },
  
  // 条件判断:只在特定情况下触发中断
  delete_file: {
    allowedDecisions: ['approve', 'reject'],
    when: (request) => {
      // 只有删除 /private/ 目录下的文件才需要审批
      return String(request.toolCall.args.path ?? '').includes('/private/');
    },
  },
  
  // calculator:不配置 = 自动放行(默认行为)
}

3.5 为什么需要 checkpointer?

interruptOn 必须配合 checkpointer(如 MemorySaver)使用,因为:

  1. 中断时保存状态 --- Agent 执行到一半被中断,checkpointer 保存当前的对话历史和工具调用状态
  2. 恢复时加载状态 --- 用户审批后,Agent 从 checkpointer 加载状态,继续执行

没有 checkpointer,中断后状态丢失,无法恢复。


4. 中断处理:检测、展示、等待、恢复

4.1 中断检测

当 Agent 触发需要审批的工具时,agent.invoke() 会立即返回,结果中包含 __interrupt__ 属性:

typescript 复制代码
const result = await agent.invoke(
  { messages: [{ role: 'user', content: '执行 echo "Hello"' }] },
  config
);

// 检查是否被中断
if (isInterrupted(result)) {
  console.log('Agent 被中断,等待审批');
}

4.2 解析中断内容

__interrupt__ 是一个数组,每个元素包含一个 value 字段,里面是 HITLRequest 对象:

typescript 复制代码
import { INTERRUPT, isInterrupted } from '@langchain/langgraph';
import type { HITLRequest } from 'langchain';

if (isInterrupted(result)) {
  const interruptData = result[INTERRUPT];
  const interruptValue = interruptData?.[0]?.value;
  
  // interruptValue 是 HITLRequest 对象
  const hitlRequest = interruptValue as HITLRequest;
  
  // actionRequests:待审批的操作列表
  const actionRequests = hitlRequest.actionRequests;
  // [{ name: 'execute', args: { command: 'echo "Hello"' }, description: '...' }]
  
  // reviewConfigs:每个操作的审批配置
  const reviewConfigs = hitlRequest.reviewConfigs;
  // [{ actionName: 'execute', allowedDecisions: ['approve', 'reject'] }]
}

4.3 完整的处理流程

我们在 src/index.ts 中实现了一个 handleInterrupt 函数,封装了完整的中断处理流程:

typescript 复制代码
async function handleInterrupt(result: any, config: any): Promise<any | null> {
  // 1. 检测中断
  if (!isInterrupted(result)) {
    return null;
  }

  // 2. 解析 HITLRequest
  const interruptData = result[INTERRUPT];
  const interruptValue = interruptData?.[0]?.value;

  if (!interruptValue) {
    console.log('\n⚠️ 检测到中断,但无法解析中断内容');
    return null;
  }

  const hitlRequest = interruptValue as HITLRequest;
  const actionRequests = hitlRequest.actionRequests || [];
  const reviewConfigs = hitlRequest.reviewConfigs || [];

  // 3. 向用户展示待审批的操作
  console.log('\n\n🛑 ════════════════════════════════════════════════════════════');
  console.log('🛑  Agent 执行被中断,需要人工审批');
  console.log('🛑 ════════════════════════════════════════════════════════════\n');

  for (let i = 0; i < actionRequests.length; i++) {
    const action = actionRequests[i];
    const reviewConfig = reviewConfigs.find(rc => rc.actionName === action.name);

    console.log(`📋 操作 ${i + 1}/${actionRequests.length}:`);
    console.log(`   工具: ${action.name}`);
    console.log(`   参数: ${JSON.stringify(action.args, null, 2)}`);

    if (action.description) {
      console.log(`   描述: ${action.description}`);
    }

    if (reviewConfig) {
      console.log(`   允许的决策: ${reviewConfig.allowedDecisions.join(', ')}`);
    }
    console.log();
  }

  // 4. 等待用户输入决策
  // 使用 process.stdin.once 替代 readline,避免 readline 生命周期影响后续 agent.invoke
  const decisions: HITLResponse['decisions'] = [];

  // 辅助函数:读取一行用户输入
  const readLine = (prompt: string): Promise<string> => {
    return new Promise<string>((resolve) => {
      process.stdout.write(prompt);
      process.stdin.once('data', (data) => {
        resolve(data.toString().trim());
      });
    });
  };

  // 确保 stdin 处于 flowing 状态
  process.stdin.resume();
  process.stdin.setEncoding('utf8');

  for (let i = 0; i < actionRequests.length; i++) {
    const action = actionRequests[i];
    const reviewConfig = reviewConfigs.find(rc => rc.actionName === action.name);
    const allowedDecisions = reviewConfig?.allowedDecisions || ['approve', 'reject'];

    console.log(`\n─── 操作 ${i + 1}: ${action.name} ───`);

    // 根据允许的决策类型构建提示
    const choices = allowedDecisions.map(d => {
      if (d === 'approve') return 'y (批准)';
      if (d === 'reject') return 'n (拒绝)';
      if (d === 'edit') return 'e (编辑)';
      return d;
    }).join(' / ');

    const answer = await readLine(`请决策 [${choices}]: `);
    const normalizedAnswer = answer.toLowerCase();

    // 解析用户决策
    if (normalizedAnswer === 'y' || normalizedAnswer === 'yes' || normalizedAnswer === 'approve') {
      decisions.push({ type: 'approve' });
      console.log('✅ 已批准');
    } else if (normalizedAnswer === 'n' || normalizedAnswer === 'no' || normalizedAnswer === 'reject') {
      const reason = await readLine('拒绝原因(可选,按回车跳过): ');
      decisions.push({
        type: 'reject',
        message: reason || '用户拒绝此操作',
      });
      console.log('❌ 已拒绝');
    } else if (normalizedAnswer === 'e' || normalizedAnswer === 'edit') {
      if (!allowedDecisions.includes('edit')) {
        console.log('⚠️ 此操作不允许编辑,自动拒绝');
        decisions.push({ type: 'reject', message: '用户尝试编辑但不被允许' });
      } else {
        // 编辑模式:让用户输入新的参数
        console.log('当前参数:', JSON.stringify(action.args, null, 2));
        const newArgsStr = await readLine('请输入新的参数 JSON(按回车保持原样): ');

        let newArgs = action.args;
        if (newArgsStr) {
          try {
            newArgs = JSON.parse(newArgsStr);
          } catch {
            console.log('⚠️ JSON 解析失败,使用原参数');
          }
        }

        decisions.push({
          type: 'edit',
          editedAction: {
            name: action.name,
            args: newArgs,
          },
        });
        console.log('✏️ 已编辑参数');
      }
    } else {
      console.log('⚠️ 无法识别的输入,默认拒绝');
      decisions.push({ type: 'reject', message: '用户未做出明确决策' });
    }
  }

  // 5. 构造 HITLResponse 并用 Command 恢复执行
  const hitlResponse: HITLResponse = { decisions };

  console.log('\n🔄 正在恢复 Agent 执行...');

  // 暂停 stdin,防止干扰后续的 agent.invoke
  process.stdin.pause();

  // 用 Command 恢复 Agent,传入决策
  const resumeResult = await agent.invoke(
    new Command({ resume: hitlResponse }),
    config
  );

  // 6. 递归处理:恢复后可能还有新的中断(多个工具连续需要审批)
  // ⚠️ 必须返回 resumeResult 本身(而不是让递归返回 null 时丢失它)
  const nextInterrupt = await handleInterrupt(resumeResult, config);
  return nextInterrupt ?? resumeResult;
}

为什么用 process.stdin.once 而不是 readline

readline 模块会接管 stdin 的生命周期,导致后续的 agent.invoke 无法正常读取输入。 用 process.stdin.once('data', ...) 可以一次性读取一行,不影响后续操作。

4.4 关键 API:Command

Command 是 LangGraph 提供的用于恢复中断的类:

typescript 复制代码
import { Command } from '@langchain/langgraph';

// 恢复中断,传入决策
await agent.invoke(
  new Command({ resume: hitlResponse }),
  config
);

Command 的其他用法:

typescript 复制代码
// 恢复 + 更新状态
new Command({
  resume: hitlResponse,
  update: { approved_by: 'human' },
})

// 恢复 + 跳转到指定节点
new Command({
  resume: hitlResponse,
  goto: 'next_node',
})

4.5 辅助函数:runWithInterruptHandling

为了简化测试代码,我们封装了一个 runWithInterruptHandling 函数:

typescript 复制代码
async function runWithInterruptHandling(userMessage: string) {
  // 第一步:invoke Agent,让它执行到完成或被中断
  const result = await agent.invoke(
    { messages: [{ role: 'user', content: userMessage }] },
    config
  );

  // 第二步:检查是否有中断
  const afterInterrupt = await handleInterrupt(result, config);

  // 第三步:获取最终结果(可能经过多次中断-恢复循环)
  const finalResult = afterInterrupt || result;

  // 第四步:从最终结果中提取 AI 的最后一条消息并展示
  const messages = finalResult.messages || [];
  const lastAiMessage = [...messages].reverse().find(
    (m: any) => m._getType?.() === 'ai' && m.content
  );

  if (lastAiMessage) {
    if (typeof lastAiMessage.content === 'string') {
      process.stdout.write(lastAiMessage.content);
    } else if (Array.isArray(lastAiMessage.content)) {
      for (const block of lastAiMessage.content) {
        if (block.type === 'text' && block.text) {
          process.stdout.write(block.text);
        }
      }
    }
  }
  console.log('\n');
}

使用方式:

typescript 复制代码
await runWithInterruptHandling('执行 echo "Hello"');

这个函数封装了"invoke → 检查中断 → 恢复 → 展示结果"的完整流程。


5. 三种决策:批准、编辑和拒绝

5.1 批准(approve)

最简单的决策:按原样执行。

typescript 复制代码
decisions.push({ type: 'approve' });

5.2 拒绝(reject)

拒绝执行,可以附带原因。

typescript 复制代码
decisions.push({
  type: 'reject',
  message: '这个命令太危险,不允许执行',
});

拒绝后,Agent 会收到拒绝消息,可以据此调整策略或向用户解释。

5.3 编辑(edit)

修改参数后执行。只有当 allowedDecisions 包含 'edit' 时才允许。

typescript 复制代码
decisions.push({
  type: 'edit',
  editedAction: {
    name: 'write_file',
    args: {
      path: '/public/safe.txt',  // 修改路径
      content: 'Modified content',  // 修改内容
    },
  },
});

编辑模式非常适合"微调参数"的场景:

  • 用户想修改文件路径("不要写到 /private/,写到 /public/")
  • 用户想修改邮件内容("把'紧急'改成'重要'")
  • 用户想修改 SQL 查询("加上 LIMIT 100")

5.4 决策流程图

text 复制代码
[Agent 准备执行工具]
   ↓
[中断,展示操作详情]
   ↓
[用户决策]
   ├── y (approve) → 按原样执行
   ├── n (reject)  → 取消执行,返回拒绝消息
   └── e (edit)    → 修改参数后执行

6. Event Streaming:实时事件流(Beta)

6.1 什么是 Event Streaming?

前几篇文章我们用的是 agent.stream()messages 模式,逐 token 输出 AI 的回复。

LangGraph 还支持其他流式模式:

streamMode 输出内容 适用场景
messages 逐 token 的消息 打字机效果
values 每一步的完整状态 调试、状态追踪
updates 每一步的状态变化 增量更新 UI
tasks 任务生命周期(含中断) 监控任务进度
tools 工具调用的 start/end 实时展示工具执行
debug 完整的调试信息 开发调试

6.2 多模式同时使用

可以同时订阅多个模式:

typescript 复制代码
const stream = await agent.stream(
  { messages: [{ role: 'user', content: '...' }] },
  { 
    streamMode: ['messages', 'updates'],  // 同时订阅两个模式
    configurable: { thread_id: 'session-1' }
  }
);

for await (const [mode, data] of stream) {
  if (mode === 'messages') {
    // data 是 [message, metadata] 元组
    console.log('Token:', data[0].content);
  } else if (mode === 'updates') {
    // data 是状态变化
    console.log('State update:', data);
  }
}

6.3 tasks 模式:监控中断

tasks 模式可以实时看到任务的生命周期,包括中断信息:

typescript 复制代码
const stream = await agent.stream(
  { messages: [{ role: 'user', content: '执行 echo "Hello"' }] },
  { streamMode: 'tasks' }
);

for await (const task of stream) {
  console.log('Task:', task.name);
  console.log('Status:', task.status);  // 'running' | 'completed' | 'interrupted'
  
  if (task.interrupts?.length > 0) {
    console.log('Interrupted!', task.interrupts);
  }
}

6.4 tools 模式:实时监控工具执行

tools 模式可以实时看到工具调用的开始、结束、错误:

typescript 复制代码
const stream = await agent.stream(
  { messages: [{ role: 'user', content: '...' }] },
  { streamMode: 'tools' }
);

for await (const event of stream) {
  if (event.event === 'on_tool_start') {
    console.log(`🔧 开始执行工具: ${event.name}`, event.input);
  } else if (event.event === 'on_tool_end') {
    console.log(`✅ 工具执行完成: ${event.name}`, event.output);
  } else if (event.event === 'on_tool_error') {
    console.log(`❌ 工具执行失败: ${event.name}`, event.error);
  }
}

6.5 Beta 流式 API:streamEvents v3

LangGraph 还提供了一个更高级的流式 API(Beta):

typescript 复制代码
const run = await agent.streamEvents(
  { messages: [{ role: 'user', content: '...' }] },
  { version: 'v3' }
);

// 流式输出 token
for await (const msg of run.messages) {
  for await (const token of msg.text) {
    process.stdout.write(token);
  }
}

// 监控工具调用
for await (const call of run.toolCalls) {
  console.log(`Tool: ${call.name}`, call.input);
  const result = await call.output;
  console.log(`Result:`, result);
}

// 检测中断
if (run.interrupted) {
  console.log('Interrupted!', run.interrupts);
}

// 获取最终状态
const finalState = await run.output;

这个 API 提供了更细粒度的控制,但还在 Beta 阶段,API 可能会变化。


7. SSE:流式输出的底层协议

7.1 什么是 SSE?

SSE(Server-Sent Events)是一种基于 HTTP 的单向流式传输协议。

与 WebSocket 的区别:

特性 SSE WebSocket
方向 单向(服务端 → 客户端) 双向
协议 HTTP WS/WSS
自动重连 ❌ 需要手动实现
适用场景 服务端推送(如流式输出) 实时双向通信(如聊天)

7.2 SSE 的数据格式

SSE 的数据格式很简单:

text 复制代码
event: message
data: {"text": "Hello"}

event: message
data: {"text": "World"}

每个事件由 event:data: 组成,事件之间用空行分隔。

7.3 LangGraph 的 SSE 支持

LangGraph 可以直接输出 SSE 格式的字节流:

typescript 复制代码
const stream = await agent.stream(
  { messages: [{ role: 'user', content: '...' }] },
  { 
    streamMode: 'messages',
    encoding: 'text/event-stream'  // 输出 SSE 格式
  }
);

// stream 现在是 Uint8Array(SSE 字节流)
// 可以直接写入 HTTP 响应
for await (const chunk of stream) {
  res.write(chunk);
}

7.4 与 Web 框架集成

在生产环境中,Agent 的流式输出通常通过 SSE 推送到前端:

typescript 复制代码
// Express 示例
app.post('/api/chat', async (req, res) => {
  const { message } = req.body;
  
  // 设置 SSE 响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  
  const stream = await agent.stream(
    { messages: [{ role: 'user', content: message }] },
    { 
      streamMode: 'messages',
      encoding: 'text/event-stream'
    }
  );
  
  for await (const chunk of stream) {
    res.write(chunk);
  }
  
  res.end();
});

前端通过 EventSource 接收:

javascript 复制代码
const eventSource = new EventSource('/api/chat', {
  // POST 请求需要用 fetch,EventSource 只支持 GET
  // 实际项目中需要用 fetch + ReadableStream
});

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Token:', data.text);
};

7.5 StreamChannel:自定义事件流

LangGraph 还提供了 StreamChannel 用于自定义事件流:

typescript 复制代码
import { StreamChannel } from '@langchain/langgraph';

// 创建一个远程可见的 channel
const channel = StreamChannel.remote('my-events');

// 推送数据
channel.push({ type: 'progress', value: 50 });
channel.push({ type: 'progress', value: 100 });

// 转换为 SSE 流
const sseStream = channel.toEventStream({
  event: 'progress',
  serialize: (item) => JSON.stringify(item),
});

这个功能适合"自定义事件推送"的场景,如实时进度更新、日志推送等。


8. 三个新测试:验证人机协作能力

8.1 测试 13:execute 工具中断

typescript 复制代码
// src/index.ts --- 测试 13
console.log('--- 测试 13:execute 工具中断(Shell 命令需要审批) ---');
console.log('用户:执行 echo "Hello from HITL!" 命令');
process.stdout.write('助手:');

await runWithInterruptHandling('执行 echo "Hello from HITL!" 命令');

预期行为:

  1. Agent 判断需要执行 shell 命令
  2. 调用 execute 工具前,触发中断
  3. 终端显示待审批的操作详情
  4. 等待用户输入 y/n/e
  5. 用户批准后,Agent 继续执行命令并返回结果

8.2 测试 14:write_file 工具中断

typescript 复制代码
// src/index.ts --- 测试 14
console.log('--- 测试 14:write_file 工具中断(文件写入需要审批) ---');
console.log('用户:在 /public/ 下创建一个 hitl-test.txt');
process.stdout.write('助手:');

await runWithInterruptHandling('在 /public/ 下创建一个 hitl-test.txt,内容是 "Human approved this!"');

预期行为:

  1. Agent 决定调用 write_file 工具
  2. 触发中断,等待审批
  3. 用户可以选择:
    • y --- 批准,按原样写入
    • n --- 拒绝,不写入
    • e --- 编辑,修改文件内容或路径后再写入

8.3 测试 15:混合场景

typescript 复制代码
// src/index.ts --- 测试 15
console.log('--- 测试 15:混合场景(自动放行 + 需要审批) ---');
console.log('用户:先帮我算 256 除以 8,然后执行 echo "计算完成"');
process.stdout.write('助手:');

await runWithInterruptHandling('先帮我算 256 除以 8,然后执行 echo "计算完成"');

预期行为:

  1. Agent 先调用 calculator 工具 --- 自动执行,不打断(calculator 没配置 interruptOn)
  2. Agent 再调用 execute 工具 --- 触发中断,等待审批
  3. 用户批准后,Agent 继续执行

这个测试展示了 interruptOn 的"选择性":安全的操作流畅执行,危险的操作需要确认。

8.4 验证

bash 复制代码
pnpm dev

预期输出:

text 复制代码
--- 测试 13:execute 工具中断(Shell 命令需要审批) ---
用户:执行 echo "Hello from HITL!" 命令
助手:
🛑 ════════════════════════════════════════════════════════════
🛑  Agent 执行被中断,需要人工审批
🛑 ════════════════════════════════════════════════════════════

📋 操作 1/1:
   工具: execute
   参数: {
     "command": "echo "Hello from HITL!""
   }
   描述: ⚠️ Shell 命令即将执行,请确认后审批
   允许的决策: approve, reject

─── 操作 1: execute ───
请决策 [y (批准) / n (拒绝)]: y
✅ 已批准

🔄 正在恢复 Agent 执行...
────────────────────────────────────────────────────────────

命令执行成功,输出:Hello from HITL!

9. 回顾与展望

9.1 本文新增了什么?

能力 实现方式 代码位置
中断配置 interruptOn 参数 src/agents.ts
中断检测 isInterrupted + INTERRUPT src/index.ts
中断处理 handleInterrupt 函数 src/index.ts
恢复执行 Command({ resume }) src/index.ts
辅助函数 runWithInterruptHandling src/index.ts
脚本拆分 dev / dev:watch package.json

package.json 变更说明:

本次更新还调整了 package.json 的脚本配置:

json 复制代码
{
  "scripts": {
    "dev:watch": "tsx watch src/index.ts",
    "dev": "tsx src/index.ts"
  },
  "packageManager": "pnpm@11.8.0+sha512..."
}
  • dev 脚本拆分 :原来的 dev 使用 tsx watch(文件变化自动重启),现在拆分为:
    • dev --- 单次运行(tsx src/index.ts),适合测试 HITL 交互场景
    • dev:watch --- 监听模式(tsx watch src/index.ts),适合开发调试

踩坑记录:为什么 HITL 场景不能用 watch 模式?

我做了什么 → 在 HITL 场景下用 tsx watch 运行项目,用户输入审批决策后,项目突然重新执行。

出了什么问题 → tsx watch 会监听文件变化并自动重启进程。当 Agent 执行 write_file 写入文件时,watch 检测到文件变更,立刻杀掉当前进程并重新启动,导致:

  1. 用户刚输入的审批决策丢失
  2. Agent 的执行状态被重置
  3. 整个流程从头开始,陷入"写入 → 重启 → 再写入 → 再重启"的死循环

为什么 → watch 模式的设计初衷是"改代码 → 自动重启",适合开发阶段。但 HITL 场景中,Agent 自己写的文件也会触发 watch,这不是"代码变更",而是"业务操作"。

怎么修的 → 把 dev 改为单次运行(tsx src/index.ts),watch 模式保留为 dev:watch 供纯开发调试使用。测试 HITL 时用 pnpm dev,不会被文件写入打断。

9.2 项目文件结构(截至本文)

text 复制代码
lingshi/
├── src/
│   ├── index.ts          # 入口文件,15 个测试场景 + HITL 处理
│   ├── agents.ts         # Agent 配置(+ interruptOn)
│   ├── tools.ts          # 自定义工具
│   └── subagents.ts      # 子 Agent 定义(reviewer 权限已注释)
├── workspace/
│   ├── AGENTS.md         # 持久记忆
│   ├── skills/           # 领域知识库
│   └── ...
├── 1. langChain + TypeScript 实战.md
├── 2. LocalShellBackend 与命令执行.md
├── 3. Memory Skills 与上下文工程.md
├── 4. 任务委派与子 Agent 并行处理.md
└── 5. 人机协作与中断恢复.md  ← 本文

重要变更说明:

在第四篇文章中,我们为 reviewer 子 Agent 设置了 permissions: [{ operations: ['read'], paths: ['/**'] }],实现只读权限隔离。

但在第五篇文章中,我们启用了 interruptOn(Human-in-the-loop),这要求主 Agent 使用 LocalShellBackend(支持 execute)。permissions 不能与 LocalShellBackendexecute 能力共存 ,因为 shell 命令可以绕过路径限制(如 cat /private/secret.txt)。

因此,我们在 src/subagents.ts 中注释掉了 reviewerpermissions 配置。如果需要真正的权限隔离,应该使用容器沙箱(如 LangSmithSandbox)。

9.3 完整运行

bash 复制代码
pnpm dev

启动日志中应该看到:

text 复制代码
Backend 已创建,工作目录: /path/to/workspace
📋 HarnessProfile: 已为 qwen 系列模型注册自定义配置
Agent 已创建,可用工具:calculator、get_current_time + 文件系统工具 + execute
📝 Memory: /AGENTS.md(持久记忆已加载)
📚 Skills: /skills/(product-faq / refund-policy / code-review)
🔄 Summarization: 超过 20 条消息自动压缩
🤖 Subagents: researcher / coder / reviewer(可通过 task 工具委派)
📋 HarnessProfile: qwen 系列模型自定义配置已注册
📝 write_todos: 内置任务规划工具已启用
⚠️ LocalShellBackend 无沙箱隔离,命令以当前用户身份执行
🛑 interruptOn: execute / write_file 需要人工审批后才能执行

9.4 生产环境的 HITL 最佳实践

  1. 分级审批 --- 不同危险程度的操作,配置不同的审批策略

    • 读操作 --- 自动放行
    • 写操作 --- 需要审批
    • 删除操作 --- 需要二次确认
  2. 动态描述 --- 根据操作内容生成审批提示

    • "即将删除文件 /private/secret.txt,是否批准?"
    • "即将发送邮件给 user@example.com,是否批准?"
  3. 审计日志 --- 记录每次审批的决策、时间、操作人

  4. 超时机制 --- 如果用户长时间未审批,自动拒绝或通知

  5. 批量审批 --- 多个操作可以一次性审批,减少打断次数

9.5 后续可以做什么

到目前为止,我们已经构建了一个功能完整的 Agent 系统:

  1. ✅ 基础工具调用(计算器、报时)
  2. ✅ 文件系统操作(读写、搜索、执行命令)
  3. ✅ 持久记忆与领域知识
  4. ✅ 任务分解与子 Agent 委派
  5. ✅ 人机协作与中断恢复

接下来可以探索的方向:

  1. 自定义 Middleware --- 扩展 Agent 的行为(如日志、监控、自动修复)
  2. LangGraph Studio --- 可视化调试 Agent 的执行流程
  3. 部署到生产 --- Docker 沙箱、LangGraph Platform、监控告警
  4. 多 Agent 协作 --- 多个 Agent 协同完成复杂任务

从"会算数"到"会操作电脑"到"有记忆有知识"到"会带团队"再到"会请示汇报"------Agent 从一个单兵作战的工具,变成了一个安全、可控、可审计的智能助手。

Human-in-the-loop 不是限制 Agent 的能力,而是让 Agent 在人类的监督下更安全地发挥作用。


附录:完整代码

src/agents.ts(关键部分)

typescript 复制代码
export const agent = createDeepAgent({
  model,
  tools: [calculatorTool, getCurrentTimeTool],
  backend,
  memory: ['/AGENTS.md'],
  skills: ['/skills/'],
  subagents,
  interruptOn: {
    execute: {
      allowedDecisions: ['approve', 'reject'] as const,
      description: '⚠️ Shell 命令即将执行,请确认后审批',
    },
    write_file: {
      allowedDecisions: ['approve', 'edit', 'reject'] as const,
      description: '📝 文件写入操作,请审批',
    },
  },
  systemPrompt: '...',
  checkpointer: new MemorySaver()
});

src/index.ts(关键部分)

typescript 复制代码
import { Command, isInterrupted, INTERRUPT } from '@langchain/langgraph';
import type { HITLRequest, HITLResponse } from 'langchain';

async function handleInterrupt(result: any, config: any): Promise<any | null> {
  if (!isInterrupted(result)) {
    return null;
  }

  const interruptData = result[INTERRUPT];
  const interruptValue = interruptData?.[0]?.value;
  const hitlRequest = interruptValue as HITLRequest;
  const actionRequests = hitlRequest.actionRequests || [];
  const reviewConfigs = hitlRequest.reviewConfigs || [];

  // 展示待审批的操作
  console.log('\n🛑 Agent 执行被中断,需要人工审批\n');
  
  // 使用 process.stdin.once 读取用户输入
  const readLine = (prompt: string): Promise<string> => {
    return new Promise<string>((resolve) => {
      process.stdout.write(prompt);
      process.stdin.once('data', (data) => {
        resolve(data.toString().trim());
      });
    });
  };

  process.stdin.resume();
  process.stdin.setEncoding('utf8');

  // 等待用户决策
  const decisions: HITLResponse['decisions'] = [];
  for (const action of actionRequests) {
    const answer = await readLine('请决策 [y/n/e]: ');
    // 解析决策...
  }

  process.stdin.pause();

  // 用 Command 恢复执行
  const hitlResponse: HITLResponse = { decisions };
  const resumeResult = await agent.invoke(
    new Command({ resume: hitlResponse }),
    config
  );

  // 递归处理多个中断
  const nextInterrupt = await handleInterrupt(resumeResult, config);
  return nextInterrupt ?? resumeResult;
}

async function runWithInterruptHandling(userMessage: string) {
  const result = await agent.invoke(
    { messages: [{ role: 'user', content: userMessage }] },
    config
  );

  const afterInterrupt = await handleInterrupt(result, config);
  const finalResult = afterInterrupt || result;

  // 提取并展示 AI 的最后一条消息
  const messages = finalResult.messages || [];
  const lastAiMessage = [...messages].reverse().find(
    (m: any) => m._getType?.() === 'ai' && m.content
  );

  if (lastAiMessage) {
    if (typeof lastAiMessage.content === 'string') {
      process.stdout.write(lastAiMessage.content);
    } else if (Array.isArray(lastAiMessage.content)) {
      for (const block of lastAiMessage.content) {
        if (block.type === 'text' && block.text) {
          process.stdout.write(block.text);
        }
      }
    }
  }
}

下一篇文章,我们将探索自定义 Middleware,让 Agent 的行为更加灵活和可扩展。

相关推荐
YFJ_mily1 小时前
**Python 实战:写一个论文 PDF 投稿自检工具|附 IPAT 2026 智能光子学会议征稿信息
人工智能·python·pdf·量子计算·论文投稿·智能光子学
科技发布1 小时前
广告投放首选:传播易、朝闻通、拓氪科技三大营销平台
人工智能·科技
孟郎郎1 小时前
AI 辅助开发编程敏感信息保护安全规范
人工智能·安全·ai·风险·隐患
财迅通Ai1 小时前
南华期货半年净利预期高增,稀缺自主清算壁垒驱动跨境业务规模放量
大数据·人工智能·区块链·南华期货
今夕资源网1 小时前
AI声音克隆软件 CosyVoice今夕一键整合包解压即用 阿里巴巴通义实验室开源 github斩获22K星标
人工智能·github·多国语言·声音克隆·零样本语音克隆·感情·ai语音克隆
CIO_Alliance1 小时前
iPaaS概念原理(2)| iPaaS的架构模型是怎样的?
人工智能·低代码·架构·ipaas·系统集成
品尚公益团队2 小时前
C#在asp.net网页开发中的带cookie登录实现
前端·c#·asp.net
Tom·Ge2 小时前
【Spring Boot 4】Spring Boot 4.0 AI Agent实战:Skills Agent + MCP协议集成
人工智能·spring boot·microsoft
风途科技~2 小时前
手持式雷达测速仪:不限道路厂区,可设置限速阈值实时监测车辆速度
大数据·人工智能