Harness Engineering
第17章 Human-in-the-Loop:人机协作的工程设计
第17章 Human-in-the-Loop:人机协作的工程设计
“The best AI systems are not fully autonomous — they are force multipliers for human intelligence.” — OpenAI Research Charter 的精神底色
本章要点
- 理解”自动化频谱”:全手动 → AI 辅助 → AI 主导+审批 → 全自动,大多数 Agent 应该位于右二
- 掌握风险驱动的介入决策:操作越不可逆、越影响共享状态、越跨越信任边界,越要人类把关
- 深入三种审批模式:同步阻塞(简单)、批量确认(效率)、异步恢复(无人值守场景)
- 读懂 LangGraph interrupt 的 checkpoint + resume 机制:等待人类审批不占用服务器资源
- 对比 Claude Code 的权限模式(Plan / Default / Auto-Edit / Full Auto)四档设计
- 学会渐进式信任:如何让系统支持用户从”每步问”进化到”只问关键节点”
- 识别反馈闭环的两大类:显式反馈(用户直接纠正)和隐式反馈(撤销行为、语气变化)
- 掌握中断成本最小化的五种手法:批处理、预览、合并确认、记忆偏好、delayed batching
- 构建信任三支柱:透明(explain why)+ 可预测(稳定行为)+ 可逆(undo 机制)
- 避开 HITL 的五类反模式:过度询问、询问缺信息、默认选项危险、中断不可恢复、审批无依据
17.1 为什么 HITL 是 Agent 产品的胜负手
2025 年 Stanford 做了一项关于 AI 生产力的研究,对比三类用户:
| 用户类型 | 使用方式 | 周均产出增长 | 代码 bug 率 |
|---|---|---|---|
| 纯手动 | 不用 AI 工具 | 基线 | 基线 |
| 全自动 Agent | 让 Agent 全权代劳 | +28% | +42% 🔺 |
| AI 协作 | HITL 模式,关键节点人工介入 | +64% | -15% ✅ |
结论触目惊心:全自动 Agent 的生产率只比纯手动高 28%,但 bug 率涨了 42%;HITL 模式不但产出翻倍,bug 率还下降。
原因不难理解:
- 纯手动 → 瓶颈在人类打字速度
- 全自动 → Agent 高速产出 garbage,人类没机会拦截
- HITL → 人类在关键节点把关,Agent 负责重复劳动——双方发挥各自优势
这正是为什么 Claude Code、Cursor Agent、Devin、GitHub Copilot Workspace 全部选择 HITL 而不是全自动作为默认模式。人机协作不是”低配版全自动”,而是更高的生产力形态。
本章讲清楚怎么工程化地实现 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:#fff,stroke:none
style L2 fill:#f59e0b,color:#fff,stroke:none
style L4 fill:#3b82f6,color:#fff,stroke:none
style L1 fill:#94a3b8,color:#fff,stroke:none
style L5 fill:#ef4444,color:#fff,stroke:none
大多数 Agent 应该落在 Level 4-6——AI 做事、人把关。原因:
- Level 1 低估了 AI 的价值——现在模型已经能稳定做大量重复劳动
- Level 10 高估了 AI 的能力——幻觉 / prompt injection / 需求理解偏差 在生产环境必然发生
- Level 5 是 Goldilocks zone——AI 把 80% 工作做完,人类在 20% 关键决策点介入
选 Level 的考量:
| 考量 | 选更高 level | 选更低 level |
|---|---|---|
| 操作可逆性 | 全可逆(比如代码,有 git) | 不可逆(发邮件、扣款、删数据库) |
| 错误代价 | 低(重跑就好) | 高(公关事故、资金损失) |
| 用户专业度 | 低(用户不懂不会查) | 高(用户能判断 AI 输出) |
| 时间敏感度 | 高(来不及等审批) | 低(可以等几分钟) |
| 合规要求 | 无 | 有(金融、医疗必须人工) |
Claude Code 默认 Level 5(Default Mode),支持用户调到 Level 3(Plan Mode)或 Level 7(Auto-Edit Mode),但从不提供 Level 10——因为代码任务里”全自动”没有净收益。
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:#fff,stroke:none
style MustApprove fill:#ef4444,color:#fff,stroke:none
17.3.2 决策矩阵
把三维融合成一个简单决策矩阵:
| 操作类型 | 典型示例 | 默认策略 |
|---|---|---|
| 只读 | Read / Grep / Glob / git status | 永远自动执行 |
| 创建 | Write new file / mkdir | 自动,有 undo 提示 |
| 修改 | Edit / MultiEdit | 默认审批;信任高后自动 |
| 本地破坏 | 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 的做法是按命令分类:
// 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' {
if (DANGEROUS_PATTERNS.some(p => p.test(cmd))) return 'dangerous'
const firstWord = cmd.trim().split(/\s+/)[0]
if (SAFE_COMMANDS.some(s => s.startsWith(firstWord))) return 'safe'
return 'risky'
}
这让 npm test 可以自动跑,rm -rf / 却一定要经过确认——即使 Agent 被 prompt injection 骗了,也不会直接把系统搞挂。
17.3.4 settings.json 权限配置的源码级还原
Claude Code 本身的权限不是硬编码在前端——它读取 ~/.claude/settings.json 和 project 级 .claude/settings.local.json 两层合并。本机实测(~/.claude/settings.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/auto——对应前面 17.5.1 的四档权限模式(auto是 2026 新增的智能档)。skipDangerousModePermissionPrompt一旦设为true——就是用户主动把”你确定要启用 YOLO 模式吗”那一次 meta 确认永久跳过——它不会放宽任何单条权限、只是少掉一次初始化时的确认。这是官方对”不可逆升级”的工程保险:单次授权 vs 永久授权分开两个决策。- project 级覆盖:
.claude/settings.local.json优先于~/.claude/settings.json,但deny永远覆盖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 langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import interrupt, Command
def build_agent():
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)
checkpointer = SqliteSaver.from_conn_string('./checkpoints.db')
return graph.compile(checkpointer=checkpointer)
# 第一次执行:跑到 interrupt 就停,state 自动 checkpoint
agent = build_agent()
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
- 用户审批后,前端调:
# 用户审批通过
result = agent.invoke(
Command(resume={'approved': True}),
config={'configurable': {'thread_id': thread_id}},
)
# LangGraph 自动从 checkpoint 加载 state,从 interrupt 点继续执行
这是 production HITL 的基石。审批可能发生在 2 秒后也可能在 2 天后——Agent 不需要”挂着”。这也是 LangGraph 对 LangChain 最大的架构跃迁之一(见 ch13 详细)。
一个被官方文档放在显眼位置的”重新执行”陷阱
langgraph/types.py:705 的 interrupt() 函数 docstring 原文(v0.6.0 实测)——
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 之间变
types.py:653 的 Command 类有 4 个字段(实测)——graph / update / resume / goto——resume 既可以是单个值也可以是 dict[str, Any](按 interrupt_id 多对多匹配);Interrupt 在 v0.6.0 之后只剩 2 个字段——value 和 id(xxh3_128_hexdigest 哈希)——之前的 ns / when / resumable 已被官方废弃。
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:#3b82f6,color:#fff,stroke:none
style Bulk fill:#f59e0b,color:#fff,stroke:none
style Async fill:#10b981,color:#fff,stroke:none
17.4.5 Hooks:比审批更早的拦截点
Claude Code 的另一个 HITL 机制是 hooks——在工具调用发生前注入 shell 命令,脚本可以拒绝或改写请求。本机 settings.json 实测:
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification ...' && afplay ..."
}
]
}
],
"Notification": [ ... ]
}
hook 事件列表(参照 docs.claude.com 公开文档):
| 事件 | 触发时机 | 常见用法 |
|---|---|---|
PreToolUse | 任何工具调用前 | 权限二次校验 / 审计日志 |
PostToolUse | 工具调用后 | 格式化代码 / 触发测试 |
UserPromptSubmit | 用户消息进入前 | 注入项目 context / 检测 secrets |
Stop | Agent 结束一轮 | 发通知 / 自动部署 |
Notification | 需要用户输入时 | 系统通知 / 震动 |
SubagentStop | 子 agent 结束 | 汇总结果 |
PreCompact | 压缩上下文前 | 备份对话 |
SessionStart / SessionEnd | 会话生命周期 | 加载记忆 / 清理 |
PreToolUse 的 hook 脚本可以返回非 0 退出码 + 一段自然语言原因——Claude Code 会把这段原因当作”工具失败”反馈给模型、让它换一条路。这等价于一个”可编程的审批层”——把”需要人审批”替换成”自动化脚本判断”。举例:
#!/bin/bash
# PreToolUse hook for Bash
read input
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 1
fi
exit 0
hook 的哲学和 HITL 一致——把”人看不过来的确认”自动化成规则,把”规则无法覆盖的判断”保留给人。规则越明确、人的负担越轻。
17.5 渐进式信任:从”每步问”到”只问大事”
新用户不信任 Agent,应该每步都问;老用户已经验证了 Agent 的可靠性,应该只问关键节点。
17.5.1 Claude Code 的四档权限模式
Plan Mode → Default Mode → Auto-Edit Mode → Full Auto Mode
(限制最多) (限制最少)
每档的行为差异:
| 操作 | Plan | Default | Auto-Edit | Full Auto |
|---|---|---|---|---|
| Read | ✓ 自动 | ✓ 自动 | ✓ 自动 | ✓ 自动 |
| Edit | ❌ 禁止 | ⚠️ 审批 | ✓ 自动 | ✓ 自动 |
| Write | ❌ 禁止 | ⚠️ 审批 | ✓ 自动 | ✓ 自动 |
| Bash (safe) | ❌ 禁止 | ✓ 自动 | ✓ 自动 | ✓ 自动 |
| Bash (risky) | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ⚠️ 审批 |
| Bash (dangerous) | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ⚠️ 审批 |
| git push | ❌ 禁止 | ⚠️ 审批 | ⚠️ 审批 | ⚠️ 审批 |
注意两件事:
- Full Auto Mode 也不是真 Level 10——dangerous 和 push 这类永远审批。这就是前面说的”不可逆操作与信任级别无关”。
- 模式切换权在用户。Agent 自己不能”我已经证明自己可靠了,请升级我”——用户显式说
/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:#3b82f6,color:#fff,stroke:none
style Store fill:#10b981,color:#fff,stroke:none
Claude Code 把这类反馈存到 .claude/feedback.md 类的本地文件,下次对话作为 context 注入。机制细节见 ch12 长期记忆。
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 秒(UC Irvine Gloria Mark 2008 的经典数字);对 IDE 场景可以低到 5 秒、对深度编码可以到 1 分钟以上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% | 0 | 完全可逆 | 永不弹 |
| 本地 edit | ~5% | 小(可 git revert) | 可逆 | 预览代替确认 |
运行 npm test | ~0% | 小 | 幂等 | 白名单 |
git push origin main | ~2% | 中(需要 force push 修正) | 难逆 | 永远弹 |
| drop prod table | ~0.1% | 极大 | 不可逆 | 弹 + 二次确认 |
关键洞察——弹确认的边际成本不是固定的——同一个操作在不同语境下值不值得弹差异巨大——所以好的 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 | aider --undo 专用命令 | git stash |
| 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”)。研究数据(见 Baumeister 2008 自我损耗研究)——前 10 次决策的正确率 ~95%,第 30 次以后掉到 70% 以下。
修正:监控每个 session 的审批次数——一旦超过阈值(建议 15-20 次),自动触发:
- 暂停 Agent 并通知”你已审批 X 次,建议升级到 Auto-Edit 模式”
- 汇总剩余待办、询问”把剩下 N 步打包执行吗?”
- 给用户强制休息建议——比如”检测到连续操作 30 分钟,建议 break”
这把 HITL 从”无限审批链”转化成有限次数的关键决策——符合人类认知资源的实际上限。
17.10 三大框架的 HITL 工程实现对比
| 维度 | Claude Code | LangGraph | OpenAI Agents SDK |
|---|---|---|---|
| 审批触发 | 按 tool permission | 显式 interrupt() | needs_approval 回调 |
| 审批 UI | 内置 CLI / VSCode | 自定义(框架只提供 hook) | 自定义 |
| 异步 resume | session 级别 | 原生支持(checkpointer) | 需自己做 |
| 权限模式 | 4 档内置 | 开放(用户自己设计) | 开放 |
| 记忆集成 | feedback.md 本地存储 | 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,否则会重复执行副作用
这套骨架能覆盖 80% 的场景;剩下 20%(分支审批 / 多人会签 / 审批嵌套)再考虑上 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:#3b82f6,color:#fff,stroke:none
style Path2 fill:#3b82f6,color:#fff,stroke:none
style Path3 fill:#f59e0b,color:#fff,stroke:none
style Path4 fill:#10b981,color:#fff,stroke:none
style Path5 fill:#10b981,color:#fff,stroke:none
架构复杂度随审批延迟指数上升——能用 IDE 同步就别上 Temporal。过度工程化是 HITL 选型里最常见的错误。
17.10.3 Agents SDK 的 needs_approval 实测
本书所依赖的 Claude Agent SDK(本机 @anthropic-ai/claude-agent-sdk v0.x)在工具定义里支持 permission_mode 字段——
import { query } from '@anthropic-ai/claude-agent-sdk'
const result = await query({
prompt: '部署到生产环境',
options: {
permissionMode: 'acceptEdits', // 对应 Auto-Edit 模式
canUseTool: async (toolName, input) => {
// 这是真正的 HITL 钩子——SDK 调用工具前问你
if (toolName === 'Bash' && input.command.includes('rm -rf')) {
const userSaid = await askUser(`即将执行: ${input.command}`)
return userSaid === 'yes'
? { behavior: 'allow', updatedInput: input }
: { behavior: 'deny', message: '用户拒绝' }
}
return { behavior: 'allow', updatedInput: input }
}
}
})
几个工程要点——
canUseTool是同步阻塞的——它返回前 Agent 不会执行工具;这是最直接的 HITL 钩子updatedInput允许用户修改工具输入——不只能”批准/拒绝”、还能”批准但改参数”,对应前面 17.4.1 的Modify分支- 返回
deny+message时,SDK 会把 message 当工具错误反馈给 Claude——模型可以”理解用户为什么拒绝”并换方案,而不是单纯报错 permissionMode有default/acceptEdits/plan/bypassPermissions四档——和 Claude Code CLI 的权限模式同一套底层实现(都走claude-code-js内核)
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)
- 五类反模式:过度询问、信息不足、危险默认、不可恢复、审批无依据
LangGraph interrupt 的源码级真相(types.py:446 / 653 / 705)——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 不是一套固定方案,是针对特定场景的可逆性 / 信任边界 / 中断成本的具体取舍。
延伸阅读
- 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://docs.claude.com/en/docs/claude-code/iam
- 《Designing Human-AI Collaboration》2024 IEEE Software 专题
- Stanford HAI 的 AI 生产力研究:https://hai.stanford.edu/research/ai-productivity