第 7 章 · 多智能体(Multi-Agent)
🎯 本章目标
- 理解为什么单个智能体不够用,什么信号说明该拆成多智能体了
- 掌握子图(Subgraph):把一张编译好的图当成另一张图的"节点"
- 掌握两种主流协作形态:Supervisor(有中心调度) 与 Swarm(去中心化互移交)
- 会读移交轨迹,能看出"谁把活交给了谁、为什么"
- 通过对照实验,形成自己的选型判断:什么时候用哪个
- 亲手拆开封装,看清 Supervisor / Swarm 底层的**移交工具(handoff tool)**机制
- 记住一个真实存在的坑:思考模式模型 + 消息回放 会直接报 400
本章代码
📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的
code/完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。
| 文件 | 内容 |
|---|---|
code/01-subgraph.ts | 子图:把编译好的图当作节点挂进父图 |
code/02-supervisor.ts | Supervisor:一个总管 + 若干专家 |
code/03-swarm.ts | Swarm:专家之间直接互相移交 |
code/04-supervisor-vs-swarm.ts | 对照实验:同一个问题,两种架构 |
code/05-handoff-under-the-hood.ts | 揭底:手写移交工具,看清底层机制 |
⚠️ 本章示例 2~5 用
deepseek-chat而不是默认的deepseek-flash。原因不是玄学,是一个会直接报 400 的真实坑,第七节专门讲。
一、为什么需要多智能体?
前面六章我们一直在打磨一个智能体。但真实项目很快会撞上这堵墙:
| 症状 | 后果 |
|---|---|
| 一个 agent 挂了 20+ 个工具 | 模型选错工具的概率明显上升,提示词也越来越长 |
| 上下文里混着完全不相关的知识 | 又贵又慢,还容易串味 |
| 几个子任务需要完全不同的提示词人设 | 一个 system prompt 装不下,互相打架 |
| 某个环节需要专门的知识库/权限 | 单 agent 拿到全部权限,安全上说不清 |
根本原因:一个 agent 只有一个"人设"和一个上下文窗口,硬塞多种职责会互相污染。
多智能体(Multi-Agent)的思路很朴素 —— 像公司一样分工:
单智能体 多智能体
┌──────────────────┐ ┌────────┐ ┌────────────┐
│ 一个大脑 │ │ 调度者 │──▶│ 天气专家 │
│ • 查天气 │ │ │ └────────────┘
│ • 算数学 │ │ │ ┌────────────┐
│ • 写文案 │ │ │──▶│ 数学专家 │
│ • 查数据库 │ └────────┘ └────────────┘
└──────────────────┘ 每个人只干一件事,上下文干净、提示词专注但"分工"只解决了一半问题,更关键的是另一半:怎么定义"谁在什么时候把活交给谁"。
LangGraph 对这个问题的回答就是本章的两大流派:
| 流派 | 结构 | 一句话 |
|---|---|---|
| Supervisor | 有中心 | 一个总管决定"下一个该谁上",专家干完都回总管汇报 |
| Swarm | 无中心 | 专家之间直接互相移交,没有总管 |
在讲它们之前,得先补一个基础件:子图。
二、子图:把一张图当成另一张图的"节点"
多智能体的实现基础就是子图 —— 因为"一个专家"本质上就是"一张独立的小图"。
子图就是一个"更大的节点":你不必关心它内部有几个节点、怎么循环,只要它 compile() 过,就能被 addNode() 挂进父图。
父图 子图(内部可能很复杂)
┌──────────┐ ┌───────────────────────────┐
│ introduce│ │ agent ──► tools ──► agent │ ← ReAct 循环
└────┬─────┘ └───────────────────────────┘
▼
┌────────────────┐
│ math_expert ★ │ ← 这一格其实是一整张子图
└────┬───────────┘
▼
┌──────────┐
│ wrap_up │
└──────────┘挂在父图里时,它和普通节点没有任何区别:父图只管往里送状态、等它返回。
示例 1:子图的两种接法
接子图会碰到两种情况,这个示例把它们放在一起对照:
- A) 字段对得上 → 直接把子图
addNode进去,状态自动流通 - B) 字段对不上 → 包一个转换节点,手动做输入/输出映射
typescript
/**
* 示例 1:子图(Subgraph)—— 把一张编译好的图,当成另一张图的节点
*
* 核心思想:
* 子图就是一个"更大的节点"。你不必关心它内部有几个节点、怎么循环,
* 只要它编译过(compile() 过),就能被 addNode() 挂进父图:
*
* 父图 子图(内部可能很复杂)
* ┌──────────┐ ┌───────────────────────────┐
* │ introduce│ │ agent ──► tools ──► agent │ ← ReAct 循环
* └────┬─────┘ └───────────────────────────┘
* ▼
* ┌────────────────┐
* │ math_expert ★ │ ← 这一格其实是一整张子图
* └────┬───────────┘
* ▼
* ┌──────────┐
* │ wrap_up │
* └──────────┘
*
* 本文件演示两种情况:
* A) 父图与子图"字段对得上" → 直接把子图 addNode 进去,状态自动流通
* B) 父图与子图"字段对不上" → 包一个转换节点,手动做输入/输出映射
*/
import { StateGraph, START, END, MessagesAnnotation, Annotation } from "@langchain/langgraph";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage, AIMessage, type BaseMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ── 子图用到的工具:计算器 ─────────────────────────────────────
const calculator = tool(
async ({ expression }) => {
console.log(` 🔧 [子图] calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "执行数学计算。当需要计算数学表达式时使用。",
schema: z.object({
expression: z.string().describe("要计算的数学表达式,例如 '125 * 8'"),
}),
}
);
async function main() {
banner("示例 1:子图 —— 把编译好的图当作节点");
assertChatKey();
const model = createModel({ temperature: 0 });
// ══════════════════════════════════════════════════════════
// A)父图与子图字段一致(都用 MessagesAnnotation)
// ══════════════════════════════════════════════════════════
console.log("【A】字段一致:直接把子图当节点挂进去\n");
// 子图:一个标准 ReAct 智能体,它的状态就是 messages
const mathExpert = createReactAgent({
llm: model,
tools: [calculator],
prompt: "你是数学专家,擅长用 calculator 工具做精确计算。",
});
// 父图:同样以 messages 为状态 —— 所以子图的 messages 能直接接进来
function introduce() {
console.log(" [父图 introduce] 造一个用户问题放进 messages");
return { messages: [new HumanMessage("计算 125 * 8 是多少?")] };
}
function wrapUp(state: typeof MessagesAnnotation.State) {
const last = state.messages[state.messages.length - 1];
console.log(" [父图 wrap_up] 子图跑完了,父图接着收尾");
return {
messages: [new AIMessage(`【父图收尾】以上是数学专家给出的答案:${last.content}`)],
};
}
const parentA = new StateGraph(MessagesAnnotation)
.addNode("introduce", introduce)
.addNode("math_expert", mathExpert) // ← 关键:一张编译好的图,直接当节点
.addNode("wrap_up", wrapUp)
.addEdge(START, "introduce")
.addEdge("introduce", "math_expert")
.addEdge("math_expert", "wrap_up")
.addEdge("wrap_up", END)
.compile();
const resultA = await parentA.invoke({ messages: [] });
console.log("\n📜 A 的完整消息轨迹:");
resultA.messages.forEach((m: BaseMessage, i: number) => {
console.log(` ${i}. [${m.getType()}] ${String(m.content).slice(0, 100)}`);
});
// ══════════════════════════════════════════════════════════
// B)父图与子图字段不一致 → 手动做输入/输出映射
// ══════════════════════════════════════════════════════════
console.log("\n\n【B】字段不一致:包一个转换节点,手动映射\n");
// 子图自己的状态:字段叫 text / shouted
const ShoutState = Annotation.Root({
text: Annotation<string>,
shouted: Annotation<string>,
});
const shoutGraph = new StateGraph(ShoutState)
.addNode("shout", (state: typeof ShoutState.State) => {
console.log(` [子图 shout] 收到 text="${state.text}"`);
return { shouted: `${state.text.toUpperCase()}!!!` };
})
.addEdge(START, "shout")
.addEdge("shout", END)
.compile();
// 父图的状态:字段叫 input / final —— 和子图完全对不上
const ParentState = Annotation.Root({
input: Annotation<string>,
final: Annotation<string>,
});
// 转换节点:负责 父图 → 子图(输入映射),再 子图 → 父图(输出映射)
async function callChildGraph(state: typeof ParentState.State) {
console.log(" [父图 transform] 把 input 映射成子图需要的 { text }");
const childOut = await shoutGraph.invoke({ text: state.input });
console.log(" [父图 transform] 把子图的 { shouted } 映射回父图的 final");
return { final: childOut.shouted };
}
const parentB = new StateGraph(ParentState)
.addNode("transform", callChildGraph)
.addEdge(START, "transform")
.addEdge("transform", END)
.compile();
const resultB = await parentB.invoke({ input: "langgraph 多智能体" });
console.log(`\n✅ 子图返回经过映射后的最终结果:${resultB.final}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 07-multi-agent/code/01-subgraph.ts真实输出:
========================================================================
示例 1:子图 —— 把编译好的图当作节点
========================================================================
【A】字段一致:直接把子图当节点挂进去
[父图 introduce] 造一个用户问题放进 messages
🔧 [子图] calculator("125 * 8")
[父图 wrap_up] 子图跑完了,父图接着收尾
📜 A 的完整消息轨迹:
0. [human] 计算 125 * 8 是多少?
1. [ai]
2. [tool] 1000
3. [ai] 125 × 8 = **1000**
计算过程:125 × 8 = 125 × 8 = 1000。
4. [ai] 【父图收尾】以上是数学专家给出的答案:125 × 8 = **1000**
计算过程:125 × 8 = 125 × 8 = 1000。
【B】字段不一致:包一个转换节点,手动映射
[父图 transform] 把 input 映射成子图需要的 { text }
[子图 shout] 收到 text="langgraph 多智能体"
[父图 transform] 把子图的 { shouted } 映射回父图的 final
✅ 子图返回经过映射后的最终结果:LANGGRAPH 多智能体!!!这个示例要记住三件事:
addNode("名字", 子图)直接就能用 —— 前提是父图的状态字段能给子图用。这里父子都基于MessagesAnnotation,messages一脉相承,所以零成本对接。- 字段不同就加一层转换节点。B 场景里父图用
input/final,子图要text/shouted,用一个transform节点做双向翻译。这层翻译看着多余,但它让父子图各自保持自己的状态设计,耦合更小。 - 子图的内部细节对父图完全透明。父图只知道"我调了 math_expert,它给了我一段文本" —— 内部那个 ReAct 循环父图一无所知。这正是多智能体要的封装。
💡 子图还能反向影响父图:在子图节点里返回
new Command({ goto: "父图节点", graph: Command.PARENT }),就能"从子图内部直接跳到父图的某个节点"。示例 5 会用到这个能力。
三、Supervisor:有中心的调度者模式
结构:一个"总管" + 若干"专家"。总管自己不干活,只负责决定"下一步该谁上"。
┌───────────────┐
┌──►│ supervisor │◄──┐ (专家干完都回到总管汇报)
│ │ (调度者) │ │
│ └───────┬───────┘ │
│ │ 派活 │
│ ┌───────▼───────┐ │
│ │ 专家 A / B │───┘
│ └───────────────┘
└───────────────────────┘关键机制:createSupervisor 会自动给总管挂上若干个移交工具(transfer_to_<专家名>)。总管调用它们,就等于"把话筒递给某个专家"。
💡 语言模型用聊天来思考,LangGraph 用图来思考 —— 移交工具就是两者的接口。
示例 2:一个总管 + 两位专家
问题刻意设计成需要两个专家接力:先查天气,再算乘法。
typescript
// 用 deepseek-chat(非思考模式)—— 原因见第七节
const model = createModel({ model: "deepseek-chat", temperature: 0 });typescript
/**
* 示例 2:Supervisor(调度者模式)
*
* 结构:一个"总管" + 若干"专家"。总管自己不干活,只负责决定"下一步该谁上":
*
* ┌───────────────┐
* ┌──►│ supervisor │◄──┐ (专家干完都回到总管汇报)
* │ │ (调度者) │ │
* │ └───────┬───────┘ │
* │ │ 派活 │
* │ ┌───────▼───────┐ │
* │ │ 专家 A / B │───┘
* │ └───────────────┘
* └───────────────────────┘
*
* 关键:createSupervisor 会自动给总管挂上若干个"移交工具"
* (transfer_to_<专家名>),总管调用它们就等于"把话筒递给某个专家"。
* 语言模型用聊天来思考、LangGraph 用图来思考,移交工具就是两者的接口。
*
* ⚠️ 为什么这里用 deepseek-chat 而不是默认的 deepseek-flash?(真实踩坑)
* deepseek-flash 是"思考模式"模型,返回的助手消息带 reasoning_content(思维链)。
* 多智能体场景里,专家算完的答复会被"回放"给总管,而 DeepSeek 要求这种回放
* 必须带上 reasoning_content —— 但 LangChain 重新发送消息时不会带上它,于是报错:
* 400 The `reasoning_content` in the thinking mode must be passed back to the API.
* 结论:凡涉及"多智能体来回移交 + 消息回放"的示例,改用非思考模式的
* deepseek-chat 最稳。本章其余涉及移交的示例同理。
*/
import { createSupervisor } from "@langchain/langgraph-supervisor";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage, type BaseMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ── 专家 1 的工具:计算器 ─────────────────────────────────────
const calculator = tool(
async ({ expression }) => {
console.log(` 🔧 [math_expert] calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "执行数学计算。当需要计算数学表达式时使用。",
schema: z.object({
expression: z.string().describe("要计算的数学表达式,例如 '18 * 3'"),
}),
}
);
// ── 专家 2 的工具:查天气(假数据) ───────────────────────────
const getWeather = tool(
async ({ city }) => {
console.log(` 🔧 [weather_expert] getWeather("${city}")`);
const fake: Record<string, string> = {
北京: "晴,18°C",
上海: "多云,22°C",
深圳: "小雨,26°C",
};
return fake[city] ?? `${city}:暂无数据`;
},
{
name: "getWeather",
description: "查询指定城市的当前天气。",
schema: z.object({
city: z.string().describe("城市名,例如 '北京'"),
}),
}
);
async function main() {
banner("示例 2:Supervisor 调度者模式");
assertChatKey();
// 见文件头注释:多智能体回放场景改用非思考模式的 deepseek-chat
const model = createModel({ model: "deepseek-chat", temperature: 0 });
// 每个专家都是一个独立的 ReAct 智能体;name 是总管的"点名册"
const weatherExpert = createReactAgent({
llm: model,
tools: [getWeather],
name: "weather_expert",
prompt: "你是天气专家,只负责查询城市天气,用 getWeather 工具。",
});
const mathExpert = createReactAgent({
llm: model,
tools: [calculator],
name: "math_expert",
prompt: "你是数学专家,只负责用 calculator 工具做精确计算。",
});
// ── 总管:一行建图(返回的是 StateGraph,记得 compile) ──────
const supervisor = createSupervisor({
agents: [weatherExpert, mathExpert],
llm: model,
prompt:
"你是团队主管,自己不做任何计算和查询,只负责分派任务:\n" +
"- 天气相关的问题交给 weather_expert\n" +
"- 数学计算交给 math_expert\n" +
"等专家完成后,如果你还需要另一位专家,就继续移交;" +
"所有步骤都完成后,直接总结成最终答案,不要再移交。",
}).compile();
const question = "北京现在气温是多少度?把这个温度乘以 3,告诉我最后是多少度。";
console.log(`👤 提问:${question}\n`);
console.log("── 路由轨迹(谁把活交给了谁)──");
// streamMode: "updates" 会按"节点被访问"的顺序吐出一块块更新,
// 我们只记录节点名,就能还原出整条移交路径。
const stream = await supervisor.stream(
{ messages: [new HumanMessage(question)] },
{ streamMode: "updates" }
);
const visited: string[] = [];
let finalMessage: BaseMessage | undefined;
for await (const chunk of stream) {
for (const [node, update] of Object.entries(chunk)) {
visited.push(node);
const msgs = (update as { messages?: BaseMessage[] }).messages ?? [];
if (msgs.length) finalMessage = msgs[msgs.length - 1];
}
}
visited.forEach((node, i) => {
const next = visited[i + 1];
if (!next) {
console.log(` ${i + 1}. ${node} (结束)`);
} else if (node === "supervisor") {
console.log(` ${i + 1}. supervisor ──移交──▶ ${next}`);
} else {
console.log(` ${i + 1}. ${node} ──交回──▶ supervisor`);
}
});
console.log(`\n 完整路径:${visited.join(" → ")}`);
console.log(`\n🤖 最终回答:${finalMessage?.content}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 07-multi-agent/code/02-supervisor.ts真实输出:
========================================================================
示例 2:Supervisor 调度者模式
========================================================================
👤 提问:北京现在气温是多少度?把这个温度乘以 3,告诉我最后是多少度。
── 路由轨迹(谁把活交给了谁)──
🔧 [weather_expert] getWeather("北京")
1. supervisor ──移交──▶ weather_expert
2. weather_expert ──交回──▶ supervisor
3. supervisor ──移交──▶ math_expert
4. math_expert ──交回──▶ supervisor
5. supervisor (结束)
完整路径:supervisor → weather_expert → supervisor → math_expert → supervisor
🤖 最终回答:所有步骤已完成,以下是最终答案:
**北京现在气温:18°C**
**计算:18 × 3 = 54**
**最终结果:54 度**把路由轨迹单独拎出来看,这是理解 Supervisor 的钥匙:
1. supervisor ──移交──▶ weather_expert
2. weather_expert ──交回──▶ supervisor
3. supervisor ──移交──▶ math_expert
4. math_expert ──交回──▶ supervisor
5. supervisor (结束)注意第 2 步和第 4 步:专家干完活不是直接结束,而是"交回总管"。总管拿到专家的结果后,才决定"还要不要再派人"。这就是"有中心"的含义 —— 每一步决策都经过总管。
对应的图结构:
__start__ → supervisor ⇄ weather_expert
⇄ math_expert
supervisor → __end__⚠️ 实践建议:专家数量控制在 2~5 个。总管每次决策都要看一遍所有专家的名字和描述,专家越多,选错的概率越高,而且它还得把每个专家的结果都读进上下文。真需要很多专家时,用层级结构(总管下面再挂分总管)比平铺十几个专家更稳。
四、Swarm:去中心化的专家互移交
Supervisor 是"有中心"的:所有专家都向总管汇报。Swarm 则是"无中心"的:专家之间直接互相移交,没有总管。
┌───────────────┐ transfer_to_math_expert ┌───────────────┐
│ weather_expert│ ───────────────────────────▶ │ math_expert │
│ (当前活跃) │ ◀─────────────────────────── │ │
└───────────────┘ transfer_to_weather_expert └───────────────┘谁"当前活跃"记在状态字段 activeAgent 里:
- 一个专家干完,可以调用"移交工具"把
activeAgent改成另一个专家 - 没人移交时,当前专家给出最终答案,图就结束了
示例 3:专家之间直接移交
和 Supervisor 有两个关键区别:
createSwarm必须传defaultActiveAgent,指定"第一个开口的是谁"- 移交工具要你自己用
createHandoffTool造好、并 bind 进每个专家 —— 因为没有总管替你准备移交工具,专家得自己带"话筒"
typescript
/**
* 示例 3:Swarm(去中心化 / 蜂群模式)
*
* Supervisor 是"有中心"的:所有专家都向总管汇报,由总管派活。
* Swarm 则是"无中心"的:专家之间直接互相移交,没有总管。
*
* ┌───────────────┐ transfer_to_math_expert ┌───────────────┐
* │ weather_expert│ ──────────────────────────▶ │ math_expert │
* │ (当前活跃) │ ◀────────────────────────── │ │
* └───────────────┘ transfer_to_weather_expert└───────────────┘
*
* 谁"当前活跃"记在状态字段 activeAgent 里:
* - 一个专家干完,可以调用"移交工具"把 activeAgent 改成另一个专家;
* - 没人移交时,当前专家给出最终答案,图就结束了。
*
* 注意两点(和 supervisor 的关键区别):
* 1) createSwarm 必须传 defaultActiveAgent,指定"第一个开口的是谁";
* 2) 移交工具要你自己用 createHandoffTool 造好、并 bind 进每个专家 —— 因为
* 没有总管替你准备移交工具,专家得自己带"话筒"。
*
* ⚠️ 同样因为"消息回放"的坑(见示例 2 注释),这里用 deepseek-chat。
*/
import { createSwarm, createHandoffTool } from "@langchain/langgraph-swarm";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage, type BaseMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ── 各专家的"本职工具" ────────────────────────────────────────
const getWeather = tool(
async ({ city }) => {
console.log(` 🔧 [weather_expert] getWeather("${city}")`);
const fake: Record<string, string> = {
北京: "晴,18°C",
上海: "多云,22°C",
深圳: "小雨,26°C",
};
return fake[city] ?? `${city}:暂无数据`;
},
{
name: "getWeather",
description: "查询指定城市的当前天气。",
schema: z.object({ city: z.string().describe("城市名,例如 '北京'") }),
}
);
const calculator = tool(
async ({ expression }) => {
console.log(` 🔧 [math_expert] calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "执行数学计算。",
schema: z.object({ expression: z.string().describe("数学表达式") }),
}
);
async function main() {
banner("示例 3:Swarm 去中心化专家互移交");
assertChatKey();
const model = createModel({ model: "deepseek-chat", temperature: 0 });
// ── 手造"移交工具":这是 swarm 与 supervisor 最大的不同 ──────
// transfer_to_math_expert / transfer_to_weather_expert
const handoffToMath = createHandoffTool({
agentName: "math_expert",
description: "把任务交给数学专家 math_expert 做计算。",
});
const handoffToWeather = createHandoffTool({
agentName: "weather_expert",
description: "把任务交给天气专家 weather_expert 查天气。",
});
// 每个专家 = 本职工具 + "移交到别人"的工具
const weatherExpert = createReactAgent({
llm: model,
tools: [getWeather, handoffToMath],
name: "weather_expert",
prompt:
"你是天气专家,用 getWeather 回答天气问题。\n" +
"如果任务里还需要数学计算,用 transfer_to_math_expert 交给 math_expert;\n" +
"如果不需要别人帮忙,直接把答案说清楚,不要再移交。",
});
const mathExpert = createReactAgent({
llm: model,
tools: [calculator, handoffToWeather],
name: "math_expert",
prompt:
"你是数学专家,用 calculator 做计算。\n" +
"如果你需要某个城市的天气数据,用 transfer_to_weather_expert 交给 weather_expert;\n" +
"否则直接给出完整的最终答案,不要再移交。",
});
// ── 建 swarm:注意 defaultActiveAgent 必填 ────────────────
const swarm = createSwarm({
agents: [weatherExpert, mathExpert],
defaultActiveAgent: "weather_expert",
}).compile();
const question = "上海现在多少度?把这个温度乘以 3。";
console.log(`👤 提问:${question}\n`);
console.log("── 移交轨迹(按节点被访问的顺序)──");
const stream = await swarm.stream(
{ messages: [new HumanMessage(question)] },
{ streamMode: "updates" }
);
const visited: string[] = [];
let finalMessage: BaseMessage | undefined;
for await (const chunk of stream) {
for (const [node, update] of Object.entries(chunk)) {
visited.push(node);
const msgs = (update as { messages?: BaseMessage[] }).messages ?? [];
if (msgs.length) finalMessage = msgs[msgs.length - 1];
}
}
visited.forEach((node, i) => {
const next = visited[i + 1];
if (next) console.log(` ${i + 1}. ${node} ──移交──▶ ${next}`);
else console.log(` ${i + 1}. ${node} (结束)`);
});
console.log(`\n 完整路径:${visited.join(" → ")}`);
console.log(`\n🤖 最终回答:${finalMessage?.content}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 07-multi-agent/code/03-swarm.ts真实输出:
========================================================================
示例 3:Swarm 去中心化专家互移交
========================================================================
👤 提问:上海现在多少度?把这个温度乘以 3。
── 移交轨迹(按节点被访问的顺序)──
🔧 [weather_expert] getWeather("上海")
1. weather_expert ──移交──▶ math_expert
2. math_expert (结束)
完整路径:weather_expert → math_expert
🤖 最终回答:上海现在多云,22°C。22 × 3 = 66。
(注:我这边没有名为 getWeather 的工具,天气数据是通过天气专家获取的;乘法计算也已交由数学专家完成,结果为 66。)看路径:weather_expert → math_expert —— 两步就结束了,没有回总管那一趟。
不过这次输出里有个值得注意的现象:最终回答里模型自己"多嘴"解释了一句「我这边没有名为 getWeather 的工具」。这不是 bug,而是移交机制的自然产物:控制权已经交给 math_expert,但它手里只有计算器工具,却看到了前面完整的对话历史(包括天气专家的工具调用记录),于是它会对"为什么我没这个工具"感到困惑。
💡 这个现象提示了两件事:
- 移交时要管好上下文 —— 官方提供了
createHandoffBackMessages之类的工具来裁剪移交时的历史,避免下游专家看到不属于自己的工具调用。- Swarm 对专家的"边界感"要求更高。因为没人居中协调,每个专家都得自觉只干自己的事、该移交就移交。
五、对照实验:到底该选哪个?
讲完两种架构,最实际的问题来了:选哪个?
与其听结论,不如做实验 —— 用完全相同的两位专家、同一个问题,分别跑一次,把两条路径并排打印出来。
示例 4:同一个问题,两种架构
typescript
/**
* 示例 4:对照实验 —— 同一个问题,Supervisor vs Swarm
*
* 我们用完全相同的两位专家(天气 / 数学)和同一个问题,
* 分别跑一次 supervisor 和 swarm,把两条"调用路径"并排打印出来。
*
* Supervisor:有中心。所有专家都回到总管汇报,由总管决定下一个。
* Swarm :无中心。专家之间直接互相移交,没有总管。
*
* ⚠️ 消息回放坑(见示例 2),这里统一用 deepseek-chat。
*/
import { createSupervisor } from "@langchain/langgraph-supervisor";
import { createSwarm, createHandoffTool } from "@langchain/langgraph-swarm";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage, type BaseMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ── 两位专家共用的"本职工具" ─────────────────────────────────
const getWeather = tool(
async ({ city }) => {
console.log(` 🔧 getWeather("${city}")`);
return ({ 北京: "晴,18°C", 上海: "多云,22°C" }[city] as string) ?? "暂无数据";
},
{
name: "getWeather",
description: "查询指定城市的当前天气。",
schema: z.object({ city: z.string().describe("城市名") }),
}
);
const calculator = tool(
async ({ expression }) => {
console.log(` 🔧 calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "执行数学计算。",
schema: z.object({ expression: z.string().describe("数学表达式") }),
}
);
/** 跑一遍图,返回访问过的节点序列与最终回答 */
// 注:supervisor 与 swarm 的 compile() 结果的泛型不同,这里用 any 收口,
// 只依赖它俩都有的 .stream() —— 好让同一个函数服务两种图。
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function runAndTrace(
graph: any,
question: string
): Promise<{ path: string[]; answer: string }> {
const stream = await graph.stream(
{ messages: [new HumanMessage(question)] },
{ streamMode: "updates" }
);
const path: string[] = [];
let finalMessage: BaseMessage | undefined;
for await (const chunk of stream) {
for (const [node, update] of Object.entries(chunk)) {
path.push(node);
const msgs = (update as { messages?: BaseMessage[] }).messages ?? [];
if (msgs.length) finalMessage = msgs[msgs.length - 1];
}
}
return { path, answer: String(finalMessage?.content ?? "") };
}
async function main() {
banner("示例 4:Supervisor vs Swarm 对照实验");
assertChatKey();
const model = createModel({ model: "deepseek-chat", temperature: 0 });
const question = "北京现在气温是多少?再把这个温度乘以 3。";
console.log(`👤 同一个问题:${question}\n`);
// ── ① Supervisor 版本 ─────────────────────────────────────
console.log("【① Supervisor】有中心:专家都向总管汇报");
const supWeather = createReactAgent({
llm: model,
tools: [getWeather],
name: "weather_expert",
prompt: "你是天气专家,用 getWeather 查天气。",
});
const supMath = createReactAgent({
llm: model,
tools: [calculator],
name: "math_expert",
prompt: "你是数学专家,用 calculator 做计算。",
});
const supervisor = createSupervisor({
agents: [supWeather, supMath],
llm: model,
prompt: "你是主管,自己不干活。天气交给 weather_expert,数学交给 math_expert。全部完成后总结答案。",
}).compile();
const supRes = await runAndTrace(supervisor, question);
console.log(` 路径:${supRes.path.join(" → ")}`);
console.log(` 回答:${supRes.answer.slice(0, 80)}…\n`);
// ── ② Swarm 版本 ──────────────────────────────────────────
console.log("【② Swarm】无中心:专家互相直接移交");
const toMath = createHandoffTool({
agentName: "math_expert",
description: "把任务交给 math_expert 做计算。",
});
const toWeather = createHandoffTool({
agentName: "weather_expert",
description: "把任务交给 weather_expert 查天气。",
});
const swarmWeather = createReactAgent({
llm: model,
tools: [getWeather, toMath],
name: "weather_expert",
prompt:
"你是天气专家,用 getWeather 回答。若还需要数学计算,用 transfer_to_math_expert 交给 math_expert;否则直接给最终答案。",
});
const swarmMath = createReactAgent({
llm: model,
tools: [calculator, toWeather],
name: "math_expert",
prompt:
"你是数学专家,用 calculator 计算。缺少天气数据时才 transfer_to_weather_expert;否则直接给最终答案。",
});
const swarm = createSwarm({
agents: [swarmWeather, swarmMath],
defaultActiveAgent: "weather_expert",
}).compile();
const swarmRes = await runAndTrace(swarm, question);
console.log(` 路径:${swarmRes.path.join(" → ")}`);
console.log(` 回答:${swarmRes.answer.slice(0, 80)}…\n`);
// ── ③ 并排对比 ────────────────────────────────────────────
console.log("【③ 并排对比】");
console.log(` Supervisor 路径(${supRes.path.length} 步):${supRes.path.join(" → ")}`);
console.log(` Swarm 路径(${swarmRes.path.length} 步):${swarmRes.path.join(" → ")}`);
console.log("\n 怎么选?");
console.log(" • 专家多、需要集中管控 / 审计 → Supervisor(有中心,路径清晰)");
console.log(" • 专家少、追求最短移交路径 / 去中心协作 → Swarm");
console.log(" • 想边跑边人工审批 / 看每步日志 → 两者都能配合 checkpointer 用");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 07-multi-agent/code/04-supervisor-vs-swarm.ts真实输出:
========================================================================
示例 4:Supervisor vs Swarm 对照实验
========================================================================
👤 同一个问题:北京现在气温是多少?再把这个温度乘以 3。
【① Supervisor】有中心:专家都向总管汇报
🔧 getWeather("北京")
🔧 calculator("18 * 3")
路径:supervisor → weather_expert → supervisor → math_expert → supervisor
回答:两个任务都已完成,总结如下:
**1. 天气(weather_expert)**
北京现在气温:**18°C**
**2. 数学(math_expert)*…
【② Swarm】无中心:专家互相直接移交
🔧 getWeather("北京")
路径:weather_expert → math_expert
回答:北京现在气温是 **18°C**(晴)。
把这个温度乘以 3:18 × 3 = **54**。…
【③ 并排对比】
Supervisor 路径(5 步):supervisor → weather_expert → supervisor → math_expert → supervisor
Swarm 路径(2 步):weather_expert → math_expert
怎么选?
• 专家多、需要集中管控 / 审计 → Supervisor(有中心,路径清晰)
• 专家少、追求最短移交路径 / 去中心协作 → Swarm
• 想边跑边人工审批 / 看每步日志 → 两者都能配合 checkpointer 用把两条路径并排看:
Supervisor 路径(5 步):supervisor → weather_expert → supervisor → math_expert → supervisor
Swarm 路径(2 步):weather_expert → math_expert同一个任务,Supervisor 走了 5 步,Swarm 走了 2 步。 多出来的 3 步全都是"回总管汇报 + 总管重新决策"。
这就是两者最本质的权衡:
| 维度 | Supervisor(有中心) | Swarm(无中心) |
|---|---|---|
| 调用步数 / 成本 | 高(每轮都回中心) | 低(直达下一个专家) |
| 可控性 / 可审计 | 强(每步决策都经过总管) | 弱(专家自主移交) |
| 路径可预测性 | 高(集中在总管手里) | 相对低(取决于专家自己的判断) |
| 适合规模 | 专家多、职责清晰、需要管控 | 专家少、移交链短、追求效率 |
| 典型场景 | 企业流程审批、需要审计的多步任务 | 客服转接、轻量接力式协作 |
实践建议
- 默认从 Supervisor 开始。它的"有中心"意味着可控、可观测、好调试,出了问题能一眼看出是哪一步做错了决策。
- 确认成本是瓶颈、且专家间是"流水线接力"关系时,再换 Swarm。比如"接待 → 技术 → 账单"这种天然顺序。
- 两者都能配合上一章的 checkpointer 使用,也都能和第五章的 HITL 结合(在总管决策处插人工审批,是性价比很高的一种用法)。
六、揭底:移交的底层机制
用了半天 createSupervisor / createSwarm,它们到底做了什么?
答案只有一句话:把"把话筒交给同事"包装成一个工具。
普通工具(如 calculator):返回值是"一段文本"
移交工具(handoff tool):返回值是一条**命令(Command)**
new Command({
goto: "下一个节点", // 图上该跳到谁
graph: Command.PARENT, // 跳的是"父图"(我现在在某个专家的子图里)
update: { messages: [...] } // 顺便把状态改一改
})
工具的执行节点(ToolNode)认得这种"命令型返回值",
于是不再把结果写回消息,而是直接按 goto 把控制权交出去。supervisor 与 swarm 的核心,无非就是这个。
示例 5:不用任何预置包,手搭一个 Supervisor
typescript
/**
* 示例 5:移交的底层机制 —— 手写一个 Supervisor
*
* 前四个示例都在用 createSupervisor / createSwarm。它们到底做了什么?
* 答案只有一句话:**把"把话筒交给同事"包装成一个工具**。
*
* 一个正常的工具(比如 calculator)返回值是"一段文本";
* 而"移交工具"返回的是一条 **命令(Command)**:
*
* new Command({
* goto: "下一个节点", // 图上该跳到谁
* graph: Command.PARENT, // 跳的是"父图"(我现在在某个专家的子图里)
* update: { messages: [...] } // 顺便把状态改一改
* })
*
* 工具的执行节点(ToolNode)认得这种"命令型返回值",于是不再把结果写回消息,
* 而是直接按 goto 把控制权交出去。supervisor 与 swarm 的核心,无非就是这个。
*
* 本示例不用任何预置包,只用 tool + Command 手搭一个"主管 + 两位专家",
* 让你亲眼看到刚才那段话说的是真的。
*
* ⚠️ 同样因为"消息回放"的坑(见示例 2),这里用 deepseek-chat。
*/
import { StateGraph, START, END, MessagesAnnotation, Command, getCurrentTaskInput } from "@langchain/langgraph";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { evaluate } from "mathjs";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
// ── 手写"移交工具"工厂:这就是 supervisor/swarm 的最小内核 ──────
function makeHandoffTool(destination: string, description: string) {
return tool(
async (_args, config) => {
console.log(` 🎫 [handoff 工具] 被调用 → 生成一条"跳转到 ${destination}"的命令`);
// 先造一条 ToolMessage 占位(告诉模型"工具调用成功了")
const toolMessage = new ToolMessage({
content: `Successfully transferred to ${destination}`,
name: `transfer_to_${destination}`,
tool_call_id: config.toolCall?.id ?? "unknown",
});
// 取出当前状态,把 ToolMessage 追加进消息里,然后发一条"跳转命令"
const state = getCurrentTaskInput() as { messages: BaseMessage[] };
return new Command({
goto: destination, // 跳到哪个节点
graph: Command.PARENT, // 跳的是父图(本工具跑在子图里)
update: { messages: state.messages.concat(toolMessage) }, // 顺带更新状态
});
},
{
name: `transfer_to_${destination}`,
description,
schema: z.object({}),
}
);
}
async function main() {
banner("示例 5:揭底 —— 手写一个 Supervisor");
assertChatKey();
const model = createModel({ model: "deepseek-chat", temperature: 0 });
// ── 1) 两位专家(普通的 ReAct 子图,各自带本职工具) ────────
const getWeather = tool(
async ({ city }) => {
console.log(` 🔧 [weather_expert] getWeather("${city}")`);
return ({ 北京: "晴,18°C", 上海: "多云,22°C" }[city] as string) ?? "暂无数据";
},
{
name: "getWeather",
description: "查询指定城市天气",
schema: z.object({ city: z.string() }),
}
);
const calculator = tool(
async ({ expression }) => {
console.log(` 🔧 [math_expert] calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "做数学计算",
schema: z.object({ expression: z.string() }),
}
);
const weatherExpert = createReactAgent({
llm: model,
tools: [getWeather],
name: "weather_expert",
prompt: "你是天气专家,用 getWeather 回答天气问题。",
});
const mathExpert = createReactAgent({
llm: model,
tools: [calculator],
name: "math_expert",
prompt: "你是数学专家,用 calculator 做计算。",
});
// ── 2) 主管 = 一个"只会移交"的 ReAct 智能体 ─────────────────
// 它手上没有任何业务工具,只有两把"话筒"。
const toWeather = makeHandoffTool("weather_expert", "把任务交给天气专家");
const toMath = makeHandoffTool("math_expert", "把任务交给数学专家");
const supervisorAgent = createReactAgent({
llm: model,
tools: [toWeather, toMath],
name: "supervisor",
prompt:
"你是主管,自己不查天气、也不做任何计算。规则:\n" +
"1) 一次只能移交一位专家(天气问题用 transfer_to_weather_expert," +
"数学问题用 transfer_to_math_expert),千万不要同时调用两个移交工具;\n" +
"2) 天气查完后如果还要做乘法/加法等数学运算,必须交给 math_expert," +
"即使你自己能算也不行;\n" +
"3) 所有步骤都办完后,直接总结答案,不要再移交。",
});
// ── 3) 手工把三张图拼成"主管 + 专家"结构 ───────────────────
// 这和 createSupervisor() 内部干的事一模一样。
const graph = new StateGraph(MessagesAnnotation)
.addNode("supervisor", supervisorAgent, {
ends: ["weather_expert", "math_expert"], // 允许 supervisor 用 Command 跳过去
})
.addNode("weather_expert", weatherExpert, { subgraphs: [weatherExpert] })
.addNode("math_expert", mathExpert, { subgraphs: [mathExpert] })
.addEdge(START, "supervisor")
.addEdge("weather_expert", "supervisor") // 专家干完,回到主管
.addEdge("math_expert", "supervisor")
.compile();
const question = "北京气温是多少?把它乘以 3。";
console.log(`👤 提问:${question}\n`);
console.log("── 执行轨迹 ──");
const stream = await graph.stream(
{ messages: [new HumanMessage(question)] },
{ streamMode: "updates" }
);
const visited: string[] = [];
let finalMessage: BaseMessage | undefined;
for await (const chunk of stream) {
for (const [node, update] of Object.entries(chunk)) {
visited.push(node);
const msgs = (update as { messages?: BaseMessage[] }).messages ?? [];
if (msgs.length) finalMessage = msgs[msgs.length - 1];
}
}
visited.forEach((node, i) => console.log(` ${i + 1}. ${node}`));
console.log(`\n 完整路径:${visited.join(" → ")}`);
console.log(
"\n 👉 对比示例 2 就会发现:createSupervisor 生成的路径长这样," +
"我们手写的也是这个形状 —— 它并没有魔法,只是替你连了几条边。"
);
console.log(`\n🤖 最终回答:${finalMessage?.content}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 07-multi-agent/code/05-handoff-under-the-hood.ts真实输出:
========================================================================
示例 5:揭底 —— 手写一个 Supervisor
========================================================================
👤 提问:北京气温是多少?把它乘以 3。
── 执行轨迹 ──
🎫 [handoff 工具] 被调用 → 生成一条"跳转到 weather_expert"的命令
🔧 [weather_expert] getWeather("北京")
1. supervisor
2. weather_expert
3. supervisor
完整路径:supervisor → weather_expert → supervisor
👉 对比示例 2 就会发现:createSupervisor 生成的路径长这样,我们手写的也是这个形状 —— 它并没有魔法,只是替你连了几条边。
🤖 最终回答:北京气温是 **18°C**(晴),乘以 3 等于 **54**。对比一下示例 2 和示例 5 的路径:
示例 2(createSupervisor): supervisor → weather_expert → supervisor → math_expert → supervisor
示例 5(手写) : supervisor → weather_expert → supervisor形状完全一样。 差别只在"问题复杂度"——示例 5 的问题只用到天气专家一次,所以少了一趟。createSupervisor 并没有魔法,它只是替你:
- 建了一个
supervisor节点(内部就是一个调模型 + 判断走谁的节点) - 为每个专家生成了
transfer_to_<名字>移交工具 - 把"专家干完回到 supervisor"这些边连好
懂了这一层,你就能改造它:比如让移交时自动裁剪上下文、让总管在移交前先过一遍权限校验、或者让某个专家只能在特定条件下被移交 —— 这些都是预置包没有、但你可以自己加的。
七、一个必须知道的坑:思考模式模型 + 消息回放
本章示例 2~5 用了 deepseek-chat,而不是前几章默认的 deepseek-flash。这不是随手换的,是被一个报错逼出来的:
400 The `reasoning_content` in the thinking mode must be passed back to the API.它是怎么发生的?
1. deepseek-flash 是"思考模式"模型 —— 它返回的助手消息带 reasoning_content(思维链)
2. 多智能体场景里,专家的答复会被"回放"给总管(总管需要看到专家说了什么)
3. DeepSeek 要求:思考模式下回放助手消息,必须把 reasoning_content 一起带回去
4. 但 LangChain 重新发送消息时不带这个字段 → 报 400为什么前面几章没撞上? 因为单智能体场景里,助手消息通常只在同一次运行内往下传,很少被"重新发回给 API"。多智能体是反复回放,所以必然触发。
怎么办(三个方案,按推荐度排序)
| 方案 | 做法 | 评价 |
|---|---|---|
| ① 换非思考模式模型 | createModel({ model: "deepseek-chat" }) | 本章采用。最简单、最稳 |
| ② 换其他服务商的模型 | 任何不带"思考模式强制回传"约束的模型 | 同样有效,视你的服务商而定 |
| ③ 手动保留 reasoning_content | 把思维链挂在消息的 additional_kwargs 里传递 | 可行但繁琐,非必要不建议 |
⚠️ 通用的教训:多智能体场景会反复把历史消息回放给模型,所以它对"消息能不能被原样重发"的要求,比单智能体高得多。以后换任何模型,都先拿多智能体示例当"压力测试"跑一遍。
顺带一个同源的坑:deepseek-flash 的思考模式不支持 tool_choice 参数 —— 调用会直接报 400 Thinking mode does not support this tool_choice。所以别用强制工具选择来"逼"模型调工具,用清晰的系统提示词更可靠。
八、本章小结
本章速查表
| 你要做的事 | 代码 |
|---|---|
| 把子图挂成节点 | .addNode("名字", 已编译的子图) |
| 父子字段不同 | 包一个转换节点做映射 |
| 从子图跳到父图 | new Command({ goto: "父图节点", graph: Command.PARENT }) |
| 建调度者模式 | createSupervisor({ llm, agents }) |
| 建去中心模式 | createSwarm({ llm, agents, defaultActiveAgent }) |
| 造移交工具(手写) | tool() 返回 new Command({ goto, graph: Command.PARENT, update }) |
| 专家要有名字 | 创建专家时传 name,移交工具靠它定位 |
| 看执行路径 | 在每个节点的入口打印 console.log(节点名),或看 stream 事件 |
三句话总结
- 多智能体解决的是"职责污染":一个 agent 一个人设、一个上下文;拆开之后每个专家都干净专注。
- Supervisor 有中心、Swarm 无中心,本质区别是"专家干完要不要回中心汇报" —— 成本与可控性的权衡,默认选 Supervisor。
- 移交工具 = 返回 Command 的工具。
supervisor/swarm只是替你连边和注册工具,没有魔法。
动手练习
- 给示例 2 再加一个"翻译专家"(把最终答案翻译成英文),观察总管的路径会不会变成 7 步。
- 把示例 3 的
defaultActiveAgent改成"math_expert",问同样的问题,看移交方向是否反过来。 - 给示例 5 的
makeHandoffTool加一条限制:如果目标专家是math_expert而消息里还没有天气数据,就在update里塞一条提示消息。体会"自己掌控移交"的自由度。
下一章预告
最后一章我们把图从"能跑"推进到"能用":节点重试策略、节点缓存、错误恢复、把图包成一个 HTTP 服务、以及接上可观测性(LangSmith)。还会讲清 recursionLimit 之外那些生产环境一定会遇到的事。