Skip to content

第 7 章 · 多智能体(Multi-Agent) ​

🎯 本章目标 ​

  • 理解为什么单个智能体不够用,什么信号说明该拆成多智能体了
  • 掌握子图(Subgraph):把一张编译好的图当成另一张图的"节点"
  • 掌握两种主流协作形态:Supervisor(有中心调度) 与 Swarm(去中心化互移交)
  • 会读移交轨迹,能看出"谁把活交给了谁、为什么"
  • 通过对照实验,形成自己的选型判断:什么时候用哪个
  • 亲手拆开封装,看清 Supervisor / Swarm 底层的**移交工具(handoff tool)**机制
  • 记住一个真实存在的坑:思考模式模型 + 消息回放 会直接报 400

本章代码

📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的 code/ 完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。

文件内容
code/01-subgraph.ts子图:把编译好的图当作节点挂进父图
code/02-supervisor.tsSupervisor:一个总管 + 若干专家
code/03-swarm.tsSwarm:专家之间直接互相移交
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 多智能体!!!

这个示例要记住三件事:

  1. addNode("名字", 子图) 直接就能用 —— 前提是父图的状态字段能给子图用。这里父子都基于 MessagesAnnotation,messages 一脉相承,所以零成本对接。
  2. 字段不同就加一层转换节点。B 场景里父图用 input / final,子图要 text / shouted,用一个 transform 节点做双向翻译。这层翻译看着多余,但它让父子图各自保持自己的状态设计,耦合更小。
  3. 子图的内部细节对父图完全透明。父图只知道"我调了 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 有两个关键区别:

  1. createSwarm 必须传 defaultActiveAgent,指定"第一个开口的是谁"
  2. 移交工具要你自己用 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,但它手里只有计算器工具,却看到了前面完整的对话历史(包括天气专家的工具调用记录),于是它会对"为什么我没这个工具"感到困惑。

💡 这个现象提示了两件事:

  1. 移交时要管好上下文 —— 官方提供了 createHandoffBackMessages 之类的工具来裁剪移交时的历史,避免下游专家看到不属于自己的工具调用。
  2. 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 并没有魔法,它只是替你:

  1. 建了一个 supervisor 节点(内部就是一个调模型 + 判断走谁的节点)
  2. 为每个专家生成了 transfer_to_<名字> 移交工具
  3. 把"专家干完回到 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 事件

三句话总结

  1. 多智能体解决的是"职责污染":一个 agent 一个人设、一个上下文;拆开之后每个专家都干净专注。
  2. Supervisor 有中心、Swarm 无中心,本质区别是"专家干完要不要回中心汇报" —— 成本与可控性的权衡,默认选 Supervisor。
  3. 移交工具 = 返回 Command 的工具。supervisor / swarm 只是替你连边和注册工具,没有魔法。

动手练习

  1. 给示例 2 再加一个"翻译专家"(把最终答案翻译成英文),观察总管的路径会不会变成 7 步。
  2. 把示例 3 的 defaultActiveAgent 改成 "math_expert",问同样的问题,看移交方向是否反过来。
  3. 给示例 5 的 makeHandoffTool 加一条限制:如果目标专家是 math_expert 而消息里还没有天气数据,就在 update 里塞一条提示消息。体会"自己掌控移交"的自由度。

下一章预告

最后一章我们把图从"能跑"推进到"能用":节点重试策略、节点缓存、错误恢复、把图包成一个 HTTP 服务、以及接上可观测性(LangSmith)。还会讲清 recursionLimit 之外那些生产环境一定会遇到的事。


← 上一章:流式输出 | 返回总目录 | 下一章:工程化与部署 →

内容基于 LangGraph.js 官方文档与示例(langchain-ai/langgraphjs,MIT License)整理并重排为中文学习路径