面试知识库
中 进阶

Human-in-the-loop确认交互设计#

一句话答案#

Agent 的写操作(下单、转账、发邮件、删数据)不能只靠 prompt 里的「先征得同意」,要在执行层拦下来:权限引擎判定为「需确认」的调用,只生成一张带参数快照、影响说明、幂等键和过期时间的确认卡,Agent 暂停(进程内挂起、checkpoint 后结束本轮,或把执行完全交给带外的 HTTP 决议);用户批准后按卡上的快照执行,拒绝、超时、改参都要有明确的状态和回给模型的结果。

核心要点

1. 为什么写操作必须人确认#

  • 模型分不清意图强度:「我想买这个」「帮我看看这个」「就这个,下单」在模型眼里差别很小,偶发误触发一次就是一张真实订单。这类错误低频、难复现,往往用户先发现。
  • prompt 约束不是边界:「未经确认不要下单」只是概率上降低误调用,挡不住模型理解偏差,更挡不住 prompt injection(网页、邮件里藏的指令让模型说「用户已同意」),见 [Agent安全与Prompt Injection防御](/topics/ai-governance/Agent安全与Prompt Injection防御)。
  • 不可逆 + 有成本:读操作错了重来即可,写操作错了要走退款、撤回、道歉流程。
  • 责任归属:确认记录(谁、何时、看到的是哪个版本的参数)是事后审计的依据。

结论:确认必须由执行层强制,模型只能「提议」,不能「批准」。

2. 和权限引擎的分工#

工具调用 ──▶ 权限引擎:ALLOW / DENY / ASK
                │        │       │
                │        │       └──▶ HITL:生成确认卡 → 暂停 → 用户决议 → 执行前复检
                │        └──▶ 直接拒绝,把原因作为工具结果回给模型
                └──▶ 直接执行
plaintext
职责权限引擎HITL 确认
回答的问题这个调用能不能做、要不要问用户要不要做、按什么参数做
输入工具名、参数、用户角色、租户策略、风险等级确认卡快照 + 用户决议
输出ALLOW / DENY / ASKapproved / rejected / expired / modified
失效方向未登记的写工具默认 ASK 或 DENY,不能默认 ALLOW过期、找不到卡一律按未批准处理

三条规则:

  • DENY 不能被确认升级成 ALLOW。用户点同意也不能越过角色或租户权限(权限模型见 RBAC权限模型设计)。
  • ASK 的范围按风险分级:只读自动放行;可撤销的低风险写(加购物车、存草稿)可放行或批量确认;不可逆、涉及金额或对外发送的写一律逐条确认。什么都弹确认会让用户养成不看就点的习惯,确认就失去了意义。
  • 执行前复检:从出卡到用户点击可能隔了几分钟,价格、库存、用户权限都可能变。批准后执行前要再过一次权限和业务校验(这是 TOCTOU 问题,检查时和使用时状态不一致)。

3. 后端怎么让 Agent 暂停:三种做法#

做法机制优点代价
进程内挂起工具里 await 一个 Future / Event,用户回复后 resolve实现最简单,模型上下文原样保留占着 worker 并发槽;进程重启就丢;多实例下回复可能打到别的进程,要做路由
checkpoint + 结束本轮保存状态后本次执行结束;用户决议时从 checkpoint 起一次新执行不占资源,可以等几小时;重启不丢状态必须可序列化;恢复时节点会重跑,中断前的副作用要幂等
带外确认卡写工具只落一张 pending 卡就返回,本轮正常结束;真正执行只能由 POST /confirmations/{id}/resolve 触发,执行函数不注册为工具模型没有任何路径能自己批准;刷新、重启都不影响确认后如果需要模型继续推理,要另起一轮;多一张表和一套前端卡片

第一种适合「等用户补一句话」这类短澄清;涉及钱和对外动作时,后两种更可靠。第三种最硬:模型最多能让系统多出一张待确认卡,不能让系统多出一张真实订单。

