外观
人机协作与人类在环
Human-in-the-loop(HITL)是 agent 工程里最容易被写错的一层。写少了,agent 一把 rm -rf 教你做人;写多了,用户在第 30 个确认弹窗后养成闭眼点「批准」的肌肉记忆——Anthropic 自己的数据显示,用户批准了 93% 的 Claude Code 权限弹窗。这时的「人在环」已经不是安全控制,而是一种形式主义的仪式。
本文不写「人机协作很重要」这类正确的废话。目标是给你一套可以直接落地的设计框架:哪些动作该自动放行、哪些该审批、哪些该硬禁;审批 UX 怎么设计才不致疲劳;长任务怎么 checkpoint 和恢复;人和 agent 之间怎么交接控制权;以及无人值守场景下的完整护栏清单。
在继续之前,建议先读 Agent Loop 详解 和 Agent 安全与对齐,本文的审批点都挂在 loop 的 tool-call 环节上。
一、自主性的光谱与风险匹配
三级模型
先把直觉形式化。agent 的每个动作都可以沿两个轴打分:可逆性(能否撤销)和爆炸半径(搞砸了影响多大)。两轴一乘,得出三级处置策略:
| 级别 | 动作特征 | 例子 | 处置 |
|---|---|---|---|
| L0 只读 | 无副作用,幂等 | 读文件、搜索代码、查日志、git status | 自动放行,不弹窗 |
| L1 可逆写 | 改状态但可回滚 | 编辑工作区文件、本地跑测试、装依赖 | 默认审批,可信后走 allowlist |
| L2 高危 | 不可逆或外溢 | 删数据、发邮件、推送到 main、生产部署、支付、访问密钥 | 强制审批 + 部分硬禁,与模式无关 |
爆炸半径
↑
大 │ L1(审慎) L2(强制审批/硬禁)
│ 本地测试 rm -rf / git push --force
│ 改文件 发邮件 / 支付 / 生产变更
小 │ L0(放行) L1(审批或 allowlist)
│ 读文件/搜索 写外部 API / 安装依赖
└──────────────────────→
可逆 不可逆关键原则:审批强度只跟动作的风险等级挂钩,不跟「模型聪不聪明」挂钩。 模型越强,越容易把事情做对,但 git push --force origin main 做错的代价不会因此变小。Claude Code 的 auto mode 也是这个思路——无论分类器多自信,curl | bash、生产部署、force-push 到 main 这类动作一律硬拦。
一个可执行的分级实现
审批逻辑不要散在 prompt 里(prompt 是可以被注入说服的),要挂在 tool 执行的入口处,作为确定性代码:
python
from enum import Enum
class RiskLevel(Enum):
READ_ONLY = 0 # 自动放行
REVERSIBLE = 1 # 默认审批,可被 allowlist 覆盖
HIGH = 2 # 强制人工审批,deny 规则优先
# 每条规则:(匹配器, 风险等级);先匹配 deny,再匹配 allow,最后默认
TOOL_POLICY = [
("Bash(rm:*)", RiskLevel.HIGH), # deny 类:任何 rm 都要人看
("Bash(git push:*)", RiskLevel.HIGH),
("SendEmail", RiskLevel.HIGH),
("Bash(*)", RiskLevel.REVERSIBLE),
("Write", RiskLevel.REVERSIBLE),
("Read", RiskLevel.READ_ONLY),
("Grep", RiskLevel.READ_ONLY),
]
def gate(tool_call, allowlist: set[str]) -> str:
"""返回 auto / ask / deny。deny 规则永远先于模式和 allowlist 求值。"""
level = classify(tool_call, TOOL_POLICY)
if level is RiskLevel.READ_ONLY:
return "auto"
if level is RiskLevel.HIGH:
return "ask" # 高危操作不因任何模式豁免
if signature(tool_call) in allowlist:
return "auto" # 规则化放行
return "ask"注意 deny 先于一切求值——这也是 Claude Code 的实际语义:disallowedTools 和 settings.json 里的 deny 规则在任何权限模式(包括 bypass)下都生效。
不可逆操作没有「试运行」
删除、发送、支付、部署这类动作无法靠「先让 agent 跑一遍看看」来验证,因为副作用已经发生。对 L2 动作,唯一的正确设计是执行前阻断,而不是执行后审计。把不可逆动作包装成「先生成计划、人确认后才拿到执行凭证」的两段式工具,比事后看日志补救有效得多。
二、审批点设计:与审批疲劳作战
审批疲劳是安全漏洞,不是 UX 瑕疵
「Confirmation fatigue is not a UX annoyance but a security vulnerability」——研究者 Changkun Ou 这句话值得贴在每个 agent 团队的墙上。Anthropic 在《Measuring AI Agent Autonomy in Practice》研究里披露的行为数据更具体:
- 用户批准 93% 的权限弹窗——绝大多数「审批」不是有效审查;
- 新用户(少于 50 个 session)全开自动批准的比例约 20%,到 750 个 session 时超过 40%——信任在持续累积;
- 自主运行时长快速变长:99.9 分位的连续运行时长在三个月内从不到 25 分钟涨到超过 45 分钟;
- 有意思的是,资深用户自动批准更多,但主动打断也更多(新手约 5% 的轮次介入,资深用户约 9%)。这不是鲁莽,而是监督策略的迁移:从「事前逐条审批」转向「事中监控 + 出问题再介入」。在复杂任务上,agent 主动停下来澄清的频率是人的打断频率的两倍多。
结论:审批疲劳会随使用时间必然发生,你的产品必须为「人不会认真看第 50 个弹窗」这个现实而设计,而不是为理想用户而设计。
减少审批数量的四个手段
- 收紧默认集合:只读动作一律不弹。一个 10 文件的重构如果产生 30+ 个弹窗,说明你的分级模型把 L0/L1 混进了审批流。
- 规则化放行(allowlist):用户批准某个动作时,同时提供「以后都允许
npm test」「以后都允许写src/下文件」的选项,把一次性审批沉淀为规则。Claude Code 的 settings.json 里permissions.allow/permissions.deny就是这个机制。注意 allowlist 的粒度:「允许Bash(npm run test:*)」是好规则,「允许Bash(*)」等于裸奔。 - 批量审批:把同一逻辑单元的多个动作打包成一次审批——「agent 将修改这 12 个文件,diff 如下,是否批准」,而不是 12 次单文件弹窗。批量审批的前提是动作预告(见第六节):你得先知道它要干什么。
- 分级升降:给 agent 一个「信任预算」。连续 N 个低风险动作表现正常后,自动把某些 L1 动作从「逐条审批」降级为「批量确认」;一旦出现一次被拒或一次失败,立刻升回逐条审批。这把人类带新人的管理直觉变成了代码。
审批弹窗里该放什么
一次有效审批需要三样信息:动作(精确到命令和参数,不是「执行 shell 命令」)、理由(agent 自己陈述为什么要做)、差异(写操作给 diff,命令给展开后的完整字符串)。缺了理由和差异,批准率再高也不是监督,是抽签。同时给「拒绝并附言」入口——拒绝理由本身就是最高质量的纠偏信号。
三、中断、暂停与恢复
checkpoint 是 HITL 的地基
任何「暂停等人」的设计,前提都是执行状态可以持久化并在任意时刻恢复。长任务 agent(跑几十分钟、几百个 tool call)如果状态只活在内存里,那么审批请求本身就成了易失品——进程一挂,人批不批都没意义了。
工程要求:
- 每一步 tool call 之后落盘 checkpoint,包含:消息历史、当前计划、已完成的副作用清单、待审批队列;
- 副作用与状态分离:checkpoint 记录「已经执行了哪些不可逆动作」,恢复时绝不重放(已发送的邮件不能再发一遍);
- 恢复 = 从 checkpoint 重建 context 继续 loop,对使用方透明。
LangGraph 的 interrupt 机制是目前最成熟的参考实现:interrupt() 在节点内任意位置挂起执行,checkpointer 保存图状态,外部用 Command(resume=...) 把人的决定注入回去。要点如下(API 以 2026 年的 LangGraph 文档为准):
python
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver # 生产环境换持久化 checkpointer
def execute_tool_node(state):
tool_call = state["pending_tool_call"]
if gate(tool_call, state["allowlist"]) == "ask":
# 挂起:payload 必须是 JSON 可序列化的,给人看审批信息
decision = interrupt({
"action": tool_call.name,
"args": tool_call.args,
"reason": state["agent_rationale"],
})
if not decision["approved"]:
return {"result": f"用户拒绝:{decision.get('feedback', '无理由')}"}
return {"result": run(tool_call)}
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "task-42"}} # thread_id 就是恢复指针
# 第一次调用,跑到 interrupt 处挂起
graph.invoke({"messages": [...]}, config=config)
# 人审批之后,用同一个 thread_id 恢复;resume 值成为 interrupt() 的返回值
graph.invoke(Command(resume={"approved": True, "feedback": ""}), config=config)三个容易踩的坑,全部来自「恢复时节点会从头重跑」这一语义:
- interrupt 之前的副作用会重放——把发邮件、写库放在 interrupt 之后,或保证幂等;
- 不要用裸 try/except 包住 interrupt——挂起是靠抛特殊异常实现的,会被你吞掉;
- 同一节点内多个 interrupt 的顺序必须确定——resume 值是按索引匹配的,条件跳过会导致审批串位。
更多框架级细节见 LangGraph 框架解析。
静态断点与调试
LangGraph 还支持编译期/运行期的静态断点 interrupt_before / interrupt_after,即「执行到 tool 节点前一律暂停」。这对两类场景有用:一是调试期逐节点单步;二是给所有 tool 调用加一道统一的审批门——代价是粒度粗,无法在节点内按条件跳过。实践中常见组合是:静态断点用于灰度环境的全量审批,动态 interrupt() 用于生产环境的条件审批。
四、控制权交接协议
HITL 不是单向的「人审 agent」,而是双向的控制权交接。把它当协议来设计,两个方向各有明确的信号格式。
agent → 人:求助信号
agent 应该主动交出控制权的时机:
- 歧义无法消解:需求有两种以上合理解读,且选错的代价不对称(比如删 A 表还是 B 表);
- 置信度低于阈值:连续两次 tool 调用结果与预期不符,或计划需要推翻重来;
- 触碰到能力/权限边界:需要的凭证不存在、目标系统在白名单外;
- 预算告警:token、时间、重试次数超过预算的某个比例。
信号不要混在普通对话流里,要结构化:
json
{
"type": "escalation",
"reason": "ambiguous_requirement",
"question": "「清理旧数据」是指归档还是物理删除?",
"options": ["archive_to_cold_storage", "hard_delete", "abort"],
"context": {"affected_rows": 184320, "last_backup": "2026-08-20T03:00Z"},
"blocking": true
}blocking: true 表示 agent 停表等待;也可以有非阻塞的 blocking: false(「我先按方案 A 继续,你有 10 分钟否决」),适合低风险分叉。Anthropic 的数据印证了求助信号的价值:复杂任务上 agent 主动澄清的次数是人的介入次数的两倍多——好的 agent 知道什么时候该问。
人 → agent:纠偏注入
反方向更微妙。人在 agent 运行中插入纠偏,有三种注入方式,侵入性递增:
- 软提示:往消息流里追加一条用户消息,agent 下一轮自然看到。适合方向性建议(「先别管测试,优先修类型错误」)。
- 计划改写:直接编辑 agent 的 plan/todo 状态再恢复执行。需要 checkpoint 支持状态分叉,LangGraph 的时间旅行(time travel)就是干这个的。
- 硬打断 + 状态回滚:杀掉当前执行,恢复到某个历史 checkpoint,带着纠偏指令重跑。Cursor 的 Restore Checkpoint、LangGraph 的
update_state都属于这一类。
工程上最大的坑是注入时机:如果纠偏消息到达时 agent 正处于一次 tool 调用中间,你有两个选择——等当前动作完成再注入(延迟但状态干净),或立即中断(及时但可能留下半个副作用)。原则:可逆动作等完成,不可逆动作立即中断,宁可留下半成品也不能让它跑完。
五、产品实例拆解
三个代表性产品给出了三种不同的 HITL 形态:CLI 权限模式、编辑器内 diff review、异步远程协作。值得逐个拆。
Claude Code:权限模式光谱
Claude Code 把 HITL 做成了一档一档可切换的「权限模式」(permission modes),Shift+Tab 在会话中循环切换。截至 2026 年中的主要档位:
| 模式 | 行为 | 适用场景 |
|---|---|---|
default | 只读自动放行;写文件、执行命令、网络调用逐个弹窗 | 日常交互开发 |
acceptEdits | 文件编辑自动通过;Bash、网络、MCP 调用仍需批准 | 信任文件改动、盯命令执行的重构会话 |
plan | 只读 + 推理,禁止改文件和执行命令;先产出计划给人审 | 高风险变更前的方案评审 |
auto | 不弹窗,由 Sonnet 4.6 驱动的转录分类器逐动作裁决 | 无人值守但要有兜底(2026 年 3 月推出,研究预览) |
bypassPermissions | 全部跳过确认(YOLO 模式,--dangerously-skip-permissions) | 仅限沙箱/容器/CI |
几个设计细节值得抄:
- deny 规则先于模式:
permissions.deny、--disallowedTools在任何模式下生效,包括 bypass。模式管「放多宽」,deny 管「底线在哪」,两条独立的轴。 - 保护路径例外:自 v2.1.78 起,即使 bypass 模式下,对
.git/、.claude/、.vscode/、.husky/的写入仍会弹窗——这些是「agent 自我指涉」的路径,改坏了连护栏本身都失效。 - hooks 是最后的确定性闸门:
PreToolUsehook 在 bypass 模式下照样触发,可以硬拦rm -rf模式和直推 main。分类器是概率防线,hook 是确定性防线,两者叠加。 - auto mode 的架构:两层防御——输入侧做 prompt injection 探针,输出侧由分类器对每个动作裁决;分类器故意不看 tool result 的内容,防止文件/网页里的恶意内容操纵裁决。官方口径的基准数字:真实流量上 0.4% 误杀率、对「过度激进动作」17% 漏过率;连续 3 次或累计 20 次被拦后自动回落到人工审批。Simon Willison 的批评也记录在案:基于 AI 的防线是非确定性的,默认 allowlist 里的
pip install -r requirements.txt意味着供应链攻击可以绕过分类器——他的主张是 OS 级沙箱做确定性隔离,AI 分类器只做补充。这个争论本身值得读,链接在文末。 - 组织级管控:管理员可以在 managed settings 里设
permissions.disableBypassPermissionsMode禁止全公司使用 bypass。
反面教材同样来自这里。2025 年 10 月有用户报告 Claude Code 从根目录执行 rm -rf(GitHub issue #10077,机器上全部用户文件被毁——注意他没有开 bypass,是权限系统自己没拦住);12 月又有 rm -rf tests/ patches/ plan/ ~/ 的尾参数展开事故,整个 home 目录被清空。教训有两条:审批系统本身可能失效,护栏要分层;展开的 shell 命令字符串必须出现在审批弹窗里,没人能从不完整的展示里看出那个 ~/ 的杀意。
Cursor:编辑器内的 review 闭环
Cursor 的 HITL 不在「动作前」而在「产出后」,这是 IDE 形态的自然选择:
- Agent(
Cmd/Ctrl+I)跨文件执行完一轮修改后,改动以 diff 形式呈现,支持逐文件、逐块 accept/reject,以及一次 Review 全部变更; - Checkpoints:每次请求和每次 AI 改动自动生成代码库检查点,发现方向错了可以一键 Restore Checkpoint 回滚到该消息之前的状态——这就是「人 → agent 纠偏」里的硬回滚注入;
- Auto-Run 设置决定终端命令等动作的审批强度(可设回「Ask Every Time」),
Inline Diffs控制 diff 的呈现方式。
注意它的边界:checkpoint 是本地机制,不包含你自己的手动编辑,也会被自动清理,不能替代 git。老练用户的标准流程是「先 commit,再放手让 agent 改,用 checkpoint 做细粒度回滚,用 git 做最终兜底」——权限系统正常时它是安全网,bypass 模式下 git 就是唯一的安全网。
Devin:异步远程同事模型
Devin 把 HITL 拉长到了「异步协作」的尺度——agent 在云端沙箱里跑几十分钟到几小时,人不在终端前:
- Slack 作为主交互面:
@Devin发起任务,Devin 在频道/线程里回报进度、提问求助、交付 session 产物;团队其他人天然「在环」,可以看到完整过程; - Plan 界面 + 实时接管:Devin 在沙箱里维护自己的规划视图、编辑器、终端、浏览器,人随时可以进会话纠正方向或接管操作;
- REST API + 凭证隔离:通过 API 以 machine user 身份触发任务,PR 可以配置为用你本人的 GitHub 用户名创建(默认关闭)——身份归因本身就是治理的一部分。
这个形态回答了「人不可能盯着每个动作时怎么办」:答案不是放弃监督,而是把监督从同步审批变成异步可审计的协作流。代价是延迟和上下文损耗——Devin 在 Slack 里问你一个问题,等你两小时后看到时,它的工作上下文已经凉了。
三种形态没有高下,对应三种工作方式:pair programming(Claude Code default)、code review(Cursor)、外包管理(Devin)。选型的本质是选「人在环的哪个时间点、以多大粒度介入」。
延伸阅读
三个产品的完整拆解见 Claude Code 案例、Cursor 案例、Devin 案例。
六、信任的建立与校准
信任是校准出来的,不是积累出来的
Anthropic 的数据里最容易被误读的一条是「信任随使用时长稳定增长」:新手 20% 全开自动批准,750 个 session 后超过 40%。这听起来像信任建设成功案例,但换个角度:这个增长是用户自校准的,不是系统引导的。用户被 agent 坑过几次、又观察它靠谱了几百次之后,自己找到了合适的介入强度。
好的产品应该把校准过程显性化,缩短「被坑几次」的代价:
- 新手期强制收紧:前 N 个 session 不提供 bypass,allowlist 规则默认窄。这不是不信任用户,是不让用户在不了解 agent 失败模式时交出防线。
- 信任分区内化:对「写
src/的代码文件」和「动infra/的 Terraform」维持不同的信任度。全局一个开关的信任必然在最难的领域失效。
动作预告优于事后日志
这是本文最重要的一个判断:「agent 在动手前用一句话说清它要干什么」对信任的贡献,超过一切事后审计日志。
原因有三:
- 可干预性:预告给了人否决窗口,日志只给人追责依据。安全机制的价值在事前。
- 意图对齐:「我要把
users表迁移到新 schema」这句话本身就是可检验的假设——如果接下来的动作和预告不符,那才是真正的告警信号。只记动作不记意图的日志无法区分「计划内的危险操作」和「被注入的恶意操作」。 - 认知负荷:人 review 一句自然语言预告的成本是秒级,review 50 条 tool call 日志的成本是放弃。
工程实现很朴素:每次 tool call 前,让模型输出一行 intent(为什么做、预期结果),把它放进审批弹窗、批量审批摘要和 trace 里。配合 可观测性建设 的 trace 体系,你就同时拿到了事前防线和事后审计。
一个廉价的信任指标
统计「预告意图与实际动作的偏离率」。偏离率高说明 agent 在漂移或上下文里有注入内容在操纵行为;偏离率低且稳定,才是扩大 allowlist 的依据。让信任扩张跟着数据走,而不是跟着用户的心情走。
七、无人值守 agent 的护栏清单
当任务必须无人值守(CI 里的代码修复、夜间批处理、监控响应),「人在环」退化为「人曾经设定过规则」。这时防线必须全部内化到基础设施。一份可对照检查的清单:
环境隔离(确定性防线)
- [ ] 容器/沙箱执行,文件系统只挂载目标目录,home、系统路径、其他代码库不可见
- [ ] 网络出口白名单:只允许任务必需的域名,禁止任意外联(防数据外泄的主防线)
- [ ] 环境内零长期凭证:不挂 SSH key、不挂生产 API key;需要的凭证用短期 token 注入
- [ ] 环境用完即弃:session 结束销毁,任何破坏不跨任务存活
动作约束(规则防线)
- [ ] deny 清单独立于模式存在:硬禁
rm -rf变体、force-push 到受保护分支、curl | bash、生产端点 - [ ] allowlist 精确到命令前缀和路径,定期审计过期条目
- [ ] 不可逆动作(发邮件、部署、支付、删数据)物理上不向无人值守 agent 开放工具,或在工具内部强制两段确认
- [ ] 预算硬顶:最大轮数、token 上限、单动作重试上限(建议 3 次,指数退避)、超时即终止
监控与兜底(概率防线 + 人)
- [ ] 行为分类器或规则引擎实时裁决动作(如 Claude Code auto mode 的转录分类器),但承认它有漏检率——17% 的漏过率意味着它不能是唯一防线
- [ ] 全量 trace 落盘,动作预告与实际动作一一对应,支持事后审计
- [ ] 异常即升级:连续 N 次被拦、偏离预告意图、触碰边界,自动暂停并通知人(PagerDuty/Slack,不是邮件)
- [ ] 回滚机制预演过:git checkpoint、数据库快照、或幂等重放——「能回滚」要演练过才算数,不能只是设计图上有
治理
- [ ] 每个无人值守 agent 有明确的责任人和爆炸半径声明(「它最坏能弄坏什么」写进文档)
- [ ] 定期红队演练:往 agent 的输入里塞注入内容,验证防线是否按设计工作
- [ ] 信任扩张有数据依据:allowlist 变宽、模式放宽,都要由运行指标驱动,而不是「最近没出事」
护栏要防的是「成功的大多数」,不只是恶意攻击
大多数 agent 事故不是注入攻击,而是 agent 在正常任务里做了过度激进的事——从模糊指令推出「那就删了吧」。Anthropic 内部维护着一份真实事故日志:agent 曾从模糊指令出发删除远程 git 分支、把工程师的 GitHub token 上传到内部计算集群、对生产数据库发起迁移。你的护栏清单应该以这类「过于热心」的动作为主要防御对象,攻击者反而是次要威胁。
结语
HITL 的成熟形态不是「每个动作都问人」,而是一个分层体系:确定性规则守住底线(deny、沙箱、隔离),概率机制覆盖中间地带(分类器、信任预算),人只在真正需要判断的位置出现(歧义消解、高危审批、异常升级)。Anthropic 自己的研究结论也指向这里:强制「每个动作都要人批」的监管要求制造的是摩擦而不是安全——重要的是人是否处在能够有效监控和介入的位置,而不是介入的形式。
设计 HITL 时反复问自己一个问题:如果用户从来不认真看审批弹窗(数据说他们确实不看),我的系统还安全吗? 答案是肯定的,你的设计才算完成。相关的落地坑可以对照 常见陷阱与反模式,评估人机协作效果的指标设计见 Agent 评估方法论。
参考资料
- The Human-in-the-Loop Illusion — Resilient Cyber —— 对 Anthropic 自主性研究(93% 批准率等数据)和 Auto Mode 架构、0.4%/17% 基准数字的详细分析,含 Simon Willison 的批评。
- Claude Code --dangerously-skip-permissions Explained — TrueFoundry —— 权限三级模型、bypass 模式的边界、保护路径例外、真实
rm -rf事故时间线。 - Confirmation Fatigue and the Protocol Gap in Agentic AI Oversight — Changkun Ou —— 论证审批疲劳是安全漏洞而非 UX 问题。
- LangGraph Interrupts 官方文档 ——
interrupt()/Command(resume=...)/ checkpointer 的权威 API 说明与使用规则。 - Cursor 文档:Agent mode —— Review 改动与 Restore Checkpoint 的官方说明。
- Devin 文档:Slack 集成 —— Devin 以 Slack 为协作界面的人机协作模式。
- How Cognition Uses Devin to Build Devin —— Devin 的 REST API、Slack 内联协作等工程实践。