Skip to content

第 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**。

把注意力放在消息轨迹上,它是理解智能体的钥匙:

#类型内容说明
0human用户的问题初始输入
1ai请求调用 calculator ×2模型决定"我需要算两次"
2tool1000工具执行结果
3tool3000工具执行结果
4ai最终回答模型看到工具结果后收尾

图只循环了两轮就结束了——因为第 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()

三句话总结

  1. 节点返回的是增量,框架负责合并进状态 —— 这是理解 LangGraph 的第一块基石。
  2. 智能体 = 一张带循环的图:两个节点(模型 / 工具)+ 一条条件边,模型自己决定何时停。
  3. createReactAgent() 只是例子 3 的封装,结构完全一样。理解了手工版,才知道封装版能改哪里。

动手练习

  1. 把示例 1 改成三个节点(sayHello → sayHowAreYou → sayBye),让 greeting 依次变长。
  2. 给示例 3 加一个 getTime 工具(返回当前时间),问它"现在几点,加上 3 小时是几点"。
  3. 用示例 3 的写法但去掉 "tools" → "agent" 这条边,跑一次,观察会发生什么。(提示:只循环一轮,工具结果没机会被模型看到)

下一章预告

第 2 章我们深入"状态"这个核心:为什么 messages 会越加越多而 name 每次被覆盖?reducer 到底是什么?怎么让一个字段并行累加、怎么让某些字段对下游节点"隐身"?搞懂状态,你才算真正入门 LangGraph。


← 上一章:环境准备 | 返回总目录 | 下一章:状态与 Reducer →

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