LangGraph 多 Agent 入门:从流程图到旅行规划助手

大模型应用一旦进入真实业务,就很少只是"一问一答"。

用户可能会说:

text 复制代码
我周末想去重庆玩一天。帮我看下天气,推荐一个适合的行程,如果天气不好就安排室内活动。最后帮我生成一封发给同行朋友的确认邮件,但发出前要让我确认。

这个请求看起来是一句话,实际包含多个任务:

  • 判断用户想做的是旅行规划。
  • 查询天气。
  • 查询城市景点或活动信息。
  • 根据天气做条件决策。
  • 生成行程。
  • 生成邮件。
  • 邮件发送前等待用户确认。

如果把所有事情都交给一个 Agent,它需要同时负责规划、查天气、查景点、写行程、写邮件、判断是否需要确认。提示词会越来越长,工具会越来越多,调试也会越来越困难。

多 Agent 的价值就在这里:把复杂任务拆成多个职责清楚的执行单元,再用图把它们组织起来。

一句话概括:

Agent 是执行单元,Graph 是流程控制,Supervisor 是调度中心,Tool 是事实来源。

为什么需要多 Agent

单 Agent 的结构通常是:

text 复制代码
用户问题 -> 一个模型 -> 一组工具 -> 最终回答

简单任务里这样很好。比如总结一段文本、查询一次库存、调用一次搜索工具,单 Agent 足够。

但复杂任务会遇到几个问题。

第一,职责混乱。

一个 Agent 同时做意图判断、工具选择、业务执行、内容生成和结果审核,prompt 会变得很重。模型不是不能做,而是不稳定。

第二,工具空间过大。

工具越多,模型越容易选错工具、漏调工具、重复调用工具。比如旅行助手同时拥有天气、酒店、机票、地图、日历、邮件、支付工具,单 Agent 很容易乱。

第三,流程不可控。

真实业务经常有固定流程:

text 复制代码
判断任务 -> 查询事实 -> 生成方案 -> 人工确认 -> 执行动作 -> 返回结果

如果只靠 prompt 让模型"自己记得流程",出错时很难定位。

第四,不容易扩展。

当你想新增"预算 Agent"或"餐厅 Agent"时,如果所有逻辑都在一个 Agent 里,修改成本很高。多 Agent 可以把新能力作为一个独立角色接入。

所以,多 Agent 不是为了堆概念,而是为了解决工程问题:

  • 让职责边界更清楚。
  • 让流程更可控。
  • 让工具使用更稳定。
  • 让系统更容易调试和扩展。

但也要注意,多 Agent 不是越多越好。它会增加模型调用次数、链路长度和调试成本。

一个实用判断标准是:

  • 一个工具调用能完成,用普通函数或单 Agent。
  • 需要分支、循环、重试、人工确认,用图。
  • 任务天然有多个专业角色,并且每个角色需要不同工具或提示词,再用多 Agent。

贯穿案例:旅行规划助手

后面一直围绕同一个主题展开:旅行规划助手。

用户的目标是:

text 复制代码
我周末想去重庆玩一天。帮我看下天气,推荐一个适合的行程。
如果天气不好就安排室内活动。最后帮我生成一封给同行朋友的确认邮件,但发出前要让我确认。

这件事会按学习顺序拆成几步:

这样安排是为了避免概念突然冒出来。比如 createAgent 会放在手写工具循环之后,因为它本质上是对标准 Agent loop 的封装;interrupt 会放在 checkpointer 之后,因为暂停恢复需要先能保存图的执行状态。

LangGraph 的核心思想:把流程画成图

LangGraph 可以理解为一个面向 Agent 的流程引擎。

它的核心概念有四个:

  • State:整张图共享的状态。
  • Node:节点,接收当前状态,返回状态更新。
  • Edge:边,决定节点之间的流转。
  • Reducer:状态合并规则,决定新状态如何写回旧状态。

最小的旅行规划流程如下:

它可以直接编译成一个可运行的 LangGraph:

js 复制代码
import { Annotation, START, END, StateGraph } from "@langchain/langgraph";

// 1. 定义图的共享状态:目的地、用户目标、最终行程。
const TripState = Annotation.Root({
  destination: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
  userGoal: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
  itinerary: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
});

