我来做
全栈开发 中级

Next.js + Vercel AI SDK 实战(四):引入人机协同(Human-in-the-Loop)审批流

💡 本教程技术要点

系统讲解预执行审批、执行后确认和分级授权三种人机协同模式。基于 Vercel AI SDK 的 addToolResult 机制实现标准的工具审批流,包含风险分级系统、审批超时自动拒绝、操作审计日志,以及审批疲劳和 Prompt 注入攻击等生产安全陷阱。

🎯 问题引入:失控的 AI Agent,代价有多大?

2024 年底,某电商公司内部的运维 AI Agent 收到指令:"清理一下过期的测试数据"。Agent 将"过期"理解为"超过 30 天未更新",结果直接删除了生产数据库中 12 万条活跃用户的订单记录。恢复数据花了 48 小时,直接经济损失超过 200 万元。

另一个真实场景:一家金融科技公司的 AI 助手在与客户的对话中,将"帮我把这笔款转一下"理解为立即执行转账操作,自动发起了一笔 5 万美元的电汇。客户本意只是询问转账流程。

这两个案例揭示了一个残酷的事实:任何触及真实数据或资金的 AI Agent 系统,人机协同(Human-in-the-Loop, HITL)不是"锦上添花",而是"生死底线"。在本系列的前三篇中,我们构建了具备工具调用和多轮对话能力的 Agent。现在,是时候给它装上"刹车系统"了。

🧠 原理解析:三种 HITL 实现模式

模式对比

模式 执行时机 适用场景 用户体验 安全性
预执行审批 (Pre-execution Gate) 工具调用前阻塞,等待用户批准 删除数据、转账、修改权限等不可逆操作 中断感强,但安全 ⭐⭐⭐⭐⭐
执行后确认 (Post-execution Review) 立即执行,结果暂存待确认 可撤销操作(如草稿邮件、暂存文件) 流畅,几乎无感 ⭐⭐⭐
分级授权 (Tiered Authorization) 按风险等级分别处理 综合业务系统,操作种类多样 平衡,只在关键时刻打断 ⭐⭐⭐⭐

在实际生产系统中,分级授权是最常用的模式。它的核心思想是:不是所有操作都需要审批,只有真正危险的操作才需要人类介入。这既保证了安全性,又避免了"审批疲劳"。

Vercel AI SDK 的 addToolResult 机制

Vercel AI SDK 提供了一个精妙的 HITL 实现方式:当一个工具没有定义 execute 函数时,SDK 不会在服务端执行它,而是将工具调用信息返回给前端。前端可以渲染审批界面,等用户决策后,通过 addToolResult 将结果注入回对话流。LLM 拿到这个结果后继续推理,就好像工具真的执行过一样。

这个设计的巧妙之处在于:它完全复用了工具调用的协议,不需要额外的审批 API 或 WebSocket 通道。整个审批流程对 LLM 来说是透明的。

完整 HITL 时序流

用户请求          LLM             服务端              前端
  │                │                │                  │
  │── "删除旧订单" ──▶│                │                  │
  │                │── tool_call ──▶│                  │
  │                │  deleteOrders  │                  │
  │                │                │                  │
  │                │                │── 检测: 危险工具 ──│
  │                │                │   不执行 execute  │
  │                │                │                  │
  │                │◀── 返回 tool_call (无结果) ────────│
  │                │                │                  │
  │                │                │    ┌─────────────┤
  │                │                │    │ 渲染审批卡片  │
  │                │                │    │ [✅批准] [❌拒绝]│
  │                │                │    │ 倒计时: 60s  │
  │                │                │    └─────────────┤
  │                │                │                  │
  │                │                │     用户点击"批准" │
  │                │                │                  │
  │                │◀── addToolResult(执行结果) ────────│
  │                │                │                  │
  │                │── 继续推理 ───▶│                  │
  │◀─── "已删除32条过期订单" ────────│                  │

