从 LangChain 到 LangGraph:多 Agent 不是玄学,是 token 账本和干扰问题
摘要 :这篇文章回答一个具体问题------为什么会从 LangChain 走到 LangGraph,图编排、状态持久化、人工中断这些关键词落到代码上长什么样。核心判断:LangGraph 的本质是把工作流组织方式从线性链升级为网状图,三件套是 State + 节点 + 边。文中 5 个最小示例均不调用 LLM API,为静态阅读整理,运行未验证。
一个决策冲突:所有功能塞给一个 Agent,还是拆开?
先把冲突摆出来。做 Agent 应用时有一个看似省事的选择:单 Agent,把所有 tool 描述、所有功能的 prompt 全部写进 system prompt。代价在仓库笔记里写得很直白(readme.md#L7-12):每次调用全量携带,token 消耗高;无关信息干扰模型,准确率下降。
拆成多个 Agent 后,每个 Agent 只带最少 prompt------省 token、无干扰、准确率高;主 Agent 下发任务,子 Agent 还能并行处理、互相讨论纠错(readme.md#L17-31)。所以本文的第一个判断是:多 Agent 拆分不是架构玄学,是 token 开销与信息干扰的工程权衡。
而组织多个 Agent(以及任何带分支、循环、暂停的工作流),LangChain 的线性链不够用了。LangChain 与 LangGraph 的分工(readme.md#L33-38):LangChain = 线性工作流编排 + 基础模块(LLM API/loaders/splitter/embedding/vector store/output parser/memory);LangGraph = 网状工作流编排,负责"工作节点 + 组织方式"。两者是配合关系,不是替代关系。
下面按"遇到什么问题 → LangGraph 给什么答案"的顺序,过一遍 5 个最小示例。
问题 0:一张图最少长什么样?
答案:State + 节点 + 边三件套。这是全文最重要的一段代码(basic-graph.mjs):
js
import { Annotation, StateGraph, START, END } from '@langchain/langgraph'
// 1. 声明 State:reducer 决定"怎么更新",default 是初始值
const StateAnnotation = Annotation.Root({
text: Annotation({
reducer: (_prev, next) => next, // 直接用新值覆盖旧值
default: () => "",
}),
})
// 2. 节点就是普通函数,返回值是"对状态的更新"(部分状态)
const step1 = (state) => ({ text: `${state.text} -> step1` })
const step2 = (state) => ({ text: `${state.text} -> step2` })
// 3. 编排:加节点 → 连边 → 编译
const graph = new StateGraph(StateAnnotation)
.addNode("step1", step1)
.addNode("step2", step2)
.addEdge(START, "step1") // START 是特殊节点:图的入口
.addEdge("step1", "step2")
.addEdge("step2", END) // END 是特殊节点:图的出口
.compile()
// 可视化:生成 mermaid 流程图文本
const drawable = await graph.getGraphAsync()
console.log(drawable.drawMermaid({ withStyles: true }))
// 运行:状态沿节点链路依次更新
console.log(await graph.invoke({ text: "hello" }))
读这段代码抓三个点:
Annotation.Root({...})声明状态 schema (basic-graph.mjs#L9-16):字段级reducer: (_prev, next) => next决定状态怎么更新(这里新值覆盖旧值),default给初始值。- 节点即函数(basic-graph.mjs#L18-21):返回值不是完整状态,是"本节点的更新",交给 reducer 合并。
invoke({text:"hello"})沿START → step1 → step2 → END流转,最终 text 为hello -> step1 -> step2(reducer 逻辑推导,运行未验证)。
对应图 API 四要素心智模型(readme.md#L40-48):开始节点(初始状态)、工作节点(职责 + state)、边(连接)、结束节点(最终状态) 。drawMermaid({withStyles:true}) 可以导出 mermaid 文本,随时检查图的形状。
问题 1:分支怎么写?
答案:决策写在节点里,路由交给条件边。示例是"算式走计算、否则走聊天"(conditional-routing.mjs):
js
// router 节点:只负责"判断",把决策写进 state
const router = (state) => ({
route: /[+\-*]/.test(state.query) ? "math" : "chat",
})
const math = (state) => {
try {
// eval 把字符串当 JS 代码执行并返回结果(如 "1+2" → 3)
return { answer: String(eval(state.query)) }
} catch {
return { answer: "数学公式有误" } // 节点内兜底错误
}
}
const chat = (state) => ({ answer: `聊天模式:${state.query}` })
// 条件边:条件函数返回 key,映射表把 key 翻译成下一个节点
graph.addConditionalEdges("router", (state) => state.route, {
math: "math",
chat: "chat",
})
注意关注点分离:router 节点只做正则判断 /[+\-*]/.test(query),返回 {route:"math"|"chat"}(#L23-28);addConditionalEdges 的条件函数返回 key,映射表把 key 翻译成下一个节点(#L51)。eval 包在 try/catch 里,失败兜底"数学公式有误"(#L31-39);eval("1+2") 返回 3 可单独验证(test.mjs)。
问题 2:循环怎么写?
答案:没有专门的循环 API,条件边指向自己就是循环(loop-retry.mjs):
js
// attempt 节点:每次把 tries + 1,第 3 次算成功
const attempt = (state) => {
const tries = state.tries + 1
const ok = tries >= 3
return { tries, ok, message: `第${tries}次${ok ? "成功" : "失败"}` }
}
// 条件边自环:未达标回到自己(retry → attempt),达标去 END
graph.addConditionalEdges(
"attempt",
(state) => (state.ok ? "done" : "retry"),
{ retry: "attempt", done: END }
)
tries 计数 + ok = tries >= 3 判定在节点里(#L23-31),条件边在未达标时把路由指回 attempt 自身,达标走 END(#L36-39)。自环 + 计数 + 终止条件三要素,缺终止条件就是死循环。
问题 3:第二次调用怎么记得上一次?
答案:compile({checkpointer}) + thread_id(checkpointer-memory.mjs):
js
import { MemorySaver } from '@langchain/langgraph'
const StateAnnotation = Annotation.Root({
visitCount: Annotation({
reducer: (_prev, next) => next,
default: () => 0,
}),
})
const visit = (state) => ({ visitCount: state.visitCount + 1 })
const checkpointer = new MemorySaver() // 内存检查点
const graph = new StateGraph(StateAnnotation)
.addNode("visit", visit)
.addEdge(START, "visit")
.addEdge("visit", END)
.compile({ checkpointer }) // 编译时挂上
const config = { configurable: { thread_id: "用户_小张" } }
await graph.invoke({}, config) // 第 1 次:visitCount = 1
await graph.invoke({}, config) // 第 2 次:基于上次状态 = 2
// 换一个 thread_id,状态从初始值重新开始
await graph.invoke({}, { configurable: { thread_id: "用户_小李" } }) // = 1
关键行为(#L36-45):同一 thread_id 连续两次 invoke,第 1 次 visitCount = 1,第 2 次基于上次状态累加为 2;换 thread_id 从初始值重新计数,会话相互隔离。MemorySaver 只存内存,中断/暂停/失败后可继续;真正持久化按材料指引可换 sqlite、redis(readme.md#L56-63)。
问题 4:高危操作执行前怎么让人确认?
答案:interrupt() 暂停 + Command({resume}) 恢复。场景是转账确认------图执行"向张三转账 $100"前停下来等人工审批(graph-interrupt.mjs):
js
const showTransfer = (state) => ({
actionSummary: "向张三转账$100", // 高危操作的摘要,供人审阅
})
const waitConfirm = (state) => {
const text = interrupt({ // 图在这里暂停,payload 抛给调用方
hint: "中断里输入[确认]或者备注后回车,图才会继续执行",
actionSummary: state.actionSummary,
})
return { useInput: String(text) } // resume 后从这里继续
}
const graph = new StateGraph(StateAnnotation)
.addNode("showTransfer", showTransfer)
.addNode("waitConfirm", waitConfirm)
.addEdge(START, "showTransfer")
.addEdge("showTransfer", "waitConfirm")
.addEdge("waitConfirm", END)
.compile({ checkpointer: new MemorySaver() }) // interrupt 必须有 checkpointer
const config = { configurable: { thread_id: "interrupt-demo" } }
// 第一次 invoke:跑到 waitConfirm 暂停
const paused = await graph.invoke({}, config)
console.log("待你确认", paused.__interrupt__?.[0]?.value)
// ......用 node:readline/promises 读取命令行人工输入(略)
// 同一 thread_id + Command resume:从暂停点继续
const done = await graph.invoke(new Command({ resume: line }), config)
console.log("done", done)
闭环四步:节点内 interrupt({hint, actionSummary}) 暂停并抛出 payload(#L27-33)→ 调用方从 paused.__interrupt__?.[0]?.value 读中断信息(#L55-56)→ 示例用 node:readline/promises 读命令行输入模拟人工确认(#L10、L59-66)→ new Command({resume: line}) 配合同一个 thread_id 再次 invoke,从暂停点继续(#L68)。这张图 drawMermaid 导出的形状:__start__ → showTransfer → waitConfirm → __end__(g.md)。一个推导判断:interrupt 依赖 checkpointer,暂停后状态无处保存就无法恢复。
收藏资产:机制---写法对照表 + 迁移自检清单
机制对照表(对应上面 5 个示例):
| 机制 | 解决什么 | 关键写法 | 出处 |
|---|---|---|---|
| State | 状态怎么声明与合并 | Annotation.Root + 字段级 reducer/default |
basic-graph.mjs#L9-16 |
| 条件边 | 分支路由 | addConditionalEdges(节点, 条件函数, 映射表) |
conditional-routing.mjs#L51 |
| 条件边自环 | 循环重试 | 条件函数未达标时指回自身 + 终止条件 | loop-retry.mjs#L36-39 |
| checkpointer + thread_id | 多会话持久化与隔离 | compile({checkpointer}) + configurable.thread_id |
checkpointer-memory.mjs#L36-45 |
| interrupt + Command | 高危操作人工确认 | interrupt(payload) → __interrupt__[0].value → new Command({resume}) |
graph-interrupt.mjs |
迁移自检清单(把 LangChain 链改造成图时逐项过):
- State 每个字段都有 reducer 和 default?
- 节点返回"部分状态"而非整个 state?
- 条件函数返回值与映射表 key 一一对应?
- 自环有终止条件,不会死循环?
- 需要 remember 的图挂了 checkpointer,invoke 带 thread_id?
- interrupt 恢复用同一 thread_id +
new Command({resume})?
结尾:一个可迁移的判断
编排方式从线性升级到网状,不改变"节点是函数、状态是数据"的本质 ------先写最小图建立心智模型,再叠加分支、循环、持久化、中断四个机制。本文 5 个示例静态阅读整理、运行未验证,且刻意不调用 LLM API 以聚焦图机制。可以立即执行的一步:跑通 basic-graph,把 drawMermaid 的输出贴进任意 mermaid 渲染器,对照本文核对每张图的形状。
核验说明:平台规则基线 last_verified 2026-07-13;文中技术事实来自本地材料,运行结果未验证。