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 交接的核心难点在于上下文传递。三种常见策略各有取舍:
- 全量历史传递(Full History Pass-through):将完整对话记录传给下一个 Agent。优点是信息无损,缺点是随着对话加长,Token 消耗爆炸式增长。
- 摘要传递(Summary):将历史消息压缩为一段摘要再传递。省 Token,但会丢失细节。
- 选择性传递(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 对路由决策的置信度低于阈值时,不直接交接,而是弹出一个确认框让用户自己选择要转接到哪个专家。这能有效防止误路由和循环交接问题。我们将在下一篇教程中深入实现这个模式。
// 典型实现逻辑 / 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)对接与私有化部署,欢迎点击下方按钮预约我们的免费诊断服务。