人机协作与中断恢复:让 Agent 学会"请示汇报"
前四篇文章,我们让 Agent 学会了算数、操作文件、记忆知识、委派任务。 但有一个关键问题始终悬而未决:Agent 真的可以随意执行危险操作吗?
想象一下:你说"帮我清理一下临时文件",Agent 理解成
rm -rf /------ 这不是段子,是真实会发生的事。这一篇,我们要给 Agent 装上"刹车系统":关键操作前暂停,等人类确认后再继续。 这就是 Human-in-the-loop(人机协作)的核心思想。
目录
- [回顾:前四篇文章结束时 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")
- [为什么需要 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")
- interruptOn:声明哪些工具需要审批
- 中断处理:检测、展示、等待、恢复
- 三种决策:批准、编辑和拒绝
- [Event Streaming:实时事件流(Beta)](#Event Streaming:实时事件流(Beta) "#6-event-streaming%E5%AE%9E%E6%97%B6%E4%BA%8B%E4%BB%B6%E6%B5%81beta")
- SSE:流式输出的底层协议
- 三个新测试:验证人机协作能力
- 回顾与展望
1. 回顾:前四篇文章结束时 Agent 能做什么?
| 阶段 | 能力 | 新增工具/机制 | 文章链接 |
|---|---|---|---|
| 第一篇 | 计算器、报时、流式输出、多轮对话、Extended Thinking | calculator、get_current_time |
langChain + TypeScript 实战 |
| 第二篇 | 文件读写、搜索、执行 shell 命令 | ls、read_file、write_file、edit_file、glob、grep、execute |
LocalShellBackend 与命令执行 |
| 第三篇 | 持久记忆、按需加载领域知识、长对话自动压缩 | Memory (AGENTS.md)、Skills (SKILL.md)、Summarization |
Memory Skills 与上下文工程 |
| 第四篇 | 任务分解、子 Agent 委派、并行执行 | task、write_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/*") ← 等等,这删的是系统临时目录!
↓
[系统] 💥 崩溃
问题在哪?
- Agent 没有"危险操作"的概念 ------ 它只知道"用户让我清理文件",不知道
rm -rf /tmp/*会删掉系统的临时文件 - 没有审批机制 ------ Agent 决定做什么就做什么,没有人在关键时刻说"等等,这不对"
- 不可逆操作 ------ 文件删除后无法恢复,错误一旦发生就来不及了
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)使用,因为:
- 中断时保存状态 --- Agent 执行到一半被中断,checkpointer 保存当前的对话历史和工具调用状态
- 恢复时加载状态 --- 用户审批后,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!" 命令');
预期行为:
- Agent 判断需要执行 shell 命令
- 调用
execute工具前,触发中断 - 终端显示待审批的操作详情
- 等待用户输入 y/n/e
- 用户批准后,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!"');
预期行为:
- Agent 决定调用
write_file工具 - 触发中断,等待审批
- 用户可以选择:
- 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 "计算完成"');
预期行为:
- Agent 先调用
calculator工具 --- 自动执行,不打断(calculator 没配置 interruptOn) - Agent 再调用
execute工具 --- 触发中断,等待审批 - 用户批准后,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检测到文件变更,立刻杀掉当前进程并重新启动,导致:
- 用户刚输入的审批决策丢失
- Agent 的执行状态被重置
- 整个流程从头开始,陷入"写入 → 重启 → 再写入 → 再重启"的死循环
为什么 →
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 不能与 LocalShellBackend 的 execute 能力共存 ,因为 shell 命令可以绕过路径限制(如 cat /private/secret.txt)。
因此,我们在 src/subagents.ts 中注释掉了 reviewer 的 permissions 配置。如果需要真正的权限隔离,应该使用容器沙箱(如 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 最佳实践
-
分级审批 --- 不同危险程度的操作,配置不同的审批策略
- 读操作 --- 自动放行
- 写操作 --- 需要审批
- 删除操作 --- 需要二次确认
-
动态描述 --- 根据操作内容生成审批提示
- "即将删除文件 /private/secret.txt,是否批准?"
- "即将发送邮件给 user@example.com,是否批准?"
-
审计日志 --- 记录每次审批的决策、时间、操作人
-
超时机制 --- 如果用户长时间未审批,自动拒绝或通知
-
批量审批 --- 多个操作可以一次性审批,减少打断次数
9.5 后续可以做什么
到目前为止,我们已经构建了一个功能完整的 Agent 系统:
- ✅ 基础工具调用(计算器、报时)
- ✅ 文件系统操作(读写、搜索、执行命令)
- ✅ 持久记忆与领域知识
- ✅ 任务分解与子 Agent 委派
- ✅ 人机协作与中断恢复
接下来可以探索的方向:
- 自定义 Middleware --- 扩展 Agent 的行为(如日志、监控、自动修复)
- LangGraph Studio --- 可视化调试 Agent 的执行流程
- 部署到生产 --- Docker 沙箱、LangGraph Platform、监控告警
- 多 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 的行为更加灵活和可扩展。