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