// 2. 节点只返回状态更新,不直接修改全局 state。
const collectRequest = (state) => ({
  destination: state.destination || "重庆",
  userGoal: state.userGoal || "周末一日游",
});

const draftItinerary = (state) => ({
  itinerary: `${state.destination}${state.userGoal}:上午解放碑,下午山城步道,晚上看两江夜景。`,
});

// 3. 用边把节点串成一条可执行流程。
const graph = new StateGraph(TripState)
  // 添加节点
  .addNode("collectRequest", collectRequest)
  .addNode("draftItinerary", draftItinerary)
  // 添加边
  .addEdge(START, "collectRequest")
  .addEdge("collectRequest", "draftItinerary")
  .addEdge("draftItinerary", END)
  // compile() 会把"图定义"变成一个真正可运行的 graph,前面就类似搭积木。
  .compile();

const result = await graph.invoke({
  destination: "重庆",
  userGoal: "周末一日游",
});

console.log(result.itinerary);

这段代码的重点不在模型调用,而在流程表达。可以先把它理解成"搭图":先定义 state,再把节点和边接起来,最后 compile() 成一个可运行的图。真正开始执行的是 invoke()

运行时大概是:

text 复制代码
START -> collectRequest -> draftItinerary -> END

collectRequestdraftItinerary 都只是普通函数。节点函数读取当前 state,只返回自己要更新的那一小段 state。LangGraph 再通过 reducer 把这段更新合并回完整 state。

所以这里最终能拿到 result.itinerary,是因为 draftItinerary 返回了新的 itinerary,并且这个字段被合并进了最终 state。

StateAnnotation:状态结构和 reducer

Annotation.Root 用来定义图的状态。

旅行规划通常需要保存这些状态:

  • 目的地:destination
  • 用户目标:userGoal
  • 天气信息:weather
  • 活动偏好:activityType
  • 行程:itinerary
js 复制代码
const TripState = Annotation.Root({
  destination: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
  weather: Annotation({
    reducer: (_prev, next) => next,
    default: () => null,
  }),
  activityType: Annotation({
    reducer: (_prev, next) => next,
    default: () => "outdoor",
  }),
  itinerary: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
});

这里的 reducer 都是覆盖式:

js 复制代码
reducer: (_prev, next) => next

意思是节点返回的新值直接覆盖旧值。

如果字段是列表,比如候选景点,可以用追加式 reducer:

js 复制代码
candidatePlaces: Annotation({
  reducer: (prev, next) => [...prev, ...next],
  default: () => [],
})

在聊天 Agent 场景里,通常直接用 MessagesAnnotation。它内置了 messages 的合并规则,不需要自己写消息 reducer。

js 复制代码
import { MessagesAnnotation, StateGraph } from "@langchain/langgraph";

const graph = new StateGraph(MessagesAnnotation);

选择建议:

  • 业务流程状态,用 Annotation.Root
  • 聊天消息状态,用 MessagesAnnotation
  • 最新值字段,用覆盖式 reducer。
  • 历史记录、候选结果、消息列表,用追加式 reducer。

条件边:根据天气选择路线

旅行规划不应该永远生成同一条路线。

如果天气好,推荐室外路线;如果下雨,推荐室内路线。这就是条件边的用途。

流程图:

实现如下:

js 复制代码
import { Annotation, START, END, StateGraph } from "@langchain/langgraph";

const TripState = Annotation.Root({
  destination: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
  weather: Annotation({ reducer: (_prev, next) => next, default: () => null }),
  itinerary: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
});

// 真实项目里这里通常是 API 调用;示例里用固定数据模拟天气服务。
const checkWeather = (state) => ({
  weather: {
    city: state.destination,
    summary: "小雨",
    suitableForOutdoor: false,
  },
});

const outdoorPlan = (state) => ({
  itinerary: `${state.destination}天气适合户外:上午解放碑,下午山城步道,傍晚南滨路看江景。`,
});

const indoorPlan = (state) => ({
  itinerary: `${state.destination}可能下雨:上午重庆中国三峡博物馆,下午重庆美术馆,晚上室内火锅店。`,
});

