我来做
全栈开发 中级

Next.js + Vercel AI SDK 实战(三):多 Agent 协作与交接可视化

💡 本教程技术要点

深入对比 Supervisor、DAG 工作流和 Swarm/Handoff 三大多 Agent 编排模式的适用场景与优劣。通过 Agent 注册表 (Registry) 模式实现可扩展的 Agent 路由和动态切换,包含上下文传递策略、循环 Handoff 检测和前端可视化交接动效。

🎯 一、问题引入:为什么单 Agent 撑不住复杂业务?

假设你正在构建一个企业级智能客服系统,需要同时覆盖账单查询技术故障排查退换货处理三大业务线。最直觉的做法是把所有能力塞进一个 Agent——给它一份包含所有领域知识的超长 System Prompt,再挂上十几个工具。

这就是典型的「瑞士军刀」反模式。根据 OpenAI 官方的 Prompt Engineering 最佳实践,当 System Prompt 超过 2000 tokens 且同时挂载超过 8 个工具时,模型的指令遵循率会显著下降。具体表现为:

  • 工具选择混乱:用户问退货进度,模型却调用了账单查询接口
  • 角色串台:用户问技术问题,模型用客服话术回复「已为您提交工单」
  • 上下文注意力稀释:Prompt 越长,模型对关键指令的注意力越分散,特别是中间位置的规则最容易被忽略(Lost in the Middle 现象)

解决思路很清晰:把一个全能 Agent 拆分成多个各司其职的专家 Agent,通过交接(Handoff)机制协作。这正是本篇教程的核心主题。

🧠 二、原理解析:三种主流多 Agent 编排模式

在设计多 Agent 系统之前,你需要理解三种核心架构范式的区别:

维度 Supervisor 模式 DAG 工作流 Swarm / Handoff 模式
控制方式 中央调度器分派任务 有向无环图按步骤执行 Agent 之间动态转交控制权
适用场景 任务可明确拆分的流水线 步骤固定的审批/处理流 对话式、意图驱动的交互
优点 集中管控,链路清晰 确定性执行,可预测 灵活、涌现式行为、低耦合
缺点 Supervisor 成为瓶颈和单点故障 不灵活,无法动态路由 难调试,存在循环交接风险
代表框架 LangGraph Agent Supervisor LangGraph StateGraph OpenAI Swarm、Vercel AI SDK

本教程采用 Swarm / Handoff 模式,因为它最贴合客服对话场景——用户的意图在对话中随时可能转变,需要 Agent 之间动态交接。下面是这个模式的完整状态流转:

┌─────────────────────────────────────────────────────┐
│                   用户发送消息                        │
└──────────────────────┬──────────────────────────────┘
                       ▼
              ┌────────────────┐
              │  Router Agent  │  ← 始终作为入口
              │  (意图识别)     │
              └───────┬────────┘
                      │ 识别意图,决策交接目标
          ┌───────────┼───────────────┐
          ▼           ▼               ▼
   ┌────────────┐ ┌──────────┐ ┌──────────────┐
   │ 账单专家    │ │ 技术专家  │ │ 退换货专家    │
   │ Agent      │ │ Agent    │ │ Agent        │
   └─────┬──────┘ └────┬─────┘ └──────┬───────┘
         │              │              │
         └──────────────┼──────────────┘
                        ▼
              ┌────────────────┐
              │  返回结果给用户  │
              │  (标注当前Agent) │
              └────────────────┘

交接时的上下文管理策略

Agent 交接的核心难点在于上下文传递。三种常见策略各有取舍:

  1. 全量历史传递(Full History Pass-through):将完整对话记录传给下一个 Agent。优点是信息无损,缺点是随着对话加长,Token 消耗爆炸式增长。
  2. 摘要传递(Summary):将历史消息压缩为一段摘要再传递。省 Token,但会丢失细节。
  3. 选择性传递(Selective Context):只传递与目标 Agent 职责相关的信息。效果最好,但实现复杂度最高。

本教程的实现采用全量历史传递 + 最近 N 轮窗口截断的折中方案——简单可靠,足以覆盖绝大多数对话场景。

💻 三、动手实现:基于 Vercel AI SDK 的多 Agent 系统

Step 1:设计 Agent Registry(智能体注册表)

