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 / ASK | approved / 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机制)):
from langgraph.types import interrupt, Command
from langgraph.checkpoint.postgres import PostgresSaver # 包名 langgraph-checkpoint-postgres
def review_order(state):
card = build_card(state["pending_call"]) # 生成快照、影响说明
decision = interrupt(card) # 图暂停,card 交给调用方展示
# ↑ 恢复时本节点从头重跑,interrupt 之前不要有副作用
if decision["type"] == "approve":
return Command(goto="execute", update={"approved_args": card["args"]})
if decision["type"] == "edit":
return Command(goto="validate", update={"pending_call": decision["args"]}) # 改过的参数重新校验
return Command(goto="agent", update={"messages": [tool_msg("用户拒绝了该操作:" + decision.get("reason", ""))]})
with PostgresSaver.from_conn_string(DB_URI) as saver:
saver.setup() # 首次使用建表
graph = builder.compile(checkpointer=saver) # interrupt 依赖 checkpointer
config = {"configurable": {"thread_id": tid}} # thread_id 是恢复用的游标
result = graph.invoke(inputs, config) # 停在 interrupt
card = result["__interrupt__"] # invoke 返回值里的中断信息
graph.invoke(Command(resume={"type": "approve"}), config) # 用户点击后续跑python按当前官方文档:invoke 把中断放在返回值的 __interrupt__ 里,用事件流时从流对象的 interrupts 读取;一个节点里多次调用 interrupt() 时,resume 值严格按调用顺序(索引)匹配;并行分支同时中断时,要用「interrupt id → resume 值」的映射一次性恢复;不要把 interrupt() 包在 try/except 里,否则它用来暂停的内部异常会被吞掉。要记住的是:恢复 = 该节点从头重跑,所以生成卡片之类的写操作要么幂等,要么放在单独的前置节点里(具体字段随版本可能变化,以官方文档为准)。
4. 确认卡的数据结构#
class ConfirmationCard(BaseModel):
card_id: str
thread_id: str
run_id: str | None
user_id: str # 只有本人能决议
action: Literal["create_order", "cancel_order", "send_email"]
args_snapshot: dict # 执行时用这份,不回头读模型或会话里的临时数据
snapshot_hash: str # 前端带回来比对:用户点的是不是他看到的那一版
impact: dict # 给人看的影响:金额、对象、是否可撤销、副作用范围
risk_level: Literal["low", "high"]
request_key: str # 出卡幂等:run_id + action + 参数指纹,唯一约束
operation_id: str # 执行幂等:下游按它去重,防止点两下下两单
status: Literal["pending", "approved", "rejected", "expired", "modified",
"invalidated", "executed", "failed"]
expires_at: datetime
decided_at: datetime | None
superseded_by: str | None # 用户改参后原卡置 modified,这里记新卡的 card_idpython几个字段的用意:
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 的结构化请求。
- ❌ “批准后直接执行模型给的参数” → 应执行卡上的服务端快照,模型复述的价格、标题可能与数据源不一致。
- ❌ “为了体验把所有写操作都改成自动执行,只保留撤销” → 发邮件、转账、对外调用往往撤不回,只有真正可撤销的低风险写才适合「先做后撤」。