第 1 章 · 第一个图:从"链"到"图"
🎯 本章目标
- 理解 LangGraph 到底解决什么问题:为什么"链"不够用
- 掌握图的三个基本零件:状态(State)、节点(Node)、边(Edge)
- 用
StateGraph亲手搭出第一个可运行的图 - 把大模型塞进节点里,让图第一次"动脑子"
- 用两个节点 + 一条条件边手工搭出 ReAct 智能体 —— 这是全书最重要的一张图
- 认识官方预置的
createReactAgent(),并搞清它替你封装了什么 - 把图导出成 Mermaid,让结构能看、能贴、能分享
本章代码
📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的
code/完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。
| 文件 | 内容 |
|---|---|
code/01-hello-graph.ts | 第一个图:不调用大模型,只看图本身 |
code/02-llm-graph.ts | 把大模型放进节点 |
code/03-manual-react-agent.ts | 手工搭 ReAct 智能体(本章核心) |
code/04-create-react-agent.ts | 官方预置 createReactAgent() 一步到位 |
code/05-visualize-graph.ts | 把图导出成 Mermaid |
一、为什么需要 LangGraph?
如果你跟着《LangChain.js 中文入门》走过一遍,你已经会写"链"了:
typescript
const chain = prompt.pipe(model).pipe(parser);链是一条直线:A → B → C → 结束。它在"输入确定、步骤固定"的场景里非常好用。
但真实业务很快就超纲了:
| 需求 | 链能做到吗 |
|---|---|
| 模型说"我需要先查资料" → 去查 → 回来接着答 | ❌ 链不会"回头" |
| 一个流程里要循环调用工具,直到问题解决 | ❌ 链不能循环 |
| 同时让 3 个专家并行分析,再汇总 | ❌ 链是单线的 |
| 中途暂停,等人类点"同意"再继续 | ❌ 链跑完就结束了 |
| 记住上一轮对话的状态(甚至几天前的) | ❌ 链是无状态的 |
这些需求的共同点是:流程不再是一条直线,而是一张图。
LangGraph 就是把"编流程"这件事,从"串一根线"升级成"画一张图":
链(Chain) 图(Graph)
A → B → C → 结束 ┌──► B ──┐
A ├──► D → 结束
└──► C ──┘
可以分支、可以循环、可以并行、可以暂停💡 一句话定义:LangGraph 是一个低阶编排框架——它不负责"怎么调用大模型"(那是 LangChain 的事),它负责"多个步骤之间怎么连接、状态怎么传递"。
官方文档原话是 "low-level orchestration framework for building stateful agents"(用于构建有状态智能体的低阶编排框架)。"低阶"不是贬义,意思是它给你最原始的积木,不替你藏东西。
二、图的三件套:State / Node / Edge
一张 LangGraph 图由三个零件组成,比想象中简单:
| 零件 | 是什么 | 代码里的样子 |
|---|---|---|
| State(状态) | 一张在节点之间传递的"共享表格" | Annotation.Root({...}) |
| Node(节点) | 一个普通函数:读状态 → 干活 → 返回要更新的字段 | function fn(state) { return {...} } |
| Edge(边) | 规定"下一步去哪" | .addEdge("a", "b") |
工作方式是:
初始状态
│
▼
┌───────────────┐
│ 节点 A │ 读 state → 返回 { a: 1 }
└───────┬───────┘
│ 状态被合并更新:state = { ...state, ...{ a: 1 } }
▼
┌───────────────┐
│ 节点 B │ 读到的是更新后的 state
└───────┬───────┘
▼
最终状态关键点:节点的返回值是"增量",不是"整体替换"。 节点只返回它想改的字段,LangGraph 负责合并。这一点会贯穿全书。
示例 1:第一个 LangGraph(不调用大模型)
先关掉大模型,只看图本身。这样你能亲眼看到状态是怎么在节点之间流动的。
typescript
/**
* 示例 1:第一个 LangGraph —— 不调用大模型,先看清"图"本身
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
// ── 1. 定义状态:这就是那张"共享表格" ──────────────────────────
const GreetingState = Annotation.Root({
/** 用户的名字 */
name: Annotation<string>,
/** 打招呼的句子 */
greeting: Annotation<string>,
});
// ── 2. 定义节点:普通函数,入参是当前状态,返回值是要更新的字段 ──
function sayHello(state: typeof GreetingState.State) {
console.log(` [节点 sayHello] 收到的状态:${JSON.stringify(state)}`);
return { greeting: `你好,${state.name}!` };
}
function sayBye(state: typeof GreetingState.State) {
console.log(` [节点 sayBye] 收到的状态:${JSON.stringify(state)}`);
return { greeting: `${state.greeting} 再见!` };
}
async function main() {
banner("示例 1:第一个 LangGraph(不使用大模型)");
// ── 3. 建图:加节点、连边 ──────────────────────────────────
const graph = new StateGraph(GreetingState)
.addNode("sayHello", sayHello) // 注册节点
.addNode("sayBye", sayBye)
.addEdge(START, "sayHello") // 入口 → sayHello
.addEdge("sayHello", "sayBye") // sayHello → sayBye
.addEdge("sayBye", END) // sayBye → 结束
.compile(); // 编译成可执行对象
console.log('👤 输入:{ name: "蜗牛" }\n');
// ── 4. 执行:传入初始状态 ──────────────────────────────────
const result = await graph.invoke({ name: "蜗牛" });
console.log("\n📦 最终状态:", JSON.stringify(result, null, 2));
console.log(`\n✅ 结果:${result.greeting}`);
}
main().catch(console.error);运行:
bash
npx tsx 01-first-graph/code/01-hello-graph.ts真实输出:
========================================================================
示例 1:第一个 LangGraph(不使用大模型)
========================================================================
👤 输入:{ name: "蜗牛" }
[节点 sayHello] 收到的状态:{"name":"蜗牛"}
[节点 sayBye] 收到的状态:{"name":"蜗牛","greeting":"你好,蜗牛!"}
📦 最终状态: {
"name": "蜗牛",
"greeting": "你好,蜗牛! 再见!"
}
✅ 结果:你好,蜗牛! 再见!请仔细看这两行日志:
[节点 sayHello] 收到的状态:{"name":"蜗牛"}
[节点 sayBye] 收到的状态:{"name":"蜗牛","greeting":"你好,蜗牛!"}sayHello 运行时,状态里只有 name。它返回 { greeting: "你好,蜗牛!" } 之后,sayBye 运行时状态里就多出了 greeting。这就是"状态在节点之间流动"——不需要任何全局变量,状态由框架帮你传。
四个必记的细节
① Annotation.Root({...}) 定义状态形状
typescript
const GreetingState = Annotation.Root({
name: Annotation<string>,
greeting: Annotation<string>,
});Annotation<string> 表示"这个字段是字符串,新值直接覆盖旧值"。后面第 2 章会讲怎么改成"追加"(用 reducer),那时 messages 数组才不会每次被清空。
类型上有一点要注意:节点函数里读状态的类型要写 typeof GreetingState.State,不是 GreetingState。
② START 和 END 是两个特殊名字
typescript
.addEdge(START, "sayHello") // 虚拟入口节点
.addEdge("sayBye", END) // 虚拟出口节点它们在图里长这样:__start__ 和 __end__(示例 5 会看到)。别自己用这两个名字给节点命名。
③ 节点返回的是"要更新的字段"
typescript
return { greeting: ... }; // ✅ 只返回想改的字段
return { ...state, greeting: ... }; // ❌ 没必要,框架会自己合并④ compile() 之后才能执行
new StateGraph(...).addNode(...)... 返回的是一个建造者(builder),它只有 addNode / addEdge / compile 这些方法。调用 .compile() 之后才得到一个可以 .invoke() / .stream() 的"可运行对象"。
StateGraph (建造者) ──compile()──► 编译后的图 (Runnable) ──invoke()──► 结果⚠️ 记忆误区:
compile()不是"编译成机器码",它更像"把图纸变成一台能开动的机器"。
三、把大模型放进节点
节点函数可以是异步的。LangGraph 会 await 它完成,再走下一步。于是"调用大模型"就只是节点里的一行代码。
示例 2:让图第一次用上模型
typescript
/**
* 示例 2:把大模型塞进节点
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { HumanMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
const QaState = Annotation.Root({
question: Annotation<string>, // 用户的问题
answer: Annotation<string>, // 模型给出的回答
});
async function main() {
banner("示例 2:把大模型放进节点");
assertChatKey();
const model = createModel({ temperature: 0 });
// 节点:读 state.question → 调模型 → 返回 { answer }
async function answerQuestion(state: typeof QaState.State) {
console.log(` [节点 answerQuestion] 开始调用模型…`);
const res = await model.invoke([new HumanMessage(state.question)]);
const reasoning = (res.additional_kwargs as Record<string, unknown>)
?.reasoning_content;
if (typeof reasoning === "string" && reasoning.length > 0) {
console.log(` [节点 answerQuestion] 模型的思考过程(节选):`);
console.log(` ${reasoning.slice(0, 120).replace(/\n/g, " ")}…`);
}
return { answer: String(res.content) };
}
const graph = new StateGraph(QaState)
.addNode("answerQuestion", answerQuestion)
.addEdge(START, "answerQuestion")
.addEdge("answerQuestion", END)
.compile();
const question = "用一句话解释什么是 LangGraph。";
console.log(`👤 提问:${question}\n`);
const result = await graph.invoke({ question });
console.log(`\n🤖 回答:${result.answer}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 01-first-graph/code/02-llm-graph.ts真实输出:
========================================================================
示例 2:把大模型放进节点
========================================================================
👤 提问:用一句话解释什么是 LangGraph。
[节点 answerQuestion] 开始调用模型…
[节点 answerQuestion] 模型的思考过程(节选):
我们需要回答用户中文请求:“用一句话解释什么是 LangGraph。” 需要用一句话解释。必须一句话。LangGraph 是什么?它是 LangChain 生态中用于构建有状态、多参与者、可循环的 AI 工作流/代理编排的框架,把应用建模为…
🤖 回答:LangGraph 是一个将 LLM 工作流建模为有状态图结构的编排框架,用于构建可循环、多智能体协作并支持持久化与人工介入的复杂 AI 应用。💡 一个真实存在的坑:本书默认使用 DeepSeek 的推理型模型(
deepseek-flash)。这类模型的思维链会放在additional_kwargs.reasoning_content里,而不是content。上面那段"思考过程(节选)"就是从这个字段取出来的。另一个相关坑:推理型模型的
maxTokens包含思考消耗。如果你把maxTokens设得太小(比如 100),思考就把额度花光了,content会返回空字符串 —— 而且不报错。排查任何"回答是空的"问题时,先怀疑这一条。
四、手工搭一个 ReAct 智能体(本章核心)
现在到了最关键的一步。
前面两个例子都是"直线":一步做完走下一步。而智能体(Agent)的本质,是让模型自己决定"下一步做什么",包括决定"要不要再调一次工具"——这就需要一个循环。
LangGraph 里表达"智能体"的经典结构叫 ReAct(Reasoning + Acting),整张图只有两个节点:
┌───────────────┐
START │ │
──────►│ agent │◄──────────┐
│ (调用模型) │ │
└───────┬───────┘ │
│ │
┌───────▼────────┐ │
│ 有工具调用吗? │ 条件边 │
└───┬────────┬───┘ │
没有 │ │ 有 │
┌───────▼──┐ ┌──▼──────────┐ │
│ END │ │ tools │────┘
└──────────┘ │ (执行工具) │
└─────────────┘- agent 节点:把消息交给模型,模型要么给出最终回答,要么说"我要调用某个工具"
- 条件边:检查模型上一条消息里有没有
tool_calls- 有 → 去
tools节点执行工具 - 没有 → 结束
- 有 → 去
- tools → agent 这条边:执行完工具再回到模型,让它看着工具结果继续判断 —— 这就是"循环"
示例 3:手工搭出这张图
typescript
/**
* 示例 3:手工搭一个 ReAct 智能体
*/
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode } 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("要计算的数学表达式,例如 '25 * 8'"),
}),
}
);
const tools = [calculator];
const toolNode = new ToolNode(tools);
async function main() {
banner("示例 3:手工搭一个 ReAct 智能体");
assertChatKey();
const model = createModel({ temperature: 0 }).bindTools(tools);
// ── 节点 A:调用模型 ──────────────────────────────────────
async function callModel(state: typeof MessagesAnnotation.State) {
console.log(` [节点 agent] 把 ${state.messages.length} 条消息交给模型…`);
const response = await model.invoke(state.messages);
const calls = (response as AIMessage).tool_calls ?? [];
console.log(
` [节点 agent] 模型返回:${
calls.length > 0
? `要调用工具 ${calls.map((c) => c.name).join(", ")}`
: "最终回答"
}`
);
// 返回数组 → 会被 reducer 追加到 messages 里(而不是覆盖)
return { messages: [response] };
}
// ── 条件边:判断下一步走 tools 还是结束 ────────────────────
function shouldContinue(
state: typeof MessagesAnnotation.State
): "tools" | typeof END {
const last = state.messages[state.messages.length - 1] as AIMessage;
if (last.tool_calls?.length) {
console.log(` [条件边] 发现工具调用 → 走 "tools"`);
return "tools";
}
console.log(` [条件边] 没有工具调用 → 走 END`);
return END;
}
const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", callModel)
.addNode("tools", toolNode)
.addEdge(START, "agent")
.addEdge("tools", "agent") // 工具执行完,回到模型再判断一次 ← 这就是"循环"
.addConditionalEdges("agent", shouldContinue, ["tools", END])
.compile();
const question = "125 乘以 8 等于多少?算完之后再乘以 3。";
console.log(`👤 提问:${question}\n`);
const result = await graph.invoke({
messages: [new HumanMessage(question)],
});
console.log("\n📜 完整消息轨迹:");
result.messages.forEach((m: BaseMessage, i: number) => {
const calls = (m as AIMessage).tool_calls;
if (calls?.length) {
console.log(
` ${i}. [${m.getType()}] 请求调用 ${calls
.map((c) => `${c.name}(${JSON.stringify(c.args)})`)
.join(", ")}`
);
} else {
console.log(` ${i}. [${m.getType()}] ${String(m.content).slice(0, 90)}`);
}
});
const last = result.messages[result.messages.length - 1];
console.log(`\n🤖 最终回答:${last.content}`);
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 01-first-graph/code/03-manual-react-agent.ts真实输出(本次运行模型把两个乘法合并成一次工具调用,是很常见的优化):
========================================================================
示例 3:手工搭一个 ReAct 智能体
========================================================================
👤 提问:125 乘以 8 等于多少?算完之后再乘以 3。
[节点 agent] 把 1 条消息交给模型…
[节点 agent] 模型返回:要调用工具 calculator, calculator
[条件边] 发现工具调用 → 走 "tools"
🔧 工具被调用:calculator("125 * 8")
🔧 工具被调用:calculator("(125 * 8) * 3")
[节点 agent] 把 4 条消息交给模型…
[节点 agent] 模型返回:最终回答
[条件边] 没有工具调用 → 走 END
📜 完整消息轨迹:
0. [human] 125 乘以 8 等于多少?算完之后再乘以 3。
1. [ai] 请求调用 calculator({"expression":"125 * 8"}), calculator({"expression":"(125 * 8) * 3"})
2. [tool] 1000
3. [tool] 3000
4. [ai] 125 × 8 = 1000
1000 × 3 = 3000
所以结果是 **3000**。
🤖 最终回答:125 × 8 = 1000
1000 × 3 = 3000
所以结果是 **3000**。把注意力放在消息轨迹上,它是理解智能体的钥匙:
| # | 类型 | 内容 | 说明 |
|---|---|---|---|
| 0 | human | 用户的问题 | 初始输入 |
| 1 | ai | 请求调用 calculator ×2 | 模型决定"我需要算两次" |
| 2 | tool | 1000 | 工具执行结果 |
| 3 | tool | 3000 | 工具执行结果 |
| 4 | ai | 最终回答 | 模型看到工具结果后收尾 |
图只循环了两轮就结束了——因为第 4 条消息里没有 tool_calls,条件边判定"可以停了"。
这就是智能体的全部秘密:一轮"模型判断 → 执行 → 回传"之后,再问模型一次"还需要吗",直到它说不需要。
三个必须理解的细节
① MessagesAnnotation 是官方预置的状态,它的 messages 字段会自动追加
如果 messages 像示例 1 那样是"直接覆盖",那么节点返回 { messages: [response] } 就会把之前的对话全冲掉。所以 LangGraph 为消息场景准备了一个预置注解:
typescript
import { MessagesAnnotation } from "@langchain/langgraph";
// 上面这个预置,等价于手写:
const StateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (left, right) => left.concat(right), // ← 追加,而不是覆盖
default: () => [],
}),
});"追加"这个行为叫 reducer(归约函数),第 2 章会专门讲。
② ToolNode 是官方提供的"执行工具"节点
typescript
const toolNode = new ToolNode(tools);它替你完成了一件麻烦事:从最后一条 AI 消息里读出 tool_calls,逐个找到对应工具、执行、把结果包装成 ToolMessage 返回。你也可以自己写这十几行,但没必要。
③ .addConditionalEdges("agent", shouldContinue, ["tools", END]) 的第三个参数
第三个参数是"这个条件可能走向哪些节点"的列表。它有两个作用:
- 让图结构能被可视化(否则框架不知道有哪些分支,示例 5 画图时就会缺边)
- 做校验:如果你 return 了一个不在列表里的名字,会立刻报错,而不是运行时莫名卡住
⚠️ 常见报错:
return的节点名拼错了、或者忘了加第三个参数 → 报InvalidUpdateError或图走到一半"没了"。条件边的返回值必须是图上真实存在的节点名、或END。
五、官方预置:createReactAgent()
示例 3 那张图,官方已经封装好了 —— 就在 @langchain/langgraph/prebuilt 包里。
示例 4:一行代码得到和示例 3 等价的图
typescript
/**
* 示例 4:用官方预置函数 createReactAgent 一步到位
*/
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { HumanMessage } 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(` 🔧 calculator("${expression}")`);
return String(evaluate(expression));
},
{
name: "calculator",
description: "执行数学计算。当需要计算数学表达式时使用。",
schema: z.object({
expression: z.string().describe("要计算的数学表达式,例如 '25 * 8'"),
}),
}
);
// 工具 2:查天气(假数据,只为演示"多工具自动选择")
const getWeather = tool(
async ({ city }) => {
console.log(` 🔧 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("示例 4:createReactAgent 一步到位");
assertChatKey();
const model = createModel({ temperature: 0 });
// 就这一句 —— 和示例 3 手写的 20 行图定义等价
const agent = createReactAgent({
llm: model,
tools: [calculator, getWeather],
});
for (const question of ["上海现在天气怎么样?", "北京气温乘以 3 是多少?"]) {
console.log(`👤 提问:${question}`);
const result = await agent.invoke({
messages: [new HumanMessage(question)],
});
const last = result.messages[result.messages.length - 1];
console.log(`🤖 回答:${last.content}\n`);
}
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 01-first-graph/code/04-create-react-agent.ts真实输出:
========================================================================
示例 4:createReactAgent 一步到位
========================================================================
👤 提问:上海现在天气怎么样?
🔧 getWeather("上海")
🤖 回答:上海目前是**多云**天气,气温 **22°C**。
体感比较舒适,适合外出活动。不过多云天气建议随身带把伞以防万一,如果需要更详细的预报(比如未来几小时或风力、湿度等信息),可以告诉我,我再帮你查。
👤 提问:北京气温乘以 3 是多少?
🔧 getWeather("北京")
🔧 calculator("18 * 3")
🤖 回答:北京当前气温为 **18°C**,乘以 3 后是 **54**(即 54°C)。第二个问题很值得琢磨:模型自己串起了两个工具——先查北京气温,拿到 18 之后,再调计算器算 18 * 3。这正是 ReAct 循环的价值:没人告诉它要先查再算,是它自己规划的。
createReactAgent 到底替你做了什么?
| 你手写(示例 3) | createReactAgent 替你做了 |
|---|---|
定义 callModel 节点 | ✅ 内置 |
定义 shouldContinue 条件边 | ✅ 内置 |
new ToolNode(tools) | ✅ 内置 |
连 START → agent → tools → agent | ✅ 内置 |
bindTools(tools) | ✅ 自动绑定 |
那为什么还要先学手工版? 因为当你需要"在模型前面插一个检查节点""工具执行完加一步校验""只在特定条件下才允许调用工具"时,预置版的固定结构就不够用了 —— 那时你要么改造它,要么回到手工挡。先懂结构,再用封装。
💡 补充:LangChain v1 里还有一个更上层的
createAgent()(从"langchain"包导入),它内部也是基于 LangGraph,额外加了中间件(middleware)、结构化输出等能力。本书记住这个分层即可:手工 StateGraph ← createReactAgent() ← createAgent()(含中间件) 最灵活 ReAct 标准结构 开箱即用
六、把图"画"出来
图的一大优势是结构可读。LangGraph 能把图导出成 Mermaid 文本,粘到 mermaid.live 就能看到图。
示例 5:导出 Mermaid
typescript
/**
* 示例 5:把图"画"出来
*/
import { writeFileSync } from "node:fs";
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { AIMessage } from "@langchain/core/messages";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import { createModel, banner } from "../../lib/model.js";
const calculator = tool(async ({ expression }) => expression, {
name: "calculator",
description: "执行数学计算",
schema: z.object({ expression: z.string() }),
});
async function main() {
banner("示例 5:把图可视化(导出 Mermaid)");
const model = createModel({ temperature: 0 }).bindTools([calculator]);
const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", async (state) => {
const res = await model.invoke(state.messages);
return { messages: [res] };
})
.addNode("tools", new ToolNode([calculator]))
.addEdge(START, "agent")
.addEdge("tools", "agent")
.addConditionalEdges(
"agent",
(state) => {
const last = state.messages[state.messages.length - 1] as AIMessage;
return last.tool_calls?.length ? "tools" : END;
},
["tools", END]
)
.compile();
// 拿到可绘制的图对象(v1 推荐用 getGraphAsync)
const drawable = await graph.getGraphAsync();
// ① 所有节点(nodes 是一个 Record,key 是节点名)
const nodes = Object.values(drawable.nodes);
console.log("📌 图中的节点:");
for (const n of nodes) console.log(` • ${n.id}`);
// ② 所有边
const edges = drawable.edges;
console.log("\n📌 图中的边:");
for (const e of edges) {
const label = e.data ? ` (条件: ${e.data})` : "";
console.log(` • ${e.source} → ${e.target}${label}`);
}
// ③ 生成 Mermaid 文本
const mermaid = await drawable.drawMermaid();
console.log("\n📌 Mermaid 源码:\n");
console.log(mermaid);
const outFile = "/tmp/langgraph-example-05.mmd";
writeFileSync(outFile, mermaid, "utf-8");
console.log(`💾 已保存到 ${outFile}`);
console.log(" 把这个文件的内容粘到 https://mermaid.live 就能看到图。");
}
main().catch((e) => {
console.error("❌ 调用失败:", e);
process.exit(1);
});运行:
bash
npx tsx 01-first-graph/code/05-visualize-graph.ts真实输出:
📌 图中的节点:
• __start__
• agent
• tools
• __end__
📌 图中的边:
• __start__ → agent
• tools → agent
• agent → tools
• agent → __end__
📌 Mermaid 源码:
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
agent(agent)
tools(tools)
__end__([<p>__end__</p>]):::last
__start__ --> agent;
tools --> agent;
agent -.-> tools;
agent -.-> __end__;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
💾 已保存到 /tmp/langgraph-example-05.mmd
把这个文件的内容粘到 https://mermaid.live 就能看到图。对照着看,图结构一目了然:
__start__ → agent (实线:固定边)
tools → agent (实线:固定边 —— 循环就靠这一条)
agent → tools (虚线:条件边)
agent → __end__ (虚线:条件边)💡 实线是
addEdge(固定边,必走),虚线是addConditionalEdges(条件边,看情况走)。另外:
drawMermaidPng()可以直接生成 PNG 图片,但它会调用外部服务(mermaid.ink)来渲染,需要联网。想零依赖、稳定复现的话,用drawMermaid()拿文本更稳妥。
七、本章小结
本章速查表
| 你要做的事 | 代码 |
|---|---|
| 定义状态 | Annotation.Root({ 字段: Annotation<类型> }) |
| 定义消息状态 | MessagesAnnotation(官方预置,messages 自动追加) |
| 加节点 | .addNode("名字", 函数) |
| 加固定边 | .addEdge("a", "b") |
| 加入口 / 出口 | .addEdge(START, "首节点") / .addEdge("末节点", END) |
| 加条件边 | .addConditionalEdges("a", 路由函数, ["b", "c", END]) |
| 编译 | .compile() |
| 执行 | await graph.invoke(初始状态) |
| 流式执行 | for await (const c of graph.stream(...))(第 6 章) |
| 可视化 | await graph.getGraphAsync() → .drawMermaid() |
三句话总结
- 节点返回的是增量,框架负责合并进状态 —— 这是理解 LangGraph 的第一块基石。
- 智能体 = 一张带循环的图:两个节点(模型 / 工具)+ 一条条件边,模型自己决定何时停。
createReactAgent()只是例子 3 的封装,结构完全一样。理解了手工版,才知道封装版能改哪里。
动手练习
- 把示例 1 改成三个节点(sayHello → sayHowAreYou → sayBye),让
greeting依次变长。 - 给示例 3 加一个
getTime工具(返回当前时间),问它"现在几点,加上 3 小时是几点"。 - 用示例 3 的写法但去掉
"tools" → "agent"这条边,跑一次,观察会发生什么。(提示:只循环一轮,工具结果没机会被模型看到)
下一章预告
第 2 章我们深入"状态"这个核心:为什么 messages 会越加越多而 name 每次被覆盖?reducer 到底是什么?怎么让一个字段并行累加、怎么让某些字段对下游节点"隐身"?搞懂状态,你才算真正入门 LangGraph。