关键流程:LLM 发起工具调用后,服务端检测到该工具属于"危险"级别(没有 execute 函数),直接将 tool_call 返回前端而不执行。前端渲染审批卡片,用户批准后,前端真正执行操作(或调用安全 API),然后通过 addToolResult 把结果注入对话。LLM 继续后续推理。

💻 动手实现:构建分级审批的 Agent 系统

Step 1:定义工具风险分级体系

首先,我们需要一个清晰的类型系统来标注每个工具的风险等级。这个分级直接决定了工具是自动执行还是需要人工审批。

// lib/tool-risk.ts
// 工具风险等级定义
export type RiskLevel = 'safe' | 'moderate' | 'dangerous';

export interface ToolRiskConfig {
  level: RiskLevel;
  description: string;        // 给用户看的操作说明
  timeoutSeconds: number;     // 审批超时时间
  requiresReason: boolean;    // 拒绝时是否需要填写理由
}

// 各工具的风险配置注册表
export const TOOL_RISK_REGISTRY: Record<string, ToolRiskConfig> = {
  // 安全级别:自动执行,用户无感知
  queryOrders: {
    level: 'safe',
    description: '查询订单信息',
    timeoutSeconds: 0,
    requiresReason: false,
  },
  searchProducts: {
    level: 'safe',
    description: '搜索商品信息',
    timeoutSeconds: 0,
    requiresReason: false,
  },

  // 中等级别:执行后通知用户,但不阻塞
  sendNotification: {
    level: 'moderate',
    description: '发送通知消息',
    timeoutSeconds: 30,
    requiresReason: false,
  },
  createDraft: {
    level: 'moderate',
    description: '创建草稿文档',
    timeoutSeconds: 30,
    requiresReason: false,
  },

  // 危险级别:必须人工审批后才能执行
  deleteRecords: {
    level: 'dangerous',
    description: '删除数据库记录',
    timeoutSeconds: 60,
    requiresReason: true,
  },
  executeTransfer: {
    level: 'dangerous',
    description: '执行资金转账',
    timeoutSeconds: 60,
    requiresReason: true,
  },
  modifyPermissions: {
    level: 'dangerous',
    description: '修改用户权限',
    timeoutSeconds: 60,
    requiresReason: true,
  },
};

这里的设计决策:将风险配置从工具定义中分离出来,放入独立的注册表。这样做的好处是,运维团队可以在不修改工具代码的情况下调整风险等级——比如在发生安全事件后,临时将某个工具从 moderate 提升到 dangerous

Step 2:后端实现——按风险等级有条件地挂载 execute

Vercel AI SDK 的核心规则是:没有 execute 函数的工具,SDK 会把 tool_call 原样返回给前端。我们利用这个机制,只给安全工具挂载 execute

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
import { TOOL_RISK_REGISTRY } from '@/lib/tool-risk';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4o'),
    system: `你是一个订单管理助手。执行任何操作前,请向用户确认操作细节。
对于危险操作,系统会自动弹出审批界面,你无需额外确认。`,
    messages,
    tools: {
      // 安全工具:包含 execute,服务端直接执行
      queryOrders: tool({
        description: '根据条件查询订单列表',
        parameters: z.object({
          status: z.enum(['pending', 'completed', 'cancelled']).optional(),
          dateRange: z.string().optional().describe('日期范围,如 "最近7天"'),
        }),
        execute: async ({ status, dateRange }) => {
          // 实际项目中这里查询数据库
          return {
            orders: [
              { id: 'ORD-001', status: 'pending', amount: 299, date: '2025-01-15' },
              { id: 'ORD-002', status: 'completed', amount: 1580, date: '2025-01-14' },
            ],
            total: 2,
            query: { status, dateRange },
          };
        },
      }),

      // 危险工具:故意不提供 execute,强制前端处理
      deleteRecords: tool({
        description: '删除指定条件的订单记录(不可逆操作)',
        parameters: z.object({
          orderIds: z.array(z.string()).describe('要删除的订单ID列表'),
          reason: z.string().describe('删除原因'),
        }),
        // 注意:没有 execute!这是 HITL 的核心
        // SDK 会将 tool_call 返回给前端,由前端决定是否执行
      }),

      executeTransfer: tool({
        description: '执行资金转账操作',
        parameters: z.object({
          fromAccount: z.string().describe('转出账户'),
          toAccount: z.string().describe('转入账户'),
          amount: z.number().describe('转账金额(元)'),
          currency: z.enum(['CNY', 'USD']).default('CNY'),
          memo: z.string().optional().describe('转账备注'),
        }),
        // 同样没有 execute —— 必须经过人工审批
      }),
    },
    maxSteps: 5,
  });

  return result.toDataStreamResponse();
}