LangGraph 的做法(机制细节见 LangGraph状态图与工作流编排,checkpoint 通用原理见 [Agent Runtime与Checkpoint机制](/topics/ai-agent/Agent Runtime与Checkpoint机制)):

按当前官方文档:invoke 把中断放在返回值的 __interrupt__ 里,用事件流时从流对象的 interrupts 读取;一个节点里多次调用 interrupt() 时,resume 值严格按调用顺序(索引)匹配;并行分支同时中断时,要用「interrupt id → resume 值」的映射一次性恢复;不要把 interrupt() 包在 try/except 里,否则它用来暂停的内部异常会被吞掉。要记住的是:恢复 = 该节点从头重跑,所以生成卡片之类的写操作要么幂等,要么放在单独的前置节点里(具体字段随版本可能变化,以官方文档为准)。

4. 确认卡的数据结构#

几个字段的用意:

  • args_snapshot 而不是引用:商品标题、价格等从服务端数据源按 id 取值后写入快照,不收模型生成的价格文本(模型复述时可能写错)。执行时只认快照,因为刷新页面后会话里的临时候选列表可能已经没了。
  • snapshot_hash:防止「用户看到的是旧卡,点击时参数已被替换」。
  • 两个幂等键分工不同:request_key 防止同一轮被重跑(队列重投、worker 重领)时出两张卡;operation_id 防止同一张卡被重复执行。参数指纹要和参数顺序无关(例如按 item_id 排序后再算),否则重跑时模型换个顺序报参数,就又出一张卡。
  • impact:不要把原始 JSON 丢给用户。「将从 xx 账户扣款 ¥299,下单后 30 分钟内可取消」比 {"amount": 299} 更能让人认真看。

5. 决议的几种结局#

结局服务端处理回给模型的内容
批准校验归属、未过期、hash 一致 → 执行前复检 → 按快照执行,operation_id 去重执行结果(订单号等)
拒绝卡置 rejected,记录原因「用户拒绝:原因」作为工具结果;同一轮内相同调用再次出现时直接拦截,避免模型换个说法反复请求
超时过期时间到后置 expired;resolve 时发现过期直接拒绝执行「确认已过期,未执行」;由用户决定是否重新发起
修改参数原卡置 modified;视为新请求:重新做参数校验和权限判定,生成新卡(新快照、新 hash),必要时再确认一次修改后的执行结果,并告诉模型参数被用户改过
重复点击同决定:原样返回上次结果;不同决定(先批准后拒绝):返回 conflict不变

允许修改的字段要有白名单(比如数量、收货地址可以改,商品和收款方不能改),否则「修改」会变成绕过权限的通道。

6. 前端交互要点#

  • 确认卡是消息流里的一种 part(状态建模见 AI对话前端流式渲染),和文字回复分开渲染,按钮就是唯一的确认入口;对话里输入「确认」不算批准。
  • 同一轮可能出多张卡,要明确提示「N 张待确认」,避免用户点完第一张以为完成了。
  • 按钮点击后立即置灰并显示处理中,请求带 snapshot_hash;刷新页面后从服务端拉 pending 卡恢复,而不是依赖前端内存。
  • 过期卡显示为不可点击,并给出「重新发起」入口。
sequenceDiagram
    participant M as 模型
    participant R as Runtime/权限引擎
    participant DB as 确认卡表
    participant U as 用户
    M->>R: create_order(item_ids, 地址)
    R->>R: 判定 ASK
    R->>DB: 按 request_key 落 pending 卡(快照 + hash + 过期时间)
    R-->>M: 「已生成确认卡,等待用户确认」
    R-->>U: 推送卡片事件
    U->>R: POST /confirmations/{id}/resolve (approve, hash)
    R->>DB: 校验归属/过期/hash,置 approved
    R->>R: 执行前复检 → 按快照执行(operation_id 幂等)
    R-->>U: 执行结果

面试回答(2分钟版)

