跳转到主要内容
HITL(human-in-the-loop,人工介入)指 agent 在执行中间停下来,把决定权交回给人:拿到回答之前不继续。它不是异常,而是 agent 产品刻意设计的安全门与信息门——敏感操作前要人批准,信息不够时要人补充,多个方案时要人拍板。 你大概率已经每天在用它:
  • Claude Code:跑一条有风险的命令、改一个权限外的文件之前,会停下来问”允许吗?“——批准了才执行,拒绝了就换路。
  • Codex:审批模式(suggest / auto-edit 等)决定哪些动作要先经人确认,本质是同一扇门。
  • AI SDK 应用:工具不带 execute 时,工具调用会浮到前端,由界面上的人确认后把结果交回(addToolResult),模型再继续——这是自研应用里最常见的审批门写法。
对被测对象来说,这条”停下来等人”的分支和别的行为一样需要回归:批准后该发生的事发生了吗?拒绝后真的没做吗?问错了人、停错了地方呢?但 eval 是自动跑的,里面没有人。NiceEval 的做法是把”人”折叠成三个可编排的动作:看到 agent 停了(t.parked())、检查它在等什么(t.requireInputRequest())、替人回答(t.respond())。批准、拒绝、补充信息,从此都是能写进 eval 的路径。

停轮如何表达:waiting + input.requested

send 返回的 Turn.status 有三个值:"completed"(跑完了)、"failed"(出错了)、"waiting"——这一轮没跑完,agent 停在等人输入上。waiting 不是失败,是挂起:回答到达后同一轮接着跑,不重发请求、不开新对话。 停下的轮次还要同时说明”在等什么”:每个待回答的问题吐一条 input.requested 事件,带一个稳定的请求 id、停在哪个动作上(action)、可选的裁决项(options,如 approve / deny)。状态和事件缺一不可——waiting 让 eval 知道该回答了,input.requested 让回答有的放矢。

一次完整握手

t.respond 底层没有专用通道——它只是又一次普通的 send,人的裁决以结构化形式随输入到达: HITL 一次完整握手:t.send 后应用停在审批上,本轮以 waiting 返回并带 input.requested;t.parked 与 t.requireInputRequest 检查停轮;t.respond 作为第二次普通 send 把结构化裁决交回,同一轮继续执行到 completed,断言照常。 两个容易想歪的地方:
  • 回答轮不是新对话。 它发生在同一条会话线上,ctx.session 还是同一个——Adapter 靠它找回上一轮挂起的现场。
  • 回答不用从文本里猜。 input.responses 逐请求带着 { requestId, optionId }(自由文本回答则是 { requestId, text }),命中选项的 optionId 在 eval 侧已校验过存在,打错的字直接抛错而不是静默传给应用。每种回答到 Adapter 长什么样,见不同回答的入参

eval 侧:三个动作

const draft = await t.send("给老板发一封周报邮件。");
draft.parked();                                          // ① 断言这一轮停下了

const req = t.requireInputRequest({ action: "send_email" });   // ② 取出待答请求

await t.respond({ request: req, optionId: "approve" });  // ③ 替人回答,发出下一轮
t.calledTool("send_email", { status: "completed" });
  • t.parked() 断言这一轮确实以 waiting 停下;反过来 t.succeeded() 在 waiting 轮上会失败——停轮是显式状态,不会被当成”跑完了”。
  • t.requireInputRequest(filter) 从待处理请求里精确取一个(按 id / prompt / action / optionIds 等字段匹配),匹配到 0 个或超过 1 个都会抛,多个请求并停时靠它消歧。
  • t.respond(...) 回答并发出下一轮;同类请求要给同一个答案时(比如逐个批准一批改动),t.respondAll(optionId) 一次处理完。
批准和拒绝是同一扇门的两个分支,各写一条 eval 才算评完:批准后该发生的发生了,拒绝后该发生的没发生。逐个 API 的完整用法见 Drive

Adapter 侧:两条义务,一次续跑

HITL 对 Adapter 的全部要求就三件事——前两件发生在停下的那一轮,第三件发生在回答到达的下一轮:
  1. 停轮时如实返回 status: "waiting",不要把挂起报成 "completed",否则 parked() 失效、succeeded() 假通过;
  2. 每个待回答的问题吐一条 input.requestedid 稳定、字段尽量填全——eval 侧的检查和对位全靠它们;
  3. 下一次 send 先交裁决、再续跑:从 input.responsesrequestId 把裁决交回应用(不要按顺序猜),然后接着上一轮挂起的地方继续,而不是重发请求。
“挂起的现场”(比如读了一半的 SSE 流)存在本会话线的 ctx.session 上——停轮时 ctx.session.hold(现场),回答轮 ctx.session.take() 取回,取到即清除。怎么和流式驱动拼起来,见写 send 第五步的完整骨架。 NiceEval 的其它能力一样,HITL 没有布尔声明:做到了就是有。send 返回过 "waiting" 并吐了 input.requestedt.respond 就能工作;没做到,eval 会在第一个 parked()requireInputRequest() 上明确失败,而不是静默假通过。能力如何从构造中来,见 Adapter

拒绝不是故障

人否决和工具报错是两回事,事件流里用不同的 status 区分:被人拒绝的调用,action.resultstatus"rejected" 而不是 "failed"。这样 t.noFailedActions() 不会被一次正当的人工否决误伤,而 t.calledTool("deploy", { status: "rejected" }) 可以精确断言”发起了调用、被人拦下了”——这正是拒绝分支的 eval 要写的断言。

哪些 agent 用不到 HITL

不是每个 agent 都有这条分支,没有就整个跳过——Adapter 不用管 waiting,eval 里也不会出现 t.respond
  • 每轮一口气跑完的 agent:问答、检索、翻译这类单发即回的服务,执行中没有等人的环节。
  • 审批门被关掉的运行形态:Claude Code、Codex 产品本身是 HITL 的,但作为被测对象在沙箱里通常全自动跑(跳过权限确认)——内置的 claude-code / codex / bub sandbox agent 就是这样,所以它们都不做 HITL。
  • 审批发生在执行循环之外:比如工单系统里人工复核 agent 的产出。那是另一个系统的流程,不经过 send,eval 评不到也不该评。
判断标准只有一条:你的 agent 执行循环里存在”停下来等人、拿到回答再继续”的分支,而你想把批准和拒绝写进 eval。

相关阅读

  • Drive — eval 侧的完整用法:t.parked()t.requireInputRequest()t.respond() / t.respondAll()
  • Adapter — 回答到 Adapter 的结构化入参,四种典型形态。
  • 写 send — Adapter 侧实操:ctx.session.hold / take 与流式 + HITL 的完整骨架。
  • 接入你的 Agent — 接入全景:最小接入、参数通道与增量地图。