// 条件边读取 state,返回下一条路线的 key。
const routeByWeather = (state) => {
  return state.weather?.suitableForOutdoor ? "outdoor" : "indoor";
};

const graph = new StateGraph(TripState)
  .addNode("checkWeather", checkWeather)
  .addNode("outdoorPlan", outdoorPlan)
  .addNode("indoorPlan", indoorPlan)
  .addEdge(START, "checkWeather")
  // routeByWeather 返回 outdoor 就进 outdoorPlan,返回 indoor 就进 indoorPlan。
  .addConditionalEdges("checkWeather", routeByWeather, {
    outdoor: "outdoorPlan",
    indoor: "indoorPlan",
  })
  .addEdge("outdoorPlan", END)
  .addEdge("indoorPlan", END)
  .compile();

const result = await graph.invoke({
  destination: "重庆",
});

console.log(result.itinerary);

addConditionalEdges 的作用是把固定流程变成动态流程。是否进入室外行程或室内行程,不再写死在代码顺序里,而是由 state 中的天气结果决定。

它接收三个东西:

js 复制代码
.addConditionalEdges("从哪个节点之后判断", 路由函数, 路由映射)

路由函数返回的是一个 key,LangGraph 会用这个 key 去路由映射里找下一个节点。

比如:

js 复制代码
routeByWeather(state) // 返回 "indoor"

就会匹配到:

js 复制代码
{
  indoor: "indoorPlan"
}

最后进入 indoorPlan 节点。

也可以用数组简写:

js 复制代码
.addConditionalEdges("agent", toolsCondition, ["tools", END])

数组写法适合"路由函数返回值和目标节点名一致"的场景。

循环:让流程可以重试

有些流程不是执行一次就结束。比如生成行程后,还要检查是否合格,不合格就重新生成。

这种流程可以理解成:

text 复制代码
生成 -> 检查 -> 不合格就重试 -> 合格才结束

图里用条件边回到前面的节点,就能形成循环。

比如旅行助手最多尝试三次生成行程,直到满足要求:

实现如下:

js 复制代码
const draftPlan = (state) => {
  const attempts = state.attempts + 1;

  return {
    attempts,
    itinerary:
      attempts >= 2
        ? "上午重庆中国三峡博物馆,下午重庆美术馆,晚上室内火锅店。节奏轻松,适合雨天。"
        : "上午解放碑,下午山城步道。雨天可能不合适。",
    approved: attempts >= 2,
  };
};

const graph = new StateGraph(TripState)
  .addNode("draftPlan", draftPlan)
  .addEdge(START, "draftPlan")
  .addConditionalEdges(
    "draftPlan",
    (state) => (state.approved ? "done" : "retry"),
    {
      retry: "draftPlan",
      done: END,
    }
  )
  .compile();

循环很有用,也很危险。实际项目里一定要设置退出条件,并在运行时加 recursionLimit

js 复制代码
await graph.invoke(input, {
  recursionLimit: 8,
});

checkpointer:让图记住同一个会话

旅行规划通常不是一次完成的。用户可能第一轮只说:

text 复制代码
我周末想去重庆玩一天,偏轻松一点。

第二轮又补一句:

text 复制代码
如果下雨,就多安排室内。

第二句话没有再说"重庆"和"一天",但系统应该知道这是同一个旅行会话。这个时候就需要 checkpointer。

流程可以这样理解:

state 里保存旅行规划会用到的信息:

js 复制代码
const TripSessionState = Annotation.Root({
  destination: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
  userGoal: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
  weatherPreference: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
  itinerary: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
});

第一个节点负责收集或补全旅行需求:

js 复制代码
function collectTripRequest(state) {
  return {
    destination: state.destination || "重庆",
    userGoal: state.userGoal || "周末一日游,节奏轻松",
    weatherPreference:
      state.weatherPreference || "如果下雨,就优先安排室内活动",
  };
}

第二个节点根据当前 state 生成行程草稿:

js 复制代码
function draftTripPlan(state) {
  return {
    itinerary: `${state.destination}${state.userGoal}:上午三峡博物馆,下午重庆美术馆,晚上室内火锅。${state.weatherPreference}。`,
  };
}

图还是普通的 StateGraph

