创建日期: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 循环。
动手
- 把 §3 的循环阈值改成 10,看
log长度; - 把
log的 reducer 改成(a,b)=>b,观察结果只剩一条; - 在 §4 里换一个
thread_id问「我叫什么」,看它还记不记得。
自测
- State / Node / Edge 各是什么?
- reducer 里的
(a,b)=>b和(a,b)=>a.concat(b)区别是什么? - 条件边怎么能造出「循环」?没有出口会怎样?
- checkpointer + thread_id 解决了什么问题?
MemorySaver有什么局限?