大模型应用一旦进入真实业务,就很少只是"一问一答"。
用户可能会说:
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
collectRequest 和 draftItinerary 都只是普通函数。节点函数读取当前 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,第二轮还能拿到第一轮保存的 destination 和 userGoal。
如果换一个 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 会做三件事:
- 读取 AI 消息里的
tool_calls。 - 根据
name找到对应工具,比如lookup_weather。 - 执行工具,把结果作为工具消息写回
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 是事实来源。