博客.
博客.
(联系方式)

保持联系

如果你想联系我,无论是讨论合作机会、分享想法,还是只是打个招呼,都可以通过以下方式找到我。

(最后更新)

版权所有 / 2026

(快捷键)C
返回学习路线
(04 · 实战与治理)2026年10月

HITL 人机协同:什么时候该打断用户

打断用户的判据不是“模型有没有把握”,而是一笔账:猜错代价 × 不确定度 > 打断成本。从核心不等式到门卡协议、长挂起恢复、并行隔离与门卡学习,把“人”这个变量做成可计算、可运营、会收敛的工程对象。

HITL 人机协同:什么时候该打断用户

开场:难点不是怎么问,是什么时候问

很多团队第一次给 Agent 加人机协同,做法朴素得像条件反射:模型不确定?那就问人。上线两周后,两种翻车必居其一——要么一天弹出两百个确认框,用户从第 50 个开始无脑点「同意」,真正危险的那条混在第 153 个里被顺手放行;要么团队被确认框折磨到崩溃,一刀切改成全自动,然后在某个深夜看着 Agent 把不可逆操作执行了个痛快。

HITL(Human-in-the-Loop)的真正难点不是「怎么问」,是「什么时候问」。 问的方式——弹窗、按钮、表单——是 UI 问题,半天能做完;问的时机是一个经济问题,需要算一笔账:这次打断换来的风险削减,值不值用户付出的注意力。这一章把这笔账算清楚,然后顺着它展开:门卡(Gate)的协议怎么设计、挂起几小时怎么恢复、并行编排里怎么隔离上下文,以及最容易被浪费的一环——如何把用户的每次回答沉淀成下次不用再问的规则。


一条核心不等式:先算账,再打断

打断用户的判据不是「模型有没有把握」,而是:

猜错代价 × 不确定度 > 打断成本

三个变量都值得拆开看。

猜错代价看两个维度:可逆性和影响面。改一个本地文件、跑一遍测试,猜错了撤销即可,代价趋近于零;删数据、转账、对外发邮件,猜错了无法撤回,代价趋近无穷。同一个动作在不同环境里代价也不同——草稿箱里写错一封邮件和已发送邮件里写错,是两件事。

不确定度是模型对当前判断的把握程度。注意它不等于模型自报的「我有九成把握」——自报置信度和真实准确率之间的校准差是出了名的。工程上更可靠的信号来自行为层面:多次采样结果是否一致、两条候选路径的支持证据是否对称、工具返回与预期是否矛盾。

打断成本最容易被低估。一次打断 = 用户从手头任务切换过来 + 重建你这个任务的上下文 + 做决策 + 切回去。关于任务切换的心理学研究(Gloria Mark 的实验系列常被引用)给出的量级是:被打断后完全回到原任务的深度状态,平均要二十多分钟。一次「顺手问一下」,代价是用户半小时的有效工作时间。而且打断成本不是常数,它随打断频率上升——第十次打断时用户的决策质量远低于第一次,这就是后面要讲的确认疲劳。

推论很直接:先消解,再升级。 遇到不确定,按顺序过三级消解——

  1. 上下文消解:答案是不是已经在上文里?用户十分钟前说过的约束、任务目标本身,经常已经把歧义消解掉了,只是模型没检索到
  2. 记忆消解:这个用户、这类任务,上次是怎么决定的?历史决策是最强的先验
  3. 默认值消解:是否存在一个安全、可逆、符合惯例的默认选项?

三级都消解不掉的残差,才代入不等式。「同门同类任务只问一次」的合法性也正来源于此:第一次问是真歧义,第二次同样的问题再出现时,记忆消解应该直接命中——再问,就是系统失忆了。


不确定性的三形态:三种病,三种药

「不确定」是个筐,里面装着三种成因完全不同的东西,用同一个「问人」处方是误诊。

