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
text 复制代码
模型产出 tool_call
  → 后端标记 pending(不执行)
  → 前端展示 ConfirmCard(参数可读化)
  → 用户 Approve / Reject
  → 后端幂等执行或回灌 rejection
  → 模型继续生成

让模型在 Prompt 里「问用户可不可以」就真执行------那只是文字,不是门禁。

二、前端确认流:状态机比组件重要

Tool Part 扩展

ts 复制代码
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 最小交互

tsx 复制代码
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>
  );
}

要点:

  1. 参数摘要 由后端生成,避免把原始 JSON 甩给用户
  2. Approve 按钮 点击后立刻 disabled,防双点
  3. Reject 也要回灌模型,否则对话卡死

刷新与续聊

pending 状态必须 落库,不能只在 React state:

text 复制代码
用户刷新 → 从 GET /api/chat/:sessionId 拉 messages + pending tools
         → 未过期 pending 继续展示 ConfirmCard

三、后端幂等:同一动作只执行一次

核心表

sql 复制代码
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
);

执行流程

python 复制代码
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 与历史
ts 复制代码
// 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 诱导过多误触

七、踩坑清单

  1. 只在客户端 gate,后端裸 exec ------ 一条 curl 绕过全部确认
  2. Approve 不 disabled ------ 双点发两封邮件
  3. pending 不落库 ------ 刷新即丢,用户以为系统坏了
  4. reject 不回灌模型 ------ Agent 永远等结果
  5. tool_call_id 用随机数但不唯一 ------ 幂等失效
  6. pending 无 TTL ------ 僵尸任务堆积
  7. 确认 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 前端

相关推荐
Rain的Java大神之路1 小时前
如何避免订单重复提交
java·redis·后端·面试·架构·rabbitmq·rocketmq
恋猫de小郭1 小时前
超好用 R8 Configuration Analyzer, 优化你的 App 大小和内存
android·前端·flutter
程序员爱钓鱼2 小时前
Rust 泛型 Generics详解:编写可复用且类型安全的代码
后端·面试·rust
gis分享者2 小时前
LangChain 和 LlamaIndex 有什么区别?各自适合什么场景?
人工智能·ai·langchain·场景·区别·llamaindex
小满zs2 小时前
Go语言第九章(错误处理)
后端·go
桦说编程2 小时前
深入理解 FutureTask 状态机——从契约到实现
java·后端·性能优化
程序员爱钓鱼2 小时前
Go 编程实战:指针 Pointer——理解地址、取址与解引用
后端·面试·go
赖龙10 小时前
pnpm vs npm
前端·npm·node.js