Nodejs也能写Agent - 14.LangGraph篇 - 核心概念入门

上一篇我们把 LangChain 的四件套打通了:Runnable、模型与 Prompt、Tools、RAG。零件齐了,你已经能做出「会调工具、能查知识库」的 AI 应用。

但零件是零件。一旦你想让 Agent 自己循环——思考 → 调工具 → 看结果 → 再思考——靠手写 while 去管状态,马上会发现:分支一多就糊成浆糊,想在中间停下来等人审批更是无从下手,会话一断上下文全丢。

这一篇我们正式进入 LangGraph.js:用来编排 Agent 的状态与流程。先把心智模型立住——后面的条件分支、Checkpointer、多 Agent,全是建在这套骨架上的。

老规矩,本文 API 均以官网最新文档核对过(https://docs.langchain.com/oss/javascript/langgraph)。LangGraph 迭代很快,跟敲时如果某个 API 对不上,以你当时的官网版本为准。

在这里插入图片描述

先装好包

LangGraph 是独立包,跟 @langchain/core 一起装即可:

npm install @langchain/langgraph @langchain/core
# 本系列继续用本地 Ollama
npm install @langchain/ollama

本系列演示仍用 ChatOllama + qwen2.5:7b。换 OpenAI / Anthropic 只是换模型包,图编排逻辑不用动。

一、为何需要 Graph:Chain 的天花板

LCEL 的 pipe 很好用,但本质是一条偏线性的管道

输入 → Prompt → Model → Parser → 输出

单次「问完就走」的任务,Chain 完全够用。下面这些场景就会开始难受:

场景 纯 Chain 的难受点 Graph 怎么解
Tool calling 循环 自己写 whiletool_calls 条件边在 agent ↔ tools 之间路由
条件分支 管道中间很难塞干净的 if/else addConditionalEdges 按状态动态跳转
多节点协作 多个 Chain 之间状态传来传去易乱 共享 State,节点只读写各自关心的字段
人机协作(HITL) 中间没法「暂停等人」 interrupt + Checkpointer 暂停/恢复
跨轮持久化 每次 invoke 各自为政 thread_id + Checkpointer 记住会话

经验法则很简单:单次输入→输出,用 Chain;需要循环、分支、共享状态、持久化,升级到 Graph。

LangGraph

可能回环

START

NodeA

NodeB

END

LCEL Chain

输入

Prompt

Model

输出

二、三要素:State、Node、Edge

官网把这件事说得很干脆:nodes do the work, edges tell what to do next——节点干活,边决定下一步。整个 Agent 工作流,就靠这三样拼出来。

1. State:大家共用的「黑板」

State 是图里所有节点共享的数据结构。每个节点读当前快照,返回需要更新的字段(Partial Update),由框架按规则合并回全局 State。

用官网现在主推的 StateSchema + Zod 来声明(简单字段默认是 last-value:新值直接覆盖旧值):

import { StateSchema } from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  text: z.string(),
  result: z.string().default(""),
});

// 完整状态类型 / 节点返回的更新类型
type FullState = typeof State.State;
type StateUpdate = typeof State.Update;

记两句话就够用,深度下一篇再掰开

  1. 节点应只返回要改的字段,别把整份 state 原样扔回去。
  2. 没配 reducer 的字段默认覆盖;消息列表这类要「追加」的,用 MessagesValue / ReducedValue(下一章重点)。

网上很多旧教程还会写 Annotation.Root({...})——现在依然能用,但官网 Graph API 的主路径已经是 StateSchema。本系列后面统一跟官网走;你若看到旧笔记里的 Annotation,心里有个数:语义类似,写法不同。

2. Node:干活的函数

Node 就是一个函数:吃进当前 State,干完活,返回 Partial Update。里面可以是纯逻辑,也可以调模型、调工具、调整条 LCEL Chain——图负责调度,节点里随便放本事

类型上推荐用 GraphNode(或 typeof State.Node),少踩 TS 坑:

import { GraphNode } from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage } from "@langchain/core/messages";

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });

const translateNode: GraphNode<typeof State> = async (state) => {
  const res = await llm.invoke([
    new HumanMessage(`将以下文本翻译为英文:${state.text}`),
  ]);
  return { result: res.content as string }; // 只更新 result
};

3. Edge:下一步去哪

  • 固定边 addEdge("a", "b"):A 跑完一定去 B
  • 条件边 addConditionalEdges(...):根据 State 动态选下一跳(本篇先知道有这东西,展开留给后文)

两个特殊虚拟节点(用常量最干净;字符串 "__start__" / "__end__" 等价):

  • START:图的唯一入口
  • END:图的唯一出口

Shared State

text

result

START

translate

summarize

END

三、compile():Builder 不能直接跑

StateGraph构建器:你在上面 addNode / addEdge,描述「图画成什么样」。描述完之前,它还不是可执行对象。