js 复制代码
import { MemorySaver } from "@langchain/langgraph";

const graph = new StateGraph(TripSessionState)
  .addNode("collectTripRequest", collectTripRequest)
  .addNode("draftTripPlan", draftTripPlan)
  .addEdge(START, "collectTripRequest")
  .addEdge("collectTripRequest", "draftTripPlan")
  .addEdge("draftTripPlan", END);

const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });

checkpointer 不是节点,它是整张图的存档器。它会把同一个 thread_id 下的 state 保存起来。

调用时再传 thread_id

js 复制代码
const tripUser = {
  configurable: {
    thread_id: "trip-user-1",
  },
};

// 第一轮:用户明确说了目的地和目标。
await app.invoke(
  {
    destination: "重庆",
    userGoal: "周末一日游,节奏轻松",
  },
  tripUser
);

// 第二轮:用户只补充下雨偏好,没有再说目的地。
const result = await app.invoke(
  {
    weatherPreference: "如果下雨,就多安排室内活动",
  },
  tripUser
);

console.log(result.itinerary);

因为两次调用用的是同一个 thread_id,第二轮还能拿到第一轮保存的 destinationuserGoal

如果换一个 thread_id,就是另一个旅行会话。

可以把 thread_id 理解成"会话编号":

  • 同一个 thread_id:接着上次保存的 state 跑。
  • 不同 thread_id:互不影响。

如果需要持久化,可以用 SQLite:

js 复制代码
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";

const checkpointer = SqliteSaver.fromConnString("./checkpoints.sqlite");

const app = graph.compile({
  checkpointer,
});

简单理解:

  • MemorySaver 适合开发和演示。
  • SQLite / Redis / 数据库适合持久化会话。
  • thread_id 是会话隔离的关键。
  • checkpointer 也是 interrupt 恢复执行的基础。

后面讲 createAgent 时还会再次看到 checkpointer。那时它的作用是一样的:让同一个 thread_id 下的多轮 Agent 对话能接着之前的状态继续。

interrupt:发送邮件前让用户确认

旅行助手如果只是生成建议,风险不高。但如果它要发送邮件,就必须让用户确认。

目标流程:

实现如下:

js 复制代码
import {
  Annotation,
  Command,
  START,
  END,
  MemorySaver,
  StateGraph,
  interrupt,
} from "@langchain/langgraph";

const EmailState = Annotation.Root({
  itinerary: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
  emailDraft: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
  approval: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
});

// 先生成草稿,不直接执行发送动作。
const draftEmail = (state) => ({
  emailDraft: `我们周末重庆一日游安排如下:${state.itinerary}`,
});

const waitForApproval = (state) => {
  // interrupt 会暂停图,并把待确认信息暴露给调用方。
  const approval = interrupt({
    action: "send_trip_email",
    message: "请确认是否发送这封行程邮件。",
    draft: state.emailDraft,
  });

  return {
    approval: String(approval),
  };
};

const graph = new StateGraph(EmailState)
  .addNode("draftEmail", draftEmail)
  .addNode("waitForApproval", waitForApproval)
  .addEdge(START, "draftEmail")
  .addEdge("draftEmail", "waitForApproval")
  .addEdge("waitForApproval", END)
  .compile({
    // 中断恢复依赖 checkpointer 记录暂停位置。
    checkpointer: new MemorySaver(),
  });

const config = {
  configurable: {
    thread_id: "trip-email-1",
  },
};

const paused = await graph.invoke(
  {
    itinerary: "上午重庆中国三峡博物馆,下午重庆美术馆,晚上室内火锅店。",
  },
  config
);

console.log(paused.__interrupt__?.[0]?.value);

// 用 Command({ resume }) 把人的确认结果交回图,流程从 interrupt 处继续。
const done = await graph.invoke(
  new Command({
    resume: "确认发送",
  }),
  config
);

console.log(done.approval);

这个人工确认流程包含几个关键点:

  • interrupt() 会暂停图。
  • 暂停信息会出现在 __interrupt__
  • 恢复时用 new Command({ resume })
  • 必须配合 checkpointer,图才能回到暂停位置继续执行。

凡是发邮件、付款、删除、部署、修改生产数据,都应该考虑这种人类确认流程。

