LangGraph 深度解析:从增强型 LLM 到生产级 Agent
你用过
withStructuredOutput,也写过bindTools,但一旦业务变复杂------多轮工具调用、条件分支、并行请求、人在回路------光靠链式调用就开始力不从心。LangGraph 就是为这个而生的。
这篇文章怎么读
这不是那种"把文档翻译一遍"的教程。它会沿着一条真实的认知路径走下来:
普通 LLM 的局限 → 增强型 LLM 能做什么 → 为什么增强型 LLM 还不够 → Agent 循环的本质困境 → LangGraph 如何用"图"来解决这一切
建议按顺序读。 每一章都在为下一章打基础,跳着看容易在某个地方卡住------尤其是第二部分(LangGraph 核心机制),它是后面所有模式的基础,值得多花点时间。
读完你会得到什么:
- 理解增强型 LLM 的底层原理,不只是会用 API
- 理解 ReAct Agent 的本质------它到底是一个什么循环
- 彻底搞懂 LangGraph 的 State / Node / Edge 和执行引擎
- 掌握六大 Agent 设计模式的完整代码实现
- 知道面试时怎么把 Agent 架构讲出层次感
- 了解 LangGraph 的生产级特性:Memory、Human-in-the-Loop、Streaming、部署
第一部分:为什么需要 LangGraph
在学一个框架之前,先搞清楚"没有它的时候,我们是怎么熬过来的"。理解了痛苦,才能理解解决方案。
第一章:从普通 LLM 到增强型 LLM
1.1 裸调 LLM 的四个硬伤
typescript
const msg = await llm.invoke("What is 2 times 3?");
console.log(msg.content);
// "2 times 3 is 6"
输入一段字,输出一段字------这是最原始的 LLM 调用。看起来没什么问题?在真实业务里,它有四个致命短板:
| 痛点 | 为什么它是问题 | 具体例子 |
|---|---|---|
| 输出不可控 | 你期望 JSON,它偏给你自然语言;你期望字符串数组,它给一个段落 | 你想把结果存数据库,LLM 返回了一句"好的,结果如下......" |
| 没有行动能力 | LLM 只能"说"它想做什么,不能真的去查数据库、调 API、发邮件 | 用户问"今天天气",LLM 只能说"你可以去天气网站查" |
| 无法验证输入输出 | LLM 返回的数据有没有缺字段、类型对不对,你完全没有保障 | 你期望 {name: string, age: number},LLM 返回 {name: "张三"} 漏了 age |
| 无法做多步骤任务 | 要"先搜索→再阅读→再总结",你需要在外面写一大堆 if/else 和 while | 每一步的结果要传给下一步,中间某步失败还要处理重试 |
1.2 第一重增强:结构化输出 --- 让 LLM 按你的格式填表
typescript
import { z } from "zod";
// 用 Zod 定义"答题卡"的格式
const searchQuerySchema = z.object({
searchQuery: z.string().describe("Query optimized for web search"),
justification: z.string().describe("Why this query is relevant"),
});
// 给 LLM 戴上"紧箍咒"
const structuredLlm = llm.withStructuredOutput(searchQuerySchema, {
name: "searchQuery",
});
// 调用------返回值是类型安全的对象,不是乱码的自然语言
const output = await structuredLlm.invoke(
"How does Calcium CT score relate to high cholesterol?"
);
console.log(output.searchQuery);
// → "Calcium CT score high cholesterol correlation"
console.log(output.justification);
// → "Directly addresses the user's question about..."
这背后发生了什么? 很多人以为 Zod 直接"约束"了 LLM 的输出------这是误解。整个流程分四步:
swift
第 1 步:翻译(Zod → JSON Schema)
z.object({ searchQuery: z.string(), ... })
↓ LangChain 内部调用 zod-to-json-schema 库 ↓
{
"type": "object",
"properties": {
"searchQuery": { "type": "string", "description": "Query optimized..." },
"justification": { "type": "string", "description": "Why..." }
},
"required": ["searchQuery", "justification"]
}
第 2 步:传递给 LLM API
LangChain 把这个 JSON Schema 塞进 OpenAI 的 response_format 或 tools 参数。
→ 相当于给 LLM 发了一张"必须按此格式填写的答题卡"。
第 3 步:LLM 按 Schema 生成 JSON 字符串
→ "{\"searchQuery\": \"...\", \"justification\": \"...\"}"
第 4 步:Zod 做"质检"(运行时验证)
→ schema.parse(jsonString)
→ 字段全不全?类型对不对?
→ 不对就抛 ZodError,阻止错误数据继续往下流
关键认知:真正"约束"LLM 输出格式的,不是 Zod,而是 OpenAI/Anthropic API 底层的
response_format或tools参数。Zod 扮演两个角色------调用前负责"画图纸"(生成 JSON Schema 给 LLM 看),调用后负责"验尺寸"(校验返回值是否达标)。
注意你代码里的 .describe() 非常重要------它会被翻译成 JSON Schema 里的 description 字段,LLM 会阅读这段描述 来理解每个字段应该填什么内容。如果你写成 z.string("描述")(这是错的),Zod 会把 "描述" 当作默认值而不是提示词,LLM 根本看不到。
1.3 第二重增强:工具调用 --- 让 LLM 能够"动手"
结构化输出解决了"说出来的话格式对不对"。但 LLM 还有一个更致命的局限------它什么也做不了。
typescript
import { tool } from "@langchain/core/tools";
import { z } from "zod";
// 定义一个真实的可执行函数
const multiply = tool(
async ({ a, b }: { a: number; b: number }) => {
// 这段代码真的会跑在你的服务器上!
return a * b;
},
{
name: "multiply",
description: "multiplies two numbers together",
schema: z.object({
a: z.number().describe("the first number"),
b: z.number().describe("the second number"),
}),
}
);
// 把工具"挂载"到 LLM 上
const llmWithTools = llm.bindTools([multiply]);
// 调用
const message = await llmWithTools.invoke("What is 2 times 3?");
// LLM 没有真的计算!它只生成了一个"调用请求"
console.log(message.tool_calls);
// [
// {
// name: "multiply",
// args: { a: 2, b: 3 },
// id: "call_abc123"
// }
// ]
用个比喻帮你记住:
LLM 就像一个坐在办公室里的经理。你不会指望经理亲自去搬箱子。你指望的是:他听懂你的需求,写一张"派工单"(tool_calls),交给真正干活的人(你的 JS 函数)去执行。经理拿到执行结果后,再决定下一步。
这个比喻引出了一个自然而然的问题:谁来当"跑腿的"------负责"拿派工单→找工人→把结果送回经理→经理继续写派工单→..."这个循环?
这个"跑腿的",就是 Agent。而这个循环的编排,就是 LangGraph 要做的事。
第二章:从增强型 LLM 到 Agent------问题升级
2.1 一个自然的演化
上一步你学会了两个增强手段:
withStructuredOutput→ LLM 说出来的话格式可控bindTools→ LLM 能写"派工单"
现在你的大脑会自动把它们串起来:
用户问一个问题 → LLM 写派工单 → 执行工具 → 把结果还给 LLM → LLM 再写派工单 → 执行 → ... → 最终回答
这就是 Agent 循环 。学术上最早系统化这个模式的,是 2022 年 Google 和 Princeton 的 ReAct(Reasoning + Acting) 论文。
2.2 ReAct:让 LLM"边想边做"
ReAct 的核心思想非常朴素:
不要一次性生成最终答案。像人一样:思考 → 行动 → 观察结果 → 再思考 → 再行动 → 直到满意。
yaml
用户:"帮我查一下苹果市值,判断是否值得投资"
第 1 轮
💭 Thought: 需要查 AAPL 当前市值
🔧 Action: search_stock_price("AAPL")
👁 Observation: 市值 = 3.2 万亿美元
第 2 轮
💭 Thought: 光看市值不够,还需要市盈率 + 行业对比
🔧 Action: get_financial_metrics("AAPL")
👁 Observation: PE = 32, 行业平均 PE = 28
第 3 轮
💭 Thought: PE 略高但有品牌护城河,中长期看好
✅ Final Answer: 苹果市值 3.2 万亿,PE 32 高于行业均值 28,
但考虑到其现金储备和服务收入增长,仍具投资价值...
2.3 不用框架手写一个 ReAct------看看有多容易崩
在 LangGraph 出现之前,这个循环只能靠手写。下面是它的样子:
typescript
async function reactAgent(userInput: string) {
const messages = [new HumanMessage(userInput)];
const tools = [searchTool, calculatorTool, weatherTool];
const MAX_LOOPS = 10;
let loopCount = 0;
while (loopCount < MAX_LOOPS) {
loopCount++;
const response = await llmWithTools.invoke(messages);
// 情况 1:LLM 认为可以给最终答案了
if (response.content && !response.tool_calls?.length) {
return response.content;
}
// 情况 2:LLM 想调工具
if (response.tool_calls?.length) {
messages.push(response); // AI 的消息(包含 tool_calls)
for (const toolCall of response.tool_calls) {
const tool = tools.find(t => t.name === toolCall.name);
// 😰 工具不存在怎么办?
if (!tool) {
messages.push(new ToolMessage({
content: `Error: tool "${toolCall.name}" not found`,
tool_call_id: toolCall.id,
}));
continue;
}
try {
const result = await tool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: JSON.stringify(result),
tool_call_id: toolCall.id,
}));
} catch (error) {
// 😰 工具执行失败怎么办?
messages.push(new ToolMessage({
content: `Error: ${error.message}`,
tool_call_id: toolCall.id,
}));
}
}
continue;
}
// 情况 3:LLM 既不回答也不调工具------😰 卡死了?
break;
}
return "Agent stopped without reaching a conclusion.";
}
这段代码看起来"能跑",但实际上藏着五个你迟早会踩到的大坑:
| 问题 | 具体表现 | 手工解决有多麻烦 |
|---|---|---|
| 无限循环 | LLM 反复调同一个工具但得不到满意结果,MAX_LOOPS 用完才强制终止 | 需要设计智能的循环终止策略(不只是靠计数器) |
| 状态失控 | 对话历史全塞在 messages 数组里,每一轮都在膨胀,Token 费用爆炸 |
需要自己实现消息摘要/压缩/选择性遗忘 |
| 崩溃即丢失 | 跑到第 8 轮时 API 超时,前面 7 轮的结果全没了,钱也花了 | 需要自己实现检查点/快照机制 |
| 无法注入人工判断 | 想在第 3 轮让人类审批一下再继续?while 循环不支持"暂停-恢复" | 需要自己实现状态机 + 持久化 + 恢复逻辑 |
| 分支逻辑混乱 | "如果搜索失败就换 Google,如果计算超时就降级"------if/else 嵌套炸了 | 缺乏结构化的控制流表达方式 |
2.4 我们到底需要一个什么样的框架
从上面的分析可以归纳出 Agent 框架的五个核心需求:
- 状态管理:每一步的状态要结构化、可追踪、可持久化
- 控制流编排:支持顺序、分支、循环,且能可视化
- 错误恢复:某一步失败了,能从失败点重试,而不是从头来
- 人在回路:能在任意步骤暂停,等人类审批后继续
- 可观测性:能追踪每一步的执行------状态、输入、输出、耗时
2.5 LCEL vs LangGraph:什么时候用管道,什么时候用图
在你进入 LangGraph 之前,先搞清楚一个问题:如果只是"链条",为什么不用 LangChain 的 LCEL?
typescript
// LCEL 管道:适合线性串联
const chain = prompt | llm | parser;
const result = await chain.invoke({ topic: "AI" });
// LangGraph 图:适合有分支和循环的场景
const graph = new StateGraph(StateAnnotation)
.addNode("step1", fn1)
.addNode("step2", fn2)
.addConditionalEdges("step1", router, { A: "step2", B: "step3" })
.compile();
| 维度 | LCEL 管道 | LangGraph 图 |
|---|---|---|
| 控制流 | 纯线性(`A | B |
| 状态 | 无持久状态 | 全程持久化 State |
| 错误恢复 | 不支持 | 支持断点恢复 |
| 人在回路 | 不支持 | 支持 interrupt/resume |
| 调试 | 打印日志 | 可视化 Studio + checkpoint 回放 |
| 适合场景 | 单次转换(翻译、摘要) | Agent、多步骤工作流 |
一句话总结:你写一个
prompt | llm | outputParser,不需要 LangGraph。你需要 LLM 主动决定"下一步做什么"的时候,LangGraph 的优势才体现出来。
第二部分:LangGraph 核心机制深入
警告:接下来的四章会有点"硬"。但它们是全文的脊梁------搞懂了,后面的六大模式四行代码你就能看懂本质;搞不懂,看什么都是魔咒。建议一口气读完,不要跳。
第三章:State --- Agent 的共享记忆
3.1 什么是 State
LangGraph 里,State 就是一张跟着数据一起跑的便签本 。图上每个节点(Node)都能读这张便签,也能往上贴新内容。所有节点看到的 State 是同一份。
typescript
import { StateGraph, Annotation } from "@langchain/langgraph";
// 定义便签本上有哪些字段、各自是什么类型
const StateAnnotation = Annotation.Root({
topic: Annotation<string>, // 用户输入
searchResults: Annotation<string>, // 搜索结果
summary: Annotation<string>, // 总结
finalAnswer: Annotation<string>, // 最终答案
});
Annotation.Root({...}) 做了三件事:
- 定义结构:便签本上允许有哪些字段
- 定义类型:每个字段是 string、number、还是复杂对象
- 提供类型推断 :后面的 Node 函数里,
state.xxx有完整的 TypeScript 类型提示
3.2 State 是"累积"的,不是"替换"的
这是新手最容易搞错的地方。看这段代码:
typescript
async function stepOne(state: typeof StateAnnotation.State) {
return { searchResults: "找到了 10 篇相关文章" };
// 返回的是一个"部分 State",不是完整 State!
}
async function stepTwo(state: typeof StateAnnotation.State) {
console.log(state.topic); // ✅ 还在!初始值 "AI safety"
console.log(state.searchResults); // ✅ 也在!stepOne 贴上去的
return { summary: "总结一下..." };
// 这次返回另一个字段
}
// 调用
const result = await graph.invoke({ topic: "AI safety" });
// 最终 State =
// {
// topic: "AI safety", ← 输入的
// searchResults: "找到了 10 篇...", ← stepOne 贴的
// summary: "总结一下...", ← stepTwo 贴的
// finalAnswer: undefined ← 没被赋值,保持空
// }
核心规则:Node 返回的是"增量更新",LangGraph 自动把它合并到全局 State 上。旧字段保留,新字段覆盖。就像便签本上贴便利贴------旧的还在,新的盖在对应位置上。
默认情况下,同名字段是直接覆盖 。如果你需要更复杂的合并逻辑(比如追加到数组而不是覆盖),可以用 reducer:
typescript
const StateAnnotation = Annotation.Root({
// 默认 reducer:新值覆盖旧值(适合字符串、数字)
topic: Annotation<string>,
// 自定义 reducer:追加到数组(适合消息历史)
messages: Annotation<BaseMessage[]>({
reducer: (current, update) => current.concat(update),
default: () => [],
}),
});
3.3 State 为什么是 LangGraph 最核心的设计
和手写 Agent 循环对比一下就清楚了:
| 手写 while 循环 | LangGraph State | |
|---|---|---|
| 状态在哪 | 散落在 messages 数组里 | 结构化 State 对象 |
| 中间状态能看吗 | 只能靠 console.log | 每个 checkpoint 都有完整 State 快照 |
| 能从中间恢复吗 | 不能 | 从任意 checkpoint 继续执行 |
| 不同分支的状态隔离 | 全局变量,容易污染 | 每次 invoke 独立 State |
| 调试 | 打断点,看局部变量 | LangGraph Studio 可视化 State 变化 |
第四章:Node --- Agent 的执行单元
4.1 Node 是什么
Node 是 LangGraph 里真正"干活"的地方。它的签名非常统一:
typescript
async function myNode(state: StateType): Promise<Partial<StateType>> {
// 1. 从 state 里读你需要的数据
// 2. 做点什么(调 LLM、调 API、算个东西......)
// 3. 返回一个"部分 State",LangGraph 会自动合并
return { fieldA: "新值" };
}
一个 Node 可以干任何事情:
- 调用 LLM(最常见)
- 调用工具/API
- 读写数据库
- 纯计算逻辑(不涉及 LLM)
- 什么也不干,只是记录日志
LangGraph 不在乎你 Node 里面做什么,它只在乎你的入参(完整的 State)和出参(部分 State)。
4.2 Node 的返回值如何影响 State
yaml
执行 myNode 之前 State = { A: 1, B: 2, C: 3 }
myNode 返回 { B: 20, D: 4 }
执行 myNode 之后 State = { A: 1, B: 20, C: 3, D: 4 }
↑ ↑ ↑
不变 覆盖 新增
4.3 这个函数是 Node 还是路由?
看一个最常见的混淆点:
typescript
// ✅ 这是 Node:消费 State、返回部分 State
async function generateJoke(state: typeof StateAnnotation.State) {
const msg = await llm.invoke(`Write a joke about ${state.topic}`);
return { joke: msg.content }; // 返回 State 更新
}
// ❌ 这不是 Node,这是路由函数:只读 State,返回目标节点名
function checkPunchline(state: typeof StateAnnotation.State) {
if (state.joke?.includes("?") || state.joke?.includes("!")) {
return "Pass"; // 这是一个"路标",不是 State 字段
}
return "Fail";
}
generateJoke 用 .addNode() 注册;checkPunchline 直接作为参数传给 .addConditionalEdges()。 路由函数不往 State 里写任何东西,它只负责"指路"。
第五章:Edge --- Agent 的控制流
5.1 两种边
LangGraph 里只有两种边:
固定边(Normal Edge): 从 A 到 B,无条件,A 执行完就去 B。
typescript
.addEdge("generateJoke", "improveJoke")
// 源节点 目标节点
条件边(Conditional Edge): 从 A 出发,根据一个路由函数的返回值,决定去 B、C、还是 D。
typescript
.addConditionalEdges("generateJoke", checkPunchline, {
Pass: "improveJoke", // checkPunchline 返回 "Pass" → 去 improveJoke
Fail: "__end__", // checkPunchline 返回 "Fail" → 去终点
})
5.2 __start__ 和 __end__:图的天头地脚
__start__(或导入START常量):图的入口。你不需要定义它,它是一个隐式的"起点节点"。__end__(或导入END常量):图的出口。当执行流到达__end__,invoke()返回最终 State。
typescript
.addEdge("__start__", "firstNode") // 图一启动,先去 firstNode
.addEdge("lastNode", "__end__") // lastNode 执行完,图结束
规则: 每个图必须至少有一条从 __start__ 出发的边;每个最终会终止的分支必须有一条到 __end__ 的边。
5.3 边的 Fan-out 和 Fan-in 语义
同一个源节点可以有多条出边:
typescript
// Fan-out:__start__ 同时连接到三个节点
.addEdge("__start__", "worker1")
.addEdge("__start__", "worker2")
.addEdge("__start__", "worker3")
// → worker1、worker2、worker3 并行执行!
多个源节点可以指向同一个目标节点:
typescript
// Fan-in:三个 worker 都指向聚合节点
.addEdge("worker1", "aggregator")
.addEdge("worker2", "aggregator")
.addEdge("worker3", "aggregator")
// → aggregator 会等待三个 worker 全部完成才启动!
5.4 边的可视化
css
Fan-out(发散) Fan-in(汇聚)
__start__ worker1 ──┐
│ │
┌──┼──┐ worker2 ──┼──→ aggregator
│ │ │ │
▼ ▼ ▼ worker3 ──┘
A B C
第六章:Pregel 执行引擎 --- LangGraph 到底怎么"跑"的
这是全文最重要的技术章节。前面讲的 State/Node/Edge 是积木,这章讲的是积木是怎么被"驱动"的。
6.1 图计算和 Pregel
LangGraph 的执行引擎基于 Google 2010 年发表的 Pregel 图计算模型。Pregel 的核心思想很简单------每个"超级步"(Superstep)里:
- 找出所有"就该在这一步执行"的节点
- 并行执行它们
- 收集它们的输出,更新全局状态
- 重复,直到没有更多节点需要执行
把它翻译成 LangGraph 的语境:
markdown
超级步 1:哪些节点该执行?
→ 遍历所有边,找到源节点是 __start__ 的边
→ __start__ → firstNode
→ 执行 firstNode
超级步 2:firstNode 执行完了,接下来呢?
→ 遍历所有边,找到以 firstNode 为源的边
→ firstNode → secondNode(固定边)
→ firstNode → router → A 或 B(条件边)
→ 评估条件边,得到目标
→ 执行目标节点
超级步 3:...重复,直到所有路径都到达 __end__
6.2 完整执行追踪:追踪一只"猫"走完整条流水线
下面这个例子会贯穿我们后面的讨论。先看代码:
typescript
const StateAnnotation = Annotation.Root({
topic: Annotation<string>,
joke: Annotation<string>,
improvedJoke: Annotation<string>,
finalJoke: Annotation<string>,
});
async function generateJoke(state: typeof StateAnnotation.State) {
const msg = await llm.invoke(`Write a short joke about ${state.topic}`);
return { joke: msg.content };
}
function checkPunchline(state: typeof StateAnnotation.State) {
if (state.joke?.includes("?") || state.joke?.includes("!")) {
return "Pass";
}
return "Fail";
}
async function improveJoke(state: typeof StateAnnotation.State) {
const msg = await llm.invoke(
`Make this joke funnier by adding wordplay: ${state.joke}`
);
return { improvedJoke: msg.content };
}
async function polishJoke(state: typeof StateAnnotation.State) {
const msg = await llm.invoke(
`Add a surprising twist to this joke: ${state.improvedJoke}`
);
return { finalJoke: msg.content };
}
const jokeGraph = new StateGraph(StateAnnotation)
.addNode("generateJoke", generateJoke)
.addNode("improveJoke", improveJoke)
.addNode("polishJoke", polishJoke)
.addEdge("__start__", "generateJoke")
.addConditionalEdges("generateJoke", checkPunchline, {
Pass: "improveJoke",
Fail: "__end__",
})
.addEdge("improveJoke", "polishJoke")
.addEdge("polishJoke", "__end__")
.compile();
// 启动!
const result = await jokeGraph.invoke({ topic: "cats" });
流程图:
scss
__start__
│
▼
[generateJoke] ← 工位 1:根据 topic 生成笑话
│
▼ (条件边:checkPunchline)
╱ ╲
Pass Fail
│ │
▼ ▼
[improveJoke] __end__ ← 没笑点?直接结束
│
▼
[polishJoke] ← 工位 3:加反转结局
│
▼
__end__
现在,追踪一次执行(假设 LLM 生成了一个包含 ? 的好笑话):
| 步骤 | 引擎在干什么 | 触发原因 | 执行哪个 Node | 执行后的 State |
|---|---|---|---|---|
| 初始 | 用户传入 { topic: "cats" } |
invoke() |
--- | { topic: "cats" } |
| Step 1 | 找到源为 __start__ 的边 → __start__ → generateJoke |
固定边 | generateJoke |
{ topic: "cats", joke: "Why did the cat sit on the computer? Because it wanted to keep an eye on the mouse!" } |
| Step 2 | generateJoke 完成 → 评估条件边 checkPunchline(state) → 返回 "Pass" |
条件边路由 | improveJoke |
{ topic: "cats", joke: "Why did the cat...", improvedJoke: "Why did the feline programmer sit on the laptop? To keep an eye on the cursor---and its mouse!" } |
| Step 3 | improveJoke 完成 → 固定边 → polishJoke |
固定边 | polishJoke |
{ topic: "cats", joke: "Why...", improvedJoke: "Why...", finalJoke: "...and then the cat realized the mouse was wireless!" } |
| Step 4 | polishJoke 完成 → 固定边 → __end__ |
到达终点 | --- | 图结束,返回最终 State |
如果 LLM 生成的是一句平淡的 "Cats are cute"(没有 ? 或 !):
| 步骤 | 引擎在干什么 | 执行哪个 Node | 执行后的 State |
|---|---|---|---|
| Step 1 | 固定边 → generateJoke | generateJoke |
{ topic: "cats", joke: "Cats are cute" } |
| Step 2 | checkPunchline(state) → 返回 "Fail" → 去 __end__ |
---(直接结束) | 最终 State:{ topic: "cats", joke: "Cats are cute" }(只有 joke,没有 improvedJoke 和 finalJoke) |
6.3 隐式屏障是什么,什么时候生效
当多条边的目标指向同一个 Node 时,LangGraph 自动形成隐式屏障(Implicit Barrier):
typescript
// 三条边都指向 aggregator
.addEdge("worker1", "aggregator")
.addEdge("worker2", "aggregator")
.addEdge("worker3", "aggregator")
引擎的行为:
arduino
worker1 完成 → 检查:"以 worker1 为源的边 → aggregator,但指向 aggregator 的还有 worker2 和 worker3 的边"
→ worker2 还没完成?那 aggregator 再等等
worker2 完成 → 同上,worker3 还没完
worker3 完成 → 所有指向 aggregator 的源节点都完成了
→ 启动 aggregator!
这个行为是自动的,你不需要写任何 await Promise.all 之类的代码。 这是 Pregel 模型的原生语义------所有消息到达后,顶点才激活。
6.4 并行执行是什么,什么时候发生
反过来,当多条边从同一个节点发出、指向不同目标时,LangGraph 自动并行执行:
typescript
.addEdge("__start__", "worker1")
.addEdge("__start__", "worker2")
.addEdge("__start__", "worker3")
引擎的行为:
markdown
超级步 1:__start__ 完成了
→ 以 __start__ 为源的边:→ worker1、→ worker2、→ worker3
→ 这些目标节点是独立的,互相不依赖
→ 🔥 并行执行 worker1、worker2、worker3
超级步 2:所有 worker 都完成了
→ 寻找下一批可执行节点...
注意:并行执行是在同一个 Node 内部是 async 的。LangGraph 会在同一超级步内同时启动所有就绪的节点,Node 之间的执行顺序不确定。
6.5 .compile() 和 .invoke() 到底做了什么
arduino
.compile()
→ 验证图的结构合法性(有没有孤立节点?每个分支是否都有终点?)
→ 预计算每个节点的"入度"(有多少条边指向它)
→ 返回一个 CompiledStateGraph 实例
.invoke(initialState)
→ 创建初始 State
→ 进入 Pregel 执行循环:
while (有节点等待执行) {
超级步:
1. 收集所有"前置依赖已满足"的节点
2. 并行执行它们
3. 应用它们的返回值更新 State
4. 评估条件边,确定下一步
}
→ 返回最终 State
6.6 总结:这一章你带走的三个关键认知
-
LangGraph 不是"一次执行所有节点" --- 它按 Pregel 超级步逐批执行,每一步只执行"前置条件已满足"的节点。
-
Fan-out = 自动并行,Fan-in = 自动等待 --- 你不需要手写 Promise.all,引擎的入度计数器帮你做。
-
条件边的路由函数是在每个超级步里被调用的 --- 它读到的是最新的 State,所以 State 的变化会影响后续的路由决策。
现在你知道了 LangGraph 的底层执行逻辑。接下来的六大模式,本质上就是 State + Node + Edge 的不同组合方式。你不需要"背"模式------你只需要看懂每种模式是怎么用这三样东西搭出来的。
第三部分:六大 Agent 设计模式
从简单到复杂,每种模式都是在前面模式的基础上加新东西。建议按顺序读,每读完一种就想一下"这个和上一个有什么不同"。
第七章:模式一 ------ Prompt Chaining(提示链)
什么时候用
任务可以拆成明确的、有先后顺序的步骤,上一步的输出是下一步的输入。
典型场景:
- 写一篇文章:生成大纲 → 撰写正文 → 润色 → 翻译
- 代码审查:读取代码 → 分析问题 → 生成修复建议 → 验证修复
- 数据分析:提取数据 → 清洗 → 分析 → 生成报告
流程图
css
__start__ → [Step 1] → [Step 2] → [Step 3] → [Step 4] → __end__
输入 输出1 输出2 输出3 最终结果
代码实现
typescript
const ChainState = Annotation.Root({
topic: Annotation<string>,
outline: Annotation<string>,
draft: Annotation<string>,
polished: Annotation<string>,
translated: Annotation<string>,
});
async function makeOutline(state: typeof ChainState.State) {
const msg = await llm.invoke(
`Create a detailed outline for a technical article about: ${state.topic}`
);
return { outline: msg.content };
}
async function writeDraft(state: typeof ChainState.State) {
const msg = await llm.invoke(
`Write a full draft based on this outline:\n\n${state.outline}`
);
return { draft: msg.content };
}
async function polishDraft(state: typeof ChainState.State) {
const msg = await llm.invoke(
`Polish this draft for clarity, conciseness, and flow:\n\n${state.draft}`
);
return { polished: msg.content };
}
async function translateToEnglish(state: typeof ChainState.State) {
const msg = await llm.invoke(
`Translate the following polished article to English:\n\n${state.polished}`
);
return { translated: msg.content };
}
const chainWorkflow = new StateGraph(ChainState)
.addNode("outline", makeOutline)
.addNode("draft", writeDraft)
.addNode("polish", polishDraft)
.addNode("translate", translateToEnglish)
.addEdge("__start__", "outline")
.addEdge("outline", "draft")
.addEdge("draft", "polish")
.addEdge("polish", "translate")
.addEdge("translate", "__end__")
.compile();
const result = await chainWorkflow.invoke({
topic: "How Transformers Changed NLP",
});
console.log(result.translated);
State 变化追踪
| 步骤 | 执行 Node | State 新增字段 | 其他字段 |
|---|---|---|---|
| 输入 | --- | topic = "How Transformers..." |
--- |
| Step 1 | outline |
outline = "...I. Introduction\nII. Attention...\n..." |
topic 保留 |
| Step 2 | draft |
draft = "The Transformer architecture..." |
topic, outline 保留 |
| Step 3 | polish |
polished = "A polished version...:" |
topic, outline, draft 保留 |
| Step 4 | translate |
translated = "The Transformer architecture..." |
topic, outline, draft, polished 保留 |
关键洞察:每一步的 Node 都可以读到前面所有步骤产生的 State 字段。比如
translate可以读topic、outline、draft、polished------但你只在 Prompt 里用了polished,这是你自己的选择。
对比:Prompt Chaining vs 单次 LLM 调用
| 维度 | 单次 LLM 调用 | Prompt Chaining |
|---|---|---|
| 质量 | 一次生成,LLM 容易遗漏和跑偏 | 每一步聚焦一个任务,质量更高 |
| 可控性 | 无法干预中间过程 | 每一步输出可检查、可修改、可缓存 |
| 成本 | 一个长 Prompt,Token 多 | 每步 Prompt 更短,但调多次 |
| 延迟 | 一次 API 调用 | T1 + T2 + T3 + T4 |
| 可调试 | 只能看最终输出 | 每步输出都存 State,容易定位问题 |
| 复杂度 | 极低 | 低 |
第八章:模式二 ------ Routing(路由)
什么时候用
输入的类型差异很大,需要先"分类"再交给不同的处理器处理。
和 Prompt Chaining 的关键区别:Chaining 是一条线 ,Routing 是分叉树------不是每一步都走,而是根据条件选一条路走。
典型场景:
- 客服系统:用户问题 → 分类(退货/技术/投诉/一般) → 走不同处理流程
- 内容平台:帖子 → 审核分类(通过/需修改/违规) → 不同处理
- 代码助手:用户请求 → 分类(写代码/改Bug/解释代码/Code Review)→ 不同 Prompt
流程图
css
┌→ [codeHandler] ──┐
│ │
__start__ → [classifier] ─┼→ [writingHandler] ─┼→ __end__
│ │
├→ [translateHandler] ┘
│
└→ [generalHandler] ──┘
代码实现
typescript
const RouterState = Annotation.Root({
input: Annotation<string>,
category: Annotation<string>,
result: Annotation<string>,
});
// 分类器 Node:判断输入属于什么类型
async function classifier(state: typeof RouterState.State) {
const msg = await llm.withStructuredOutput(
z.object({ category: z.enum(["code", "writing", "translation", "general"]) })
).invoke(
`Classify this user request: "${state.input}"`
);
return { category: msg.category };
}
// 四个专用处理器
async function handleCode(state: typeof RouterState.State) {
const msg = await llm.invoke(
`You are a senior software engineer. ${state.input}`
);
return { result: msg.content };
}
async function handleWriting(state: typeof RouterState.State) {
const msg = await llm.invoke(
`You are a professional content writer. ${state.input}`
);
return { result: msg.content };
}
async function handleTranslation(state: typeof RouterState.State) {
const msg = await llm.invoke(
`Translate: ${state.input}`
);
return { result: msg.content };
}
async function handleGeneral(state: typeof RouterState.State) {
const msg = await llm.invoke(`Answer: ${state.input}`);
return { result: msg.content };
}
// 路由函数:根据 category 决定下一站
function routeByCategory(state: typeof RouterState.State) {
return state.category; // "code" | "writing" | "translation" | "general"
}
const routerWorkflow = new StateGraph(RouterState)
.addNode("classifier", classifier)
.addNode("code", handleCode)
.addNode("writing", handleWriting)
.addNode("translation", handleTranslation)
.addNode("general", handleGeneral)
.addEdge("__start__", "classifier")
.addConditionalEdges("classifier", routeByCategory, {
code: "code",
writing: "writing",
translation: "translation",
general: "general",
})
.addEdge("code", "__end__")
.addEdge("writing", "__end__")
.addEdge("translation", "__end__")
.addEdge("general", "__end__")
.compile();
State 变化追踪
假设输入 "帮我写一个 Python 的快速排序",分类器判断为 "code":
| 步骤 | 执行 Node | 触发 | State |
|---|---|---|---|
| 输入 | --- | --- | { input: "帮我写...", category: <空>, result: <空> } |
| Step 1 | classifier |
固定边 __start__ → classifier |
{ input: "帮我写...", category: "code", result: <空> } |
| Step 2 | code(不是 writing!) |
routeByCategory 返回 "code" |
{ input: "帮我写...", category: "code", result: "def quick_sort(arr):..." } |
| Step 3 | --- | 固定边 code → __end__ |
图结束,writing/translation/general 从未执行 |
注意:只有一条分支被执行。其他三个处理器完全没有运行。这是 Routing 的核心------选一条路走,而不是走遍所有路。
关键细节:分类器用了 withStructuredOutput
注意 classifier 内部用了 withStructuredOutput + z.enum(),强制 LLM 只返回 "code" | "writing" | "translation" | "general" 中的一个。这保证了路由函数收到的 state.category 一定是这四个值之一,不会出现"路由到不存在的节点"的运行时错误。
对比:Routing vs Prompt Chaining
| 维度 | Prompt Chaining | Routing |
|---|---|---|
| 控制流 | 直线:A → B → C → D | 分叉:A → (B 或 C 或 D) |
| 执行节点数 | 所有节点都执行 | 只有被路由到的节点执行 |
| 延迟 | 所有步骤延迟之和 | 分类器延迟 + 被选中节点的延迟 |
| 成本 | 所有步骤的 Token 之和 | 只有分类器 + 一个处理器 |
| 复杂性来源 | 步骤间数据依赖 | 分类准确性 |
| 适合场景 | 所有步骤都必须做 | 只有一个分支需要做 |
第九章:模式三 ------ Parallelization(并行化)
什么时候用
多个子任务互相独立------你不等我、我不等你------可以同时执行,最后汇总结果。
和 Routing 的关键区别:Routing 是选一条路 ,Parallelization 是所有路一起走,然后在终点等齐了再汇总。
典型场景:
- 内容生成:同一主题 → 同时生成文章/视频脚本/社交媒体帖子
- 多维度分析:同一数据 → 同时做技术分析/财务分析/市场分析
- 多源搜索:同一问题 → 同时搜 Google/Bing/内部知识库
- 多模型投票:同一问题 → 同时发给 GPT-4/Claude/Gemini → 投票最佳答案
流程图
css
┌──[worker1]──┐
│ │
__start__ ────┼──[worker2]──┼──→ [aggregator] → __end__
│ │
└──[worker3]──┘
Fan-out(发散) Fan-in(汇聚,隐式屏障)
代码实现
typescript
const ParallelState = Annotation.Root({
topic: Annotation<string>,
joke: Annotation<string>,
story: Annotation<string>,
poem: Annotation<string>,
combinedOutput: Annotation<string>,
});
// 三个独立 Worker------互不依赖,各干各的
async function generateJoke(state: typeof ParallelState.State) {
const msg = await llm.invoke(`Write a joke about ${state.topic}`);
return { joke: msg.content };
}
async function generateStory(state: typeof ParallelState.State) {
const msg = await llm.invoke(`Write a short story about ${state.topic}`);
return { story: msg.content };
}
async function generatePoem(state: typeof ParallelState.State) {
const msg = await llm.invoke(`Write a poem about ${state.topic}`);
return { poem: msg.content };
}
// 汇聚节点------等三个 Worker 全部完成才启动
async function aggregator(state: typeof ParallelState.State) {
const combined = [
`📖 STORY:\n${state.story}\n`,
`😂 JOKE:\n${state.joke}\n`,
`🎵 POEM:\n${state.poem}`,
].join("\n---\n");
return { combinedOutput: combined };
}
const parallelWorkflow = new StateGraph(ParallelState)
.addNode("genJoke", generateJoke)
.addNode("genStory", generateStory)
.addNode("genPoem", generatePoem)
.addNode("aggregator", aggregator)
// Fan-out:__start__ 同时连接三个 Worker
.addEdge("__start__", "genJoke")
.addEdge("__start__", "genStory")
.addEdge("__start__", "genPoem")
// Fan-in:三个 Worker 都指向 aggregator
.addEdge("genJoke", "aggregator")
.addEdge("genStory", "aggregator")
.addEdge("genPoem", "aggregator")
.addEdge("aggregator", "__end__")
.compile();
执行时间线
scss
时间 →
genJoke ████████████████░░░░ (2.1s) ← 同时启动
genStory ██████████░░░░░░░░░░ (1.5s) ← 同时启动
genPoem ████████████████████ (2.8s) ← 同时启动(最慢的)
aggregator ████ (0.3s) ← 等最慢的那个完成后才启动
总耗时 ≈ max(2.1, 1.5, 2.8) + 0.3 = 3.1s
串行耗时 = 2.1 + 1.5 + 2.8 + 0.3 = 6.7s
加速比 ≈ 2.2x
隐式屏障详细解释
你可能注意到 .addEdge("genJoke", "aggregator") 等三条边都是固定边,没有提到"等待"。那 LangGraph 怎么知道 aggregator 要等三个都完成?
这就是第六章讲的 Pregel 引擎的隐式屏障机制:
- 编译时,LangGraph 统计每个节点的入度(指向它的边的数量)
aggregator的入度 = 3(三条边指向它)- 运行时,内部有一个计数器:genJoke 完成 → 计数器 +1;genStory 完成 → 计数器 +1;genPoem 完成 → 计数器 +1
- 只有当计数器 = 入度 = 3 时,
aggregator才被激活执行
你不需要手写任何等待逻辑。这是图结构的天然语义。
常见疑问
Q: 如果 genJoke 报错了,aggregator 会一直等下去吗?
不会。LangGraph 有错误处理机制------如果某个节点抛出未捕获的异常,整个图的执行会终止(不会让 aggregator 傻等)。
Q: 如果某个 Worker 特别慢,我能设超时吗?
可以在 Node 函数内部用 Promise.race 或其他超时机制,但 LangGraph 本身不提供"节点级超时"------这是一种有意为之的设计选择,因为"超时后做什么"是业务逻辑,应该由你决定。
对比:Parallelization vs Routing
| 维度 | Routing | Parallelization |
|---|---|---|
| 分支选择 | 只走一条路 | 所有路一起走 |
| 节点执行 | 只执行被选中的分支 | 所有分支都执行 |
| 延迟 | 分类器延迟 + 一个处理器 | max(所有 Worker) + 聚合器 |
| 成本 | 分类器 + 一个处理器的 Token | 所有 Worker + 聚合器的 Token |
| 适用场景 | 任务互斥(只需要一个答案) | 任务互补(需要多维度结果) |
| 结果结构 | 单一输出 | 多源输出 → 合并为一个 |
第十章:模式四 ------ Evaluator-Optimizer(评估器-优化器)
什么时候用
你有一个"生成"节点和一个"评估"节点。生成的结果如果不达标,就带着评估反馈回到生成节点重新来。循环到达标为止。
和 Parallelization 的关键区别:Parallelization 是一次性并行 ,Evaluator-Optimizer 是迭代式串行循环------可能跑 1 轮就过,也可能跑 5 轮。
典型场景:
- 内容生成:写文案 → 评分(吸引力、CTA、创意) → 不达标就带着反馈重写
- 代码生成:写代码 → 跑测试 → 测试不通过就修复
- 翻译质量:翻译 → 校对评分 → 不够地道就润色
流程图
scss
__start__ → [generator] → [evaluator] ──(不达标)──┐
│ │
(达标) │
│ │
▼ │
__end__ ←─────────────┘ (循环回去)
代码实现
typescript
const EvalState = Annotation.Root({
task: Annotation<string>,
draft: Annotation<string>,
score: Annotation<number>,
feedback: Annotation<string>,
iteration: Annotation<number>,
});
async function generator(state: typeof EvalState.State) {
const iter = (state.iteration || 0) + 1;
// 第一轮:根据 task 生成
// 后续轮次:根据 task + 上一轮的反馈重新生成
const context = state.feedback
? `Previous attempt score: ${state.score}/10.\nFeedback: ${state.feedback}\n\nPlease rewrite based on the feedback.`
: "Write the first version.";
const msg = await llm.invoke(
`Task: ${state.task}\n\n${context}`
);
return { draft: msg.content, iteration: iter };
}
async function evaluator(state: typeof EvalState.State) {
const msg = await llm.withStructuredOutput(
z.object({
score: z.number().min(1).max(10).describe("Quality score 1-10"),
feedback: z.string().describe("Specific improvement suggestions"),
})
).invoke(
`Evaluate this marketing copy on engagement, clarity, call-to-action, and creativity.
Rate 1-10 and give SPECIFIC suggestions:\n\n${state.draft}`
);
return { score: msg.score, feedback: msg.feedback };
}
// 质量门:决定继续还是结束
function qualityGate(state: typeof EvalState.State) {
const MAX = 5; // 最多迭代 5 轮
if (state.score >= 8) {
return "pass"; // 达标,结束
}
if (state.iteration >= MAX) {
return "maxed"; // 达到上限,勉强结束
}
return "retry"; // 不达标,回炉重造
}
const evalWorkflow = new StateGraph(EvalState)
.addNode("generator", generator)
.addNode("evaluator", evaluator)
.addEdge("__start__", "generator")
.addEdge("generator", "evaluator")
.addConditionalEdges("evaluator", qualityGate, {
pass: "__end__",
maxed: "__end__",
retry: "generator", // ← 回退到 generator,形成循环
})
.compile();
State 变化追踪(假设迭代了 3 轮才达标)
| 轮次 | 步骤 | 执行 Node | 关键 State |
|---|---|---|---|
| 输入 | --- | --- | { task: "写一个AI课程的营销文案" } |
| 第 1 轮 | Step 1 | generator |
{ draft: "想学AI吗?来报名吧...", iteration: 1 } |
| Step 2 | evaluator |
{ score: 5, feedback: "太平淡,缺少情感共鸣..." } |
|
| 路由 | qualityGate |
score=5 < 8, iter=1 < 5 → "retry" |
|
| 第 2 轮 | Step 3 | generator |
{ draft: "还在为加班写重复代码烦恼吗?...", iteration: 2 } |
| Step 4 | evaluator |
{ score: 7, feedback: "好了很多,但CTA还不够紧迫..." } |
|
| 路由 | qualityGate |
score=7 < 8, iter=2 < 5 → "retry" |
|
| 第 3 轮 | Step 5 | generator |
{ draft: "...限时优惠,立即报名!", iteration: 3 } |
| Step 6 | evaluator |
{ score: 9, feedback: "非常棒!" } |
|
| 路由 | qualityGate |
score=9 ≥ 8 → "pass" → 结束 |
关键设计:
generator在迭代时能读到上一轮的feedback和score。这不是框架做的------是你自己在generator的 Prompt 里引用了state.feedback和state.score。State 的历史累积能力让你可以在任何时候引用任何之前步骤产生的数据。
循环终止策略
qualityGate 里有双重保护:
- 质量保护:score ≥ 8 直接通过
- 兜底保护:iteration ≥ 5 强制结束(避免永远不达标导致的无限循环)
这个双重保护是生产级 Agent 必须考虑的设计模式------永远不要让循环只有一个终止条件。
对比:Evaluator-Optimizer vs Parallelization
| 维度 | Parallelization | Evaluator-Optimizer |
|---|---|---|
| 执行模式 | 一次性并行 | 迭代式串行循环 |
| 延迟 | max(Worker) | N × (生成 + 评估),N 不确定 |
| 成本 | 固定(所有 Worker) | 变数(取决于迭代次数) |
| 输出质量 | 取决于单次生成 | 逐步提升,有质量保证 |
| 复杂度 | 中 | 中高(需要设计评估标准和循环终止策略) |
| 适合场景 | 独立子任务,结果互补 | 对输出质量有严格要求 |
第十一章:模式五 ------ Agent / ReAct 循环
什么时候用
你有一个 LLM + 一堆工具。你需要 LLM 自己决定"什么时候调工具、调哪个工具、调几次、调完工具后结果是否满意、要不要再调一次"。
这是 LangGraph 最经典的模式------你在 ChatGPT 里用的联网搜索、代码执行,底层就是这个循环。
和 Evaluator-Optimizer 的关键区别:
- Evaluator-Optimizer 的循环是固定的(总是 generator → evaluator → 可能回退)
- Agent 循环的路径是LLM 动态决定的(LLM 决定调用哪个工具,甚至决定现在是不是该结束了)
流程图
scss
┌──────────────────────────────┐
│ │
▼ │
__start__ → [agent] ──(LLM决定调工具)──→ [tools]
│
(LLM决定结束)
│
▼
__end__
代码实现
typescript
import { AIMessage, HumanMessage, ToolMessage } from "@langchain/core/messages";
import { ToolNode } from "@langchain/langgraph/prebuilt";
// ===== 定义工具 =====
const searchTool = tool(
async ({ query }: { query: string }) => {
// 实际项目中这里调 Google/Bing API
return `Search results for "${query}": [result1, result2, ...]`;
},
{
name: "web_search",
description: "Search the web for current information",
schema: z.object({
query: z.string().describe("The search query"),
}),
}
);
const calculatorTool = tool(
async ({ expression }: { expression: string }) => {
// 实际项目中用安全的数学求值库
return `Result: ${eval(expression)}`;
},
{
name: "calculator",
description: "Evaluate a mathematical expression",
schema: z.object({
expression: z.string().describe("Math expression to evaluate"),
}),
}
);
// ===== 定义 Agent State =====
const AgentState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (current, update) => current.concat(update),
default: () => [],
}),
});
// ===== 绑定工具到 LLM =====
const tools = [searchTool, calculatorTool];
const llmWithTools = llm.bindTools(tools);
// ===== 定义 Node =====
// Agent 节点:调用 LLM
async function callAgent(state: typeof AgentState.State) {
const response = await llmWithTools.invoke(state.messages);
return { messages: [response] };
}
// ToolNode 是 LangGraph 预置的工具执行节点
const toolNode = new ToolNode(tools);
// ===== 路由函数 =====
function shouldContinue(state: typeof AgentState.State) {
const lastMessage = state.messages[state.messages.length - 1];
// 如果 LLM 返回了 tool_calls → 执行工具
if (lastMessage instanceof AIMessage && lastMessage.tool_calls?.length) {
return "tools";
}
// 否则 → 结束
return "__end__";
}
// ===== 构建图 =====
const agentWorkflow = new StateGraph(AgentState)
.addNode("agent", callAgent)
.addNode("tools", toolNode)
.addEdge("__start__", "agent")
.addConditionalEdges("agent", shouldContinue, {
tools: "tools",
__end__: "__end__",
})
.addEdge("tools", "agent") // ← 这条边形成循环!
.compile();
执行追踪:一个完整的 Agent 对话
arduino
用户: "东京现在天气多少度?把摄氏度换算成华氏度。"
| 轮次 | 步骤 | 执行 Node | messages 数组变化 | LLM 在做什么 |
|---|---|---|---|---|
| 初始 | --- | --- | [HumanMessage("东京现在天气...")] |
--- |
| 第 1 轮 | Step 1 | agent |
[..., AIMessage(tool_calls=[{name:"web_search", args:{query:"东京天气"}}])] |
🤔 "我不知道东京天气,得搜一下" → 写搜索派工单 |
| 路由 | shouldContinue |
--- | 检测到 tool_calls → "tools" |
|
| Step 2 | tools |
[..., ToolMessage("Search results: 东京当前 25°C")] |
🔧 执行 web_search,返回搜索结果 | |
| 边 | tools → agent |
--- | 回到 agent(形成循环) | |
| 第 2 轮 | Step 3 | agent |
[..., AIMessage(tool_calls=[{name:"calculator", args:{expression:"25*9/5+32"}}])] |
🤔 "25°C 转华氏度 = 25×9÷5+32,得算一下" → 写计算派工单 |
| 路由 | shouldContinue |
--- | 检测到 tool_calls → "tools" |
|
| Step 4 | tools |
[..., ToolMessage("Result: 77")] |
🔧 执行 calculator | |
| 边 | tools → agent |
--- | 回到 agent | |
| 第 3 轮 | Step 5 | agent |
[..., AIMessage("东京当前 25°C,约合 77°F。")] |
✅ "信息足够了" → 直接给最终答案,无 tool_calls |
| 路由 | shouldContinue |
--- | 无 tool_calls → "__end__" → 结束 |
ToolNode 做了什么
ToolNode 是 LangGraph 提供的一个预置组件。你不需要自己写"遍历 tool_calls → 找到对应工具 → 执行 → 返回 ToolMessage"的逻辑。它自动:
- 从最后一条 AIMessage 中提取
tool_calls - 并行执行所有工具调用(如果 LLM 同时要求调两个工具)
- 返回一个
ToolMessage数组,包含每个工具的执行结果
typescript
// 你不需要写这些:
for (const toolCall of lastMessage.tool_calls) {
const tool = tools.find(t => t.name === toolCall.name);
const result = await tool.invoke(toolCall.args);
messages.push(new ToolMessage({ content: result, tool_call_id: toolCall.id }));
}
// 只需要:
.addNode("tools", new ToolNode(tools)) // 一行搞定
Agent 模式和其他模式的核心区别
Agent 循环看起来只是一个 agent + tools 之间的二节点循环------但它是最灵活的模式,因为:
- LLM 决定循环次数(不像 Evaluator 有固定的 generator→evaluator 路径)
- LLM 决定调用哪个工具(不像 Routing 由分类器决定)
- LLM 可以根据工具返回结果改变策略(比如搜索没结果,换一个搜索词重试)
对比:Agent vs Evaluator-Optimizer
| 维度 | Evaluator-Optimizer | Agent / ReAct |
|---|---|---|
| 循环决策者 | 评估函数(规则/LLM评分) | LLM 自主决定 |
| 路径 | 固定(generator↔evaluator) | 动态(agent→任意工具→agent→任意工具→...) |
| 工具数量 | 通常 0-1 个(评估可能也是 LLM) | 任意多个,LLM 自由选择 |
| 循环终止 | 评分达标 或 次数上限 | LLM 决定不再需要工具 |
| 适合场景 | 同一任务的反复优化 | 多步信息检索和处理 |
第十二章:模式六 ------ Coordinator / Supervisor(多 Agent 协作)
什么时候用
一个 Agent 不够------任务太复杂,需要多个不同专业的 Agent 协同工作。一个"总指挥"(Supervisor)负责分析任务、分配给合适的子 Agent、评估结果、决定是继续分配还是汇总结束。
这是最复杂的模式,它本质上是 Routing + Agent 循环 + Evaluator 的组合。
典型场景:
- 全栈开发助手:Supervisor → 前端专家 / 后端专家 / DBA / DevOps
- 科研助手:Supervisor → 文献检索 / 实验设计 / 数据分析 / 论文润色
- 企业决策支持:Supervisor → 市场分析 / 财务分析 / 风险评估
流程图
scss
┌──[frontend_expert]──┐
│ │
__start__ → [supervisor] ─┼──[backend_expert]───┼──→ 回到 supervisor
│ │
└──(FINISH)───────────┘
│
▼
[compileReport] → __end__
代码实现
typescript
const TeamState = Annotation.Root({
task: Annotation<string>,
messages: Annotation<{ role: string; content: string }[]>({
reducer: (current, update) => current.concat(update),
default: () => [],
}),
next: Annotation<string>,
finalReport: Annotation<string>,
});
// ===== Supervisor =====
async function supervisor(state: typeof TeamState.State) {
const systemPrompt = `You are a supervisor managing a development team:
Team members:
- frontend_expert: UI/UX, React, CSS, component design
- backend_expert: API design, database, server logic, auth
- devops_expert: deployment, CI/CD, infrastructure, scaling
Your job:
1. Analyze the user's task
2. Decide which expert should work next
3. If enough information has been gathered, respond "FINISH"
Reply with ONLY one word:
frontend_expert, backend_expert, devops_expert, or FINISH`;
const msg = await llm.invoke([
{ role: "system", content: systemPrompt },
...state.messages.map(m => ({ role: m.role, content: m.content })),
{ role: "user", content: `[CURRENT TASK]: ${state.task}\n\n[YOUR INSTRUCTION]: Who should act next? Reply with ONE word.` },
]);
const decision = msg.content.trim().toLowerCase();
return {
next: decision,
messages: [{ role: "supervisor", content: `→ Assigning to: ${decision}` }],
};
}
// ===== 子 Agent =====
async function frontendExpert(state: typeof TeamState.State) {
const msg = await llm.invoke([
{ role: "system", content: "You are a senior frontend engineer. Provide detailed, production-ready frontend solutions with code examples." },
{ role: "user", content: state.task },
]);
return {
messages: [{ role: "frontend_expert", content: msg.content }],
};
}
async function backendExpert(state: typeof TeamState.State) {
const msg = await llm.invoke([
{ role: "system", content: "You are a senior backend engineer. Provide detailed API designs, database schemas, and server architecture with code examples." },
{ role: "user", content: state.task },
]);
return {
messages: [{ role: "backend_expert", content: msg.content }],
};
}
async function devopsExpert(state: typeof TeamState.State) {
const msg = await llm.invoke([
{ role: "system", content: "You are a senior DevOps engineer. Provide deployment strategies, CI/CD configurations, and infrastructure designs." },
{ role: "user", content: state.task },
]);
return {
messages: [{ role: "devops_expert", content: msg.content }],
};
}
// ===== 报告编译 =====
async function compileReport(state: typeof TeamState.State) {
const msg = await llm.invoke([
{ role: "system", content: "Synthesize all expert inputs into a comprehensive final report. Structure it clearly with sections." },
...state.messages.map(m => ({ role: m.role, content: m.content })),
]);
return { finalReport: msg.content };
}
// ===== 路由 =====
function supervisorRouter(state: typeof TeamState.State) {
if (state.next === "finish") return "compileReport";
// 必须是已注册的节点名
if (["frontend_expert", "backend_expert", "devops_expert"].includes(state.next)) {
return state.next;
}
return "compileReport"; // fallback
}
// 子 Agent 干完活 → 回到 Supervisor
function expertRouter(state: typeof TeamState.State) {
return "supervisor";
}
// ===== 构建图 =====
const teamWorkflow = new StateGraph(TeamState)
.addNode("supervisor", supervisor)
.addNode("frontend_expert", frontendExpert)
.addNode("backend_expert", backendExpert)
.addNode("devops_expert", devopsExpert)
.addNode("compileReport", compileReport)
.addEdge("__start__", "supervisor")
.addConditionalEdges("supervisor", supervisorRouter, {
frontend_expert: "frontend_expert",
backend_expert: "backend_expert",
devops_expert: "devops_expert",
compileReport: "compileReport",
})
// 子 Agent 执行完 → 总是回到 Supervisor
.addEdge("frontend_expert", "supervisor")
.addEdge("backend_expert", "supervisor")
.addEdge("devops_expert", "supervisor")
.addEdge("compileReport", "__end__")
.compile();
执行追踪
vbnet
用户: "帮我设计一个用户认证系统,包括前端登录页面和后端JWT实现"
Step 1: supervisor → 分析任务 → "这个任务涉及前后端,先让 backend_expert 设计 API"
→ next = "backend_expert"
Step 2: backend_expert → 输出完整的 JWT API 设计和数据库 schema
Step 3: 回到 supervisor → "后端设计已就绪,现在需要前端实现"
→ next = "frontend_expert"
Step 4: frontend_expert → 输出登录页面 React 组件和 Token 管理方案
Step 5: 回到 supervisor → "前后端方案都已就绪,交付最终报告"
→ next = "finish"
Step 6: compileReport → 整合所有专家的输出,生成最终报告
Coordinator 模式的三个关键设计决策
1. 为什么子 Agent 总是回到 Supervisor?
这是 Coordinator 和 Agent 循环的最大区别。Agent 循环里是 agent↔tools 之间反复;Coordinator 里是 supervisor→expert→supervisor→expert→...。
原因: Supervisor 需要评估每个专家的输出质量,决定"这个专家给的信息够不够?要不要换另一个专家?还是可以汇总了?" 如果不回到 Supervisor,就无法实现这种评估。
2. 子 Agent 之间怎么"通信"?
它们不直接通信。所有信息通过共享 State 传递------frontend_expert 写入的 messages,backend_expert 和 supervisor 都能读到。这是一种"共享白板"式的通信模型。
3. 怎么防止 Supervisor 不停地分配任务?
和 Evaluator 一样,需要双重终止条件。Supervisor 的 Prompt 里明确要求"如果信息够了就 FINISH",同时在 supervisorRouter 里对未知的 next 值有 fallback 到 compileReport。
六大模式总结对比
| 模式 | 图结构 | LLM 调用次数 | 延迟特征 | 复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Prompt Chaining | 直线 | N(固定) | 串行延迟之和 | 低 | 大纲→正文→润色→翻译 |
| Routing | 分叉树 | 2(分类+处理) | 分类+一枝 | 低 | 客服分流转接 |
| Parallelization | 散聚 | N+1(N个Worker+聚合) | max(Worker) | 中 | 多维度分析/多源搜索 |
| Evaluator-Optimizer | 循环 | 2K(K轮迭代) | K×(生成+评估) | 中高 | 文案改写直到达标 |
| Agent / ReAct | 循环+工具 | 动态(取决于任务) | 动态 | 高 | 联网搜索+计算+总结 |
| Coordinator | 多Agent+总控 | 动态(取决于任务) | 动态 | 最高 | 跨专业复杂项目 |
怎么选模式------决策树
markdown
你的任务能拆成明确的步骤吗?
│
├── 能,且步骤有先后依赖
│ └── 用 Prompt Chaining
│
├── 能,但只需要做其中一个分支
│ └── 用 Routing
│
├── 能,且各步骤互相独立
│ └── 用 Parallelization
│
├── 能,但需要迭代优化到一定标准
│ └── 用 Evaluator-Optimizer
│
└── 不能预先确定步骤------需要 LLM 自己决定
│
├── 单个 LLM + 工具就够了
│ └── 用 Agent / ReAct 循环
│
└── 需要多个专业 Agent 协作
└── 用 Coordinator / Supervisor
第四部分:LangGraph 生产级特性
前面六种模式解决的是"Agent 怎么写"的问题。但"写出来"和"能上线"之间还隔着一大段距离。下面四个特性是 LangGraph 把 Agent 从原型推向生产的关键。
第十三章:Memory & Checkpointer --- 状态持久化
为什么需要持久化
考虑一个典型场景:你的 Agent 正在执行工具调用循环,已经跑了 5 轮,调了 3 次搜索、2 次计算,累计花了 1.5 美元 API 费。结果第 6 轮 API 超时了。如果没有持久化------钱花了,结果全丢了。
Checkpointer 是 LangGraph 的答案:每一步执行后自动保存 State 快照。
开发 vs 生产
typescript
import { MemorySaver } from "@langchain/langgraph";
// 开发环境:内存存储(重启丢失,但零配置)
const memorySaver = new MemorySaver();
const devGraph = workflow.compile({ checkpointer: memorySaver });
// 生产环境:SQLite(单机)或 PostgreSQL(分布式)
import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
const db = SqliteSaver.fromConnString("./checkpoints.db");
const prodGraph = workflow.compile({ checkpointer: db });
多会话隔离
typescript
// 会话 A:用户 Alice
await graph.invoke(input, {
configurable: { thread_id: "alice-session-001" }
});
// 会话 B:用户 Bob------完全独立,互不影响
await graph.invoke(input, {
configurable: { thread_id: "bob-session-002" }
});
LangGraph 用 thread_id 隔离不同用户/会话的 State。同一个图实例可以同时服务数千个用户,每个用户的 State 完全独立。
断点恢复------这个能力改变了一切
typescript
// 第一次调用,执行到一半------API 出错了
try {
await graph.invoke(
{ messages: [new HumanMessage("查找所有用户最近的订单")] },
{ configurable: { thread_id: "task-42" } }
);
} catch (error) {
console.log("Agent 在第 N 步崩溃了");
}
// 不用重新输入!直接 resume------
// LangGraph 自动从最后一个 checkpoint 继续执行
const result = await graph.invoke(null, { // ← 注意:传入 null!
configurable: { thread_id: "task-42" },
});
传入 null 作为输入时,LangGraph 会从最新的 checkpoint 恢复 State 并继续执行未完成的步骤。这在长任务(比如自动代码审查跑了 10 分钟)中非常关键。
第十四章:Human-in-the-Loop --- 人在回路
什么时候需要
不是所有操作都能让 Agent 自己决定。以下场景需要人工审批:
- 发送重要邮件/通知:Agent 起草了,但需要人确认
- 执行数据库变更:Agent 生成的 SQL,但需要 DBA 审核
- 大额交易/支付:额度超过阈值
- 敏感内容发布:AI 生成的文章需编辑审核
interrupt() --- 让图在任意节点暂停
typescript
import { interrupt } from "@langchain/langgraph";
async function humanApproval(state: typeof State.State) {
// 图在这里暂停!等待外部输入
const approval = interrupt({
action: "review_and_approve",
draft: state.generatedContent,
instruction: "请审核 AI 生成的内容,批准或拒绝",
});
if (approval === "approved") {
return { status: "published" };
}
return { status: "rejected", reason: approval };
}
调用端的交互
typescript
const config = { configurable: { thread_id: "content-123" } };
// 第一次调用:跑到 interrupt 处暂停
const partialResult = await graph.invoke(
{ task: "写一篇产品发布公告" },
config
);
// → 返回暂停时的 State,包含 generatedContent
// 人类审核后,发送 Command 恢复执行
import { Command } from "@langchain/langgraph";
const finalResult = await graph.invoke(
new Command({ resume: "approved" }), // 人类的选择
config
);
// → 从暂停点继续:humanApproval 收到 "approved" → 发布 → 结束
关键点: interrupt() 暂停时,State 已经被 Checkpointer 持久化了。审核人不需要立刻审批------几分钟后、几小时后都可以,State 不会丢。
第十五章:Streaming 与 部署
三种 Streaming 模式
typescript
// Mode 1: values ------ 每个 Node 完成后推一次完整 State
for await (const chunk of graph.stream(input, {
streamMode: "values",
})) {
// chunk = 当前完整 State 快照
// 适合:调试、记录完整状态
}
// Mode 2: updates ------ 只推本次 Node 的增量
for await (const chunk of graph.stream(input, {
streamMode: "updates",
})) {
// chunk = { "nodeName": { "field": "newValue" } }
// 适合:前端显示 "正在搜索..."、"正在分析..." 等步骤状态
}
// Mode 3: messages ------ LLM 逐 token 输出
for await (const chunk of graph.stream(input, {
streamMode: "messages",
})) {
// chunk = 单个 token
// 适合:ChatGPT 那样的打字机效果
}
| 模式 | 粒度 | 触发时机 | 前端体验 |
|---|---|---|---|
values |
完整 State | 每个 Node 完成 | 状态面板 |
updates |
Node 增量 | 每个 Node 完成 | 步骤进度条 |
messages |
LLM Token | 实时逐字 | 打字机效果 |
三种模式可以组合使用 ,比如同时用 updates 显示步骤进度 + messages 显示最终答案的打字机效果。
部署
bash
# 一行命令部署到 LangGraph Platform
npx @langchain/langgraph-cli deploy
# 自动获得:
# - REST API 端点
# - 内置 Streaming
# - Checkpointer 管理
# - 并发处理
# - 监控和日志
第五部分:面试指南
下面是面试时关于 Agent 架构和 LangGraph 的完整答题思路。关键不是"背答案",而是理解这个递进逻辑。
第十六章:面试语术体系
经典开场问题
"你们项目的 Agent 是怎么设计的?"
错误答法: "我们用 LangGraph,定义了 StateGraph,加了几个 Node 和 Edge......"(一上来就讲技术细节,面试官跟不上的)
正确答法: 按四个层次递进讲。
层次一:从问题出发(30 秒)
"我们首先要搞清楚------普通 LLM 能做什么、不能做什么。单次 LLM 调用只能处理'一问一答'的场景。但我们的业务需要 LLM 能主动调用工具 (查数据库、搜网页)、多步推理 (先查 A 再根据 A 的结果决定 B)、处理分支逻辑 (不同类型的请求走不同流程)。这些需求不是靠写几行
await llm.invoke()能解决的。"
目的: 展示你理解 LLM 的局限,不是"为了用框架而用框架"。
层次二:Agent 循环的本质(30 秒)
"Agent 本质上是一个循环:LLM 决定下一步做什么 → 执行(调工具或给答案)→ 观察结果 → 再决定下一步。学术上这叫 ReAct 模式。手写这个循环会遇到五个问题------无限循环、状态丢失、不可恢复、无法注入人工判断、分支逻辑混乱。所以我们需要一个框架来管理这个循环。"
目的: 展示你理解 Agent 的底层运行方式,而且知道手写的痛点。
层次三:LangGraph 的解决方案(60 秒)
"LangGraph 用有向图来管理 Agent 的执行流程。三个核心概念:
- State:全局共享的状态字典。每一个 Node 的执行结果会自动合并到 State 里,后面的 Node 能看到前面所有 Node 产生的数据。
- Node :执行单元。可以是 LLM 调用、工具执行、或者纯逻辑。Node 签名为
(state) => Partial<State>。- Edge:流转规则。固定边表示无条件跳转,条件边表示根据路由函数的返回值决定下一站。
LangGraph 底层是一个 Pregel 式的图计算引擎。它在每个"超级步"中找出所有"前置条件已满足"的 Node 并行执行,通过入度计数器实现 Fan-in 的隐式屏障。这和手写 Promise.all 的本质区别是------图的结构本身就编码了依赖关系,引擎自动推导执行顺序。"
目的: 展示你对执行模型有深入理解,不只是"会用 API"。
层次四:具体模式和选型(40 秒)
"在我们的项目中,根据任务复杂度我们用了不同的 Agent 模式。简单的链式任务用 Prompt Chaining;需要分类分发的用 Routing;多个独立分析维度用 Parallelization;对质量要求高的内容生成用 Evaluator-Optimizer,LLM 评分不达标就自动重写;需要 LLM 自主决定工具调用时用 ReAct 循环;跨专业复杂任务用 Coordinator 模式------一个 Supervisor 协调前端、后端、数据库多个专家 Agent。"
目的: 展示你知道什么时候用什么模式,不是在乱堆技术。
层次五:生产级考量(20 秒)
"另外我们还用到了 LangGraph 的生产级特性:Checkpointer 做状态持久化支持断点恢复,interrupt 做人工审批节点,Streaming 做前端实时反馈。这些是把 Agent 从'跑得通'到'能上线'的关键。"
目的: 展示你有生产环境经验,不只是玩具项目。
常见追问与回答
追问 1:"LangGraph 的 Fan-in 是怎么知道要等的?"
"底层是 Pregel 图计算引擎。编译时统计每个 Node 的入度(有多少条边指向它)。运行时维护一个计数器,每个上游 Node 完成时计数器 +1。只有当计数器等于入度时,该 Node 才被激活执行。这是图结构的天然语义,不需要手写等待逻辑。"
追问 2:"如果 Agent 一直在循环不停止怎么办?"
"需要双重终止条件。第一层是 LLM 自主决定------当它认为信息足够时不再返回 tool_calls。第二层是兜底------设置最大循环次数,超过就强制终止。另外在 Evaluator 模式里还可以设质量阈值和迭代上限。"
追问 3:"多个子 Agent 之间怎么通信?"
"它们通过共享 State 通信------这就是为什么 State 设计是 LangGraph 最核心的概念。frontend_expert 写的内容,backend_expert 和 supervisor 都能读到。这是一种'共享白板'模式,避免了点对点通信的复杂度。"
追问 4:"LangGraph 和其他 Agent 框架(CrewAI、AutoGPT)有什么区别?"
"CrewAI 和 AutoGPT 的优势是上手快、概念少,适合快速原型。但它们的执行模型是黑盒的------你很难精确控制执行流程,也很难做持久化和断点恢复。LangGraph 的优势是精确的控制流编排、透明的状态管理、完整的生产级支持(Checkpointer、Streaming、Human-in-the-Loop)。选型上:原型验证阶段可以用 CrewAI/AutoGPT,但要上生产建议 LangGraph。"
第六部分:附录
第十七章:学习路径建议
javascript
第 1 步:裸调 LLM API 写几个简单应用
→ 理解 messages、temperature、system prompt
→ 感受"裸调"的局限
第 2 步:学 withStructuredOutput + bindTools
→ 理解增强型 LLM 的两个核心能力
→ 理解 Zod → JSON Schema 的底层转换
第 3 步:手写一个 ReAct 循环
→ 不用任何框架,纯 while + tool_calls
→ 感受手写方案的痛点(为 LangGraph 辩护)
第 4 步:用 LangGraph 重写上面的 ReAct
→ 理解 StateGraph.addNode.addEdge.addConditionalEdges
→ 理解为什么图比 while 循环好
第 5 步:实现六大模式(按本文顺序)
→ 从简单到复杂,感受每种模式解决了什么问题
→ 重点是理解模式之间的递进关系,不是背代码
第 6 步:加上生产级特性
→ Checkpointer → Human-in-the-Loop → Streaming
第十八章:参考资源
- LangGraph 官方文档 --- 最权威的参考
- Anthropic: Building Effective Agents --- Agent 模式总结的经典文章
- ReAct 论文 --- Reasoning + Acting 的原始论文
- Google Pregel 论文 --- LangGraph 执行引擎的理论基础
- LangGraph GitHub --- 源码和示例
最后想说: Agent 不是银弹。大部分场景下,一个精心设计的 Prompt Chain 可能比一个"全能 Agent"更可靠、更便宜、更可预测。LangGraph 给了你搭建复杂系统的能力,但用不用这种能力,是你作为架构师的判断。先问自己"这个任务真的需要循环和动态决策吗",再决定是否引入 Agent。