必须 .compile(),才会得到能 invoke / stream 的编译图:

const builder = new StateGraph(State)
  .addNode("translate", translateNode)
  .addEdge(START, "translate")
  .addEdge("translate", END);

// await builder.invoke(...)  — 构建器不能这么调

const graph = builder.compile(); // 得到可运行图
await graph.invoke({ text: "你好" });

compile() 主要干几件事:

  1. 结构校验(比如有没有孤立节点、路径是否合理)
  2. 挂运行时能力——比如后面要讲的 Checkpointer、断点(breakpoint),都是在这里注入的
  3. 内部按 Pregel 思路做 super-step 调度(本篇不用抠算法,知道「图是按步推进」就行)

铁律:忘了 compile,百分之百报错。看见 StateGraph 就想 invoke 的,先拍自己一下。

四、LangGraph 和 LangChain:姐妹,不是父子

这里必须纠正一个很常见的说法——「LangGraph 构建在 LangChain Runnable 之上」。

官网的定位更准确:

干什么
LangGraph 编排运行时:状态、边、持久化、流式、人机协作
LangChain 能力与集成:模型、Prompt、Tool、RAG、LCEL

LangGraph 可以单独用,不强制依赖 LangChain。只不过在真实项目里,节点内部调用 ChatOllamatool()、RAG chain 太常见了,所以两者经常「捆着卖」。

一句话:LangGraph 管「何时跑哪个节点」,LangChain(以及你自己的代码)管「节点里具体怎么调 AI」。 本系列两个都用,是因为集成爽,不是因为二选一。

五、动手:最小可跑的 StateGraph

两节点线性流程——先翻译,再总结。把「定义 State → 写 Node → 连边 → compile → invoke」这条肌肉记忆练出来:

import {
  StateGraph,
  StateSchema,
  GraphNode,
  START,
  END,
} from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage } from "@langchain/core/messages";
import * as z from "zod";

// 1. 定义 State(简单字段 = last-value 覆盖)
const State = new StateSchema({
  text: z.string(),
  result: z.string().default(""),
});

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });

// 2. 定义 Node:只返回要更新的字段
const translateNode: GraphNode<typeof State> = async (state) => {
  const res = await llm.invoke([
    new HumanMessage(`将以下文本翻译为英文:${state.text}`),
  ]);
  return { result: res.content as string };
};

const summarizeNode: GraphNode<typeof State> = async (state) => {
  const res = await llm.invoke([
    new HumanMessage(`用一句话总结:${state.result}`),
  ]);
  return { result: res.content as string };
};

// 3. 构图并 compile
const graph = new StateGraph(State)
  .addNode("translate", translateNode)
  .addNode("summarize", summarizeNode)
  .addEdge(START, "translate")
  .addEdge("translate", "summarize")
  .addEdge("summarize", END)
  .compile();

// 4. 执行
const output = await graph.invoke({
  text: "LangGraph 让 Agent 工作流可编排。",
});
console.log(output.result);

跑通之后你就已经掌握了 LangGraph 90% 入门姿势:剩下的都是「边更复杂、状态更聪明、要不要持久化」。

六、对比彩蛋:管道插不进的「校验节点」

同一任务,LCEL 管道很难在中间干净地塞一个「校验失败就重试」的逻辑;Graph 只是多插一个节点、多连一条边

const validateNode: GraphNode<typeof State> = async (state) => {
  if (!state.result?.trim()) {
    throw new Error("翻译结果为空,需要重试");
  }
  return {}; // 只检查、不改字段,也完全合法
};

const graphWithValidation = new StateGraph(State)
  .addNode("translate", translateNode)
  .addNode("validate", validateNode) // Graph 的灵活:中间随时可插
  .addNode("summarize", summarizeNode)
  .addEdge(START, "translate")
  .addEdge("translate", "validate")
  .addEdge("validate", "summarize")
  .addEdge("summarize", END)
  .compile();

真要「校验失败就绕回路重跑」,会用到条件边——那是后文的戏。这里只要你感受到:Graph 把流程拆成可插拔的节点,比一条死管道好扩展得多。

常见坑

  1. 忘记 .compile():对 StateGraph 构建器直接 invoke,必死。先 compile,再跑。
  2. Node 返回完整 state:应返回 Partial Update。整份扔回去,碰上带 reducer 的字段时,合并行为会让你怀疑人生。
  3. 分不清 Builder 和编译后的图:Builder 用来拼结构;只有 compile() 的结果才能 invoke / stream
  4. 每个 Node 里 new ChatOllama(...):教程图省事可以;正式代码请在图外创建一次、节点内复用,少吃初始化成本。
  5. 漏连 END / 孤立节点:某节点没有出边,compile 时可能警告或直接报错——构图时把路径画闭合。
  6. 被旧教程的 Annotation.Root 带偏:不是不能用,但新项目优先 StateSchema;看到 Annotation 知道是旧主推写法即可。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