工具调用:让事实来自工具,而不是模型编造

到目前为止,天气是假写在节点里的。真实系统里,天气、景点、订单、库存、用户数据都应该来自工具。

进一步看工具调用。旅行 Agent 不再把天气结果写死在节点里,而是根据用户问题决定是否调用天气工具。

图结构:

这张图里有两个容易混的点:

  • bindTools 发生在模型侧,让模型知道"可以叫哪些工具"。
  • ToolNode 发生在图执行阶段,负责"真的把工具跑起来"。

标准工具循环可以这样组织:

js 复制代码
import { HumanMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import {
  START,
  END,
  MessagesAnnotation,
  StateGraph,
} from "@langchain/langgraph";
import { ToolNode, toolsCondition } from "@langchain/langgraph/prebuilt";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

// 工具是 Agent 获取事实的入口,天气不再由模型猜。
const lookupWeather = tool(
  async ({ city }) => {
    return JSON.stringify({
      city,
      summary: "小雨",
      tempLowC: 16,
      tempHighC: 22,
      suitableForOutdoor: false,
      suggestion: "更适合安排博物馆、重庆美术馆等室内活动。",
    });
  },
  {
    name: "lookup_weather",
    description: "查询城市天气,并判断是否适合户外活动。",
    schema: z.object({
      city: z.string().describe("城市名,如重庆"),
    }),
  }
);

const tools = [lookupWeather];

const llm = new ChatOpenAI({
  modelName: process.env.AI_MODEL,
  apiKey: process.env.AI_KEY,
  configuration: {
    baseURL: process.env.AI_BASE_URL,
  },
}).bindTools(tools);

// LLM 节点只负责决定:直接回答,还是先发起 tool call。
async function travelAgent(state) {
  const response = await llm.invoke([
    {
      role: "system",
      content:
        "你是旅行助手。涉及天气判断时必须调用 lookup_weather,不要凭空猜天气。",
    },
    ...state.messages,
  ]);

  return {
    messages: response,
  };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("travelAgent", travelAgent)
  .addNode("tools", new ToolNode(tools))
  .addEdge(START, "travelAgent")
  // toolsCondition 会检查最后一条 AI 消息中是否存在 tool_calls。
  .addConditionalEdges("travelAgent", toolsCondition, ["tools", END])
  // 工具结果回到 Agent,由模型基于真实结果组织最终回答。
  .addEdge("tools", "travelAgent")
  .compile();

const result = await graph.invoke({
  messages: [
    new HumanMessage("周末去重庆玩一天,天气适合户外吗?顺便给个建议。"),
  ],
});

console.log(result.messages.at(-1)?.content);

这里最容易混的是这两个东西:

js 复制代码
const llm = new ChatOpenAI({...}).bindTools(tools);

和:

js 复制代码
.addNode("tools", new ToolNode(tools))

它们不是重复功能,职责不一样。

bindTools(tools) 是把工具说明交给模型。模型看到这些工具后,才知道:

  • 有一个工具叫 lookup_weather
  • 这个工具可以查询城市天气。
  • 调用它时需要传 { city: string }

但模型本身不会真的执行工具。它只会在 AI 消息里生成一个"我想调用工具"的请求,类似:

js 复制代码
{
  tool_calls: [
    {
      name: "lookup_weather",
      args: { city: "重庆" }
    }
  ]
}

也就是说,bindTools 解决的是:让模型知道可以叫哪些工具,以及怎么叫。

ToolNode(tools) 解决的是另一件事:真的执行工具。

toolsCondition 发现最后一条 AI 消息里有 tool_calls 时,流程会进入 tools 节点。ToolNode 会做三件事:

  1. 读取 AI 消息里的 tool_calls
  2. 根据 name 找到对应工具,比如 lookup_weather
  3. 执行工具,把结果作为工具消息写回 messages

所以完整过程是:

text 复制代码
travelAgent 调模型
  ↓
模型生成 tool_calls,说"我要查重庆天气"
  ↓
toolsCondition 发现有 tool_calls
  ↓
ToolNode 真正执行 lookup_weather
  ↓
工具结果回到 messages
  ↓
travelAgent 再次调模型
  ↓
模型基于天气结果生成最终回答

可以简单记成一句话:

bindTools 让模型会"点工具",ToolNode 负责真的"跑工具"。

这个工具循环由四个部分组成:

  • MessagesAnnotation 负责保存聊天消息。
  • ToolNode 负责读取 tool_calls 并执行工具。
  • toolsCondition 负责判断最后一条 AI 消息里有没有工具调用。
  • .addEdge("tools", "travelAgent") 让工具结果回到模型,模型再生成最终回答。

这就是标准 Agent loop。

createAgent:标准工具 Agent 的快捷方式

如果只是标准工具 Agent,不需要手写 StateGraph + ToolNode + toolsCondition,可以用 createAgent

同样的旅行天气助手,可以简化成:

js 复制代码
import { HumanMessage } from "@langchain/core/messages";
import { MemorySaver } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { createAgent, tool } from "langchain";
import { z } from "zod";

const lookupWeather = tool(
  async ({ city }) => {
    return JSON.stringify({
      city,
      summary: "小雨",
      suitableForOutdoor: false,
      suggestion: "建议安排室内活动。",
    });
  },
  {
    name: "lookup_weather",
    description: "查询城市天气,并判断是否适合户外活动。",
    schema: z.object({
      city: z.string(),
    }),
  }
);

const model = new ChatOpenAI({
  modelName: process.env.AI_MODEL,
  apiKey: process.env.AI_KEY,
  configuration: {
    baseURL: process.env.AI_BASE_URL,
  },
});

const agent = createAgent({
  model,
  tools: [lookupWeather],
  systemPrompt:
    "你是旅行天气助手。用户问天气或户外安排时,必须调用 lookup_weather。",
  // checkpointer 让同一个 thread_id 下的多轮对话可以延续上下文。
  checkpointer: new MemorySaver(),
});

const result = await agent.invoke(
  {
    messages: [new HumanMessage("重庆周末适合户外玩吗?")],
  },
  {
    configurable: {
      thread_id: "trip-user-1",
    },
  }
);

console.log(result.messages.at(-1)?.content);

createAgent 内部已经封装了常见工具循环。你仍然可以查看它生成的图:

js 复制代码
const drawable = await agent.graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));