形态症状例子处置
真歧义两个解释都合理,证据对称「把结果发给团队」——发邮件还是发群消息,都对问人:这正是不等式成立的标准场景
缺参可降级缺一个参数,但存在安全默认值没指定输出格式,而 Markdown 是行业惯例带默认值继续,并在产出中显著标注「此处用了默认值,可改」
低置信有答案但没把握,证据单薄对某个 API 的参数名记不牢别问人,先自救:提高采样次数看一致性、换更强的模型重试、用工具查证

三者的区分标准是:人是不是这个信息的最优来源。 真歧义的答案只在用户脑子里,必须问;缺参的答案在公共惯例里,默认值就是答案;低置信的答案在外部世界或更强模型里,问人反而是用最贵的渠道取最便宜的信息——用户多半也不知道那个参数名,他得去查文档,而这件事 Agent 自己就能做。

打断决策流:不确定性出现先过三级消解,消解不掉的残差代入不等式,大于打断成本才升级门卡

工程落点是把三形态做成不同的返回类型,而不是同一个 clarify 弹窗:真歧义出门卡,缺参降级继续跑且产出带标注区,低置信进重试策略。后面「门卡记账」一节会看到,这个分类还决定了哪些用户决策值得沉淀成规则。


门卡(Gate)的协议设计:挂起是状态,不是异常

门卡最常见的错误实现是:抛个异常,外层 catch 住,等用户输入,然后重试。Demo 里能跑,生产里是灾难——挂起混进了错误通道,监控告警乱响,重试逻辑和等待逻辑缠成一团,超时设置互相打架。

正确的建模是:挂起是一个非错误终态。 任务状态机里,awaiting_gate 和 completed、failed 平级,是三种终态之一。它不是失败,不触发告警,不进重试队列——它只是在等一个外部事件。

协议上有四件事必须定死。

一、门卡请求必须带齐上下文快照。 用户打开审批界面时,任务可能是几小时前挂起的,他早忘了前因后果。快照至少包含:input manifest(任务目标、关键约束、相关输入的摘要)和 output manifest(Agent 打算做什么、影响什么、附上 diff 或预览)。给人一坨 JSON 或一句「是否继续?」,等于没问。

二、用户的动作不是只有「是 / 否」,是五个:

  • approve:照做
  • edit:改完再做——用户直接修改计划或参数,比打回重做高效得多
  • answer:回答一个具体提问(真歧义的出口)
  • reject:这步不做,但任务继续走别的路径
  • cancel:整个任务终止

只提供 approve / reject 两键的审批界面,会把大量「改一下就对了」的场景逼成全量重做,打断成本白白翻倍。

三、resume 是开启新一轮逻辑流,不是续播旧录像。 用户决策到达后,系统从 checkpoint 重建状态,开启一个带新回合 ID 的执行流。

四、迟到的旧事件必须被忽略。 挂起期间,上一轮的工具调用可能才刚超时返回,或者前端重发了一个旧的完成事件。新回合只接受本回合 ID 的事件,旧 ID 一律丢弃——否则你会看到用户已经拒绝了某个操作,那个操作的结果却在下一秒悄悄写进状态。

门卡协议状态机:awaiting_gate 是与 completed、failed 平级的非错误终态,resume 开启新回合并丢弃迟到的旧事件;门卡请求携带上下文快照与五个用户动作

一个最小实现长这样:

type TaskState =
  | { status: "running"; roundId: string }
  | { status: "awaiting_gate"; roundId: string; gate: GateRequest }
  | { status: "completed" }
  | { status: "failed"; reason: string };

interface GateRequest {
  kind: "approval" | "question";
  snapshot: { inputManifest: string; outputManifest: string };
  actions: ("approve" | "edit" | "answer" | "reject" | "cancel")[];
  deadline?: { at: number; onTimeout: "reject" | "cancel" }; // 高危默认拒绝
}

function resume(state: TaskState, decision: GateDecision): TaskState {
  if (state.status !== "awaiting_gate") return state; // 非挂起态不接受决策
  if (decision.action === "cancel") return { status: "failed", reason: "user-cancelled" };
  // 开启新一轮:新 roundId,旧回合的迟到事件全部失效
  return { status: "running", roundId: newRoundId() };
}