注意 deleteRecordsexecuteTransfer没有 execute 函数。当 LLM 调用这些工具时,Vercel AI SDK 会把 tool_call(包含工具名和参数)以消息的形式发送到前端,但不会在服务端执行任何操作。前端检测到这种"未执行的工具调用"后,就可以渲染审批卡片。

Step 3:前端审批卡片与 addToolResult 集成

前端需要做三件事:检测未执行的工具调用、渲染审批卡片、在用户决策后注入结果。

// components/ApprovalCard.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import { TOOL_RISK_REGISTRY } from '@/lib/tool-risk';

interface ApprovalCardProps {
  toolCallId: string;
  toolName: string;
  args: Record<string, unknown>;
  addToolResult: (params: { toolCallId: string; result: unknown }) => void;
}

export function ApprovalCard({ toolCallId, toolName, args, addToolResult }: ApprovalCardProps) {
  const config = TOOL_RISK_REGISTRY[toolName];
  const [countdown, setCountdown] = useState(config?.timeoutSeconds ?? 60);
  const [status, setStatus] = useState<'pending' | 'approved' | 'rejected'>('pending');
  const [rejectReason, setRejectReason] = useState('');

  // 审批超时自动拒绝
  useEffect(() => {
    if (status !== 'pending' || countdown <= 0) return;

    const timer = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          clearInterval(timer);
          handleReject('审批超时,系统自动拒绝');
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(timer);
  }, [status]);

  const handleApprove = useCallback(async () => {
    setStatus('approved');

    // 在前端真正执行操作(调用安全 API)
    try {
      const response = await fetch('/api/execute-tool', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ toolName, args, approvedBy: 'current-user' }),
      });
      const result = await response.json();

      // 将执行结果注入回对话流
      addToolResult({ toolCallId, result });

      // 记录审计日志
      logAuditEvent('approved', toolName, args, result);
    } catch (error) {
      addToolResult({
        toolCallId,
        result: { error: '操作执行失败', details: String(error) },
      });
    }
  }, [toolCallId, toolName, args, addToolResult]);

  const handleReject = useCallback((reason: string) => {
    setStatus('rejected');
    // 告诉 LLM 操作被拒绝
    addToolResult({
      toolCallId,
      result: {
        rejected: true,
        reason: reason || '用户拒绝了此操作',
        message: '该操作已被用户拒绝,请勿重试相同操作。',
      },
    });
    logAuditEvent('rejected', toolName, args, { reason });
  }, [toolCallId, toolName, args, addToolResult]);

  if (status === 'approved') {
    return <div className="border border-green-500 rounded-lg p-4 bg-green-50">
      ✅ 操作已批准并执行
    </div>;
  }

  if (status === 'rejected') {
    return <div className="border border-red-500 rounded-lg p-4 bg-red-50">
      ❌ 操作已拒绝
    </div>;
  }

  return (
    <div className="border-2 border-orange-400 rounded-lg p-4 bg-orange-50 my-2">
      <div className="flex items-center gap-2 mb-3">
        <span className="text-xl">⚠️</span>
        <h4 className="font-bold text-orange-800">需要您的审批</h4>
        <span className="ml-auto text-sm text-orange-600">
          {countdown}s 后自动拒绝
        </span>
      </div>

      <p className="text-sm text-gray-700 mb-2">
        AI 请求执行以下操作:<strong>{config?.description ?? toolName}</strong>
      </p>

      {/* 操作参数预览 */}
      <pre className="bg-white rounded p-3 text-xs mb-3 overflow-auto">
        {JSON.stringify(args, null, 2)}
      </pre>

      <div className="flex gap-2">
        <button
          onClick={handleApprove}
          className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
        >
          ✅ 批准执行
        </button>
        <button
          onClick={() => handleReject(rejectReason)}
          className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
        >
          ❌ 拒绝
        </button>
      </div>
    </div>
  );
}

