随着 Agent 接入的工具和职责越来越多,把全部说明与处理逻辑塞进同一个 system prompt,既会增加上下文成本,也容易让无关信息干扰决策。将能力拆分给多个 Agent 之后,还需要一个能够处理分支、循环和状态流转的编排层。LangGraph.js 正是为这类图式工作流提供基础能力,下面从最小可运行示例开始理解它的核心模型。
单 Agent 的 system prompt 越堆越长——所有工具的说明、所有功能的提示词全塞进去,可真正执行某个功能时,用到的可能只有一小部分。
拆成多 Agent 是解法。但拆完之后问题来了:谁来编排这些 Agent? 一条链走到底的线性编排不够用了,需要的是「网状」。
这篇用 5 个能直接跑的 demo,把 LangGraph.js 从最基础的图模型一路讲到中断与恢复——那是 Agent 敢碰真实业务的入场券。
单 Agent 架构下,所有工具的说明、每个功能的 prompt 都堆在 system prompt 里。但实际执行某个功能时,只需要其中一部分,剩下全是陪跑。
陪跑的代价不只是 token 更贵。更要命的是后半句:
无关信息会干扰模型思考,让它效率更低、更容易出错。
拆成多 Agent 之后,每个 Agent 只保留自己需要的那部分 prompt。虽然调用 LLM 的次数变多了,但每次消耗的 token 更少,而且没有冗余信息干扰,准确率更高。
理解这件事有个好用的公式:
Agent = LLM(大脑) + Harness(tool + mcp + rag + skill + ...)
而且每个大脑还能选适合自己的模型——便宜的干粗活,贵的干细活;Agent 本身也可以按需加载、动态加载。
① 决策准确率更高,token 消耗更低
每个 Agent 只带必要的最少 prompt,没有冗余信息干扰。调用 LLM 的次数多了,但总量更省。
② 并行思考和任务处理
主管把任务拆开分派下去,子 Agent 同时干活,整体效率更高。
③ 多角色互相讨论,纠错能力更强
这是最容易被低估的一条。典型例子:写代码的 Agent 负责实现,测试 Agent 负责写测试用例(TDD),验证 Agent 负责检查代码是否符合预期,最后告诉主 Agent「通过了」。这种互相 review 的结构,纠错能力远强于单个模型自己检查自己。AutoGen 那套「法庭」式辩论也是同一个思路。
| LangChain | LangGraph | |
|---|---|---|
| 定位 | 基础模块 + 线性工作流编排 | 网状工作流编排 |
| 提供什么 | LLM API、Document Loaders、Splitter、Embedding、Vector Store、Output Parser、Memory… | 工作节点 + 节点之间的组织方式 |
| 编排形态 | 线性的 | 网状的 |
| 能做什么 | 简单 Agent | 复杂的多 Agent 协作 |
两者共享底层基础设施(LLM、工具、记忆都是同一套),区别在编排能力:LangChain 的链是一条线走到底,LangGraph 的图可以分支、可以循环、可以并行。
所以这条演进路线可以概括成一句话:简单 Agent → 复杂多 Agent 协作,编排方式从线性走向网状。
LangGraph 的图模型非常简洁,就四样东西:
| 要素 | 是什么 | 在代码里 |
|---|---|---|
| 开始节点 | 入口,携带初始状态 | START |
| 工作节点 | 一个职责,读写状态 | addNode("名字", 函数) |
| 边 | 连接节点,决定往哪走 | addEdge / addConditionalEdges |
| 最终状态 | 结束,输出结果 | END |
直接把最小可运行的图写出来(src/basic-graph.mjs):
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
// ① 声明状态:数据长什么样
const StateAnnotation = Annotation.Root({
text: Annotation({
// _prev 是来到当前节点之前的状态,next 是当前节点返回的状态
// reducer 决定状态怎么变
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();
// ④ 跑
console.log(await graph.invoke({ text: "hello" }));
输出:
{ text: 'hello -> step1 -> step2' }
四段式——声明状态 → 定义节点 → 连边 → invoke,后面所有复杂的图都是在这上面加东西。
reducer 决定怎么变,default 决定从哪开始text: Annotation({
reducer: (_prev, next) => next,
default: () => "",
})
reducer:状态的合并规则。(_prev, next) => next 表示用新值直接覆盖;想累加就写成 (prev, next) => prev + next(这个名字确实取自 JS 数组的 reduce——把一串更新「消消乐」成一个最终状态)。default:初始值。注意它必须是个工厂函数,写成 default: "" 会直接报错。step1 和 step2 都是普通函数,收 state、返回要更新的字段(不用返回完整 state,只需要返回你想改的那部分)。这个设计让节点之间的耦合非常低——加一个节点,你只要关心它读什么、写什么。
LangGraph 内置了可视化能力,drawMermaid() 会吐出 Mermaid 文本:
const drawable = await graph.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));
%%{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 default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
这个小功能在调复杂图的时候非常救命——节点一多,光靠读代码很难确认连线有没有连错,画出来一眼就看到了。
「网状」不是随便说说,它落在两个具体能力上。
addConditionalEdges用一个路由节点决定下一步走哪(src/conditional-routing.mjs):
const router = (state) => {
const isMath = /[+-*/]/.test(state.query);
// 下一步怎么走?是数学问题走 math,否则走 ch@t
return { route: isMath ? "math" : "ch@t" };
};
const mathNode = (state) => {
try {
return { answer: String(eval(state.query)) }; // "1+2" -> 3 -> "3"
} catch {
return { answer: "表达式无法计算" };
}
};
const ch@tNode = (state) => ({ answer: `你说的是:${state.query}` });
const graph = new StateGraph(StateAnnotation)
.addNode("router", router)
.addNode("math", mathNode)
.addNode("ch@t", ch@tNode)
.addEdge(START, "router")
// 条件跳转:按 state.route 的值决定走哪个分支
.addConditionalEdges("router", (state) => state.route, {
math: "math",
ch@t: "ch@t"
})
.addEdge("math", END)
.addEdge("ch@t", END)
.compile();
跑两次,走的是两条不同的路:
result: { query: '你好', route: 'ch@t', answer: '你说的是:你好' }
result: { query: '1+2', route: 'math', answer: '3' }
画出来能直接看到那个分叉:
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
router(router)
math(math)
ch@t(ch@t)
__end__([<p>__end__</p>]):::last
__start__ --> router;
ch@t --> __end__;
math --> __end__;
router -.-> math;
router -.-> ch@t;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
addConditionalEdges 三个参数要看清:
.addConditionalEdges(
"router", // 从哪个节点出发
(state) => state.route, // 返回一个「key」
{ math: "math", ch@t: "ch@t" } // key → 真实节点名的映射表
)
第二个参数返回的不是节点名,而是一个 key,真正的映射交给第三个参数。多一层看似啰嗦,实则方便——路由函数不用知道节点叫什么名字,判断逻辑和拓扑结构解耦了。
把条件边的目标指回上游节点,就形成了重试(src/loop-retry.mjs):
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();
console.log(await graph.invoke({ tries: 0 }));
result: { tries: 3, ok: true, message: '第3次成功' }
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
attempt(attempt)
__end__([<p>__end__</p>]):::last
__start__ --> attempt;
attempt -. done .-> __end__;
attempt -. retry .-> attempt;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
那条 attempt -. retry .-> attempt 的自环,就是 LangGraph 相比 LangChain 最本质的差别:链可以无限长,但永远回不了头;图能回头。
Agent 场景里这条自环太常用了——工具调用失败要重试、模型输出不合格要重新生成、代码跑不过测试要改到过为止,全都是循环。
eval()数学节点里用了 eval()——它会把传入的字符串当作 JS 代码执行,返回执行结果。eval("1+2") 就是 3,在 demo 里省事得很。
但生产环境千万别对用户输入用 eval(),那等于把任意代码执行权直接交出去。本文为了 demo 简洁保留了它,正式代码请换成安全的表达式解析器。
thread_id到这里图能跑、能分支、能循环了,但还有一个致命问题:每次 invoke 都是全新的开始,状态活在内存里,跑完就没。
这直接堵死了后面要说的一切——Agent 执行中断了、失败了、要暂停等授权,进程一停状态全没,恢复时只能从头再来。
LangGraph 的解法是给图配一个 checkpointer(存档器)。最简单的实现是 MemorySaver(src/checkpointer-memory.mjs):
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)); // 第1次
console.log(await app.invoke({}, user1)); // 第2次
console.log(await app.invoke({}, user1)); // 第3次
console.log(await app.invoke({}, user2)); // 另一个人,从第1次开始
真实输出:
{ visitCount: 1, message: '这是你在本会话里第1次进入。' }
{ visitCount: 2, message: '这是你在本会话里2次进入' }
{ visitCount: 3, message: '这是你在本会话里3次进入' }
{ visitCount: 1, message: '这是你在本会话里第1次进入。' }
小张连着进三次,计数一路累加;小李是另一份独立记录,从 1 开始。
关键在 thread_id——它就是「会话/线程」的身分证:
const config = { configurable: { thread_id: "会话ID" } };
一句话记住这两个概念的分工:
checkpointer决定状态存在哪,thread_id决定状态属于谁。
同一个 thread_id 的状态会续上,不同 thread_id 互相隔离——这就是多用户、多会话的基础。
用户:帮我给张三转 100 块
Agent 内心:识别到意图 → 准备执行 transfer("张三", 100)
这一步能直接执行吗?不能。 不可逆,模型理解错一个字代价就是真金白银。所以在真正扣款前,必须有人点头。
| 场景 | 为什么要中断 |
|---|---|
| 、下单、删除数据 | 不可逆操作,要人确认 |
| 调用付费 API、跑大规模任务 | 成本高,要授权 |
| 修改生产环境配置 | 风险高,要审批 |
| 触发敏感工具 | 合规要求,要人工介入 |
这些场景的共同点是:流程不是一口气跑完的,中间必须停下来等一个外部输入,而这个输入可能要等很久——几秒、几小时、甚至几天。
这就是 Harness 这一层最难做对的部分:
暂停时,当前执行到哪、状态是什么,必须被完整保存下来;恢复时,要能从暂停的那一点继续,而不是从头再来。
这也是为什么第四节要先讲持久化——没有 checkpointer,就没有中断恢复。
完整例子在 src/graph-interrupt.mjs:
START → showTransfer(展示待办)→ waitConfirm(等待确认)→ END
%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
__start__([<p>__start__</p>]):::first
showTransfer(showTransfer)
waitConfirm(waitConfirm)
__end__([<p>__end__</p>]):::last
__start__ --> showTransfer;
showTransfer --> waitConfirm;
waitConfirm --> __end__;
classDef default fill:#f2f0ff,line-height:1.2;
classDef first fill-opacity:0;
classDef last fill:#bfb6fc;
两个节点各司其职:
// 节点一:准备待确认的动作
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() }); // ← 必须挂 checkpointer
interrupt() 做了三件事第一,它让图停下来。 调用 interrupt() 的节点立刻挂起,图不再往下走。注意——这不是报错,是正常的暂停,别用 try/catch 去接它。
第二,它把信息带出去。 传给 interrupt() 的参数会作为中断的载荷返回给调用方:
const config = { configurable: { thread_id: "interrupt-demo" } };
const paused = await graph.invoke({}, config);
console.log("待你确认", paused.__interrupt__?.[0]?.value);
待你确认 { hint: '终端里输入[确认]或者备注后回车,图才会继续', actionSummary: '向张三 $100' }
返回结果里带着一个 __interrupt__ 数组,[0].value 就是你传进去的那份数据。前端就是靠它渲染出「确认弹窗 / 卡片」的。
第三,它会记住自己停在哪。 这正是 compile() 必须挂 checkpointer 的原因:
.compile() // ❌ 运行直接抛 GraphValueError: No checkpointer set
.compile({ checkpointer: new MemorySaver() }) // ✅
interrupt() 的返回值这是最容易忽略的一点。再看 waitConfirm:
const text = interrupt({ ... });
return { userInput: String(text) };
interrupt() 有返回值,返回值就是恢复时传进来的那份数据。也就是说,同一个节点会经历两个阶段:第一次执行到 interrupt() 停下来;恢复后这个节点会从头重新执行,这次 interrupt() 直接返回你给的输入,代码继续往下走。
这个设计很巧妙:
你不需要额外写「恢复之后该做什么」的逻辑,把恢复后的处理直接写在
interrupt()后面就行。
Command({ resume })用户输入完之后,用 Command 把它送回图里:
const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question("> ")).trim();
await rl.close();
const done = await graph.invoke(new Command({ resume: line }), config);
console.log("done:", done);
| 说明 | |
|---|---|
Command | 命令对象,告诉图「这次不是新的一轮,而是带着指令回来」 |
resume | 恢复时携带的数据,会成为 interrupt() 的返回值 |
config | 必须是同一个 thread_id |
完整跑一遍:
待你确认 { hint: '终端里输入[确认]或者备注后回车,图才会继续', actionSummary: '向张三 $100' }
> 你输入了 确认
done: { actionSummary: '向张三 $100', userInput: '确认' }
图从中断点接上了,用户的输入被写进了状态。这就是一个「Agent 停下来问人,人回答完它接着干」的完整闭环。
MemorySaver 有个明显的局限:它存在进程内存里。进程一重启,所有会话状态全丢——对「等用户明天来确认」这种场景完全不可接受。
生产环境要换成持久化的 checkpointer:
| 存储 | 适用 |
|---|---|
MemorySaver | 本地开发、demo、单元测试 |
| SQLite | 单机部署、中小规模,文件型数据库 |
| Redis | 多实例部署、需要高性能读写和过期策略 |
换起来很简单——中断恢复的业务代码一行都不用改,只换 compile() 里那个 checkpointer 实例。
这也再次印证了那个分层:
图描述「做什么」,checkpointer 决定「状态存在哪」。 两者解耦,所以能从内存一路平滑升级到数据库。
这一节是我在写 demo 时实际踩到、并且逐个验证过的,比前面任何一段都值得细看。
defaultValue 是静默失效的状态默认值要写 default,不是 defaultValue:
tries: Annotation({
reducer: (_prev, next) => next,
defaultValue: 0, // ❌ 不报错,但被静默忽略
default: () => 0, // ✅ 而且必须是工厂函数
})
坑就坑在它不报错。defaultValue 会被直接无视,state.tries 是 undefined——然后 undefined + 1 = NaN,整个判断逻辑悄悄失灵。
顺带一提,default 写成 default: 0(不是函数)会直接抛 initialValueFactory is not a function。安全那种写法反而不会。
GraphRecursionError上面那个静默失效的 defaultValue 在循环图里后果被放大。因为 tries 永远是 NaN,ok 永远是 false,图就会一直 retry 下去。这时候你会看到:
GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition.
You can increase the limit by setting the "recursionLimit" config key.
LangGraph 默认的递归上限是 25 步,是个保护机制,防止图跑飞。所以:
recursionLimit 配置调大:await graph.invoke({ ... }, { recursionLimit: 100 });
thread_id 不一致:不是从头重跑,而是静默拿不到存档很多文章(包括我第一版的理解)会说「thread_id 不一致 = 新会话 = 从头执行」。实测下来不是这样:
中断 thread_id = "A",用 thread_id = "B" 去 resume
→ 返回 { actionSummary: '', userInput: '' }
showTransfer 没有重新执行,图也没有报错,你传进去的 resume 数据被静默丢弃了,拿回来的是一个空状态(如果那条线程本来有存档,就是它上次的最终状态)。
这比「从头重跑」更危险——从头重跑你一眼就看得出来,静默失败你根本发现不了。所以实践里一定这么写:
const config = { configurable: { thread_id: sessionId } }; // 抽成变量,两处共用
这个我在 demo 里专门打了日志验证:
第1次 invoke:
[waitConfirm 开始执行, 副作用发生了]
-> { __interrupt__: [ ... ] }
resume:
[waitConfirm 开始执行, 副作用发生了] ← 又来了一次
[interrupt 返回了, 继续往下走]
-> { log: '|after-interrupt', userInput: '确认' }
「副作用发生了」打印了两次——被中断的节点确实整个重跑了一遍,包括 interrupt() 之前的代码。
所以有一条铁律:
interrupt()之前的代码必须能安全地重复执行。 别在里面写扣款、发消息、写库这类有副作用的操作——第一次执行到interrupt()时虽然会挂起,但恢复时这段代码会重新跑。
正确的做法是把副作用放到 interrupt() 之后,或者放到一个独立的、不会重跑的节点里。
interrupt() 是正常的流程控制,不是抛错。别用 try/catch 去接它,也别当成失败处理——它抛出来的 __interrupt__ 是设计好的返回值,不是错误。
eval() 别用在用户输入上前面提过,这里再强调一次:demo 里用它算数学题很省事,但用户输入 + eval() = 任意代码执行漏洞,正式代码请换成安全的表达式解析器。
把这篇串成一条线:
Annotation 声明,reducer 决定怎么合并(记住是 default 不是 defaultValue),default 给初始值且必须是工厂函数。addConditionalEdges 做分支,条件边指回自己就是循环——这是 LangGraph 相比 LangChain 最本质的差别。checkpointer 决定状态存在哪,thread_id 决定状态属于谁。interrupt():图挂起,载荷通过 __interrupt__ 带出去给前端渲染确认卡片;恢复用 new Command({ resume }),同一个 thread_id,图就从停下的地方接着跑。interrupt() 的返回值就是 resume 传进来的数据,恢复后的处理逻辑直接写在它后面即可——但要注意被中断的节点会整个重跑,前面的代码不能有副作用。MemorySaver 只适合开发,生产要换 SQLite / Redis;好在业务代码不用动,只换 checkpointer 实例。最后记一句:
一个能自动跑的 Agent 已经不容易了,但真正让它能用于生产的,是「该停的时候停得住,该继续的时候接得上」。
中断与恢复不是锦上添花的功能,而是 Agent 敢碰真实业务的入场券。
本文的 5 个 demo 都在
langgraph-test/src下(basic-graph.mjs、conditional-routing.mjs、loop-retry.mjs、checkpointer-memory.mjs、graph-interrupt.mjs),装好依赖后node src/xxx.mjs直接就能跑。如果你也在做 Agent 落地,希望这篇帮你把「编排 → 状态 → 持久化 → 中断恢复」这条链路理清楚。有问题欢迎评论区交流 ?