1616 字
约 5 分钟
7
人工介入HITL
2026-10-13
无标签
系列:ToyAgent 智能体知识库 19/26
人工介入(HITL):把"人"变成可等待的工具
一句话核心:信息缺失时别瞎猜,主动问人——核心思想是把"异步的人"翻译成"同步的工具结果",生产版用 Future 阻塞-唤醒,教学版用会话挂起-续答,语义完全一致。
为什么需要
"帮我订机票"——没说出发地。猜一个?错了浪费时间,对了纯属运气。聪明的智能体必须懂得什么时候该开口问:当任务的关键参数缺失、且猜错的代价大于问一次的成本时,提问是正确策略,而不是能力不足的表现。
难点在于:人不在进程里,没法像函数一样被调用。人可能 10 秒后回复,也可能一小时后才回。要把"等一个人"塞进"调一个工具"的同步语义里,需要专门的等待机制。
核心机制
生产版:CompletableFuture 阻塞-唤醒
工具执行线程阻塞在 Future 上;问题经 UI 推给前端;人的回复一到,future.complete(answer) 唤醒线程,它拿到的就是普通的"工具结果",循环继续跑。
工具线程:future = askHuman("从哪个城市出发?") // 阻塞挂起
前端/UI:问题推给用户
用户回复:从上海出发
工具线程:future.complete("从上海出发") → 线程醒来 → 返回 Observation → 循环续步
教学版:会话挂起 + 续答
同步 HTTP 架构没有常驻线程可阻塞,那就把一次等待摊开成两次请求——用会话状态记录挂起位:
// 第一次请求:模型发现缺出发地 → 发起 ask_user_question
if (tool.equals("ask_user_question")) {
pendingQuestion = question; // 问题持久化在会话状态(挂起)
return question + "(直接回复即可)"; // turn 暂停,先答复人
}
// 第二次请求:用户的回复不进模型,而是作为人工答复回填
if (pendingQuestion != null) {
history.add(Message.user("人工答复:" + input));
return complete(pendingQuestion); // turn 恢复,带着完整信息继续
}
两种实现的对照:
| 生产版(Future 阻塞) | 教学版(会话挂起) | |
|---|---|---|
| 等待机制 | CompletableFuture 阻塞执行线程 | pendingQuestion 会话状态挂起 |
| 答复到达 | future.complete() 唤醒 | 下一条消息回填挂起位 |
| 语义 | 完全一致:人的一次回答 = 一次工具结果注入 | 同左 |
人在回路的完整谱系
问人不只一种形式:主动提问(ask,缺参数时问)、方案选择(给出选项让人选)、打断纠偏(steer,人中途改方向),共同构成完整的"人在回路"能力集。
举例
一次"订机票挂起 → 续答恢复"的执行轨迹:
用户:帮我订一张去北京的机票
[turn 开始] 模型解析任务 → 发现缺出发地(历史与画像中均无)
→ 工具调用 ask_user_question(question="请问从哪个城市出发?")
→ 会话状态:pendingQuestion = "请问从哪个城市出发?"
→ 会话挂起 PENDING_ASK,turn 结束
Agent:请问从哪个城市出发?(直接回复即可)
用户:从上海出发
[新请求] 检测到挂起位非空 → 走续答分支
→ 注意:这条输入没有经过模型决策,直接作为事实注入
→ history += Message.user("人工答复:从上海出发")
→ 原任务带着完整参数恢复执行
→ 工具调用 book_flight(from=上海, to=北京, date=明天)
→ turn 完成
Agent:已为您预订明天上海→北京的航班。(你的答复是权威输入,模型不会重新解读)
关键细节是续答不经过模型:如果"从上海出发"作为普通用户消息进模型,模型可能回一句"好的,从上海出发,请问要订几点的?"——又来一轮。续答分支把它直接钉成事实,保证一次问答就补齐参数。
sequenceDiagram
participant U as 用户
participant A as Agent
participant S as 会话状态
participant F as 工具
U->>A: 帮我订一张去北京的机票
A->>A: 解析任务发现缺出发地
A->>S: 写入挂起位 pendingQuestion
S-->>A: 会话挂起 PENDING_ASK
A-->>U: 请问从哪个城市出发
U->>A: 从上海出发
A->>S: 检测到挂起位非空走续答分支
A->>S: 人工答复不经模型直接注入历史
S-->>A: 参数补齐回合恢复
A->>F: book_flight 上海到北京明天
F-->>A: 预订成功
A-->>U: 已为您预订航班
挂起-等待-恢复时序:缺参数时把问题持久化进会话挂起位,用户的答复作为权威事实直接注入历史而不经过模型,任务带着完整参数原地恢复。
面试考点
- HITL 的同步语义怎么实现? 把"人的回答"包装成一次工具结果:生产用 Future 阻塞-唤醒(线程挂起等 complete),无常驻线程的架构用会话状态挂起 + 下一条消息回填。两种实现的协议语义相同。
- 挂起期间会话状态要注意什么? pendingQuestion 必须持久化并可序列化(类比工作流的 Checkpoint)——服务重启、多实例部署下挂起位不能丢,否则用户的答复会找不到归属的回合。
- 什么时候该问人而不是猜? 经验准则:参数缺失 + 猜错代价不可逆(订错票、删错文件)→ 问;参数可从上下文合理推断 + 猜错可撤销 → 先猜后确认。
生产化路径
- 挂起状态落 Redis/DB,带过期时间;超时未答复降级为"取消任务并告知",防止会话永久悬挂。
- 提问聚合:一次回合发现多个缺失参数时合并成一个结构化表单(前端一次填完),避免连环追问。
- 与审批门禁区分:ask_user_question 是"缺信息",审批是"缺授权"——两者都挂起会话,但挂起位类型不同,UI 呈现也应不同(表单 vs 批准/拒绝按钮)。
- 流式架构下的实现:SSE 推出提问事件,回复经独立接口到达后唤醒执行线程,本质仍是 Future 语义。
评论
0 条
还没有评论,先写一条吧。