整个多 Agent 系统的核心数据结构是一个 Agent 注册表。每个 Agent 都是一个配置对象,包含名称、描述、系统提示词、工具集和交接规则。这个设计的关键好处是:新增一个 Agent 只需要加一条配置,不需要改任何逻辑代码

// lib/agent-registry.ts
import { tool } from 'ai';
import { z } from 'zod';

// Agent 配置的类型定义
interface AgentConfig {
  name: string;           // Agent 唯一标识
  displayName: string;    // 前端展示名称
  description: string;    // 供 Router 判断何时交接的描述(极其重要)
  systemPrompt: string;   // 该 Agent 的系统提示词
  tools: Record<string, any>;  // 该 Agent 独有的工具集
  canHandoffTo: string[]; // 允许交接的目标 Agent 列表
}

// ---- 定义各专家 Agent 的工具 ----

const billingTools = {
  queryBill: tool({
    description: '查询用户的账单信息',
    parameters: z.object({
      userId: z.string().describe('用户ID'),
      month: z.string().optional().describe('查询月份,格式 YYYY-MM'),
    }),
    execute: async ({ userId, month }) => {
      // 实际项目中这里调用计费系统 API
      return { userId, month: month ?? '2026-06', amount: 299, status: 'paid' };
    },
  }),
};

const techSupportTools = {
  checkServiceStatus: tool({
    description: '检查某个服务的运行状态',
    parameters: z.object({
      serviceName: z.string().describe('服务名称'),
    }),
    execute: async ({ serviceName }) => {
      return { service: serviceName, status: 'healthy', latency: '45ms' };
    },
  }),
  queryErrorLogs: tool({
    description: '查询最近的错误日志',
    parameters: z.object({
      serviceName: z.string().describe('服务名称'),
      hours: z.number().default(24).describe('查询最近多少小时'),
    }),
    execute: async ({ serviceName, hours }) => {
      return { service: serviceName, errors: [], period: `${hours}h` };
    },
  }),
};

const returnTools = {
  createReturnOrder: tool({
    description: '创建退换货工单',
    parameters: z.object({
      orderId: z.string().describe('原订单号'),
      reason: z.string().describe('退换货原因'),
    }),
    execute: async ({ orderId, reason }) => {
      return { returnId: `RT-${Date.now()}`, orderId, reason, status: 'created' };
    },
  }),
};

// ---- Agent 注册表 ----
export const agentRegistry: Record<string, AgentConfig> = {
  router: {
    name: 'router',
    displayName: '智能路由',
    description: '负责识别用户意图并转交给合适的专家',
    systemPrompt: `你是一个智能客服路由器。你的唯一职责是判断用户意图,然后通过 handoff 工具将对话交接给正确的专家 Agent。

判断规则:
- 涉及费用、账单、付款、订阅 → 交接给 billing(账单专家)
- 涉及报错、故障、性能问题、技术咨询 → 交接给 techSupport(技术专家)
- 涉及退货、换货、退款、商品质量 → 交接给 returns(退换货专家)

重要约束:
1. 不要自行回答任何业务问题,你只做路由。
2. 如果意图不明确,先追问用户,不要猜测。
3. 每次回复必须调用 handoff 工具或向用户提问,不做其他事情。`,
    tools: {},  // Router 的工具在下一步动态注入 handoff
    canHandoffTo: ['billing', 'techSupport', 'returns'],
  },

  billing: {
    name: 'billing',
    displayName: '账单专家',
    description: '处理所有与费用、账单、付款、订阅相关的问题',
    systemPrompt: `你是账单专家 Agent。你只处理与费用、账单、付款、订阅相关的问题。

身份锚定规则:
- 如果用户的问题超出你的职责范围,使用 handoff 工具交回给 router。
- 绝对不要尝试回答技术故障或退换货问题。
- 在每次回复开头,不需要自报身份,直接回答问题即可。`,
    tools: billingTools,
    canHandoffTo: ['router'],
  },

  techSupport: {
    name: 'techSupport',
    displayName: '技术专家',
    description: '处理技术故障排查、错误日志分析、性能问题诊断',
    systemPrompt: `你是技术支持专家 Agent。你只处理技术故障排查、系统错误和性能问题。

身份锚定规则:
- 如果用户的问题超出你的职责范围,使用 handoff 工具交回给 router。
- 绝对不要尝试回答账单或退换货问题。
- 排查问题时,先使用工具获取数据,再基于数据给出分析。`,
    tools: techSupportTools,
    canHandoffTo: ['router'],
  },

  returns: {
    name: 'returns',
    displayName: '退换货专家',
    description: '处理商品退货、换货和退款申请',
    systemPrompt: `你是退换货专家 Agent。你只处理退货、换货和退款相关的问题。

身份锚定规则:
- 如果用户的问题超出你的职责范围,使用 handoff 工具交回给 router。
- 在创建退换货工单前,必须确认订单号和退换原因。
- 绝对不要尝试回答账单或技术问题。`,
    tools: returnTools,
    canHandoffTo: ['router'],
  },
};