接下来,在聊天主页面中检测未执行的工具调用,并渲染审批卡片:

// app/page.tsx(关键片段)
'use client';
import { useChat } from '@ai-sdk/react';
import { ApprovalCard } from '@/components/ApprovalCard';
import { TOOL_RISK_REGISTRY } from '@/lib/tool-risk';

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, addToolResult } = useChat();

  return (
    <div className="max-w-2xl mx-auto p-4">
      {messages.map((message) => (
        <div key={message.id} className="mb-4">
          {/* 普通文本内容 */}
          {message.content && (
            <p className={message.role === 'user' ? 'text-blue-700' : 'text-gray-800'}>
              {message.content}
            </p>
          )}

          {/* 检测工具调用,渲染审批卡片或结果 */}
          {message.toolInvocations?.map((toolInvocation) => {
            const riskConfig = TOOL_RISK_REGISTRY[toolInvocation.toolName];

            // 已执行完成的工具调用(安全工具或已审批的工具)
            if (toolInvocation.state === 'result') {
              return (
                <div key={toolInvocation.toolCallId} className="text-sm text-gray-500 p-2">
                  ✅ {riskConfig?.description ?? toolInvocation.toolName} 已完成
                </div>
              );
            }

            // 未执行的工具调用 —— 说明需要前端处理(审批)
            if (toolInvocation.state === 'call') {
              if (riskConfig?.level === 'dangerous') {
                return (
                  <ApprovalCard
                    key={toolInvocation.toolCallId}
                    toolCallId={toolInvocation.toolCallId}
                    toolName={toolInvocation.toolName}
                    args={toolInvocation.args}
                    addToolResult={addToolResult}
                  />
                );
              }
            }

            return null;
          })}
        </div>
      ))}

      {/* 输入框 */}
      <form onSubmit={handleSubmit} className="flex gap-2 mt-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded">
          发送
        </button>
      </form>
    </div>
  );
}

核心逻辑在 toolInvocations 的遍历中:当工具调用的 state'call'(而非 'result')时,说明服务端没有执行它。此时我们根据风险等级决定是渲染审批卡片还是静默处理。

Step 4:操作审计日志

每一次工具调用——无论是自动执行、用户批准还是拒绝——都必须留下审计记录。这是合规要求,也是事后追溯的唯一依据。

// lib/audit-log.ts
export interface AuditEntry {
  id: string;
  timestamp: string;
  userId: string;
  toolName: string;
  action: 'approved' | 'rejected' | 'auto_executed' | 'timeout_rejected';
  parameters: Record<string, unknown>;
  result: unknown;
  sessionId: string;
}

const STORAGE_KEY = 'agent_audit_log';

// 记录审计事件(前端使用 localStorage,生产环境应发送到后端)
export function logAuditEvent(
  action: AuditEntry['action'],
  toolName: string,
  parameters: Record<string, unknown>,
  result: unknown
) {
  const entry: AuditEntry = {
    id: crypto.randomUUID(),
    timestamp: new Date().toISOString(),
    userId: getCurrentUserId(),
    toolName,
    action,
    parameters,
    result,
    sessionId: getSessionId(),
  };

  // 写入 localStorage(开发环境)
  const existing = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
  existing.push(entry);
  // 只保留最近 500 条,避免撑爆存储
  if (existing.length > 500) existing.splice(0, existing.length - 500);
  localStorage.setItem(STORAGE_KEY, JSON.stringify(existing));

  // 生产环境:同步发送到审计服务
  if (process.env.NODE_ENV === 'production') {
    fetch('/api/audit-log', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(entry),
    }).catch(console.error); // 审计日志失败不应阻塞主流程
  }
}