建议:

  • 学习底层机制时,用 StateGraph + ToolNode + toolsCondition
  • 做普通工具 Agent 时,用 createAgent
  • 需要复杂流程、多 Agent、人工确认时,再回到图或 supervisor。

多 Agent:把旅行助手拆成专业角色

旅行规划可以进一步拆成多个专业角色。

旅行规划这件事可以拆成三个角色:

  • weather_agent:只负责天气判断。
  • local_agent:只负责城市景点和活动建议。
  • planner_agent:只负责把天气和城市信息整合成行程。

再加一个 supervisor:

  • supervisor:判断用户任务,决定调用哪些子 Agent,最后汇总。

结构:

为什么这样拆?

  • 天气 Agent 只拿天气工具,不会乱讲景点。
  • 城市 Agent 只拿景点工具,不会编天气。
  • 行程 Agent 不查事实,只负责组织方案。
  • supervisor 只调度和汇总,不亲自编事实。

Supervisor 可以这样组装:

js 复制代码
import { HumanMessage } from "@langchain/core/messages";
import { createSupervisor } from "@langchain/langgraph-supervisor";
import { ChatOpenAI } from "@langchain/openai";
import { createAgent, tool } from "langchain";
import { z } from "zod";

const model = new ChatOpenAI({
  modelName: process.env.AI_MODEL,
  apiKey: process.env.AI_KEY,
  configuration: {
    baseURL: process.env.AI_BASE_URL,
  },
});

// 子 Agent A:只负责天气事实。
const lookupWeather = tool(
  async ({ city }) => {
    return JSON.stringify({
      city,
      summary: "小雨",
      suitableForOutdoor: false,
      suggestion: "建议安排室内活动。",
    });
  },
  {
    name: "lookup_weather",
    description: "查询城市天气,并判断是否适合户外活动。",
    schema: z.object({
      city: z.string().describe("城市名,如重庆"),
    }),
  }
);