注意 deadline 那一行:超时策略必须显式声明,且高危动作默认拒绝。 一个没人处理的审批队列,本质上是被 DoS 的系统;超时默认放行,等于把审批变成了延迟执行器。


长挂起的工程问题:连接会死,会话不能死

真实场景里,用户不会守在屏幕前等门卡。他可能在开会、在通勤、在睡觉——挂起几小时是常态。而连接(HTTP 长连接、WebSocket)的存活是以分钟计的。如果恢复机制依赖「连接还活着」,长挂起就等于任务死刑。

设计原则一句话:连接与会话解耦,恢复靠快照重建,不靠连接保活。 拆成三层。

状态层:checkpoint 落盘,原子写。 挂起时把完整状态(对话历史、工具结果、当前计划、回合 ID)写入持久存储。写入必须原子——先写临时文件,再 rename 覆盖。否则进程写到一半崩溃,留下半截 checkpoint,恢复出来的状态比没有还危险。这是所有写状态文件的工具的通用纪律,不只是门卡。

import { writeFileSync, renameSync } from "node:fs";

function saveCheckpoint(id: string, state: unknown) {
  const tmp = `/checkpoints/${id}.tmp`;
  writeFileSync(tmp, JSON.stringify(state), "utf8");
  renameSync(tmp, `/checkpoints/${id}.json`); // 同文件系统内 rename 是原子操作
}

传输层:挂起期间前端按固定间隔续流。 用户开着页面时,前端需要知道「门卡还在等」。做法是按固定间隔重新拉取或续流状态,关键是这种续流不能消耗重连退避次数——它是预期行为,不是连接故障。把「主动续流」和「断线重连」做成两条独立计数器,否则挂起一小时后前端把退避打满,用户回来反而连不上了。

恢复层:从 checkpoint 冷启动重建,而不是续接。 用户决策到达时,进程可能都换了一个。恢复路径必须能从纯快照启动:读 checkpoint → 校验 schema 版本(几周前的旧任务也要能恢复)→ 开启新回合。这还带来一个顺手的红利:checkpoint 天然就是审计日志——每个门卡的快照加上用户决策,构成完整的「谁、在什么时候、基于什么信息、批准了什么」记录,合规审计直接取材。


并行编排中的隔离:一个挂门,不该拖垮全场

真实的 Agent 任务经常是 DAG:规划器拆出五个并行分支,各自调工具、各自维护中间状态。这时分支 B 挂了门卡,会发生什么?

错误答案是:整个编排暂停,四个健康分支陪绑。挂起的分支可能等上几小时,兄弟分支的中间结果(有些带时效,比如查到的库存、占住的资源)就这么烂在原地。挂起的粒度必须是节点,不是任务。 调度器要支持部分挂起:B 进入 awaiting_gate,A、C、D、E 继续跑,汇合点等所有前置就绪。

更难的是第二个问题:并行分支的上下文隔离。 门卡触发时要往「当前上下文」里写状态——挂起原因、快照、等待的决策。如果「当前上下文」是个全局变量,并行分支会互相覆写:B 挂了门,A 的中间状态被盖掉,恢复时文案串位、状态错乱,排查时你以为见了鬼。

解法和 Web 框架处理并发请求是同一招:按执行栈隔离上下文。 Node 里是 AsyncLocalStorage,Python 里是 contextvars,Go 里是显式传递 context——每个并行分支在独立的上下文容器里跑,读写只落在自己的栈上:

import { AsyncLocalStorage } from "node:async_hooks";

interface BranchContext {
  branchId: string;
  gate?: GateRequest;      // 本分支的门卡状态,兄弟分支不可见
  scratchpad: unknown[];   // 本分支的中间结果
}

const branchCtx = new AsyncLocalStorage<BranchContext>();

function runBranch(branch: Branch) {
  const ctx: BranchContext = { branchId: branch.id, scratchpad: [] };
  return branchCtx.run(ctx, () => execute(branch));
  // B 在这里挂起,只冻结 B 的 ctx;A、C 的 ctx 互不感知
}

并行编排中的门卡隔离:DAG 里一个分支挂门进入等待,兄弟分支继续执行,每个分支持有独立上下文,互不覆写