注意 description 字段的重要性——Router Agent 正是根据这段描述来决定交接目标的。它不是装饰性的注释,而是参与实际推理的关键输入。

Step 2:实现 Handoff 机制的 API 路由

接下来是核心的 API 路由。它需要做两件事:根据当前活跃的 Agent 名称加载对应的配置,并动态生成 handoff 工具注入给当前 Agent。

// app/api/chat/route.ts
import { streamText, tool, type CoreMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { agentRegistry } from '@/lib/agent-registry';

// 循环交接检测:追踪最近的交接历史
const MAX_HANDOFFS_PER_REQUEST = 3;

export async function POST(req: Request) {
  const { messages, activeAgent = 'router' } = await req.json() as {
    messages: CoreMessage[];
    activeAgent: string;
  };

  const agent = agentRegistry[activeAgent];
  if (!agent) {
    return Response.json({ error: `Unknown agent: ${activeAgent}` }, { status: 400 });
  }

  // 动态生成 handoff 工具 —— 只包含当前 Agent 允许交接的目标
  const handoffTool = agent.canHandoffTo.length > 0
    ? {
        handoff: tool({
          description: `将对话交接给另一个专家 Agent。可选目标:${
            agent.canHandoffTo.map(name => {
              const target = agentRegistry[name];
              return `"${name}"(${target?.description ?? ''})`;
            }).join('、')
          }`,
          parameters: z.object({
            targetAgent: z.enum(agent.canHandoffTo as [string, ...string[]])
              .describe('目标 Agent 的名称'),
            reason: z.string()
              .describe('交接原因,用一句话说明为什么需要转交'),
          }),
          // 注意:这里不用 execute,而是让前端处理交接逻辑
        }),
      }
    : {};

  // 合并 Agent 自身的工具 + handoff 工具
  const allTools = { ...agent.tools, ...handoffTool };

  const result = streamText({
    model: openai('gpt-4o'),
    system: agent.systemPrompt,
    messages,
    tools: allTools,
    maxSteps: 5,  // 允许多步工具调用,但限制上限防止失控
  });

  return result.toDataStreamResponse();
}

这里有一个重要的设计决策:handoff 工具没有 execute 函数。这意味着当模型决定交接时,工具调用会以 tool-call 事件流回前端,由前端来处理 Agent 切换的逻辑。为什么不在后端直接切换?因为我们需要在前端可视化交接过程,让用户感知到正在发生什么。

Step 3:前端实现 Agent 切换与可视化

前端要做三件事:管理当前活跃的 Agent 状态,拦截 handoff 工具调用并执行切换,以及展示交接过渡动画。

// app/page.tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { useState, useCallback, useRef } from 'react';
import { agentRegistry } from '@/lib/agent-registry';

// 交接记录,用于循环检测
interface HandoffRecord {
  from: string;
  to: string;
  timestamp: number;
}

export default function MultiAgentChat() {
  const [activeAgent, setActiveAgent] = useState('router');
  const [handoffHistory, setHandoffHistory] = useState<HandoffRecord[]>([]);
  const [showTransition, setShowTransition] = useState(false);
  const [transitionInfo, setTransitionInfo] = useState({ from: '', to: '', reason: '' });
  const handoffCountRef = useRef(0);

  const { messages, input, handleInputChange, handleSubmit, setMessages, isLoading } = useChat({
    api: '/api/chat',
    body: { activeAgent },
    maxSteps: 5,
    onToolCall: async ({ toolCall }) => {
      // 拦截 handoff 工具调用
      if (toolCall.toolName === 'handoff') {
        const { targetAgent, reason } = toolCall.args as {
          targetAgent: string;
          reason: string;
        };

        // ---- 循环检测 ----
        handoffCountRef.current += 1;
        if (handoffCountRef.current > MAX_HANDOFFS_PER_REQUEST) {
          return `交接被阻止:连续交接次数超过 ${MAX_HANDOFFS_PER_REQUEST} 次,可能存在循环。请直接回答用户的问题。`;
        }

        const fromAgent = agentRegistry[activeAgent];
        const toAgent = agentRegistry[targetAgent];

        // 记录交接历史
        setHandoffHistory(prev => [...prev, {
          from: activeAgent,
          to: targetAgent,
          timestamp: Date.now(),
        }]);

        // 显示过渡动画
        setTransitionInfo({
          from: fromAgent?.displayName ?? activeAgent,
          to: toAgent?.displayName ?? targetAgent,
          reason,
        });
        setShowTransition(true);

        // 短暂延迟后切换 Agent,让用户看到过渡效果
        setTimeout(() => {
          setActiveAgent(targetAgent);
          setShowTransition(false);
        }, 1500);

        return `已成功交接给${toAgent?.displayName},原因:${reason}`;
      }
    },
  });

  // 每次用户发送新消息时重置连续交接计数
  const onSubmit = useCallback((e: React.FormEvent) => {
    handoffCountRef.current = 0;
    handleSubmit(e);
  }, [handleSubmit]);

  const currentAgent = agentRegistry[activeAgent];

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* 顶部:当前 Agent 标识 */}
      <header className="p-4 border-b flex items-center gap-3">
        <div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
        <span className="font-semibold">{currentAgent?.displayName}</span>
        <span className="text-sm text-gray-500">正在为您服务</span>
      </header>

      {/* 交接过渡动画 */}
      {showTransition && (
        <div className="mx-4 my-2 p-3 bg-blue-50 border border-blue-200 rounded-lg
                        animate-in slide-in-from-top duration-300">
          <div className="flex items-center gap-2 text-sm">
            <span className="font-medium text-blue-700">{transitionInfo.from}</span>
            <span className="text-blue-400">→</span>
            <span className="font-medium text-blue-700">{transitionInfo.to}</span>
          </div>
          <p className="text-xs text-blue-600 mt-1">{transitionInfo.reason}</p>
        </div>
      )}

      {/* 消息列表 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map(m => (
          <div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-[80%] rounded-lg p-3 ${
              m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-100'
            }`}>
              {m.content}
            </div>
          </div>
        ))}
      </div>

      {/* 输入框 */}
      <form onSubmit={onSubmit} className="p-4 border-t flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入你的问题..."
          className="flex-1 border rounded-lg px-4 py-2"
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading}
                className="bg-blue-600 text-white px-4 py-2 rounded-lg">
          发送
        </button>
      </form>

      {/* 底部:交接历史时间线 */}
      {handoffHistory.length > 0 && (
        <div className="p-3 border-t bg-gray-50 text-xs text-gray-500">
          <strong>交接记录:</strong>
          {handoffHistory.map((h, i) => (
            <span key={i}>
              {agentRegistry[h.from]?.displayName} → {agentRegistry[h.to]?.displayName}
              {i < handoffHistory.length - 1 && ' | '}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

const MAX_HANDOFFS_PER_REQUEST = 3;

Step 4:循环交接检测机制详解

循环交接是多 Agent 系统中最危险的失败模式之一。例如:用户问了一个模棱两可的问题,Router 交给 Billing,Billing 觉得不是自己的事又交回 Router,Router 再次交给 Billing……如此往复,消耗大量 Token 且用户什么也得不到。

上面的代码中,我们用了一个简单但有效的计数器方案:每次用户发送新消息时重置 handoffCountRef,每次发生交接时递增。当连续交接次数超过阈值(默认 3 次),handoff 工具直接返回一段强制性提示词,告诉当前 Agent「停止交接,直接回答」。这个返回值会作为工具调用结果注入对话流,相当于用工具返回值来覆盖 Agent 的行为倾向。

⚠️ 四、生产陷阱:多 Agent 系统的常见翻车现场

陷阱 1:上下文爆炸

现象:对话进行到第 20 轮时,每次请求的 Token 数高达数万,响应速度急剧下降,成本飙升。

原因:全量历史传递模式下,每个 Agent 收到的消息数组不断膨胀。尤其是包含工具调用结果的消息,一个结构化 JSON 返回值可能就占数百 Token。

解决方案:在交接时进行上下文摘要压缩。用一次轻量 LLM 调用将历史消息压缩为 300 Token 以内的摘要,作为新 Agent 的初始上下文。对于最近 3 轮对话保持原文不动,仅压缩更早的历史。

陷阱 2:循环交接死锁

现象:Agent A 认为问题属于 B 的职责,B 认为属于 A 的职责,二者不断互踢皮球。

原因:Agent 的职责边界定义模糊,存在灰色地带。或者用户的问题恰好跨越两个领域(比如「我退货后退款怎么还没到账」——既是退换货也是账单问题)。

解决方案:除了计数器之外,增加冷却期机制——刚被交接走的 Agent 在 3 轮对话内不允许再被交接回来。同时在系统提示词中明确规定:当问题涉及多个领域时,优先由当前 Agent 处理主要问题,然后提示用户单独咨询其他方面。

陷阱 3:Router Agent 路由偏好

现象:统计发现 Router 将 70% 的问题都交给了技术专家,即使其中很多是账单问题。

原因:Router 的 System Prompt 中对技术专家的描述写得最详细,模型产生了注意力偏好。或者因为训练数据中技术类对话占比更高。

解决方案:确保 Agent Registry 中每个 Agent 的 description 长度和细节度大致相当。在 Router 的 System Prompt 中加入显式路由规则表(如上面代码所示的关键词映射),而不是让模型自由判断。定期从日志统计路由分布,发现偏移及时调整。

陷阱 4:Agent 身份遗忘

现象:交接给账单专家后,它有时会回答技术问题,或者用 Router 的口吻说「让我帮您转接」。

原因:对话历史中包含其他 Agent 的回复,模型会从这些历史中「学习」到错误的行为模式。特别是当对话经历多次交接后,历史中混杂了多个 Agent 的风格。

解决方案:在每个 Agent 的 System Prompt 开头加入身份锚定语句(Identity Anchoring),明确声明「你是 XX 专家,你只做 XX,绝不做 YY」。如上面 Registry 代码中所示,每个 Agent 都有一段不可忽略的身份约束。

陷阱 5:意图稀释

现象:用户最初说「我上个月的账单好像多扣了钱,而且退的那个货也没收到退款」。经过 Router → 账单专家 → Router → 退换货专家的交接链后,退换货专家只知道要处理退款,完全丢失了「账单多扣」的上下文。

解决方案:在 handoff 工具的参数中增加一个 contextSummary 字段,由发起交接的 Agent 主动总结用户的完整意图和已解决的部分,而不是依赖下一个 Agent 从历史中自行提取。

🔗 五、扩展阅读:框架横评与进阶方向

框架 语言 交接机制 可视化 生产就绪度
OpenAI Swarm Python 函数返回 Agent 对象触发交接 无(教学用途) ⭐⭐(实验性参考实现)
LangGraph Python / JS StateGraph 节点间有条件边 LangSmith Trace ⭐⭐⭐⭐
CrewAI Python 角色 + 任务自动委派 有限 ⭐⭐⭐
AutoGen Python 对话式多轮消息传递 AutoGen Studio ⭐⭐⭐
Vercel AI SDK TypeScript 工具调用 + 前端状态管理 自定义(本教程方案) ⭐⭐⭐⭐

大型产品中的多 Agent 实践参考:像 Claude Code 和 Cursor 这类 AI 编程工具,内部也采用了类似的多 Agent 分工思路——规划型 Agent 负责理解需求和拆分任务,执行型 Agent 负责代码编写和文件操作,审查型 Agent 负责验证结果。它们的核心经验是:Agent 之间的边界越清晰,每个 Agent 的系统提示词就越短越精准,整体效果反而更好

下一步进阶方向:本教程的实现是全自动交接。在生产环境中,很多场景需要加入 Human-in-the-Loop(人工介入门)——当 Agent 对路由决策的置信度低于阈值时,不直接交接,而是弹出一个确认框让用户自己选择要转接到哪个专家。这能有效防止误路由和循环交接问题。我们将在下一篇教程中深入实现这个模式。

💻 核心参考代码 (Reference Implementation)
// 典型实现逻辑 / Code outline
// 如需获取该场景下完整可运行的代码库与技术顾问指导,请联系我们
console.log("Loading module: $全栈开发...");
console.log("Configuring agent pipeline: $Next.js + Vercel AI SDK 实战(三):多 Agent 协作与交接可视化...");
console.log("Dependencies active. Pipeline initializing...");
// TODO: Custom code hooks for wolaizuo solutions.

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

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