// 子 Agent B:只负责本地活动事实。
const lookupLocalActivities = tool(
  async ({ city, activityType }) => {
    const indoor = ["重庆中国三峡博物馆", "重庆美术馆", "室内火锅店"];
    const outdoor = ["解放碑步行", "山城步道", "南滨路江景步道"];

    return JSON.stringify({
      city,
      activityType,
      activities: activityType === "indoor" ? indoor : outdoor,
    });
  },
  {
    name: "lookup_local_activities",
    description: "按城市和活动类型查询适合的一日游活动。",
    schema: z.object({
      city: z.string(),
      activityType: z.enum(["indoor", "outdoor"]),
    }),
  }
);

const weatherAgent = createAgent({
  name: "weather_agent",
  description: "判断目的地天气,以及是否适合户外活动。",
  model,
  tools: [lookupWeather],
  systemPrompt:
    "你只负责天气。必须调用 lookup_weather。输出要包含是否适合户外活动。",
});

const localAgent = createAgent({
  name: "local_agent",
  description: "根据城市和 indoor/outdoor 类型推荐本地活动。",
  model,
  tools: [lookupLocalActivities],
  systemPrompt:
    "你只负责城市活动推荐。必须调用 lookup_local_activities,不要编造工具没有的地点。",
});

const plannerAgent = createAgent({
  name: "planner_agent",
  description: "把天气信息和活动建议整合成一日游行程。",
  model,
  tools: [],
  systemPrompt:
    "你只负责生成结构清晰的一日游行程。不要编造天气或地点,只使用已有信息。",
});

// supervisor 只做调度:决定先调用谁、后调用谁、何时汇总。
const workflow = createSupervisor({
  agents: [weatherAgent.graph, localAgent.graph, plannerAgent.graph],
  llm: model,
  addHandoffMessages: false,
  prompt: `
你是旅行规划调度员,只负责选择合适的子 Agent,并在信息齐全后汇总。

调度规则:
- 需要天气判断时,调用 weather_agent。
- 需要景点或活动建议时,调用 local_agent。
- 已经拿到天气和活动信息后,调用 planner_agent 生成行程。
- 如果天气不适合户外,local_agent 应使用 activityType=indoor。
- 如果天气适合户外,local_agent 应使用 activityType=outdoor。
- 不要自己编造天气、地点或行程细节。
- 每个子任务完成后再决定下一步,信息齐全就输出最终答案。
`,
});

const app = workflow.compile();

const input = {
  messages: [
    new HumanMessage(
      "我周末想去重庆玩一天。看下天气,如果不适合户外就安排室内活动,并给我一份行程。"
    ),
  ],
};

const result = await app.invoke(input, {
  recursionLimit: 12,
});

console.log(result.messages.at(-1)?.content);

这里的多 Agent 不是让多个 Agent 随机对话,而是由 supervisor 按职责调度,每个子 Agent 只处理自己边界内的任务。

Supervisor 关键知识点

handoff:控制权怎么交给子 Agent

在 supervisor 模式里,子 Agent 不是普通函数调用。

它更像控制权交接:

text 复制代码
Supervisor -> Handoff Tool -> Child Agent -> Supervisor

使用 createSupervisor 时,框架会根据子 Agent 自动生成 handoff 工具。supervisor 通过这些工具把任务交给对应 Agent。

因此子 Agent 的这些信息非常重要:

  • name:给 supervisor 精确识别。
  • description:告诉 supervisor 什么时候调用它。
  • systemPrompt:限制子 Agent 的职责边界。
  • tools:限定子 Agent 能做什么。

如果 description 写得模糊,supervisor 就容易选错 Agent。如果子 Agent 工具太多,它也容易越界。

stream:怎么观察多 Agent 是否按预期运行

只看最终回答不够。多 Agent 系统一定要看路径。

可以用 stream

js 复制代码
// stream 不只拿最终结果,而是边执行边把过程吐出来。
const stream = await app.stream(input, {
  // updates:每一步哪些节点产生了更新。
  // values:每一步执行后的完整 state 快照。
  streamMode: ["updates", "values"],
  // 防止 supervisor 和子 Agent 来回交接导致无限循环。
  recursionLimit: 12,
});

// 用来记录执行过哪些节点,比如 supervisor -> weather_agent -> supervisor。
const nodePath = [];

