LangGraph 状态图:从节点、边到中断恢复,手写一个多 Agent 工作流
摘要:LangChain 拼乐高,LangGraph 画图纸。复杂 Agent 需要分支、循环、并行、人工审批和状态持久化,这些用线性链做不到。本文从状态图三要素出发,拆解 Annotation、Reducer、条件边、Checkpointer 与 Interrupt,带你从零手写一个可编排的多 Agent 工作流。
📑 目录
- 为什么复杂 Agent 需要“图”?
- 状态图三要素:State、Node、Edge
- State 与 Annotation:Reducer 决定状态怎么变
- 第一个图:两个节点,一条直线
- 条件边:让 Agent 会“分岔”
- 循环与重试:条件边指回上游
- Checkpointer:让图“记得住”上次跑到哪
- Interrupt:让人在环路中间“踩刹车”
- 图的可视化:Mermaid 帮你画出来
- 互动讨论
为什么复杂 Agent 需要“图”?
LangChain 像是一套搭好的乐高套装,按说明书拼就能跑;LangGraph 是给你底板和积木,自己画图纸、自己连线。
| 维度 | LangChain | LangGraph |
|---|---|---|
| 工作流形态 | 线性链(pipe) | 网状图(节点 + 边) |
| 控制流 | 顺序执行 | 分支、循环、并行 |
| 人工介入 | 不支持 | 原生 interrupt |
| 状态持久化 | 手动管理 | Checkpointer 自动快照 |
| 适用场景 | 简单线性流程 | 多 Agent 协作、复杂编排 |
多 Agent 为什么必要?
在单 Agent 架构下,所有工具的描述、每个功能的 prompt 都放在 system prompt 里。执行每个功能实际上只需要其中一部分 prompt,但整个 system prompt 每次都会被带上——token 浪费、消耗更高,更重要的是很多无关信息被填入,干扰 Agent 注意力,思考效率变低且容易出错。
多 Agent 的核心收益有三个:
| 原因 | 说明 |
|---|---|
| 决策准确率高、token 消耗更低 | 每个 Agent 只带必要的最少 Prompt,没有冗余信息干扰 |
| 并行思考和任务处理 | 主管分派子任务,子 Agent 并行处理,整体效率更高 |
| 多角色互相讨论,纠错能力更强 | 编程 Agent、测试 Agent、验证 Agent 各司其职 |
而多 Agent 之间的协作、路由、状态共享,正是 LangGraph 状态图要解决的问题。
状态图三要素:State、Node、Edge
| 要素 | 本质 | 职责 |
|---|---|---|
| State | 共享数据结构 | 在整个图中流动的上下文快照,节点读写它 |
| Node | 普通函数 | 接收状态,执行计算,返回增量更新 |
| Edge | 路由机制 | 决定下一个执行哪个节点 |
核心设计:节点之间不直接传数据,所有信息都经过 State。节点返回的是增量更新,不是完整状态替换,LangGraph 负责原子性合并。
运行时模型:Super-step
LangGraph 的运行时受 Google Pregel 启发,按 super-step(超步) 推进。每个超步内所有收到新消息的节点并行执行,节点执行完把增量更新作为消息沿边发送,下游节点在下一个超步被激活。没有活跃节点也没有消息在途时,图终止。
State 与 Annotation:Reducer 决定状态怎么变
用 Annotation.Root 声明根状态,每个 key 是一个字段,value 是该字段的通道定义:
javascript
import { Annotation, START, END, StateGraph } from '@langchain/langgraph';
const StateAnnotation = Annotation.Root({
text: Annotation({
reducer: (_prev, next) => next, // 覆盖语义
default: () => "", // 默认值必须是函数
})
});
Reducer 机制
签名:reducer(current, update) => newValue
current:当前已有状态值update:节点返回的增量值- 返回值:合并后的最终值
关键认知:节点返回的是增量,不是完整状态。节点返回 { stepCount: 1 },意思是“这次贡献 1”,最终值由 reducer 决定(累加的话就是 current + 1)。
默认语义:没有自定义 reducer 的字段,默认走 LastValue,后写覆盖前写。
default 的坑
default 必须是函数,不是直接值:
javascript
default: () => 0, // ✅ 正确
default: 0, // ❌ 不符合约定
原因:对数组、对象等引用类型,每次初始化必须生成新引用,否则所有图实例共享同一个可变对象。
另一个坑:Annotation({ default: () => 0 }) 只传 default、不传 reducer,会命中 LastValue 分支,default 被直接忽略,初始值是 undefined。想让 default 生效,必须同时提供 reducer。
第一个图:两个节点,一条直线
javascript
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
const StateAnnotation = Annotation.Root({
text: Annotation({
reducer: (_prev, next) => next,
default: () => "",
})
});
// 函数就是节点
const step1 = (state) => ({ text: `${state.text} -> step1` });
const step2 = (state) => ({ text: `${state.text} -> step2` });
// 实例化图,编排节点和边
const graph = new StateGraph(StateAnnotation)
.addNode("step1", step1)
.addNode("step2", step2)
.addEdge(START, "step1")
.addEdge("step1", "step2")
.addEdge("step2", END)
.compile();
const result = await graph.invoke({ text: "hello" });
console.log(result);
// { text: 'hello -> step1 -> step2' }
构建流程
text
Annotation.Root 定义 State
→ new StateGraph(State)
→ .addNode("name", fn) × N
→ .addEdge(START, "firstNode")
→ .addEdge / .addConditionalEdges 连接各节点
→ .addEdge("lastNode", END)
→ .compile({ checkpointer?, store? })
→ .invoke(input, config) / .stream() / .batch()
关键点:
START/END不是节点,是特殊常量字符串,代表图的入口和出口compile()返回的不是“流程图”,是可执行的RunnableaddEdge引用的节点名必须已经通过addNode注册过
条件边:让 Agent 会“分岔”
条件边根据路由函数的返回值,动态决定下一个执行哪个节点:
javascript
const StateAnnotation = Annotation.Root({
query: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
route: Annotation({ reducer: (_prev, next) => next, default: () => "chat" }),
answer: Annotation({ reducer: (_prev, next) => next, default: () => "" })
});
// 路由节点:判断是数学还是聊天
const router = (state) => {
const isMath = /[+-*/]/.test(state.query);
return { route: isMath ? "math" : "chat" };
};
const mathNode = (state) => {
try {
return { answer: String(eval(state.query)) };
} catch {
return { answer: "表达式无法计算" };
}
};
const chatNode = (state) => ({ answer: `你说的是:${state.query}` });
const graph = new StateGraph(StateAnnotation)
.addNode("router", router)
.addNode("math", mathNode)
.addNode("chat", chatNode)
.addEdge(START, "router")
.addConditionalEdges("router", (state) => state.route, {
math: "math",
chat: "chat"
})
.addEdge("math", END)
.addEdge("chat", END)
.compile();
await graph.invoke({ query: "你好" }); // 走 chat
await graph.invoke({ query: "1+2" }); // 走 math
条件边的执行时机
源节点执行完、返回值已通过 reducer 合并进 state 之后,路由函数才被调用。路由函数看到的是更新后的 state。
关键认知:路由函数不是节点。它不进入图、不更新状态、不被 checkpointer 持久化、不出现在图可视化里。它是一个纯函数,输入 state,输出字符串,执行完就结束。
路由函数返回值必须是映射表的 key
返回映射表里没有的字符串,运行时抛错。compile() 阶段不检查,因为返回值是动态的。
映射表可以省略,前提是路由函数直接返回节点名。但省略后 compile() 无法校验目标节点是否存在,拼写错误要到运行时才暴露,所以建议显式写。
循环与重试:条件边指回上游
条件边指回上游节点,就形成了循环:
javascript
const StateAnnotation = Annotation.Root({
tries: Annotation({ reducer: (_prev, next) => next, defaultValue: 0 }),
ok: Annotation({ reducer: (_prev, next) => next, default: () => false }),
message: Annotation({ reducer: (_prev, next) => next, default: () => "" })
});
const attempt = (state) => {
const tries = state.tries + 1;
const ok = tries >= 3; // 第 3 次才成功
return {
tries,
ok,
message: ok ? `第${tries}次成功` : `第${tries}次失败`
};
};
const graph = new StateGraph(StateAnnotation)
.addNode("attempt", attempt)
.addEdge(START, "attempt")
.addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
retry: "attempt", // 指回上游,形成循环
done: END
})
.compile();
await graph.invoke({ tries: 0 });
死循环防护
LangGraph 默认 recursionLimit: 25,超过抛 GraphRecursionError。生产环境应在路由函数里显式检查重试次数,不能只依赖兜底。
Command:节点内部路由 + 状态更新
Command 是节点返回值的特殊标记对象,让节点同时更新状态和指定下一步去哪:
javascript
import { Command } from "@langchain/langgraph";
return new Command({
update: { riskLevel: "high" }, // 状态增量
goto: "reject", // 下一个节点
});
| 维度 | 条件边 | Command |
|---|---|---|
| 路由决策位置 | 外部路由函数 | 节点函数内部 |
| 是否更新状态 | 否,只路由 | 是,路由同时更新状态 |
| 适用场景 | 路由逻辑与节点业务解耦 | 路由逻辑是节点业务的一部分 |
new 的意义:Command 是运行时类型标记,调度器靠 instanceof Command 识别它,普通对象 { update, goto } 不会被识别。
Send:并行扇出
Send 用于 Map-Reduce 式的并行分发,出现在条件边路由函数的返回值里:
javascript
import { Send } from "@langchain/langgraph";
.addConditionalEdges(START, (state) => {
return state.topics.map((topic) => new Send("summarize", { topic }));
})
每个 Send 创建一个目标节点的独立实例,每个实例的输入状态 = 全局 state + Send.arg 的合并。所有实例并行执行,各自返回增量,最后走对应字段的 reducer 合并回全局 state。
Checkpointer:让图“记得住”上次跑到哪
Checkpointer 在每个 super-step 结束后,把整个 State 的完整快照存下来,加上执行元信息(当前在哪个节点、下一步去哪、checkpoint_id、parent_checkpoint_id)。
MemorySaver:内存持久化
javascript
import { MemorySaver } from '@langchain/langgraph';
const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });
// 一个应用会有多用户
const user1 = { configurable: { thread_id: "用户-小张" } };
const user2 = { configurable: { thread_id: "用户-小李" } };
await app.invoke({}, user1); // 第 1 次
await app.invoke({}, user1); // 第 2 次
await app.invoke({}, user1); // 第 3 次
await app.invoke({}, user2); // 另一个用户,从 0 开始
thread_id 隔离机制
一个 thread_id 对应一系列 checkpoint,不是一条记录。每次节点执行完、每个 super-step 结束,都会生成新 checkpoint,用 checkpoint_id 标识,通过 parent_checkpoint_id 串成链。
text
thread_id: "user-123"
├── checkpoint_1 (初始状态)
├── checkpoint_2 (节点A执行后)
├── checkpoint_3 (节点B执行后)
└── checkpoint_4 (最新)
取“最新状态”就是取这条链的末端。回滚就是取中间的某个 checkpoint。
续跑机制
第二次调用做的是:加载最新快照 → 把本次新输入通过 reducer 合并进去 → 从快照记录的下一步节点继续执行。
不是“从快照原样继续”,新输入会走 reducer 合并。
持久化实现
| 实现 | 存储 | 适用 |
|---|---|---|
MemorySaver | 进程内存 | 开发、测试,重启就丢 |
SqliteSaver | SQLite 文件 | 单机、小规模 |
PostgresSaver | PostgreSQL | 生产、多实例 |
Checkpointer vs 记忆模块存储
| 维度 | 记忆模块存储 | Checkpointer |
|---|---|---|
| 存储内容 | 消息列表 | 完整 State 快照 + 执行元信息 |
| 读写方式 | 应用层手动 addMessage | 运行时自动拦截、自动保存、自动恢复 |
| 粒度 | 一条消息 | 一个 super-step 的完整状态 |
| 用途 | 对话历史 | 断点续跑、时间旅行、人工介入、持久化 |
Interrupt:让人在环路中间“踩刹车”
interrupt 是节点内部调用的暂停函数,让图停在当前节点,把控制权交还给调用方,等人或外部系统给出输入后再从暂停点继续。
前提:必须配 checkpointer,因为暂停时要把当前 State 和暂停位置持久化。
完整示例
javascript
import {
Annotation, END, START, StateGraph,
Command, interrupt, MemorySaver
} from '@langchain/langgraph';
import { createInterface } from "node:readline/promises";
const StateAnnotation = Annotation.Root({
actionSummary: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
userInput: Annotation({ reducer: (_prev, next) => next, default: () => "" })
});
const showTransfer = () => ({
actionSummary: "向张三转 $100"
});
const waitConfirm = (state) => {
const text = interrupt({
hint: "终端输入[确认]或者备注后回车,图才能继续执行",
actionSummary: state.actionSummary
});
return { userInput: String(text) };
};
const graph = new StateGraph(StateAnnotation)
.addNode("showTransfer", showTransfer)
.addNode("waitConfirm", waitConfirm)
.addEdge(START, "showTransfer")
.addEdge("showTransfer", "waitConfirm")
.addEdge("waitConfirm", END)
.compile({ checkpointer: new MemorySaver() });
const config = { configurable: { thread_id: "interrupt-demo" } };
// 第一次调用:跑到 waitConfirm 节点,调 interrupt() 暂停
const paused = await graph.invoke({}, config);
console.log("待你确定", paused.__interrupt__?.[0]?.value);
// 终端输入
const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question("> ")).trim();
rl.close();
// 恢复:用 Command({ resume: 值 }) 继续
const done = await graph.invoke(new Command({ resume: line }), config);
console.log("done", done);
暂停与恢复的机制
resume 的值会作为 interrupt() 的返回值,赋给 text。节点从 interrupt() 那一行继续往下跑。
Command.resume 是专门配合 interrupt 的字段,只在有中断暂停的情况下有意义。Command 的三个字段分工:update 更新状态、goto 路由、resume 恢复中断。
关键坑:interrupt 之前的代码会重跑
恢复时,节点函数从头重新执行,直到再次遇到 interrupt()。interrupt() 之前的副作用(写数据库、发请求)会执行两次。
javascript
async function review(state) {
await sendEmail(state.request); // ⚠️ 恢复时会再发一次
const decision = interrupt({ question: "批准吗?" });
return { approved: decision === "approve" };
}
正确做法:副作用放到 interrupt() 之后,或用幂等键去重。官方文档明确要求 interrupt 之前的代码必须能安全重跑。
其他坑
- 同一节点多次
interrupt要用 id 区分:interrupt({ q: "第一步" }, { id: "step-1" }) - 恢复时
thread_id必须一致,否则找不到暂停点 interrupt不是边,不决定去哪,只是暂停
Interrupt vs Command
| Command | interrupt | |
|---|---|---|
| 位置 | 节点返回值 | 节点内部调用 |
| 作用 | 更新状态 + 决定下一节点 | 暂停,等外部输入 |
| 是否需要 checkpointer | 不需要 | 必须 |
| 恢复方式 | 不需要恢复 | new Command({ resume: value }) |
| 典型场景 | 动态路由 | 人工审批、外部 API 回调、HITL |
两者常配合使用:节点里先 interrupt 等人审批,拿到结果后返回 Command 决定走哪条分支。
图的可视化:Mermaid 帮你画出来
LangGraph 提供了把编译后的图导出为 Mermaid 代码的 API:
javascript
const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log(mermaid);
输出类似:
text
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
step1(step1)
step2(step2)
__end__([<p>__end__</p>]):::last
__start__ --> step1;
step1 --> step2;
step2 --> __end__;
classDef first fill-opacity:0
classDef last fill-opacity:0
把这段代码贴进支持 Mermaid 的渲染器(VS Code 的 Mermaid 插件、mermaid.live、Notion、GitHub Markdown)即可出图。
可视化能看到什么、看不到什么
能看到:编译后的静态图结构——节点、静态边、条件边的源和目标。
看不到:运行时状态,以及 Command 和 Send 的动态路由目标,因为那些是运行时才决定的。条件边画出来是虚线连到映射表里的所有可能目标,但哪条真的走了,图上看不出来。
调试建议:compile() 之后立刻 drawMermaid() 打印一次,能快速发现“边连错了”“孤立节点”“入口没连”这些问题。
互动讨论
💬 LangGraph 和 LangChain 到底是什么关系?
两者是不同抽象层级的互补。LangChain 是 Agent 框架,LangGraph 是底层的编排运行时。LangChain 1.0 的 createAgent 底层确实生成一个 LangGraph 状态图,但 LangGraph 可以完全独立于 LangChain 使用,不依赖它的模型抽象和工具封装。
💬 Reducer 的返回值到底是什么?
返回值是合并后的最终值。reducer 的职责是定义“如何合并”,返回的就是合并结果。节点返回的是增量,不是完整状态替换。
💬 为什么只写 default 不写 reducer,默认值会失效?
Annotation<T>({ default: () => 0 }) 只传 default、不传 reducer,会命中 LastValue 分支,default 被直接忽略,初始值是 undefined。想让 default 生效,必须同时提供 reducer。
💬 Checkpointer 存的是消息历史吗?
不是。Checkpointer 存的是完整 State 快照 + 执行元信息,不是只存消息列表。这是它能支持断点续跑、时间旅行、人工介入的根本原因。
💬 Interrupt 恢复时,节点会从哪里继续执行?
节点函数从头重新执行,直到再次遇到 interrupt()。interrupt() 之前的副作用会执行两次,所以必须保证 interrupt 之前的代码能安全重跑。