一个反直觉的推论:并行度越高,HITL 设计越重要,而不是越不重要。 直觉上「全自动并行」和「人工审批」互相矛盾;实际上并行把单点故障面扩大了 N 倍,一个分支的误判会污染汇合结果——门卡加隔离,恰恰是并行敢放开跑的前提。


门卡也是学习机会:同门同类,只问一次

大多数实现把用户的门卡决策当成一次性消费品:拿到 approve,任务继续,决策本身随风飘散。这是巨大的浪费——每一次门卡都是一次付费购买的标注数据,用户在告诉你「这类情况我希望怎么做」。

沉淀机制是门卡记账:每次挂门记一条——任务类型、门卡类型(歧义 / 高危 / 缺参)、用户决策、当时上下文的关键特征。然后跑两条回路。

回路一:同门同类只问一次。 用户批准过「向本仓库提交分支」三次之后,第四次同类操作出现时,系统应该带着先例来问,或者直接把该动作从 approve 降级为 allow——并明确告诉用户这条规则从哪来、怎么撤销。信任是挣来的,但挣来的信任要兑现成自动化,否则记账没有意义。

回路二:决策反哺默认规则。 定期把门卡记录聚类:某个缺参场景,用户十次里有九次选了同一个值,那它就是该场景的新默认值,处置从「问人」降级为「带默认值继续 + 标注」。门卡系统因此是自收敛的——理想曲线上,单位时间的门卡数量应随系统运行时间单调下降。

两个护栏必须焊死。其一,学到的规则要带置信度和过期机制:用户偏好会漂移,三个月前批的「直接发布」不等于今天还成立。其二,规则升降级不对称:从「问人」降级为「自动」可以基于统计;但从「自动」升级回「问人」,一次事故就够,且永久生效直到人工复核。


打断的成本核算:门卡数量是要收敛的指标

回到开场的不等式,把镜头拉远到系统层面:不等式右边的打断成本,由你自己控制。门卡发得越多,单次打断的边际价值越低——不是风险变了,而是人的决策质量塌了。

人因工程对此有半个世纪的实证。Parasuraman 等人综述过两种经典失败模式:complacency(自动化长期靠谱后,人放弃主动监控)和 automation bias(人把系统建议当默认正确答案,无视矛盾证据)。在审批队列上两者叠加:一天两百个审批,用户既懒得看(complacency),又倾向于相信「系统提的方案多半没错」(bias)。第 153 个审批里藏着真正危险的操作,被第 50 个审批时就已麻木的手指顺手放行。

这就是确认疲劳悖论:门卡越多,每个门卡越形同虚设。安全防护总量不是单调上升的,过拐点后反而下降。所以门卡数量要当成一个需要收敛的核心指标来运营,和错误率、延迟并列:

  • 总量监控:人均日门卡数设上限,超了不是用户该更努力,是分级阈值定错了
  • 分级送审:只有不可逆且影响大的动作进实时门卡;可逆的走事后审计加抽样复审
  • 掺 canary:定期往队列里插入已知 bad case,度量审批人的检出率——审批质量本身变成可监控指标,检出率下滑说明队列太满
  • 收敛目标:靠反哺机制,门卡量应逐月下降;运行半年后门卡量不降反升的系统,学习回路一定断了

争议:全自动 vs 步步确认

这是 Agent 社区真实的路线之争。

全自动派(Coding Agent 圈最有代表性):在沙箱环境里,全自动的效果反而更好——人审把吞吐拖慢一个数量级,而连续审五十个 diff 之后,人的判断力并不比模型强,审批只提供虚假安全感。真正该做的不是在路径上设卡,而是把环境做成可逆的:一切可回滚,自然不需要事前确认。对 Agent 的信任应该像对资深同事——你不会要求同事每写一行代码都找你签字。

步步确认派(企业场景最有代表性):Agent 运行的环境是对抗性的,提示注入可以诱导 Agent 发起恶意操作,全自动等于把攻击面直接接到执行器上;不可逆操作出错无法挽回;出了事故,责任链上需要一个人类决策者,审计和合规要求「谁批准的」必须能回答。