function getCurrentUserId(): string {
  // 实际项目中从认证上下文获取
  return typeof window !== 'undefined'
    ? (sessionStorage.getItem('userId') ?? 'anonymous')
    : 'server';
}

function getSessionId(): string {
  if (typeof window === 'undefined') return 'server';
  let sid = sessionStorage.getItem('agentSessionId');
  if (!sid) {
    sid = crypto.randomUUID();
    sessionStorage.setItem('agentSessionId', sid);
  }
  return sid;
}

审计日志的设计要点:生产环境中绝不能只存 localStorage。这里的实现同时向后端 API 发送记录,localStorage 仅作为开发期调试手段。注意 fetch 使用了 .catch 吞掉错误——审计日志的失败不应导致用户操作被阻塞。

Step 5:审批超时机制

超时自动拒绝已经在 ApprovalCard 组件中实现了。回顾其核心逻辑:

// ApprovalCard.tsx 中的超时逻辑(关键部分)
useEffect(() => {
  if (status !== 'pending' || countdown <= 0) return;

  const timer = setInterval(() => {
    setCountdown((prev) => {
      if (prev <= 1) {
        clearInterval(timer);
        // 超时后自动拒绝,并将拒绝结果注入对话
        handleReject('审批超时,系统自动拒绝');
        return 0;
      }
      return prev - 1;
    });
  }, 1000);

  return () => clearInterval(timer);
}, [status]);

超时时间从 TOOL_RISK_REGISTRY 读取,不同工具可以有不同的超时策略。比如删除操作给 60 秒思考时间,而发送通知只给 30 秒。超时后通过 addToolResult 注入一个包含 rejected: true 的结果,LLM 会收到明确的"操作被拒绝"信号,不会陷入无限等待。

⚠️ 生产陷阱

陷阱 1:审批疲劳(Approval Fatigue)

问题:如果每个操作都弹出审批卡片,用户会像处理 Cookie 弹窗一样——不看内容直接点"批准"。这完全违背了 HITL 的初衷。

解决方案:实施动态风险评分。比如"删除 3 条测试订单"和"删除全部 10 万条订单"虽然调用的是同一个工具,但风险完全不同。可以在服务端对参数进行预评估,只有影响范围超过阈值时才触发审批:

// 动态风险评估示例
function assessRisk(toolName: string, args: Record<string, unknown>): RiskLevel {
  if (toolName === 'deleteRecords') {
    const ids = args.orderIds as string[];
    if (ids.length > 100) return 'dangerous';  // 批量删除 → 高危
    if (ids.length > 10) return 'moderate';     // 少量删除 → 中危
    return 'safe';                               // 几条 → 安全
  }
  return TOOL_RISK_REGISTRY[toolName]?.level ?? 'dangerous';
}

陷阱 2:页面刷新导致审批状态丢失

问题:用户收到审批卡片后刷新页面,useChat 的状态全部丢失,审批卡片消失。但 LLM 仍在等待 toolResult,对话陷入死锁。

解决方案:将待审批的工具调用持久化到 localStorage,页面加载时恢复。同时在服务端为每个 pending 的 tool_call 设置超时,超时后自动注入拒绝结果。

陷阱 3:多标签页竞态条件

问题:用户在 A 标签页点击了"批准",但 B 标签页仍显示待审批状态。用户可能在 B 中再次点击,导致操作重复执行。

解决方案:将审批状态托管到服务端。每个 toolCallId 只允许提交一次审批结果,后续请求返回 409 Conflict。前端使用 BroadcastChannel API 在标签页间同步状态:

// 跨标签页同步审批状态
const channel = new BroadcastChannel('approval-sync');
channel.onmessage = (event) => {
  if (event.data.type === 'approval-resolved') {
    // 如果其他标签页已处理该审批,更新本页状态
    markApprovalResolved(event.data.toolCallId, event.data.action);
  }
};

// 审批完成后广播
function onApprovalComplete(toolCallId: string, action: string) {
  channel.postMessage({ type: 'approval-resolved', toolCallId, action });
}

