Human-in-the-loop:前端确认流与后端幂等
·
一周备稿 · 2026-08-11 · 17:00
上篇:articles/drafts/w2-embedding-choose.md
相关阅读:Tool Calling 前端怎么接 · Next.js AI Route
下篇预告:Claude Code 工作流
Agent 一旦能发邮件、改数据库、删文件,「全自动」就不该是默认。
Human-in-the-loop(HITL)不是拖慢产品,而是 高危动作的生产准入证。
先给结论:前端负责「展示待确认动作 + 收集用户决策」;后端负责「幂等执行 + 审计日志」;同一 tool_call_id 重复提交不能执行两次。
你将学到
- HITL 适用边界:哪些工具必须确认
- 前端确认流状态机(pending → approved / rejected)
- 后端幂等键、去重与超时释放
- 与 AI SDK tool calling 的衔接方式
- 踩坑:双点提交、刷新丢状态、幽灵 pending
- 最小 API 设计:
/api/tools/confirm
一、先结论:按风险分级,不是什么都弹窗
| 风险级 | 示例 | UI |
|---|---|---|
| L0 只读 | 查天气、读文档 | 直接执行 |
| L1 可逆 | 创建草稿、写缓存 | 可选确认 |
| L2 难逆 | 发邮件、改配置 | 必须确认 |
| L3 高危 | 删数据、转账、发布 | 确认 + 二次输入 / MFA |
模型产出 tool_call
→ 后端标记 pending(不执行)
→ 前端展示 ConfirmCard(参数可读化)
→ 用户 Approve / Reject
→ 后端幂等执行或回灌 rejection
→ 模型继续生成
别让模型在 Prompt 里「问用户可不可以」就真执行——那只是文字,不是门禁。
二、前端确认流:状态机比组件重要
Tool Part 扩展
type ToolPart = {
type: "tool";
id: string; // tool_call_id
name: string;
args: unknown;
status:
| "pending_approval" // 等用户
| "running"
| "done"
| "rejected"
| "error"
| "expired";
riskLevel: "L0" | "L1" | "L2" | "L3";
summary?: string; // 人话:「将删除订单 #123」
};
ConfirmCard 最小交互
function ConfirmCard({ part, onApprove, onReject }: Props) {
if (part.status !== "pending_approval") return null;
return (
<div className="confirm-card">
<p>{part.summary ?? `${part.name}(${JSON.stringify(part.args)})`}</p>
<button onClick={() => onApprove(part.id)}>确认执行</button>
<button onClick={() => onReject(part.id)}>取消</button>
</div>
);
}
要点:
- 参数摘要 由后端生成,避免把原始 JSON 甩给用户
- Approve 按钮 点击后立刻 disabled,防双点
- Reject 也要回灌模型,否则对话卡死
刷新与续聊
pending 状态必须 落库,不能只在 React state:
用户刷新 → 从 GET /api/chat/:sessionId 拉 messages + pending tools
→ 未过期 pending 继续展示 ConfirmCard
三、后端幂等:同一动作只执行一次
核心表
CREATE TABLE tool_executions (
id TEXT PRIMARY KEY, -- tool_call_id
session_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
args_json JSONB NOT NULL,
status TEXT NOT NULL, -- pending|approved|running|done|rejected|expired
result_json JSONB,
idempotency_key TEXT UNIQUE, -- 可选:client 生成
created_at TIMESTAMPTZ DEFAULT now(),
executed_at TIMESTAMPTZ
);
执行流程
def confirm_tool(tool_call_id: str, decision: str, user_id: str):
with db.transaction():
row = db.get_for_update(tool_call_id)
if row.status != "pending":
return row.result_json # 已处理,直接返回(幂等)
if row.session.user_id != user_id:
raise Forbidden()
if row.created_at < now() - timedelta(minutes=15):
row.status = "expired"
return {"error": "expired"}
if decision == "reject":
row.status = "rejected"
db.save(row)
return {"tool_result": "user_rejected"}
row.status = "running"
db.save(row)
result = exec_tool(row.tool_name, row.args_json) # 事务外执行
row.status = "done"
row.result_json = result
db.save(row)
return result
幂等规则:
- 同一
tool_call_id第二次 Approve → 返回第一次结果,不重复执行 - 并发双 Approve →
SELECT FOR UPDATE或乐观锁 version - 执行失败 → 状态
error,允许用户「重试」时 新 tool_call_id,别复用旧 id
四、API 设计
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/chat |
流式对话;高危 tool 只创建 pending |
| POST | /api/tools/confirm |
{ toolCallId, decision: approve\|reject } |
| GET | /api/chat/:sessionId |
恢复 pending 与历史 |
// POST /api/tools/confirm
export async function POST(req: Request) {
const user = await auth(req);
const { toolCallId, decision } = await req.json();
const result = await confirmTool(toolCallId, decision, user.id);
return Response.json(result);
}
确认后 把 tool_result 追加进 messages,再触发模型继续——可在同一会话里二次 streamText,或客户端把 result 贴回输入框。
五、与 Route Handler 分工
| 职责 | 放哪 |
|---|---|
| 流式生成 | /api/chat |
| 高危 tool 拦截 | chat 内 policy:L2+ 只写 pending |
| 真正执行 | /api/tools/confirm 或 Worker |
| 审计 | 独立 audit 表,存 userId、IP、参数摘要 |
长时工具(>15s)可在 Approve 后返回 jobId,前端轮询或 SSE——别让用户对着 ConfirmCard 干等。
六、安全与合规
- 确认页展示 后果,不是原始 API 字段名
- L3 操作加 二次输入(键入 DELETE / 订单号后四位)
- 服务端再次校验权限,不信前端传的
allowed: true - 日志脱敏:args 里的 phone、email 打码
- 拒绝也要可观测:统计 reject 率,发现 Prompt 诱导过多误触
七、踩坑清单
- 只在客户端 gate,后端裸 exec —— 一条 curl 绕过全部确认
- Approve 不 disabled —— 双点发两封邮件
- pending 不落库 —— 刷新即丢,用户以为系统坏了
- reject 不回灌模型 —— Agent 永远等结果
- tool_call_id 用随机数但不唯一 —— 幂等失效
- pending 无 TTL —— 僵尸任务堆积
- 确认 UI 展示不可读 JSON —— 用户瞎点通过
相关阅读
| 主题 | 链接 |
|---|---|
| Tool Calling UI | 163083715 |
| Route Handler | articles/drafts/w2-nextjs-ai-route.md |
| MCP 安全 | articles/drafts/w2-mcp-security.md |
| 下篇 · Claude Code | articles/drafts/w2-claude-code-workflow.md |
小结
Human-in-the-loop = 风险分级 + 前端状态机 + 后端幂等表。
高危 tool 先 pending,用户确认后再执行;同一 tool_call_id 只生效一次,拒绝也要回灌。
能自动的是 L0/L1,不是「模型说可以就可以」。
标签:Human-in-the-loop · Tool Calling · 幂等 · AI 前端
更多推荐


所有评论(0)