两派的分歧表面上是「信不信任模型」,实质是对环境的假设不同:沙箱、可逆、无对抗,全自动成立;生产数据、不可逆、有注入面,门卡不可省。务实的答案不是二选一,而是回到开头那张不等式——审批点越来越少、每个审批点越来越重,同时用环境工程(沙箱、回滚、checkpoint)不断扩大「可以全自动」的领土。信任与审计也不互斥:checkpoint 加门卡记录天然就是审计日志,关键是把「确认」从批量动作变成少数精心设计的决策仪式。


常见误区

  • 不确定就问人,不算账:把打断成本当零,结果是确认疲劳,门卡越多越形同虚设
  • 把挂起当异常处理:挂起走 catch、进重试队列、触发告警——挂起是终态不是失败,混入错误通道会让监控和重试逻辑全部失真
  • 门卡不带上下文快照:用户打开审批看到的是一坨 JSON 或一句「是否继续」,要么瞎批要么盲拒;快照里的 input / output manifest 才是门卡的本体
  • 并行分支不隔离上下文:全局状态在 DAG 并行下互相覆写,挂起的分支拖垮全场或状态串位,必须按执行栈隔离
  • 用户决策当一次性消费:每次门卡都是付费标注,不记账、不反哺默认规则,系统运行一年和运行一天一样笨
  • 超时策略缺省或默认放行:没人处理的审批队列就是被 DoS 的系统,高危动作超时必须默认拒绝

术语表

名词定义一句话直觉常见混淆
HITLHuman-in-the-Loop,在关键决策点引入人类确认发射按钮要两把钥匙同时转不是「人工兜底」:兜底是出事才喊人,HITL 是设计好的前置决策点
门卡(Gate)任务流中挂起等待人类决策的节点高速公路的收费站不是弹窗组件:是带快照、动作集、超时策略的协议
awaiting_gate挂起状态,与 completed / failed 平级的非错误终态暂停营业,不是倒闭不是异常:不进重试队列、不触发告警
上下文快照门卡请求携带的 input / output manifest给决策者的一页纸简报不是全量日志:是决策所需信息的最小完备集
resume用户决策到达后开启新一轮逻辑流重开一局,不是续播录像不是恢复旧回合:新回合 ID,旧迟到事件一律丢弃
checkpoint挂起时落盘的完整状态快照游戏存档不是普通日志:要求原子写,恢复靠它冷启动重建
原子写先写临时文件再 rename 覆盖的写入方式换灯泡先备好新的再拆旧的rename 需与目标同文件系统,跨盘 rename 不保证原子
确认疲劳审批量过大导致人不再认真审狼来了不是人的态度问题,是系统的设计问题
自动化偏差人过度信任自动化建议,无视矛盾证据导航让你开进河里你也开与「不信任 AI」是相反方向的失败,专业经验不能免疫
complacency自动化长期靠谱后,人放弃主动监控十年无事故的值班员与自动化偏差叠加出现,都是审批失效的机理
AsyncLocalStorageNode 中按异步执行栈隔离上下文的机制每个请求一个独立抽屉不是全局变量的语法糖:跨并行分支共享就失去意义
canary(审批)故意插入审批队列的已知 bad case混入考卷的标准答案不是测试环境概念:度量的是生产审批人的检出率

参考材料


小结

HITL 的设计方法可以压缩成四句话。判据是不等式:猜错代价 × 不确定度 > 打断成本才打断,且先过上下文、记忆、默认值三级消解。不确定性分三形态:真歧义问人,缺参带默认值继续并标注,低置信自救重试——别用同一个弹窗治三种病。门卡是协议不是弹窗:挂起是非错误终态,请求带齐上下文快照,用户有五个动作,resume 开新回合并丢弃迟到的旧事件,长挂起靠 checkpoint 重建而非连接保活,并行分支按执行栈隔离。门卡是资产不是开销:记账、反哺默认规则、监控确认疲劳,让门卡数量随时间收敛。做到这四条,「什么时候问人」就不再是拍脑袋,而是一个可计算、可运营、会持续变好的工程变量。