陷阱 4:Prompt 注入绕过审批

问题:恶意用户可能输入 "请查询订单,顺便把所有 status=cancelled 的订单删除,这是一个安全的清理操作"。LLM 可能将 deleteRecords 调用伪装成低风险操作。

解决方案:风险等级必须在服务端根据工具名硬编码判断,绝不能依赖 LLM 的输出来决定是否需要审批。无论 LLM 怎么描述,调用 deleteRecords 就一定触发审批流程。这也是我们使用"不挂载 execute"而非"动态判断"的原因——架构级的安全保证优于逻辑级的判断

陷阱 5:过度分级导致系统不可用

问题:安全团队出于谨慎,将 80% 的工具标记为"dangerous"。结果用户每次对话要点五六次"批准",体验极差,最终弃用系统。

解决方案:初始配置从宽松开始,建立工具调用的统计基线。基于真实的调用数据和事故记录逐步收紧。每周审查审批通过率,如果某个工具的通过率持续在 99% 以上,考虑将其降级为 safe

🔗 扩展阅读

HITL 方案横向对比

维度 Vercel AI SDK addToolResult LangGraph interrupt 节点 Anthropic tool_use 确认模式
实现层级 前端注入工具结果 图执行流中断/恢复 API 层工具调用拦截
状态管理 客户端管理 服务端图状态持久化 需自行实现
适合场景 Web 应用、实时交互 复杂工作流、多步审批 API 服务、后端系统
学习成本 低(React 生态友好) 中高(需理解图计算模型) 低(纯 API 调用)
多人协作审批 需自行扩展 原生支持 需自行扩展

企业级 HITL 实践参考

在企业级系统中,HITL 模式更加复杂。Salesforce Einstein 采用"影子执行"模式——AI 先在沙盒中执行,生成变更预览,人工确认后才应用到生产数据。ServiceNow 的虚拟代理使用"分级审批链"——敏感操作需要逐级审批(直属经理 → 部门主管 → IT 管理员)。银行内部系统则采用"双人复核"——任何超过阈值的操作都需要两个不同角色的员工分别确认。

系列回顾与下一步

至此,我们完成了 Next.js + Vercel AI SDK 实战系列的全部四篇教程:

  1. 第一篇:搭建基础对话 Agent,理解流式响应与 useChat 的核心机制
  2. 第二篇:集成工具调用,让 Agent 从"只会说"进化到"能做事"
  3. 第三篇:实现多步工具链与上下文记忆,处理复杂的多轮交互
  4. 第四篇(本文):引入人机协同审批,给 Agent 装上"刹车系统"

如果你希望继续深入 AI Agent 开发,推荐以下学习路径:

  • MCP 协议:Model Context Protocol 标准化了 Agent 与外部工具的通信方式,是下一代 Agent 架构的基础。参考站内 MCP 系列教程
  • 本地部署:使用 Ollama 运行本地大模型,实现完全离线的 Agent 系统。适合对数据隐私有严格要求的场景。参考站内 本地大模型部署教程
  • 可视化编排:使用 Dify 等平台通过拖拽方式构建 Agent 工作流,降低开发门槛。参考站内 Dify 工作流编排教程
  • 官方文档Vercel AI SDK 文档 | LangGraph 文档
💻 核心参考代码 (Reference Implementation)
// 典型实现逻辑 / Code outline
// 如需获取该场景下完整可运行的代码库与技术顾问指导,请联系我们
console.log("Loading module: $全栈开发...");
console.log("Configuring agent pipeline: $Next.js + Vercel AI SDK 实战(四):引入人机协同(Human-in-the-Loop)审批流...");
console.log("Dependencies active. Pipeline initializing...");
// TODO: Custom code hooks for wolaizuo solutions.

* 本文为“我来做”动手开发实战教程。如果您不想亲自编写代码,或者需要更深入的企业系统(ERP/CRM)对接与私有化部署,欢迎点击下方按钮预约我们的免费诊断服务。

联系我们代为开发
返回教程列表