Harness Engineering
第17章 Human-in-the-Loop:人机协作的工程设计
本章要点
- 理解"自动化频谱":全手动 → AI 辅助 → AI 主导+审批 → AI 主导+通知 → 全自动,大多数 Agent 应该位于 Level 4-6(AI 主导 + 审批)
- 掌握风险驱动的介入决策:操作越不可逆、越影响共享状态、越跨越信任边界,越要人类把关
- 深入三种审批模式:同步阻塞(简单)、批量确认(效率)、异步恢复(无人值守场景)
- 读懂 LangGraph interrupt 的 checkpoint + resume 机制:等待人类审批不占用服务器资源
- 对比 Claude Code 的权限模式:六种用户可选模式(Plan / Default / Auto-Edit / Full Auto / dontAsk / auto)+ 一种内部模式(bubble)
- 学会渐进式信任:如何让系统支持用户从"每步问"进化到"只问关键节点"
- 识别反馈闭环的两大类:显式反馈(用户直接纠正)和隐式反馈(撤销行为、后续操作模式)
- 掌握中断成本最小化的五种手法:批处理、预览、合并确认、记忆偏好、delayed batching
- 构建信任三支柱:透明(explain why)+ 可预测(稳定行为)+ 可逆(undo 机制)
- 避开 HITL 的七类反模式:过度询问、询问缺信息、默认选项危险、中断不可恢复、审批无依据、把 HITL 当免责声明、审批疲劳累积
17.1 为什么 HITL 是 Agent 产品的胜负手
本节不引用"某研究表明产出提升 X%"这类数字——公开材料里这类结论的任务类型、样本与统计口径差异极大,拿来支撑一个工程判断并不可靠。这里给的是一个结构性论证,你可以在自己的场景里验证它:
| 用户类型 | 使用方式 | 产出速度的瓶颈 | 错误进入主干的拦截点 |
|---|---|---|---|
| 纯手动 | 不用 AI 工具 | 人的思考与打字速度 | 每一行都经过人 |
| 全自动 Agent | 让 Agent 全权代劳 | 模型吞吐 | 无 |
| AI 协作 | HITL 模式,关键节点人工介入 | 模型吞吐 + 审批等待 | 不可逆动作前有人把关 |
结论:全自动省掉的恰恰是审阅环节,而审阅是"错误不进主干"的关键保障;HITL 保留审阅、把重复劳动交给 Agent,代价只是若干个审批点上的等待。
原因不难理解:
- 纯手动 → 瓶颈在人类打字速度
- 全自动 → Agent 高速产出 garbage,人类没机会拦截
- HITL → 人类在关键节点把关,Agent 负责重复劳动——双方发挥各自优势
所以 Claude Code、Cursor Agent、Devin、GitHub Copilot coding agent 的默认流程里都保留了人的审阅(逐条审批、PR 审阅,或 Claude Code auto 模式下的事后监督),而不是无人把关的全自动。人机协作不是"低配版全自动",而是更高的生产力形态。
本章讲清楚怎么工程化地实现 HITL。
17.2 自动化频谱:你的 Agent 该在哪里
Agent 不是要么全自动要么全手动——它是一个频谱。根据 1978 年 Sheridan & Verplank 提出的 10 级自动化分类,应用到 Agent 场景上:
graph LR
L1[Level 1<br/>全手动<br/>人做一切]
L2[Level 2-3<br/>AI 辅助<br/>AI 建议/补全<br/>人决策]
L3[Level 4-6<br/>AI 主导+审批<br/>AI 执行<br/>人在关键节点审批]
L4[Level 7-9<br/>AI 主导+通知<br/>AI 全自主<br/>人事后知会]
L5[Level 10<br/>全自动<br/>AI 独立<br/>无人类参与]
L1 --> L2 --> L3 --> L4 --> L5
Note1[GitHub Copilot<br/>IDE 搜索建议]
Note2[Claude Code<br/>Cursor Agent<br/>本专栏建议范围]
Note3[Devin 某些模式<br/>CI 里的小任务]
Note4[定时 cron<br/>数据管道]
L2 -.-> Note1
L3 -.-> Note2
L4 -.-> Note3
L5 -.-> Note4
style L3 fill:#10b981,color:#333,stroke:none
style L2 fill:#f59e0b,color:#333,stroke:none
style L4 fill:#2563eb,color:#fff,stroke:none
style L1 fill:#94a3b8,color:#333,stroke:none
style L5 fill:#dc2626,color:#fff,stroke:none
大多数 Agent 应该落在 Level 4-6——AI 做事、人把关。原因:
- Level 1 低估了 AI 的价值——现在模型已经能稳定做大量重复劳动
- Level 10 高估了 AI 的能力——幻觉 / prompt injection / 需求理解偏差 在生产环境必然发生
- Level 5 是 Goldilocks zone——AI 做完大部分工作,人类只在少数关键决策点介入
选 Level 的考量:
| 考量 | 选更高 level | 选更低 level |
|---|---|---|
| 操作可逆性 | 全可逆(比如代码,有 git) | 不可逆(发邮件、扣款、删数据库) |
| 错误代价 | 低(重跑就好) | 高(公关事故、资金损失) |
| 用户专业度 | 低(用户不懂不会查) | 高(用户能判断 AI 输出) |
| 时间敏感度 | 高(来不及等审批) | 低(可以等几分钟) |
| 合规要求 | 无 | 有(金融、医疗必须人工) |
Claude Code 默认落在 Level 4-6(Default Mode),支持用户调到 Level 3(Plan Mode)、限制更少的 Auto-Edit Mode,甚至真正的 Level 10(bypassPermissions)——但 Level 10 从来不是默认:它需要用户显式开启并确认一次性警告,因为代码任务里"无人把关的全自动"风险大于收益,只适合隔离环境里的无人值守场景。(以上是 2.1.88 快照时的默认。据官方 permission-modes 文档(2026-09-25 核对),今天 Pro / Max / Team 计划的内置起始模式已改为 auto:本该弹窗的操作改由分类器代人审批,人从逐条确认退到事后监督与规则配置。)
17.3 何时需要介入:风险驱动的决策矩阵
不是每个操作都需要人类审批。决定何时介入的核心依据是风险 + 可逆性。
17.3.1 三维风险评估
graph TB
subgraph "风险维度一:可逆性"
A1[完全可逆<br/>创建文件<br/>git branch]
A2[有代价可逆<br/>覆盖文件<br/>可 git revert]
A3[难以回滚<br/>rm -rf<br/>git push --force]
A4[不可逆<br/>发邮件<br/>扣款<br/>API 副作用]
end
subgraph "风险维度二:影响范围"
B1[个人 sandbox]
B2[项目本地]
B3[共享仓库]
B4[生产环境]
end
subgraph "风险维度三:信任边界"
C1[全本地]
C2[局域网]
C3[外部 API]
C4[用户数据]
end
A1 & B1 & C1 --> NoApproval[✅ 自动执行]
A3 --> MustApprove[❌ 必须审批]
A4 --> MustApprove
B4 --> MustApprove
C4 --> MustApprove
style NoApproval fill:#10b981,color:#333,stroke:none
style MustApprove fill:#dc2626,color:#fff,stroke:none
17.3.2 决策矩阵
把三维融合成一个简单决策矩阵:
| 操作类型 | 典型示例 | 默认策略 |
|---|---|---|
| 只读 | Read / Grep / Glob / git status | 永远自动执行 |
| 创建 | Write new file / mkdir | 自动,有 undo 提示 |
| 修改 | Edit(早期另有 MultiEdit,2.1.89 已不作为工具注册) | 默认审批;信任高后自动 |
| 本地破坏 | rm file / git branch -D | 审批 |
| Shell 命令 | 按命令白名单 | 安全命令自动;其他审批 |
| 远程/副作用 | git push / send email / API POST | 永远审批(任何信任级别) |
| 生产操作 | deploy / drop table | 审批 + 二次确认 |
| 跨信任边界 | 操作他人账户 / 发给外部 | 禁止(除非明确授权) |
"永远审批"这一行特别重要——不是因为新用户不信任,而是操作本身不应该在 Agent 手里独立执行。即使是老用户使用了几年也应该问一下"确认把 main 分支推上去吗?"——这不是不信任 Agent,是在工程上给人保留一个"最后一道门"。
17.3.3 Shell 命令的白名单设计
Bash 这类通用工具特别棘手——一个工具能执行无限多种操作。Claude Code 的思路是按命令分类。下面用一段教学示意代码还原这个思路(注意:这不是 Claude Code 的真实实现——实际由 17.3.4 讲的 permissions 规则 + 内置危险命令识别完成):
// Safe commands(自动允许)
const SAFE_COMMANDS = [
'npm test', 'npm run test', 'npm install',
'git status', 'git diff', 'git log', 'git branch',
'ls', 'cat', 'wc', 'grep', 'find',
'pwd', 'echo', 'date',
]
// Risky commands(默认审批)
const RISKY_COMMANDS = [
'git push', 'git pull', 'git commit',
'git reset', 'git rebase',
'curl', 'wget',
'docker run', 'kubectl apply',
]
// Dangerous commands(强制审批+warning)
const DANGEROUS_PATTERNS = [
/rm\s+-rf/,
/chmod\s+777/,
/curl.*\|\s*(bash|sh)/,
/>\s*\/dev\/(sda|nvme)/,
/dd\s+if=/,
]
function classifyCommand(cmd: string): 'safe' | 'risky' | 'dangerous' {
const normalized = cmd.trim()
if (DANGEROUS_PATTERNS.some(p => p.test(normalized))) return 'dangerous'
// 必须匹配完整命令或其开头(例如 "git status" 是 safe,但 "git push" 不是)
if (SAFE_COMMANDS.some(s => normalized === s || normalized.startsWith(s + ' '))) return 'safe'
if (RISKY_COMMANDS.some(s => normalized === s || normalized.startsWith(s + ' '))) return 'risky'
return 'risky'
}
这让 npm test 可以自动跑,rm -rf / 却一定要经过确认——即使 Agent 被 prompt injection 骗了,也不会直接把系统搞挂。
17.3.4 settings.json 权限配置的源码级还原
Claude Code 本身的权限不是硬编码在前端——它读取 ~/.claude/settings.json 和 project 级 .claude/settings.json 两层合并(本地-only 覆盖可放在 .claude/settings.local.json)。下面是一份典型的用户级配置节选(// 注释只为讲解,写进真实文件前请删去):
{
"permissions": {
"allow": [
"Bash(git:*)", // 允许任意 git 子命令
"Bash(pnpm:*)",
"Bash(rm:*)", // 注意:这里允许了 rm——取决于用户风险偏好
"Read", "Write", "Edit", "Glob", "Grep"
],
"defaultMode": "auto"
},
"skipDangerousModePermissionPrompt": true
}
几点工程细节值得记录——
Bash(cmd:*)语法:冒号前是命令前缀,:*表示其后可跟任意参数(按词边界匹配,今天的官方文档里末尾写*与之等价);所以Bash(git:*)放行任意git子命令、而Bash(git status:*)只放行git status本身及其带参数的形式。这是 Claude Code 权限 DSL 的内置匹配规则、不是 shell glob。defaultMode可选值:default/acceptEdits/plan/bypassPermissions/dontAsk/auto——其中auto是较新的一档,由分类器代替人工审批;dontAsk则更激进——凡是没被规则预先允许的请求直接自动拒绝、而不是弹窗询问;它们与 17.5.1 讲的那条主线共用同一套底层实现(完整的七种模式见第14章 §14.2)。skipDangerousModePermissionPrompt一旦设为true——就是用户主动把"你确定要启用 YOLO 模式吗"那一次 meta 确认永久跳过——它不会放宽任何单条权限、只是少掉一次初始化时的确认。这是官方对"不可逆升级"的工程保险:单次授权 vs 永久授权分开两个决策。- project 级覆盖:
.claude/settings.json(或本地-only 的.claude/settings.local.json)优先于~/.claude/settings.json,但deny永远覆盖allow(规则按 deny → ask → allow 的顺序判定,与所在层级无关)——即使 user settings 允许了rm:*,project settings 里一个deny: ["Bash(rm -rf:*)"]也能挡住。这是双向白名单 / 黑名单的经典设计——禁止永远强于允许。
项目里把危险命令放进 deny 比指望用户审批点得仔细更有效——工程上要用"事前约束"代替"事后审批",人类注意力是有限资源。
17.4 三种审批模式的工程实现
17.4.1 模式一:同步阻塞审批
最直观的模式:Agent 执行到审批点 → 停 → 人类点击 → 继续。
async function executeWithApproval(action: Action): Promise<Result> {
if (action.needsApproval) {
const approval = await ui.requestApproval({
title: '需要确认',
description: summarizeAction(action),
actions: ['Approve', 'Deny', 'Modify'],
})
if (approval.decision === 'Deny') {
throw new UserRejectedError(approval.reason)
}
if (approval.decision === 'Modify') {
action = approval.modifiedAction
}
}
return await action.execute()
}
适用场景:IDE 集成、CLI 工具——有人类坐在前面。
缺点:如果某一步耗时很长(比如跑 30 分钟测试),期间 Agent 完全卡住。
17.4.2 模式二:批量确认
相关操作打包成一个审批单元:
// ❌ 粒度过细
for (const file of filesToEdit) {
await approve(`Edit ${file}?`) // 8 次询问
await editFile(file)
}
// ✅ 批量确认
const plan = filesToEdit.map(f => ({ file: f, changes: planChangesFor(f) }))
const approval = await approve({
title: '批量修改',
summary: `${filesToEdit.length} 个文件的 JWT 集成`,
plan: plan,
options: ['全部批准', '逐个审核', '取消'],
})
if (approval === '全部批准') {
await Promise.all(plan.map(p => applyChanges(p)))
} else if (approval === '逐个审核') {
for (const p of plan) await approveAndApply(p)
}
适用场景:一次性要做的多个相关操作(批量 refactor、多文件 rename)。
关键:审批单元要能让人理解意图——不是"修改 8 个文件",而是"用 JWT 替代 session 认证(涉及 8 个文件)"。后者传达了为什么。
17.4.3 模式三:异步审批(LangGraph interrupt)
真正 production-grade 的模式——Agent 可以"暂停等人",期间不占用服务器资源。
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver # 需另装 langgraph-checkpoint-sqlite
from langgraph.types import interrupt, Command
class State(TypedDict, total=False):
task: str
plan: str
approved: bool
result: str
# analyze_task / execute_plan 是业务占位函数,需自行实现
def build_agent(checkpointer):
graph = StateGraph(State)
def analyze(state):
# 做分析工作
plan = analyze_task(state['task'])
return {'plan': plan}
def approval_gate(state):
# 触发人类审批
decision = interrupt({
'type': 'approval_request',
'plan': state['plan'],
'message': '准备执行以下操作,请审批',
})
return {'approved': decision['approved']}
def execute(state):
if not state['approved']:
return {'result': 'cancelled'}
result = execute_plan(state['plan'])
return {'result': result}
graph.add_node('analyze', analyze)
graph.add_node('approval_gate', approval_gate)
graph.add_node('execute', execute)
graph.add_edge(START, 'analyze')
graph.add_edge('analyze', 'approval_gate')
graph.add_edge('approval_gate', 'execute')
graph.add_edge('execute', END)
return graph.compile(checkpointer=checkpointer)
# 第一次执行:跑到 interrupt 就停,state 自动 checkpoint
# 注意:from_conn_string 返回的是 context manager,要用 with 打开
with SqliteSaver.from_conn_string('./checkpoints.db') as checkpointer:
agent = build_agent(checkpointer)
thread_id = 'task-123'
result = agent.invoke(
{'task': '集成 JWT 认证'},
config={'configurable': {'thread_id': thread_id}},
)
# result 里有 __interrupt__,告诉前端需要审批
当 interrupt() 被调用:
- 当前 state 自动保存到 checkpointer(SQLite/Postgres/Redis)
- 执行返回,Agent 进程可以服务其他请求
- 前端显示审批 UI
- 用户审批后,前端调:
# 用户审批通过(同样在 with SqliteSaver.from_conn_string(...) 作用域内)
result = agent.invoke(
Command(resume={'approved': True}),
config={'configurable': {'thread_id': thread_id}},
)
# LangGraph 自动从 checkpoint 加载 state,从 interrupt 点继续执行
这是 production HITL 的基石。审批可能发生在 2 秒后也可能在 2 天后——Agent 不需要"挂着"。这也是 LangGraph 对 LangChain 最大的架构跃迁之一(会话状态如何持久化,详见本专栏第13章《多轮对话与会话状态机》)。
一个被官方文档放在显眼位置的"重新执行"陷阱
langgraph/types.py 中 interrupt() 函数的 docstring 原文(langgraph 1.1.6)——
The graph resumes from the start of the node, re-executing all logic.
也就是——resume 不是从 interrupt() 那一行接着跑、是从整个 node 函数的第一行重跑一遍。这意味着上面 approval_gate 函数里 interrupt() 之前的任何有副作用的代码(写 DB、发邮件、扣钱)都会再被执行一次。这是新手最常踩的坑。
两个工程上的应对——
- 写任何有副作用的代码都放在单独的 node 里、不要和
interrupt()同 node——一个 node 一件事、resume 重跑没事 - 同一个 node 里有多个
interrupt()——LangGraph 按调用顺序匹配 resume 值(types.py 注释:"matches resume values to interrupts based on their order in the node")——所以顺序不能在 resume 之间变
langgraph/types.py 中的 Command 类有 4 个字段(实测)——graph / update / resume / goto——resume 既可以是单个值(交给下一个待恢复的 interrupt)也可以是 dict[str, Any](按 interrupt 的 id 逐一映射到各自的 resume 值);Interrupt 在 v0.6.0 之后只剩 2 个字段——value 和 id(xxh3_128_hexdigest 哈希)——之前的 ns / when / resumable 已在 v0.6.0 移除,interrupt_id 只保留为已弃用的别名(请改用 id)。
17.4.4 三种模式的适用场景
graph TB
Q[你的 Agent 产品形态?]
Q --> CLI{CLI / IDE 集成?}
CLI -->|是, 人在前面| Sync[模式一:同步阻塞]
CLI -->|否| Q2{长时任务?}
Q2 -->|否, 都是短操作| Bulk[模式二:批量确认]
Q2 -->|是, 可能几十分钟| Async[模式三:异步审批<br/>推荐 LangGraph interrupt]
Example1[Claude Code CLI<br/>Cursor Agent]
Example2[Batch refactor 工具]
Example3[CI 里的 deploy agent<br/>客服工单系统]
Sync -.-> Example1
Bulk -.-> Example2
Async -.-> Example3
style Sync fill:#2563eb,color:#fff,stroke:none
style Bulk fill:#f59e0b,color:#333,stroke:none
style Async fill:#10b981,color:#333,stroke:none
17.4.5 Hooks:比审批更早的拦截点
Claude Code 的另一个 HITL 机制是 hooks——在工具调用前后等生命周期节点运行 shell 命令,PreToolUse 的脚本可以拒绝或改写请求。settings.json 里的 hook 配置是"事件 → matcher 组 → hooks 数组"三层嵌套,形如(macOS 示例):
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification ...' && afplay ..."
}
]
}
],
"Notification": [ ... ]
}
常用 hook 事件(完整列表以官方 hooks 文档 https://code.claude.com/docs/en/hooks 为准,2026-09-25 核对;其中还有专门拦截权限对话框的 PermissionRequest):
| 事件 | 触发时机 | 常见用法 |
|---|---|---|
PreToolUse |
任何工具调用前 | 权限二次校验 / 审计日志 |
PostToolUse |
工具调用后 | 格式化代码 / 触发测试 |
UserPromptSubmit |
用户消息进入前 | 注入项目 context / 检测 secrets |
Stop |
Agent 结束一轮 | 发通知 / 自动部署 |
Notification |
需要用户输入时 | 系统通知 / 震动 |
SubagentStop |
子 agent 结束 | 汇总结果 |
PreCompact |
压缩上下文前 | 备份对话 |
SessionStart / SessionEnd |
会话生命周期 | 加载记忆 / 清理 |
PreToolUse 的 hook 脚本用退出码 2 表示阻断——Claude Code 会拦下这次工具调用、并把脚本写到 stderr 的自然语言原因反馈给模型、让它换一条路(其他非 0 退出码是"非阻断错误"、只展示给用户,不会拦住工具调用)。这等价于一个"可编程的审批层"——把"需要人审批"替换成"自动化脚本判断"。举例:
#!/bin/bash
# PreToolUse hook for Bash
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command')
if echo "$cmd" | grep -qE 'git push.*--force'; then
echo "禁止 force push——请用 --force-with-lease" >&2
exit 2 # 退出码 2 = 阻断,stderr 反馈给模型
fi
exit 0
hook 的哲学和 HITL 一致——把"人看不过来的确认"自动化成规则,把"规则无法覆盖的判断"保留给人。规则越明确、人的负担越轻。
17.5 渐进式信任:从"每步问"到"只问大事"
新用户不信任 Agent,应该每步都问;老用户已经验证了 Agent 的可靠性,应该只问关键节点。
17.5.1 Claude Code 的权限模式阶梯
Plan Mode → Default Mode → Auto-Edit Mode → Full Auto Mode
(限制最多) (限制最少)
这条阶梯是六种用户可选模式里的四种主线;dontAsk 不在阶梯上——它把"未预授权"直接判为拒绝而非询问;auto 也不在阶梯上——它用分类器代替人工审批。另有内部模式 bubble 不对用户开放配置。完整的七种模式见第14章 §14.2。
这四档的行为差异:
| 操作 | Plan | Default | Auto-Edit | Full Auto |
|---|---|---|---|---|
| Read | ✓ 自动 | ✓ 自动 | ✓ 自动 | ✓ 自动 |
| Edit | ❌ 禁止 | ⚠️ 审批 | ✓ 自动 | ✓ 自动 |
| Write | ❌ 禁止 | ⚠️ 审批 | ✓ 自动 | ✓ 自动 |
| Bash (safe) | ✓ 只读命令可跑 | ✓ 自动 | ✓ 自动 | ✓ 自动 |
| Bash (risky) | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ✓ 自动 |
| Bash (dangerous) | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ✓ 自动 |
| git push | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ✓ 自动 |
表中 Plan 与 Auto-Edit 两列是简化:Plan 模式允许为探索而跑只读命令;Auto-Edit(acceptEdits)除编辑文件外,还会自动放行工作目录内的 mkdir / touch / rm / mv / cp / sed 等常见文件系统命令(见第14章)。
注意两件事:
- Full Auto(
bypassPermissions)是真正的 Level 10——除 deny 规则、显式 ask 规则等少数例外,权限提示都被跳过、dangerous 和 push 也不例外。正因如此它默认不启用:首次开启时有一次性红色警告(就是 17.3.4 讲的 YOLO meta 确认),官方也建议只在隔离环境(容器、一次性 sandbox)里用。"不可逆操作永远审批"是前三档共同守住的底线——选了bypassPermissions,等于用户亲手关掉了这道门、并为此承担全部后果。 - 模式切换权在用户。Agent 自己不能"我已经证明自己可靠了,请升级我"——必须由用户显式动手:2.1.89 里切档走的是 Shift+Tab 循环切换(模式名在配置里写作
acceptEdits/bypassPermissions),细粒度的允许/拒绝规则则归/permissions管;没有/auto-edit这样一条命令,把包解开搜这个字符串是零命中。
17.5.2 自动累积信任的陷阱
有些 Agent 尝试自动学习"用户总是批准这类操作 → 下次自动执行"。这个设计有坑:
- Prompt injection 可以故意诱导用户批准危险操作几次,之后再攻击
- 用户状态会变——有时候累、有时候分心——不应该基于早期数据永久降低门槛
- 没有可见的"我现在到了哪一档"信号,用户失去控制感
更好的做法是让 Agent 记忆偏好但不自动降级——看到相似操作可以说"上次类似情况你批准了 X,这次要一样处理吗?"。决定权仍在用户。
17.5.3 "这次/一直/取消" 的三选项模式
每个审批 prompt 应该给三个选项,而不是两个:
Agent 想要: rm -rf node_modules
[✓ 这次允许] [🔓 一直允许此命令] [✗ 拒绝]
- 这次允许:一次性,下次再问
- 一直允许:加入 session 级白名单
- 拒绝:不执行,Agent 需要换方案
给 "一直允许" 但限定 session 级别——关闭进程就重置。这比"永久允许"更安全,也比"每次都问"更贴心。
17.6 反馈闭环:让系统变好的燃料
人类的每一次纠正都是 Agent 变好的信号。工程化地收集、存储、利用这些信号。
17.6.1 两类反馈
显式反馈:用户直接告诉 Agent "不对/错了"
- thumbs up/down
- "不对,应该..."
- 删除 Agent 输出重新生成
- 切换到其他 Agent
隐式反馈:用户行为里藏着的信号
git checkout撤回 Agent 的修改 → 强烈负向- 直接
git commit && git push→ 强烈正向 - 看完 Agent 回复后关闭对话 → 可能不满意
- 看完后继续追问同一问题 → 上一次答案不够好
17.6.2 反馈 → 记忆的链路
graph LR
User[用户行为/话] --> Collect[反馈收集器]
Collect --> Classify{分类}
Classify -->|修正代码风格| Memory1[代码风格偏好]
Classify -->|拒绝某操作| Memory2[操作黑名单]
Classify -->|喜欢某方案| Memory3[方案偏好]
Memory1 & Memory2 & Memory3 --> Store[长期记忆库]
Store --> NextSession[下次对话自动加载]
style Collect fill:#2563eb,color:#fff,stroke:none
style Store fill:#10b981,color:#333,stroke:none
Claude Code 把这类偏好沉淀到 CLAUDE.md(项目/用户级记忆文件)或 auto-memory(MEMORY.md)里,下次对话作为 context 注入。机制细节见本专栏第12章《长期记忆:持久化与检索》。
17.6.3 反馈收集的最佳实践
原则 1:降低反馈门槛。不要弹 questionnaire——就一个 thumbs down + 可选"原因"框。
原则 2:立即展示效果。用户标"不好"之后 Agent 说"我记下了,下次我会..."——让用户感到反馈有用,下次才愿意继续反馈。
原则 3:双向确认。Agent 主动说"我猜你更喜欢 A,对吗?"——让用户确认偏好,避免 Agent "自作主张"。
17.7 中断成本最小化:五种手法
每次人类介入都付出成本——打断心流、上下文切换、等待延迟。优秀的 HITL 设计最小化不必要的中断。
17.7.1 手法一:批处理中断
把多个独立的小确认合并成一个大确认(前面 17.4.2 讲过)。
17.7.2 手法二:预览代替确认
让用户看到"要做什么" + 一键撤销,代替"做之前先问":
[Agent 已经执行] 修改了 src/auth.ts(+23 -18)
✓ Keep ← 保留修改
✗ Undo ← 一键撤销
✏ Edit ← 我再改
这比"我要修改 src/auth.ts,批准吗?"更好——用户能看到结果再决定,信息更完整。只对可完全 undo 的操作用这种模式。
17.7.3 手法三:合并相似确认
❌ 差的设计
[1/5] 运行 npm test? Y
[2/5] 运行 npm test? Y
[3/5] 运行 npm test? Y
[4/5] 运行 npm test? Y
[5/5] 运行 npm test? Y
✅ 好的设计
5 次相同的 npm test。
[ ] 每次都问
[✓] 这个 session 都允许
[ ] 永久允许
让用户一次选择,多次生效。
17.7.4 手法四:充分信息的确认 prompt
一个好的审批 prompt 要让用户 3 秒内做出决策。不足信息会让用户点 Cancel(safer default),或者强行点 OK(没看清)。
❌ 信息不足
"执行 npm install?" ← 装什么?为什么?
✅ 信息充分
准备执行: npm install @auth/core
目的: 添加 JWT 库以完成登录 bug 修复
影响: package.json 新增 1 行,node_modules 扩大 ~3 MB
可撤销: 是 (npm uninstall 或 git checkout)
[确认] [拒绝] [查看完整依赖树]
17.7.5 手法五:Delayed Batching
Agent 把多个小审批暂存,一次性呈现:
Agent 执行到一半,积累了 3 个待审批:
1. 修改 src/auth.ts
2. 修改 src/auth.test.ts
3. 运行 npm test
询问: "以上 3 步作为一个整体执行?[是/分别审核/取消]"
这比一步一确认高效很多,尤其适合 Agent 一开始就知道整体 plan 的情况。Claude Code 里,TodoWrite 工具把整体步骤预先摆给用户看,Plan Mode 则让用户一次批准整份计划——都是这类"预演 + 批量确认"思路。
17.7.6 中断成本的量化公式
前面讲了五种手法,但团队经常纠结"这个操作到底值不值得弹确认"。可以用一个量化模型做决策——
中断期望代价 C(interrupt) ≈ T_switch × P(user_busy) + T_wait × N(parallel_ops) + P(wrong_decision) × Cost_mistake
T_switch(上下文切换时间)——常被引用的 23 分 15 秒出自 UC Irvine 的 Gloria Mark 2006 年接受 Gallup 采访时的说法:被打断的工作平均隔这么久才被重新拾起(中间往往还插进了别的任务),它不是某篇论文的结论。工程估算不必照搬这个极端值——一次轻量确认(用户本来就在盯着屏幕)可以按秒级估,但打断深度编码的心流时、切换代价要按分钟级计P(user_busy)——用户此刻是否在心流中的概率,可以用"离键盘 idle 时间"近似T_wait × N(parallel_ops)——Agent 等审批期间,其他并行工作被阻塞的总时间P(wrong_decision) × Cost_mistake——用户在信息不足时做出错误决策的风险
反过来——不中断的代价 C(no_interrupt) ≈ P(mistake) × Cost_mistake × P(discovered_late)
两者比较——如果 C(interrupt) > C(no_interrupt)、就不要弹。对比一下典型场景:
| 场景 | P(mistake) | Cost_mistake | 可逆性 | 结论 |
|---|---|---|---|---|
| 读文件 | 几乎为零 | 0 | 完全可逆 | 永不弹 |
| 本地 edit | 偶有 | 小(可 git revert) | 可逆 | 预览代替确认 |
运行 npm test |
几乎为零 | 小 | 幂等 | 白名单 |
git push origin main |
低 | 中(需要 force push 修正) | 难逆 | 永远弹 |
| drop prod table | 极低 | 极大 | 不可逆 | 弹 + 二次确认 |
关键洞察——弹确认的边际成本不是固定的——同一个操作在不同语境下值不值得弹差异巨大——所以好的 HITL 设计是语境感知的:工作时间、用户是否离开、前一次审批距今多久、本次 session 错误率,这些信号都能影响下一次是否要弹。
17.7.7 Cursor / Aider / Cline 的对比
三个主流编码 Agent 在中断成本处理上的差异(以 2026-04 公开版本的文档为准,产品迭代快,细节以各自最新文档为准)——
| 能力 | Cursor Composer | Aider | Cline (VSCode) |
|---|---|---|---|
| 默认审批粒度 | 按 diff chunk | 不逐次审批编辑:直接应用并自动 commit | 按 tool call |
| 批量 diff 接受 | 一键 accept all | 无需接受,事后审阅 commit | 逐个 Y/N |
| 预览模式 | inline diff(直接在编辑器里) | 输出到 terminal | side-panel diff |
| 回滚机制 | VSCode 撤销栈 + git | 聊天内 /undo 命令(撤销上一次 AI commit) |
Checkpoints(独立影子 git 仓库里的工作区快照,可恢复) |
| session 白名单 | 设置里的命令自动运行白名单(.cursorrules 只是提示规则文件) |
.aider.conf.yml |
auto-approve 设置 |
| 异步 resume | 不支持 | 不支持 | 不支持 |
三个工具都没有 LangGraph 那样的真异步 resume——因为它们是前台 IDE 工具、假设用户一直坐在前面。这也说明产品形态决定 HITL 架构——IDE 选同步阻塞、工作流平台选异步 checkpoint、CI agent 选批量预报。不要照搬框架架构、要按场景选。
17.8 信任三支柱:透明 / 可预测 / 可逆
让用户愿意长期使用 HITL Agent,需要建立信任。信任来自三根支柱:
17.8.1 支柱一:透明(Explain why)
Agent 应该解释自己在做什么、为什么这样做:
❌ Agent: 我修改了 auth.ts
✅ Agent: 我修改了 auth.ts:
原因: 当前的 JWT 验证在 token 过期时直接拒绝,
但你报告"用户突然被登出"——我认为原因是过期 token
没有刷新机会。
方案: 验证失败时先检查 refresh_token,而非直接拒绝。
影响: 修改 auth.ts 第 45-68 行;不影响其他文件。
回滚: git checkout src/auth.ts 可以恢复。
"解释"是透明的最小单元。每一个决策都能追溯到"为什么",用户就不会怀疑 Agent "在背后搞什么鬼"。
17.8.2 支柱二:可预测(Consistent behavior)
相同的输入应该产生相似的行为。
- Agent 今天谨慎、明天激进 → 用户无法形成心智模型
- Agent 今天问、明天不问 → 用户不知道什么时候要准备审批
- Agent 今天用 A 方案、明天用 B 方案 → 用户觉得"它没有原则"
工程上保证可预测的做法:
- 固定审批阈值:每个操作类型有明确的"要不要审批"规则,不依赖模型判断
- System Prompt 里写清楚行为准则:用户阅读 SP 就能预期 Agent 会怎么做
- 变化有预告:升级版本 / 改变默认行为前明确告知
17.8.3 支柱三:可逆(Undo is sacred)
让用户有心理安全感——即使 Agent 做错了,也能回滚。
每次 Agent 操作后都显示:
✓ 已完成: <description>
↶ 如果要撤销: <具体命令 / 按钮>
Git 是最好的盟友:
// 每次敏感修改前自动 stash
async function safeEdit(file: string, changes: Edit) {
await exec('git stash push -m "before-agent-edit" -- ' + file)
try {
await applyEdit(file, changes)
console.log(`✓ Edited ${file}. To undo: git stash pop`)
} catch (e) {
await exec('git stash pop') // 失败自动恢复
throw e
}
}
对不支持 git 的资源(数据库、API 调用)——设计可逆的操作序列或提供 audit log。如果完全不可逆,就提高审批门槛(不只是审批,要打字确认某个词)。
17.8.4 三支柱在源码层的检查清单
三支柱不是抽象口号——每一根都可以落到代码检查项。下面给出一个实际可用的 review checklist——
透明支柱的检查项:
- 每一个工具调用的前端展示是否包含工具名 + 参数摘要 + 预期输出类型——缺一项都算"不够透明"
- Agent 的推理链路(thinking / plan / todo)是否对用户可见——至少可折叠展开、不要完全隐藏
- 失败时是否输出原因 + 下一步建议而不只是
Error——"报错"和"解释"是两件事 - 所有 prompt injection 防御机制(比如
<user_input>标签)是否在 UI 上清晰区分 AI 和用户内容
可预测支柱的检查项:
- 相同任务的前后 5 次执行是否产生同类行为——这需要在 CI 里跑 replay 测试、而不是人肉观察
- 权限模式切换时是否显式展示新模式的行为差异——不要让用户"不知道已经升级了"
- System Prompt 变更是否有 changelog——用户可以看到"为什么今天 Agent 行为变了"
- 温度参数(temperature)对关键决策路径是否固定为 0 或 低值——避免"同样输入不同输出"
可逆支柱的检查项:
- 所有写操作是否在 git 仓库内——不在 git 管的(DB/API)要另做 audit log
- 关键动作的 undo 命令是否在操作完成后立即展示给用户——事后才想起 undo 就晚了
- 长任务的 checkpoint 间隔是否 ≤ 5 分钟——更长就意味着"crash 后用户要重跑 5 分钟"
- 审批拒绝后 Agent 的 state 是否回到审批前——不能"拒绝以后留下半成品"
把这些检查项做成单元测试或 linter 规则,而不是只在设计评审里口头 check——HITL 工程化的最后一公里就是把原则代码化。
17.9 HITL 的七类反模式
总结生产中踩过的坑,避免下一次:
反模式 1:过度询问
每一步都问——用户很快疲劳、开始机械点 OK。
修正:只对值得问的操作问。只读操作永远不问,安全 Shell 不问,modify 操作视信任级别。
反模式 2:询问时缺关键信息
"执行这个命令?" ← 什么命令?为什么?
修正:审批 prompt 必须包含"做什么 + 为什么 + 影响 + 怎么回滚"四要素。
反模式 3:默认选项危险
"执行 rm -rf / ? [Y/n]" ← 默认 Y,按回车就炸了
修正:危险操作的默认应该是 否。甚至不应该提供快捷确认——要求打字"confirm"或类似词才执行。
反模式 4:中断后无法恢复
Agent 跑到一半 crash / 超时 / 用户关页面——所有 state 丢了。下次要从头开始。
修正:关键节点 checkpoint。LangGraph 的 interrupt() 自带这个,自研 agent 也要做类似设计。
反模式 5:审批无依据
"是否批准这个修改?" ← 但用户看不到修改了什么。
修正:审批 UI 必须展示完整 diff / 命令预览 / 操作影响分析。否则用户在盲审。
反模式 6:把 HITL 当成"免责声明"
有些团队上线 Agent 以后——每个操作都弹确认、美其名曰"HITL",实际是把责任推给用户。用户点了 100 个 OK,第 101 次点错了——团队辩护"用户自己确认的"。这是典型的把 HITL 异化成免责机制。
修正:HITL 的目的是减少错误、不是转移责任。工程上区分两种确认:
| 确认类型 | 目的 | 示例 |
|---|---|---|
| 决策型确认 | 用户提供 Agent 没有的信息 | "要覆盖还是追加?"——Agent 不知道答案 |
| 审计型确认 | 用户二次校验 Agent 输出 | "diff 如下,是否正确?"——Agent 有答案但可能错 |
| 免责型确认 ❌ | 让用户为 Agent 背锅 | "我要执行 rm,你确认一下" |
免责型确认应该删掉、换成更好的默认或 hook 规则——让 Agent 自己承担风险评估,而不是把判断抛给用户。
反模式 7:审批 fatigue 的累积效应
一个 session 里弹过 30 次审批以后——用户的审批质量会明显下降(心理学称为 "decision fatigue")。Vohs、Baumeister 等人 2008 年关于自我损耗的研究认为,连续决策会消耗自控资源;但自我损耗效应在后来的大规模重复实验中结果不一,具体下降幅度也因任务和个体而异,本专栏不再引用难以复现的精确百分比。
修正:监控每个 session 的审批次数——一旦超过阈值(建议 15-20 次),自动触发:
- 暂停 Agent 并通知"你已审批 X 次,建议升级到 Auto-Edit 模式"
- 汇总剩余待办、询问"把剩下 N 步打包执行吗?"
- 给用户强制休息建议——比如"检测到连续操作 30 分钟,建议 break"
这把 HITL 从"无限审批链"转化成有限次数的关键决策——符合人类认知资源的实际上限。
17.10 三大框架的 HITL 工程实现对比
| 维度 | Claude Code | LangGraph | OpenAI Agents SDK |
|---|---|---|---|
| 审批触发 | 按 tool permission | 显式 interrupt() |
工具上的 needsApproval(Python 为 needs_approval) |
| 审批 UI | 内置 CLI / VSCode | 自定义(框架只提供 hook) | 自定义 |
| 异步 resume | session 级别 | 原生支持(checkpointer) | interruptions + RunState 序列化恢复(JS / Python 均支持,2026-09-25 核对官方文档) |
| 权限模式 | 7 种内置(6 种用户可选 + 1 种内部) | 开放(用户自己设计) | 开放 |
| 记忆集成 | CLAUDE.md / auto-memory |
checkpointer 里 | 自己做 |
| 最佳适用 | IDE / CLI 场景 | 复杂长时工作流 | OpenAI 生态自研 |
新项目优先选 LangGraph——interrupt() + checkpointer 是目前最成熟的 HITL 工程实现。Claude Code / Cursor 这类产品级工具是参考对象(设计优秀)不是框架(你自己要写)。
17.10.1 自研 HITL 的最小可用骨架
不想用 LangGraph 的情况下,自研一套 HITL 的最小可用实现大约是 150 行代码。以 TypeScript + Postgres 为例——
// 1. 审批请求的持久化
interface ApprovalRequest {
id: string
thread_id: string
node: string // 审批发生在哪个 node
payload: object // 审批内容(diff / command / plan)
status: 'pending' | 'approved' | 'rejected'
created_at: Date
resolved_at?: Date
resume_data?: object // 用户审批时带入的数据
}
// 2. Agent 执行到审批点调用这个
async function requestApproval(
threadId: string,
node: string,
payload: object
): Promise<object> {
const req = await db.approvals.insert({
thread_id: threadId, node, payload, status: 'pending',
})
// 保存 agent state
await db.checkpoints.insert({
thread_id: threadId, state: currentAgentState, approval_id: req.id,
})
// 抛出一个"暂停"信号——顶层 handler 捕获后返回 HTTP 200 + pending
throw new AwaitApprovalSignal(req.id)
}
// 3. 用户批准后
async function resumeAfterApproval(approvalId: string, decision: object) {
const approval = await db.approvals.update(approvalId, {
status: 'approved', resolved_at: new Date(), resume_data: decision,
})
const cp = await db.checkpoints.findByApprovalId(approvalId)
// 恢复 state 并从审批 node 继续执行
return await agent.resume(cp.state, approval.resume_data)
}
几个必须抓住的工程点:
- state 持久化要和审批请求在同一个数据库事务里——不然审批创建成功但 state 没存下,用户批准后没法恢复
resume_dataschema 要严格校验——用户端可能传脏数据进来,没校验就是 injection 入口- 审批过期机制——建议默认 7 天 TTL、超时后 status 改成
expired,避免"僵尸审批" - 幂等性——同一个
approval_id被 resume 两次,第二次必须是 no-op,否则会重复执行副作用
这套骨架能覆盖大多数场景;剩下的复杂场景(分支审批 / 多人会签 / 审批嵌套)再考虑上 LangGraph 或更重的工作流引擎(Temporal / Airflow / Prefect)。但不要一开始就上重型框架——先跑通最小闭环、再看真实需求选型。
17.10.2 HITL 架构选型决策树
graph TB
Start[要做 HITL Agent] --> Q1{人会坐在前面吗?}
Q1 -->|是, IDE/CLI| Path1[同步阻塞 + 预览模式<br/>参考 Claude Code]
Q1 -->|否| Q2{审批可能超过 1 小时吗?}
Q2 -->|否, 几分钟内| Path2[同进程等待 + timeout<br/>简单 async/await 就够]
Q2 -->|是| Q3{同时在跑的 thread 数?}
Q3 -->|< 10| Path3[自研 150 行骨架<br/>单机 + Postgres]
Q3 -->|> 10| Q4{需要分布式?}
Q4 -->|否| Path4[LangGraph + SqliteSaver]
Q4 -->|是| Path5[LangGraph + PostgresSaver<br/>或 Temporal workflow]
style Path1 fill:#2563eb,color:#fff,stroke:none
style Path2 fill:#2563eb,color:#fff,stroke:none
style Path3 fill:#f59e0b,color:#333,stroke:none
style Path4 fill:#10b981,color:#333,stroke:none
style Path5 fill:#10b981,color:#333,stroke:none
架构复杂度随审批延迟指数上升——能用 IDE 同步就别上 Temporal。过度工程化是 HITL 选型里最常见的错误。
17.10.3 Claude Agent SDK 的 canUseTool 实测
Claude Agent SDK(@anthropic-ai/claude-agent-sdk)在 query 的 options 中支持 permissionMode 字段——
import { query } from '@anthropic-ai/claude-agent-sdk'
for await (const message of query({
prompt: '部署到生产环境',
options: {
permissionMode: 'acceptEdits', // 对应 Auto-Edit 模式
canUseTool: async (toolName, input, options) => {
// 这是真正的 HITL 钩子——本该弹权限确认时,SDK 改为调用它
// options 包含 signal(中止信号)、suggestions(权限规则建议)字段
const command = String(input.command ?? '') // input 类型是 Record<string, unknown>
if (toolName === 'Bash' && command.includes('rm -rf')) {
const userSaid = await askUser(`即将执行: ${command}`)
return userSaid === 'yes'
? { behavior: 'allow', updatedInput: input }
: { behavior: 'deny', message: '用户拒绝' }
}
return { behavior: 'allow', updatedInput: input }
}
}
})) {
// query() 返回异步生成器,必须迭代它 Agent 才会真正运行
console.log(message)
}
几个工程要点——
canUseTool对这次工具调用是阻塞的——它返回前 Agent 不会执行该工具;它只在权限流程落到"需要询问"时才被调用(被 allow 规则或模式直接放行的调用不会经过它);这是最直接的 HITL 钩子updatedInput允许用户修改工具输入——不只能"批准/拒绝"、还能"批准但改参数",对应前面 17.4.1 的Modify分支- 返回
deny+message时,SDK 会把 message 当工具错误反馈给 Claude——模型可以"理解用户为什么拒绝"并换方案,而不是单纯报错 permissionMode有default/acceptEdits/plan/bypassPermissions/dontAsk/auto等可选值——和 Claude Code CLI 的权限模式同一套底层实现(都走同一个 Claude Code CLI 内核)
SDK 的 HITL 设计把"本该问人时怎么办"的决策权交给用户代码——这比硬编码权限矩阵更灵活——你可以基于任意信号(数据库状态 / 用户角色 / 当前时间)做决策,而不只是看工具名。
17.11 本章小结
HITL 是 Agent 产品能否落地的胜负手:
- 不是 Level 10 全自动,也不是 Level 1 全手动——大多数 Agent 应在 Level 4-6(AI 主导 + 人审批)
- 风险驱动的介入:可逆性 × 影响范围 × 信任边界三维评估;不可逆操作永远审批
- 三种审批模式:同步阻塞(IDE)、批量确认(效率)、异步 resume(production 主力)
- LangGraph interrupt() 是异步审批的工程化基石——checkpointer 让 Agent 等审批时不占资源
- 渐进式信任:六种用户可选权限模式 + 一种内部模式;不自动升级,决定权在用户;Session 级白名单是妥协好点
- 反馈闭环:显式 + 隐式两类;收集 → 分类 → 记忆 → 下次应用
- 中断最小化:批处理、预览代替确认、合并相似、充分信息、delayed batching
- 信任三支柱:透明(explain why)+ 可预测(一致行为)+ 可逆(git stash / undo)
- 七类反模式:过度询问、信息不足、危险默认、不可恢复、审批无依据、把 HITL 当免责声明、审批疲劳累积
LangGraph interrupt 的源码级真相(参见 langgraph/types.py 中 Interrupt、Command、interrupt())——Interrupt 在 v0.6.0 之后只剩 value + id 两字段;Command 4 字段(graph/update/resume/goto);resume 重跑整个 node——副作用代码必须隔离到独立 node。
Claude Code 权限的源码级真相——settings.json 里 allow 用 Bash(cmd:*) DSL;deny 永远强于 allow;skipDangerousModePermissionPrompt 只跳过一次 meta 确认、不改变单条权限——工程上把危险命令写进 deny 比依赖审批更可靠。
SDK 层 HITL 的源码级真相——@anthropic-ai/claude-agent-sdk 的 canUseTool 钩子返回 { behavior, updatedInput | message };updatedInput 支持"批准但改参数";deny + message 会被反馈给模型让它换方案。
架构选型的硬约束——审批延迟 < 几分钟用同步阻塞;几分钟到几天用 LangGraph checkpointer;跨天多人会签上 Temporal——不要用重型框架解决轻型问题。
本章涉及的机制(interrupt / canUseTool / settings.json DSL / hooks)都是当前 production AI 产品真正在跑的工程——照抄可以用、但更好的做法是理解背后的权衡——HITL 不是一套固定方案,是针对特定场景的可逆性 / 信任边界 / 中断成本的具体取舍。
下一章开始进入生产化部分。人在关键节点把关解决的是单次决策的对错,但"这个 Agent 整体上是变好了还是变差了"人是感觉不出来的——第 18 章《评估与测试方法论:如何测量不确定的系统》要回答的就是这件事。
延伸阅读
- Sheridan & Verplank 自动化 10 级:Human and Computer Control of Undersea Teleoperators (1978)
- LangGraph interrupt 文档:https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/
- Claude Code 权限模式设计:https://code.claude.com/docs/en/permission-modes(2026-09-25 核对)
- Amershi et al., "Guidelines for Human-AI Interaction", CHI 2019
- Mark, Gudith & Klocke, "The Cost of Interrupted Work: More Speed and Stress", CHI 2008(中断代价研究;§17.7.6 的"23 分 15 秒"出自 Gloria Mark 2006 年的 Gallup 访谈 "Too Many Interruptions at Work?",并非该论文)
- 相关讨论可参考 Stanford HAI、Anthropic 等机构的公开博客与研究报告(数据口径因场景而异,本专栏不再引用具体百分比)