1616 字
约 5 分钟
7
人工介入HITL
无标签

系列: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: 已为您预订航班

挂起-等待-恢复时序:缺参数时把问题持久化进会话挂起位,用户的答复作为权威事实直接注入历史而不经过模型,任务带着完整参数原地恢复。

面试考点

  1. HITL 的同步语义怎么实现? 把"人的回答"包装成一次工具结果:生产用 Future 阻塞-唤醒(线程挂起等 complete),无常驻线程的架构用会话状态挂起 + 下一条消息回填。两种实现的协议语义相同。
  2. 挂起期间会话状态要注意什么? pendingQuestion 必须持久化并可序列化(类比工作流的 Checkpoint)——服务重启、多实例部署下挂起位不能丢,否则用户的答复会找不到归属的回合。
  3. 什么时候该问人而不是猜? 经验准则:参数缺失 + 猜错代价不可逆(订错票、删错文件)→ 问;参数可从上下文合理推断 + 猜错可撤销 → 先猜后确认。

生产化路径

  • 挂起状态落 Redis/DB,带过期时间;超时未答复降级为"取消任务并告知",防止会话永久悬挂。
  • 提问聚合:一次回合发现多个缺失参数时合并成一个结构化表单(前端一次填完),避免连环追问。
  • 与审批门禁区分:ask_user_question 是"缺信息",审批是"缺授权"——两者都挂起会话,但挂起位类型不同,UI 呈现也应不同(表单 vs 批准/拒绝按钮)。
  • 流式架构下的实现:SSE 推出提问事件,回复经独立接口到达后唤醒执行线程,本质仍是 Future 语义。
人工介入HITL
https://clxhxhhr.top/posts/4719/
作者
clxstart
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。