Agent 的写操作要人确认,原因是模型分不清「想买」和「确认要买」,prompt 里写「先征求同意」只能降低概率,还挡不住 prompt injection 伪造的同意,而下单、转账这类操作不可逆。所以确认要在执行层强制。分工上,权限引擎先给出 ALLOW、DENY 或 ASK,DENY 用户点同意也没用,只有 ASK 才进确认流程,而且按风险分级,不然弹窗太多用户会闭眼点。暂停 Agent 有三种做法:进程内 await 一个 Future,最简单但占 worker、重启就丢;checkpoint 后结束本轮,用户决议时恢复,LangGraph 的 interrupt 加 Command(resume) 就是这种,注意恢复时节点会从头重跑;第三种是带外确认卡,写工具只落一张 pending 卡,真正执行只能通过 HTTP resolve 接口触发,执行函数不注册成工具,模型没有路径自己批准。卡上要有参数快照、给人看的影响说明、snapshot_hash、两个幂等键——一个防止重跑出两张卡,一个防止点两下执行两次——还有过期时间。拒绝要作为工具结果回给模型并拦住同轮重复请求;超时置 expired 不执行;改参数当新请求重新校验和判权限;批准后执行前还要复检一次价格和权限。结合项目时可以讲:选了三种暂停方式里的哪一种、为什么,出卡和执行的幂等键分别怎么构造,以及用什么测试证明模型没有路径自己批准。

追问与易错

追问方向:

  • “为什么不让模型调一个 confirm_order 工具来完成确认?” → 那样确认就成了模型能自己做的事,一段注入文本就能让它调用;执行函数不注册为工具,只由带用户身份的 HTTP 接口触发,模型最多只能多生成一张卡。
  • “LangGraph interrupt 恢复后为什么会重复发请求?” → 恢复时从被中断节点的开头重新执行,interrupt() 之前的代码会再跑一遍;有副作用的步骤要幂等,或者拆到中断之前的独立节点。另外别用 try/except 包住 interrupt(),否则中断信号被吞掉、图不会暂停。
  • “进程内等待用户回复,在多实例下有什么问题?” → Future 在某个 worker 的内存里,用户回复打到任意一个 API 实例;需要在 Redis 登记等待令牌,API 收到回复后通过 Pub/Sub 广播,持有 Future 的 worker 比对回复 id 后再 resolve,令牌过期按取消处理。
  • “同一张卡同时点两次会怎样?” → 先后两次靠查 operation_id 已有结果直接返回;真正并发时两路都查不到,要靠订单表上 operation_id 列的唯一约束,撞键的一路重读并返回同一个订单号。
  • “任务被队列重投,会不会出两张确认卡?” → 会,除非出卡时按 request_key(run_id + 动作 + 与顺序无关的参数指纹)做唯一约束;先查一次挡住顺序重跑,唯一约束挡住并发插入。
  • “用户确认时价格已经变了怎么办?” → 执行前复检发现与快照不一致时不执行,把卡置为 invalidated 并提示用户重新确认;不能静默按新价格执行,也不能按旧价格强行下单。
  • “拒绝之后模型又发起同一个调用怎么办?” → 拒绝原因作为工具结果写回上下文,并在本轮记录被拒绝的参数指纹,相同调用直接由 Runtime 拦截返回「已被用户拒绝」,不再出卡。
  • “新增一个写工具忘了配置权限会怎样?” → 应该让它默认进入 ASK(多问一次),而不是默认放行;可以加一条测试断言「所有写工具都显式配置了策略」。

易错:

  • ❌ “对话里用户回复『确认』就可以执行” → 文本回复可能是注入内容或理解偏差,确认必须来自带身份和卡片 id 的结构化请求。
  • ❌ “批准后直接执行模型给的参数” → 应执行卡上的服务端快照,模型复述的价格、标题可能与数据源不一致。
  • ❌ “为了体验把所有写操作都改成自动执行,只保留撤销” → 发邮件、转账、对外调用往往撤不回,只有真正可撤销的低风险写才适合「先做后撤」。