LangGraph.js 实战:从状态图到分支、循环、检查点与人工中断
当一个 Agent 只需要完成"接收问题---调用模型---返回答案"时,一段线性代码往往就够了。但随着任务变复杂,我们很快会遇到更多控制需求:
- 不同类型的问题要交给不同的处理节点;
- 某一步失败后需要重试,而不是直接结束;
- 同一个会话再次进入时,需要接着之前的状态运行;
- 转账、删除等敏感操作执行前,需要暂停并等待人工确认;
- 更复杂的场景还可能把任务拆给多个职责不同的 Agent。
这类流程已经不再是一条简单的调用链,而更像一张由节点和边组成的图。LangGraph 的作用,就是用"状态图"来表达这些流程。
本文不接入大模型,而是用几个足够小的 JavaScript 示例,依次讲清 LangGraph 最核心的五种能力:顺序执行、条件分支、循环重试、会话状态保存和人工中断。没有模型调用的干扰,我们反而能更清楚地看到图是怎样运转的。
一、为什么复杂 Agent 需要"图"
LangChain 提供模型调用、文档加载、文本切分、Embedding、向量存储、输出解析等基础能力。LangGraph 更关注另一个问题:这些能力应该以什么顺序执行,执行过程中怎样共享状态,又该如何分支、循环、暂停和恢复。
可以把二者的关系简单理解为:
- LangChain 提供完成任务所需的基础模块;
- LangGraph 负责把工作节点组织成一个可控制的状态化流程。
一个可工作的 Agent 也不只是 LLM 本身。可以把 LLM 看成负责理解与决策的"大脑",把工具、MCP、RAG、Skill 等外部能力统称为它的运行支撑(Harness)。图编排负责决定哪个大脑在什么时候使用哪部分支撑能力:简单流程可以只有一个 Agent,复杂流程则可以组合多个 Agent,甚至让不同角色按任务选用不同模型。
图结构也是构建多 Agent 系统的重要基础。单 Agent 往往要携带所有工具说明和所有功能提示词,即使本轮任务只会用到其中一小部分。这会增加上下文开销,也会让无关信息干扰决策。拆分之后,每个 Agent 只保留完成自身职责所需的提示词和工具,主 Agent 再负责分派任务与汇总结果。
这种设计主要有三个价值:
- 缩小单个 Agent 的上下文范围。 每个角色只关注自己的任务,减少无关信息干扰。
- 允许并行处理。 主 Agent 可以把互不依赖的任务同时交给多个子 Agent。
- 便于交叉检查。 例如编程 Agent 负责实现,测试 Agent 负责测试,验证 Agent 再检查结果是否符合预期。
不过,多 Agent 并不是越多越好。它会增加模型调用次数和调度复杂度。是否拆分,应看任务能否形成边界清楚、上下文相对独立的职责。本文先不急着实现多 Agent,而是把它依赖的图编排基础打牢。
二、LangGraph 的四个基本概念
先看一条最简单的流程:
text
START → step1 → step2 → END
它包含四个基本概念。
1. State:贯穿整张图的状态
状态是节点之间传递的数据。前一个节点读取当前状态并返回更新,后一个节点会收到合并后的新状态。
2. Node:真正执行工作的节点
节点通常就是一个函数。函数接收当前状态,返回需要更新的部分状态。节点不必每次都返回完整对象。
3. Edge:决定节点之间怎样流转
普通边表示固定的下一步,条件边则根据当前状态动态选择下一步。边还可以指回前面的节点,从而形成循环。
4. START 与 END:图的入口和出口
START、END 是 LangGraph 提供的特殊节点。它们不处理业务,只负责标记流程从哪里开始、到哪里结束。
本文示例只需要安装 LangGraph:
bash
pnpm add @langchain/langgraph
示例以 @langchain/langgraph 1.4.x 为基础,使用 ESM 模块语法,文件扩展名可以使用 .mjs。
三、第一张图:让两个节点顺序执行
下面实现一张只有两个工作节点的图。输入文本先经过 step1,再经过 step2。
js
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
{ text: 'hello -> step1 -> step2' }
Annotation.Root 在声明什么
Annotation.Root 定义整张图允许使用的状态字段。这里的状态只有一个 text:
js
const StateAnnotation = Annotation.Root({
text: Annotation({
reducer: (_prev, next) => next,
default: () => "",
}),
});
每个字段可以配置两个关键部分:
default:调用方没有提供该字段时使用的默认值;reducer:旧值与节点返回的新值应该怎样合并。
这里的 reducer 直接返回 next,所以节点返回的新文本会覆盖旧文本。虽然 step1 和 step2 的实现是在原文本后追加内容,但追加动作发生在节点函数里;站在 reducer 的角度看,仍然是用新字符串替换旧字符串。
如果状态字段是消息数组,reducer 也可以把前后两个数组合并。也就是说,节点负责产生"更新",reducer 负责定义"更新如何进入总状态"。
节点返回的是状态更新,不是下一节点
step1 返回:
js
{
text: "hello -> step1",
}
它表达的是对状态的更新,并没有决定下一步去哪里。真正的流转关系由边定义:
js
.addEdge(START, "step1")
.addEdge("step1", "step2")
.addEdge("step2", END)
把"做什么"和"接下来去哪"分开,是状态图非常重要的设计。节点可以专注业务,流程关系则集中留在图定义中。
compile() 与 invoke()
new StateGraph(...) 得到的是图构建器。添加完节点和边后,需要调用 compile() 得到可执行图,再用 invoke(initialState) 传入初始状态并运行。
因此,一次完整执行可以概括成:
text
初始状态
↓
step1 读取状态并返回更新
↓ reducer 合并
step2 读取新状态并返回更新
↓ reducer 合并
最终状态
四、把图画出来:用 Mermaid 检查结构
当节点越来越多,仅靠阅读链式调用很难快速确认图的走向。编译后的图可以生成 Mermaid 文本:
js
const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({
withStyles: true,
});
console.log(mermaid);
最小顺序图的核心结构如下:
withStyles: true 会在生成结果中附加样式定义。Mermaid 文本可以放进支持 Mermaid 的 Markdown 工具中渲染。即使发布平台不直接渲染,也可以在开发阶段用它核对节点、固定边和条件边是否符合预期。
五、条件分支:让图根据状态选择下一步
顺序图的下一步始终固定,但实际业务经常需要路由。例如,包含运算符的输入进入数学节点,其他输入进入普通对话节点:
text
┌→ math ─→ END
START → router ──┤
└→ chat ─→ END
对应的状态包含输入、路由结果和最终答案:
js
import {
Annotation,
END,
START,
StateGraph,
} from "@langchain/langgraph";
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();
console.log("result:", await graph.invoke({ query: "1+2" }));
console.log("result:", await graph.invoke({ query: "你好" }));
运行结果:
text
result: { query: '1+2', route: 'math', answer: '3' }
result: { query: '你好', route: 'chat', answer: '你说的是:你好' }
addConditionalEdges 的三个参数
这一段是条件分支的核心:
js
.addConditionalEdges(
"router",
(state) => state.route,
{
math: "math",
chat: "chat",
},
)
三个参数分别表示:
- 从哪个节点开始判断,这里是
router; - 用什么函数得到分支键,这里读取
state.route; - 分支键与目标节点怎样映射。
当 router 把 route 更新为 "math" 时,条件函数返回 "math",再通过映射进入同名的数学节点。普通文本则进入 chat。
路由节点只负责分类,不直接回答问题;工作节点只负责各自类型的处理。这种职责分离也是以后把不同任务交给不同 Agent 的基础。
这个演示中的 eval 为什么不能直接用于业务
eval() 会把字符串当作 JavaScript 代码执行。它不只会算 1+2,恶意字符串也可能访问运行环境或执行其他代码,因此不能用它处理用户提交的不可信内容。
另外,/[+\-*/]/ 只是判断字符串中是否出现运算符,并不能证明输入是合法的数学表达式。这里保留它,是为了把注意力集中在条件边的运行方式上,而不是实现一个表达式解析器。生产环境必须换成受约束的解析与计算方案。
六、循环:让节点在满足条件前不断重试
图与普通线性调用相比,一个很重要的区别是边可以指回已经执行过的节点。下面让 attempt 节点不断增加尝试次数,第三次成功后才结束:
text
START → attempt ──done──→ END
↑
└──retry────┘
js
import {
Annotation,
END,
START,
StateGraph,
} from "@langchain/langgraph";
const StateAnnotation = Annotation.Root({
tries: Annotation({
reducer: (_prev, next) => next,
default: () => 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;
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();
console.log("result:", await graph.invoke({ tries: 0 }));
最终结果是:
text
result: { tries: 3, ok: true, message: '第3次成功' }
执行过程并不复杂:
| 执行次数 | tries |
ok |
分支 |
|---|---|---|---|
| 第一次 | 1 | false |
retry → attempt |
| 第二次 | 2 | false |
retry → attempt |
| 第三次 | 3 | true |
done → END |
这里有一个很容易忽略的细节:状态定义中的字段名、节点读取的字段名和节点返回的字段名必须完全一致。例如,状态定义为 message,节点也必须返回 message;如果一处误写成 messaage,提示文本就不会进入预期的状态字段。
默认值配置也应写成:
js
default: () => 0
而不是把键名写成 defaultValue。状态字段较多时,统一命名比看起来更重要,因为条件边做出的决定完全依赖合并后的状态。
循环还必须拥有明确的退出条件。这个例子以 tries >= 3 为边界,所以一定会终止。如果退出条件永远不能满足,边就会不断回到 attempt。实际设计重试流程时,尝试次数应是状态的一部分,并设置清晰的成功或停止条件。
七、检查点:让同一个会话记住上一次状态
到目前为止,每次 invoke() 都可以看成一次独立运行。如果希望同一位用户再次调用时从上次状态继续,就需要 checkpointer,也就是检查点保存器。
下面用 MemorySaver 在内存中保存每个会话的访问次数:
js
import {
Annotation,
END,
START,
StateGraph,
MemorySaver,
} from "@langchain/langgraph";
const StateAnnotation = Annotation.Root({
visitCount: Annotation({
reducer: (_prev, next) => next,
default: () => 0,
}),
message: Annotation({
reducer: (_prev, next) => next,
default: () => "",
}),
});
function recordVisit(state) {
const visitCount = state.visitCount + 1;
const message =
visitCount === 1
? "这是你在本会话第1次进入"
: `这是你在本会话第${visitCount}次进入`;
return {
visitCount,
message,
};
}
const graph = new StateGraph(StateAnnotation)
.addNode("recordVisit", recordVisit)
.addEdge(START, "recordVisit")
.addEdge("recordVisit", END);
const checkpointer = new MemorySaver();
const app = graph.compile({
checkpointer,
});
const user1 = {
configurable: {
thread_id: "用户-小落",
},
};
const user2 = {
configurable: {
thread_id: "用户-小月",
},
};
console.log(await app.invoke({}, user1));
console.log(await app.invoke({}, user2));
console.log(await app.invoke({}, user1));
console.log(await app.invoke({}, user2));
输出如下:
text
{ visitCount: 1, message: '这是你在本会话第1次进入' }
{ visitCount: 1, message: '这是你在本会话第1次进入' }
{ visitCount: 2, message: '这是你在本会话第2次进入' }
{ visitCount: 2, message: '这是你在本会话第2次进入' }
thread_id 是会话状态的隔离键
MemorySaver 并不是把所有调用都合并到一起,而是通过配置中的 thread_id 区分会话:
js
{
configurable: {
thread_id: "用户-小落"
}
}
小落和小月第一次进入时,读取到的都是各自会话的初始状态,所以计数都是 1。第二次使用相同的 thread_id 调用时,图会基于各自已经保存的状态继续执行,于是计数分别变成 2。
这里保存的是会话状态 ,thread_id 不一定必须等于用户 ID。同一个用户可以拥有多个会话,只要为不同会话分配不同的 thread_id 即可。
MemorySaver 适合演示,不等于数据库持久化
MemorySaver 把检查点放在当前进程内存中。它很适合本地演示和快速验证,但进程结束后,内存中的状态也会消失。如果业务要求重启后仍能恢复,就要把检查点保存到 SQLite、Redis 等持久化存储中。
检查点不仅能"记住访问次数"。Agent 的执行可能因为异常、暂停、授权等原因中断,保存状态后,后续才能沿着同一个会话继续运行。下一节的人工中断正好会用到它。
八、人工中断:暂停图,收到输入后继续执行
敏感操作不能只靠 Agent 自己决定。一个常见流程是先展示即将执行的动作,暂停等待用户输入,然后再恢复图:
text
START → showTransfer → waitConfirm → END
↑
在这里暂停
LangGraph 使用 interrupt() 发出中断,使用 Command({ resume }) 携带人工输入恢复执行:
js
import {
Annotation,
END,
START,
StateGraph,
MemorySaver,
Command,
interrupt,
} 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",
},
};
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();
console.log("你输入了:", line);
await rl.close();
const done = await graph.invoke(
new Command({ resume: line }),
config,
);
console.log("done", done);
输入"确认"后,可以看到如下过程:
text
待确认 {
hint: '终端输入[确认]或者备注后回车,图才会继续执行',
actionSummary: '向张三转账 $100'
}
> 确认
你输入了: 确认
done {
actionSummary: '向张三转账 $100',
userInput: '确认'
}
第一次 invoke():运行到中断点
图先经过 showTransfer,把操作摘要写进状态。进入 waitConfirm 后,interrupt() 抛出一个需要外部处理的中断值:
js
{
hint: "终端输入[确认]或者备注后回车,图才会继续执行",
actionSummary: state.actionSummary,
}
调用方可以从结果的 __interrupt__ 中取出这段信息,再显示给用户。此时图没有走到 END,而是等待恢复。
第二次 invoke():用 Command 恢复
终端读到用户输入后,代码没有重新传入普通初始状态,而是传入:
js
new Command({ resume: line })
这个值会成为 interrupt() 的返回结果,于是 waitConfirm 可以把它转换成字符串并写进 userInput,图随后到达 END。
暂停与恢复使用了同一个 config,也就是同一个 thread_id。同时,编译时配置了 MemorySaver。这两点共同保证恢复操作能够找到对应的中断状态,而不是启动一次无关的新会话。
暂停并不等于已经完成授权判断
这个示例展示的是中断机制:用户输入任何文本并回车,图都会恢复,然后把文本保存到 userInput。它并没有判断输入是否严格等于"确认",也没有真正执行转账。
因此,若把它扩展成真实审批流程,还需要在恢复后验证输入,再决定批准、拒绝还是要求重新输入。不要因为流程中出现了人工输入,就把"有人回复了"和"操作已获批准"当成同一件事。
九、把五个示例连成一套思维模型
前面的代码从简单到复杂,其实是在逐步回答五个不同问题:
| 能力 | 解决的问题 | 核心 API |
|---|---|---|
| 顺序执行 | 工作节点按什么顺序运行 | addNode、addEdge |
| 条件分支 | 当前状态不同,下一步去哪里 | addConditionalEdges |
| 循环重试 | 条件不满足时如何再次执行 | 指回原节点的条件边 |
| 状态保存 | 下次调用怎样接着当前会话 | MemorySaver、thread_id |
| 人工中断 | 如何暂停并等待外部输入 | interrupt、Command |
它们共享同一个核心模型:
text
节点读取 State
↓
节点返回部分更新
↓
Reducer 合并新旧状态
↓
普通边或条件边选择下一节点
↓
必要时保存、暂停或继续执行
掌握这个模型后,再把某个节点换成模型调用、工具调用、RAG 检索或子 Agent,本质上仍是在做同一件事:读取状态、完成职责、更新状态,再让图决定下一步。
十、几个容易踩坑的细节
最后集中检查几个会直接影响正确性的地方。
1. 状态字段名必须保持一致
状态声明、节点读取、节点返回、条件函数中的字段名必须一致。拼写偏差不会得到想要的状态,后续判断自然也可能出错。
2. 默认值使用 default 函数
正确形式是:
js
default: () => 0
不同类型的字段返回各自合适的初始值,例如字符串返回 "",布尔值返回 false。
配置键的拼写也必须准确:默认值使用 default,状态合并函数使用 reducer。如果把 reducer 误写成其他形式,配置的合并逻辑就不会按预期生效。
3. Reducer 决定的是状态合并方式
js
reducer: (_prev, next) => next
表示直接用新值覆盖旧值。不要把 reducer 和业务节点混为一谈:节点计算更新内容,reducer 只处理新旧值怎样合并。
4. 条件分支的返回值必须命中映射
条件函数返回 "math",映射对象中就必须存在 math。分支键、映射键和节点名称虽然可以写成一样,但它们承担的角色不同。
5. 循环必须能到达出口
循环边很强大,也最容易制造永不结束的流程。应把尝试次数放进状态,并保证某个条件能够路由到 END。
6. 不要对不可信输入使用 eval
eval 只是最小数学分支示例里的演示手段。它会执行任意 JavaScript 字符串,不能直接进入生产代码。
7. 恢复中断时必须使用同一个会话标识
暂停与恢复依赖检查点。恢复时换了 thread_id,就无法对应到原来的中断执行。
8. MemorySaver 的生命周期只是当前进程
它能展示检查点机制,却不能保证服务重启后仍有数据。需要长期保存时,应使用持久化存储。
总结
LangGraph 并不是简单地把几个函数串起来。它把 Agent 工作流拆成了三部分:
- 用 State 描述共享数据;
- 用 Node 描述每一步工作;
- 用 Edge 描述固定流转、条件分支和循环。
在这套基础上,checkpointer 又让流程拥有跨调用延续的会话状态,interrupt 与 Command 则让程序可以在关键位置暂停,把决定权交回给人。
从最小顺序图开始,再逐步加入分支、循环、检查点和中断,是理解 LangGraph 最稳妥的路径。等这些控制能力清晰之后,无论节点内部接入大模型、检索系统、工具,还是把它扩展成多个 Agent 分工协作,整体结构都不会再显得神秘。