Harness Engineering

第17章 Human-in-the-Loop:人机协作的工程设计

作者 杨艺韬 · 9,060 字

第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
}

几点工程细节值得记录——

  1. Bash(cmd:*) 语法:冒号前是命令基名,冒号后 * 是通配符;所以 Bash(git:*) 放行任意 git 子命令、而 Bash(git status:*) 只放行 git status 开头的命令。这是 Claude Code 权限 DSL 的内置匹配规则、不是 shell glob。
  2. defaultMode 可选值:default / acceptEdits / plan / bypassPermissions / auto——对应前面 17.5.1 的四档权限模式(auto 是 2026 新增的智能档)。
  3. skipDangerousModePermissionPrompt 一旦设为 true——就是用户主动把”你确定要启用 YOLO 模式吗”那一次 meta 确认永久跳过——它不会放宽任何单条权限、只是少掉一次初始化时的确认。这是官方对”不可逆升级”的工程保险:单次授权 vs 永久授权分开两个决策。
  4. 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() 被调用:

  1. 当前 state 自动保存到 checkpointer(SQLite/Postgres/Redis)
  2. 执行返回,Agent 进程可以服务其他请求
  3. 前端显示审批 UI
  4. 用户审批后,前端调:
# 用户审批通过
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:705interrupt() 函数 docstring 原文(v0.6.0 实测)——

The graph resumes from the start of the node, re-executing all logic.

也就是——resume 不是从 interrupt() 那一行接着跑、是从整个 node 函数的第一行重跑一遍。这意味着上面 approval_gate 函数里 interrupt() 之前的任何有副作用的代码(写 DB、发邮件、扣钱)都会再被执行一次。这是新手最常踩的坑。

两个工程上的应对——

  1. 任何有副作用的代码都放在单独的 node 里、不要和 interrupt() 同 node——一个 node 一件事、resume 重跑没事
  2. 同一个 node 里有多个 interrupt()——LangGraph 按调用顺序匹配 resume 值(types.py 注释:“matches resume values to interrupts based on their order in the node”)——所以顺序不能在 resume 之间变

types.py:653Command 类有 4 个字段(实测)——graph / update / resume / goto——resume 既可以是单个值也可以是 dict[str, Any](按 interrupt_id 多对多匹配);Interrupt 在 v0.6.0 之后只剩 2 个字段——valueidxxh3_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
StopAgent 结束一轮发通知 / 自动部署
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
(限制最多)                                               (限制最少)

每档的行为差异:

操作PlanDefaultAuto-EditFull Auto
Read✓ 自动✓ 自动✓ 自动✓ 自动
Edit❌ 禁止⚠️ 审批✓ 自动✓ 自动
Write❌ 禁止⚠️ 审批✓ 自动✓ 自动
Bash (safe)❌ 禁止✓ 自动✓ 自动✓ 自动
Bash (risky)❌ 禁止⚠️ 审批⚠️ 审批⚠️ 审批
Bash (dangerous)❌ 禁止⚠️ 审批⚠️ 审批⚠️ 审批
git push❌ 禁止⚠️ 审批⚠️ 审批⚠️ 审批

注意两件事

  1. Full Auto Mode 也不是真 Level 10——dangerous 和 push 这类永远审批。这就是前面说的”不可逆操作与信任级别无关”。
  2. 模式切换权在用户。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 ComposerAiderCline (VSCode)
默认审批粒度按 diff chunk按 commit按 tool call
批量 diff 接受一键 accept all/commit 打包逐个 Y/N
预览模式inline diff(直接在编辑器里)输出到 terminalside-panel diff
回滚机制VSCode 撤销栈 + gitaider --undo 专用命令git stash
session 白名单支持 .cursorrules.aider.conf.ymlauto-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 次),自动触发:

  1. 暂停 Agent 并通知”你已审批 X 次,建议升级到 Auto-Edit 模式”
  2. 汇总剩余待办、询问”把剩下 N 步打包执行吗?”
  3. 给用户强制休息建议——比如”检测到连续操作 30 分钟,建议 break”

这把 HITL 从”无限审批链”转化成有限次数的关键决策——符合人类认知资源的实际上限。

17.10 三大框架的 HITL 工程实现对比

维度Claude CodeLangGraphOpenAI Agents SDK
审批触发按 tool permission显式 interrupt()needs_approval 回调
审批 UI内置 CLI / VSCode自定义(框架只提供 hook)自定义
异步 resumesession 级别原生支持(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)
}

几个必须抓住的工程点

  1. state 持久化要和审批请求在同一个数据库事务里——不然审批创建成功但 state 没存下,用户批准后没法恢复
  2. resume_data schema 要严格校验——用户端可能传脏数据进来,没校验就是 injection 入口
  3. 审批过期机制——建议默认 7 天 TTL、超时后 status 改成 expired,避免”僵尸审批”
  4. 幂等性——同一个 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 }
    }
  }
})

几个工程要点——

  1. canUseTool 是同步阻塞的——它返回前 Agent 不会执行工具;这是最直接的 HITL 钩子
  2. updatedInput 允许用户修改工具输入——不只能”批准/拒绝”、还能”批准但改参数”,对应前面 17.4.1 的 Modify 分支
  3. 返回 deny + message 时,SDK 会把 message 当工具错误反馈给 Claude——模型可以”理解用户为什么拒绝”并换方案,而不是单纯报错
  4. permissionModedefault / 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.jsonallowBash(cmd:*) DSL;deny 永远强于 allowskipDangerousModePermissionPrompt 只跳过一次 meta 确认、不改变单条权限——工程上把危险命令写进 deny 比依赖审批更可靠

SDK 层 HITL 的源码级真相——@anthropic-ai/claude-agent-sdkcanUseTool 钩子返回 { behavior, updatedInput | message }updatedInput 支持”批准但改参数”;deny + message 会被反馈给模型让它换方案。

架构选型的硬约束——审批延迟 < 几分钟用同步阻塞;几分钟到几天用 LangGraph checkpointer;跨天多人会签上 Temporal——不要用重型框架解决轻型问题

本章涉及的机制(interrupt / canUseTool / settings.json DSL / hooks)都是当前 production AI 产品真正在跑的工程——照抄可以用、但更好的做法是理解背后的权衡——HITL 不是一套固定方案,是针对特定场景的可逆性 / 信任边界 / 中断成本的具体取舍。


延伸阅读