// 用来保存最后一次完整 state,后面从里面取最终回答。
let finalState = null;

// stream 是异步迭代器,所以这里用 for await 一步步读取事件。
for await (const event of stream) {
  // event 的结构类似:["updates", {...}] 或 ["values", {...}]
  const [mode, payload] = event;

  // updates 表示"这一步哪些节点有输出"。
  // payload 的 key 就是节点名,比如 weather_agent、local_agent。
  if (mode === "updates" && payload && typeof payload === "object") {
    nodePath.push(...Object.keys(payload));
  }

  // values 表示"当前完整 state"。
  // 每次 values 都会覆盖 finalState,循环结束后留下的就是最终 state。
  if (mode === "values") {
    finalState = payload;
  }
}

console.log("执行路径:", nodePath.join(" → "));
console.log("最终回答:", finalState?.messages.at(-1)?.content);

理想路径应该类似:

text 复制代码
supervisor -> weather_agent -> supervisor -> local_agent -> supervisor -> planner_agent -> supervisor

如果你看到 supervisor 反复调用同一个 Agent,说明停止条件不清楚。

如果你看到 planner_agent 在没有天气和活动信息时就开始规划,说明调度规则不够明确。

如果你看到 local_agent 编造地点,说明工具边界或 system prompt 需要收紧。

supervisor prompt:调度规则怎么写

supervisor prompt 不应该写成知识库,而应该写成调度规则。

好的 supervisor prompt 要写清楚:

  • 每个 Agent 负责什么。
  • 什么情况下必须调用哪个 Agent。
  • 多个 Agent 的调用顺序。
  • supervisor 不能自己做什么。
  • 什么条件下停止调度并汇总。
  • 如何避免重复调用。

一个清晰版本:

text 复制代码
你是旅行规划调度员,不直接编造事实。

- 天气问题交给 weather_agent。
- 本地活动推荐交给 local_agent。
- 行程整合交给 planner_agent。
- 天气不适合户外时,本地活动必须按 indoor 查询。
- 天气适合户外时,本地活动必须按 outdoor 查询。
- 天气和活动都拿到后,再调用 planner_agent。
- planner_agent 完成后,输出最终答案,不要继续交接。

实践中,多 Agent 是否稳定,很大程度取决于 supervisor prompt 是否像"调度手册",而不是像"聊天提示词"。

最后总结

LangGraph 里的图解决的是"流程可控"。

工具调用解决的是"连接真实能力"。

checkpointer 解决的是"记忆和恢复"。

interrupt 解决的是"人在环路"。

supervisor 多 Agent 解决的是"复杂任务分工"。

多 Agent 的本质不是多个模型互相聊天,而是一个可观察、可恢复、可约束的协作系统。

再次总结:

Agent 是执行单元,Graph 是流程控制,Supervisor 是调度中心,Tool 是事实来源。

相关推荐
阿拉斯攀登2 小时前
安全与可控性:输出校验、权限控制
人工智能·chatgpt·agent·memory·claude·知识库·向量数据库
wumingxiaoyao5 小时前
从 0 开始学 AI:第 2 课,AI、机器学习、深度学习和大模型是什么关系?
人工智能·深度学习·机器学习·ai·大模型·llm
闯荡7 小时前
summary 上下文总结机制
agent
掉鱼的猫7 小时前
Agent Harness 实战指南:构建生产级 AI Agent 的"马具"框架
java·llm·aigc
带刺的坐椅7 小时前
Agent Harness 实战指南:构建生产级 AI Agent 的"马具"框架
java·ai·llm·agent·solon-ai
星航夜空的帆舟8 小时前
ADK 会话与状态管理:三级状态、内存服务与多存储后端
go·agent
阿里云大数据AI技术8 小时前
DataWorks Data Agent的演进与工程化实践
人工智能·agent
weigangwin8 小时前
LlamaIndex 第一次试用:别先写 RAG Demo,先验上下文合同
python·ai·agent·rag·检索·llamaindex·观测性
闲研随记9 小时前
【文献阅读】BunnyFinder:探寻以太坊共识机制中的激励机制漏洞
区块链·agent·漏洞·以太坊·ppo·incentive flaws