跳到主要内容

创建日期:2026-09-14 | 最近更新:2026-09-14 本机真实运行(@langchain/langgraph 1.4.15);下方输出均为实测。

LangGraph.js 入门:状态图、条件边与「记忆」

看完还是觉得糊?跳到 图解篇:用 mermaid 画图 + 逐步执行轨迹重讲一遍。

LangGraph 只补 LangChain 缺的那一块:带状态的多步循环。它的全部概念就三件:State(状态)、Node(节点)、Edge(边)——外加一个可选的 Checkpointer(记忆)。这篇用真实运行的例子把四样都过一遍。

1. 为什么要有「图」

回到你手写的 Agent 循环(frontend-agent 篇 2):

while (true) {
reply = await callModel(messages); // ① 状态:messages
if (!reply.tool_use) break;
messages.push(...toolResults); // ② 循环 + 状态累积
}

这里其实有「状态」(messages 变量)和「循环」(while)。当你还要加「分支(走 A 还是 B)」「记忆(跨会话保留)」「人工确认(暂停等待)」时,手写就会越来越乱。LangGraph 的做法:把它画成图——状态是节点之间传递的数据,循环是回边,分支是条件边,记忆是 checkpoint。

2. 三件套:State / Node / Edge

import { StateGraph, Annotation, START, END } from '@langchain/langgraph';

// ① State:定义「图上流动的数据长什么样」
const S = Annotation.Root({
n: Annotation({ reducer: (a, b) => b, default: () => 0 }),
});

// ② 建图:加节点(函数)+ 连边(顺序)
const g = new StateGraph(S)
.addNode('inc', (s) => ({ n: s.n + 1 })) // 节点收到 state,返回“增量”
.addNode('double', (s) => ({ n: s.n * 2 }))
.addEdge(START, 'inc')
.addEdge('inc', 'double')
.addEdge('double', END)
.compile();

console.log(await g.invoke({ n: 5 }));

实测输出:

{"n":12}

三个要点:

  • 节点返回的是「状态增量」,不是完整状态——框架帮你合并(inc 返回 {n: 6},只改 n);
  • START / END 是虚拟节点,标出入口出口;
  • annotation 里的 reducer 决定「同一个 key 如何合并」(a,b)=>b覆盖(默认语义);要「追加」就写 (a,b)=>a.concat(b)——这是后面做消息记忆的关键

3. 条件边:让图会分叉

把「下一步去哪」交给一个函数:

.addConditionalEdges('inc', (s) => (s.n >= 4 ? 'done' : 'inc'))

上面这行的意思是:inc 跑完后,看 n >= 4 —— 满足就去 done,否则回到 inc这条回边就是「循环」,等价于你手写的 while

完整例子(含状态累加):

const S = Annotation.Root({
n: Annotation({ reducer: (a, b) => b, default: () => 0 }),
log: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }), // ← 追加型 reducer
});

const g = new StateGraph(S)
.addNode('inc', (s) => ({ n: s.n + 1, log: [`inc:${s.n + 1}`] }))
.addNode('done', (s) => ({ log: [`done@${s.n}`] }))
.addEdge(START, 'inc')
.addConditionalEdges('inc', (s) => (s.n >= 4 ? 'done' : 'inc'))
.addEdge('done', END)
.compile();

console.log(await g.invoke({ n: 0 }));

实测输出(inc 被反复执行直到 n>=4):

[inc] 0 -> 1
[inc] 1 -> 2
[inc] 2 -> 3
[inc] 3 -> 4
结果: {"n":4,"log":["inc:1","inc:2","inc:3","inc:4","done@4"]}

注意 log 用了追加型 reducer,所以 5 条记录全在;如果它也写 (a,b)=>b,就只剩最后一条了。

⚠️ 循环要有出口:条件边判错可能无限循环。LangGraph 有 recursion_limit(默认 25 步)会抛错兜底——这跟你手写「最大轮数保险丝」是一个道理。

4. 记忆:Checkpointer + thread_id

前面每次 invoke 都是「无状态」的(图跑完状态就没了)。给图配一个 checkpointer,它就会把每一步的状态按 thread_id 存下来——下次带着同一个 thread_id 进来,状态是接着上次的

import { StateGraph, Annotation, START, END, MemorySaver } from '@langchain/langgraph';
import { HumanMessage } from '@langchain/core/messages';

const S = Annotation.Root({
messages: Annotation({ reducer: (a, b) => a.concat(b), default: () => [] }), // ← 消息是“追加”
});

const g = new StateGraph(S)
.addNode('chat', async (s) => ({ messages: [await model.invoke(s.messages)] }))
.addEdge(START, 'chat')
.addEdge('chat', END)
.compile({ checkpointer: new MemorySaver() }); // ★ 打开记忆

const cfg = { configurable: { thread_id: 'u1' } };
await g.invoke({ messages: [new HumanMessage('我叫小林,记一下。')] }, cfg);
const r2 = await g.invoke({ messages: [new HumanMessage('我叫什么?')] }, cfg);

实测输出:

第1轮: "好的,小林,我记住了。有什么需要随时说。"
第2轮: "你叫小林。"
累计消息条数: 4

第二轮的图里根本不含第一轮的问题,但它答对了——因为 checkpointer 把第一轮的 state 按 thread_id: 'u1' 载入了,messages 累计到 4 条(用户1 + 回复1 + 用户2 + 回复2)。

三个要点:

  • MemorySaver 是内存版(进程重启就没了);生产要持久化换 SQLite/Postgres 的 checkpointer;
  • thread_id 就是「会话 id」:不同用户给不同 thread_id,天然多会话;
  • messages 用追加 reducer,否则新消息会把旧消息覆盖掉——这是最常见的写错点。

5. 已经能做什么

到这儿,你已经具备搭 Agent 的全部零件了:

状态(messages 等) + 节点(调模型 / 执行工具) + 条件边(要不要再调工具) + 记忆(thread_id)

下一篇就把它拼成一个 ReAct Agent——模型自己决定调哪个工具、调几次,而这正好对应你手写过的那个 while 循环。

动手

  1. 把 §3 的循环阈值改成 10,看 log 长度;
  2. log 的 reducer 改成 (a,b)=>b,观察结果只剩一条;
  3. 在 §4 里换一个 thread_id 问「我叫什么」,看它还记不记得。

自测

  1. State / Node / Edge 各是什么?
  2. reducer 里的 (a,b)=>b(a,b)=>a.concat(b) 区别是什么?
  3. 条件边怎么能造出「循环」?没有出口会怎样?
  4. checkpointer + thread_id 解决了什么问题?
  5. MemorySaver 有什么局限?

下一篇:实战:用 LangGraph 写一个 ReAct Agent