核心结论:LangGraph 的价值不只是"能画图",而是把 Agent 从一段不可控的长 Prompt,拆成可观察、可恢复、可路由、可中断的状态机。多 Agent 架构真正解决的也不是"角色更多",而是把复杂任务里的上下文、工具权限、执行路径和人工确认边界拆清楚。
很多人第一次写 Agent,会自然写成一个"大而全"的助手:一个 system prompt 里塞进所有规则,绑定所有工具,然后把用户问题丢进去让模型自己判断。小 demo 能跑,但一到真实业务就会出现几个问题:
- 工具越来越多,模型容易选错工具;
- Prompt 越来越长,token 成本和干扰都上升;
- 任务需要重试、分支、人工确认时,只靠一次 LLM 调用很难控制;
- 出错后不知道卡在哪一步,也不好恢复;
- 多个专业任务混在一个 Agent 里,职责边界模糊。
LangGraph 适合解决的正是这类问题:它不是替代 LangChain 组件,而是在组件之上提供一层图编排引擎。节点负责执行动作,边负责控制流,状态负责在节点之间传递上下文。
从链式调用到图式编排
如果只是"输入 -> Prompt -> 模型 -> 输出",LCEL 或普通 LangChain Chain 已经够用。但 Agent 系统经常不是线性的。
比如库存助手可能需要:
- 判断用户是不是在查库存;
- 如果是,调用库存工具;
- 工具返回 JSON;
- 把 JSON 转成人能看的回答;
- 如果工具结果异常,进入兜底;
- 如果涉及高风险操作,中断等待人工确认;
- 如果一次任务包含多个子任务,交给不同 Agent。
这已经不是一条链,而是一张有分支、有循环、有状态、有恢复点的图。
LangGraph 的基本抽象很简单:
- State:整张图共享的状态;
- Node:读取 state,返回部分 state 更新;
- Edge:决定下一个节点;
- Checkpointer:保存状态,支持恢复;
- Interrupt:在图中暂停,等待外部输入;
- Prebuilt:封装常见 Agent loop、工具节点、多 Agent supervisor。
最小图:状态比函数返回值更重要
当前 demo 里的 basic-graph.mjs 展示了 LangGraph 最小结构:
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" })
这段代码真正值得关注的不是 step1 和 step2,而是 Annotation.Root。
Annotation 定义的是图的状态协议。每个字段都可以指定:
default:没有输入时的默认值;reducer:多个节点更新同一字段时怎么合并。
这里的 reducer 是 (_prev, next) => next,含义是"后一次更新覆盖前一次"。这适合 route、answer、message 这类单值字段。
但如果是消息流,通常不能覆盖,而要追加。LangGraph 内置的 MessagesAnnotation 就是为消息数组准备的状态结构。
工程上要记住一句话:**LangGraph 里的节点不是互相直接调用,而是通过 state 间接通信。**这也是它能恢复、中断、观察执行过程的基础。
条件路由:把"模型自己想"变成显式控制流
conditional-routing.mjs 演示了分支:
js
const router = state => {
const isMath = /[+\-*/]/.test(state.query)
return { route: isMath ? "math" : "chat" }
}
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()
这里的关键 API 是 addConditionalEdges。它把"下一步去哪"从节点逻辑里拆出来,变成图结构的一部分。
这在 Agent 工程里很重要。很多系统的问题不是模型不会回答,而是你把所有控制权都交给了模型。比如:
- 是否需要工具调用;
- 工具调用失败是否重试;
- 是否需要人工确认;
- 是否交给另一个 Agent;
- 是否结束任务。
这些决策不一定都应该由 LLM 自由发挥。能用规则明确表达的,就应该落到图的边上;必须依赖语义理解的,再交给模型。
顺带一提,demo 里的 mathNode 使用了 eval,这只适合教学演示。真实系统里不能把用户输入直接交给 eval,应该换成受限表达式解析器或后端计算服务。
循环和重试:本质还是条件边
loop-retry.mjs 里的重试图很典型:
js
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()
循环不是特殊能力,而是条件边指回之前的节点。
真实业务里,重试通常要更克制:
- 设置最大重试次数;
- 区分可重试错误和不可重试错误;
- 对外部 API 加退避策略;
- 把失败原因写入 state;
- 避免让 LLM 无限修复自己的输出。
LangGraph 提供的是控制流能力,不会替你决定重试策略。策略仍然是工程设计的一部分。
Checkpointer:Agent 记忆不只是聊天历史
checkpointer-memory.mjs 用 MemorySaver 保存会话状态:
js
const checkpointer = new MemorySaver()
const app = graph.compile({ checkpointer })
const user1 = { configurable: { thread_id: "用户-小张" } }
const user2 = { configurable: { thread_id: "用户-小李" } }
await app.invoke({}, user1)
await app.invoke({}, user1)
await app.invoke({}, user2)
这里最关键的是 thread_id。同一张图在不同 thread 下有不同状态。源码里的 recordVisit 每执行一次就把 visitCount + 1,所以同一个用户会延续上次状态,不同用户互不影响。
这和"把聊天记录塞回 prompt"不是一回事。Checkpointer 保存的是图执行状态,可以包括:
- 当前执行到哪一步;
- 消息历史;
- 工具调用结果;
- 重试次数;
- 人工确认前的上下文;
- 多 Agent 的中间交接信息。
demo 用的是内存存储,适合本地演示。生产环境通常要换成 Redis、SQLite、Postgres 或其他持久化方案。否则进程重启后状态就没了。
Interrupt:高风险动作必须能停下来
graph-interrupt.mjs 模拟了一个转账确认流程:
js
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() })
第一次执行时,图会停在 interrupt:
js
const paused = await graph.invoke({}, config)
console.log(paused.__interrupt__?.[0]?.value)
用户输入后,再用 Command({ resume }) 恢复:
js
const done = await graph.invoke(new Command({ resume: line }), config)
这类能力在真实 Agent 产品里非常关键。凡是涉及外部副作用的动作,比如转账、下单、发邮件、删数据、提交工单,都不应该只靠模型自己决定。
比较合理的边界是:
- 查询类操作可以自动执行;
- 写入类操作需要权限控制;
- 高风险写入必须 interrupt;
- interrupt 前要把动作摘要写清楚;
- resume 后也要记录确认人、确认内容和时间。
ToolNode:工具调用不是结束,业务表达才是结束
当前源码里的 prebuilt-tool-node.mjs 比普通 demo 多了一步 summarize,这是一个很好的工程处理。
工具定义如下:
js
const getProductStock = tool(
async ({ sku }) => getProductBySku(sku),
{
name: "get_product_stock",
description: "按 SKU 查商品名与库存,SKU 如 SKU-001。",
schema: z.object({
sku: z.string().describe("商品 SKU"),
}),
}
)
模型节点负责判断是否调用工具:
js
async function agent(state) {
const response = await llm.invoke([
new SystemMessage(
"你是库存查询助手。遇到 SKU 库存问题时必须先调用 get_product_stock,且只调用一次。不要编造库存。"
),
...state.messages,
])
return { messages: [response] }
}
然后用 LangGraph 内置的 ToolNode 执行工具,用 toolsCondition 判断是否进入工具节点:
js
const toolNode = new ToolNode(tools)
const graph = new StateGraph(MessagesAnnotation)
.addNode("agent", agent)
.addNode("tools", toolNode)
.addNode("summarize", summarizeToolResult)
.addEdge(START, "agent")
.addConditionalEdges("agent", toolsCondition, ["tools", END])
.addEdge("tools", "summarize")
.addEdge("summarize", END)
.compile()
重点在 summarizeToolResult:
js
function summarizeToolResult(state) {
const lastMessage = state.messages.at(-1)
const rawContent = String(lastMessage?.content ?? "")
try {
const data = JSON.parse(rawContent)
const content = data.found
? `商品 ${data.name}(${data.sku})当前库存还有 ${data.stock} 件。`
: `没有查到 SKU ${data.sku} 对应的商品库存。`
return { messages: [new AIMessage(content)] }
} catch {
return {
messages: [new AIMessage(`工具返回了无法解析的结果:${rawContent}`)],
}
}
}
为什么不让 LLM 拿到工具结果后自己总结?因为库存查询这种场景不需要模型自由发挥。工具已经返回结构化数据,最终回答可以由确定性代码生成。这样可以减少幻觉,也能保证格式稳定。
这是一个很实用的判断:**LLM 适合做语义判断,不一定适合做所有结果格式化。**能确定性处理的部分,尽量用代码处理。
createAgent:常用 Agent Loop 的封装
prebuilt-agent.mjs 使用了更高层的 createAgent:
js
const agent = createAgent({
model,
tools: [getProductStock],
systemPrompt:
"你是仓库助手。问库存时必须调用 get_product_stock(模拟数据),禁止编造。",
checkpointer: new MemorySaver(),
})
const result = await agent.invoke(
{ messages: [new HumanMessage("SKU-002 还剩多少库存?")] },
{ configurable: { thread_id: "demo-thread" } }
)
createAgent 适合常规工具型 Agent:模型判断工具、调用工具、再继续生成回答。它内部仍然是图,只是帮你封装了常见节点和边。
默认建议是:
- 简单工具助手:优先用
createAgent; - 需要插入确定性业务节点:自己写
StateGraph; - 需要人工确认、复杂路由、重试恢复:自己写图;
- 多个专业 Agent 协同:用 supervisor 模式。
不要为了"显得架构复杂"而手写所有图。工程里默认应该选择足够简单但可扩展的方案。
多 Agent:不是越多越好,而是职责要窄
multi-agent-supervisor.mjs 是一个 supervisor-worker 模式:
weather_agent只查天气;trivia_agent只查城市小知识;- supervisor 只负责任务分派和最终汇总。
子 Agent 的定义很清晰:
js
const weatherAgent = createAgent({
name: "weather_agent",
description: "专门查天气",
model,
tools: [lookupWeatherTool],
systemPrompt:
"你只处理天气。用户提到城市时,必须调用 lookup_weather,且只调用一次。拿到工具结果后,用中文简短说明天气、温度和空气质量,然后停止。",
})
另一个 Agent 只处理城市小知识:
js
const triviaAgent = createAgent({
name: "trivia_agent",
description: "专门讲与城市相关的小知识;必须调用 lookup_city_trivia。",
model,
tools: [lookupCityTriviaTool],
systemPrompt:
"你只讲城市小知识。必须先调用 lookup_city_trivia,且只调用一次。拿到工具结果后,用中文一句话转述,不要编造工具里没有的内容,然后停止。",
})
Supervisor 的提示词也做了边界约束:
js
const workflow = createSupervisor({
agents: [weatherAgent.graph, triviaAgent.graph],
llm: model,
outputMode: "last_message",
includeAgentName: "inline",
prompt: `你是调度员,只负责分派子任务和在最后汇总答案,不要自己编造天气或城市小知识。
- 问天气、气温、下不下雨、空气质量 → 用 weather_agent
- 问小知识、名胜、历史、一句介绍 → 用 trivia_agent
执行规则:
1. 一次只交给一个 agent。
2. 如果用户同时要求天气和城市小知识,先调用 weather_agent,再调用 trivia_agent。
3. 某个子任务已经得到结果后,不要再次把同一子任务交给同一个 agent。
4. 当所有要求的信息都已拿到后,直接输出最终中文答案并结束,不要继续 handoff。
`,
})
这里有两个配置值得注意:
outputMode: "last_message":最终状态里更关注最后回答,避免输出过多中间消息;includeAgentName: "inline":把 Agent 名称放进消息里,便于 supervisor 理解是谁返回的结果。
多 Agent 的收益来自职责隔离,而不是 Agent 数量。拆分的标准应该是:
- 工具集是否明显不同;
- Prompt 规则是否互相干扰;
- 子任务是否可以独立完成;
- 是否需要不同权限;
- 是否需要不同模型或成本策略;
- 是否需要并行或分阶段处理。
如果只是两个很相似的问答能力,强行拆成两个 Agent 反而会增加调度成本和失败点。
stream:观察执行路径,而不是盲猜 Agent 在干什么
源码里通过 streamMode: ["updates", "values"] 同时观察增量和全量状态:
js
const nodePath = []
let finalState = null
const stream = await app.stream(input, { streamMode: ["updates", "values"] })
for await (const event of stream) {
const [mode, payload] = event
if (mode === "updates" && payload && typeof payload === "object") {
nodePath.push(...Object.keys(payload))
} else if (mode === "values") {
finalState = payload
}
}
console.log("路径:", nodePath.join(" → "))
const last = finalState?.messages?.at(-1)
console.log(last?.content ?? finalState?.messages)
updates 更适合看"哪个节点刚刚更新了状态",values 更适合拿最终完整状态。
调试 Agent 系统时,不要只看最终回答。至少要看三件事:
- 实际走过哪些节点;
- 调用了哪些工具;
- 每个 Agent 是否越权处理了不属于自己的任务。
这也是图编排相比黑盒 Agent 的优势:你可以把执行路径暴露出来,用日志、断点、可视化图和状态快照定位问题。
方案对比:什么时候用哪种编排方式
| 方案 | 适合场景 | 优点 | 代价 |
|---|---|---|---|
| 普通函数调用 | 固定流程、无 LLM 决策 | 简单、稳定、成本低 | 不适合语义路由 |
| LCEL / Chain | 线性 LLM 流程 | 表达简洁,适合管道 | 分支、循环、恢复较弱 |
createAgent |
常规工具调用助手 | 少写样板代码 | 复杂控制流不够透明 |
手写 StateGraph |
有分支、循环、中断、恢复 | 控制力强,可观察 | 需要设计状态结构 |
| Supervisor 多 Agent | 多职责、多工具、多阶段任务 | 上下文隔离,扩展性好 | 调度成本更高,链路更复杂 |
我的默认建议是:先用最小可控方案,不要一上来就多 Agent。
- 一个工具型问答助手,用
createAgent; - 需要确定性节点或人工确认,改成
StateGraph; - 当 Prompt 和工具集开始互相干扰,再拆多 Agent;
- 当子任务需要并行、独立权限或专业角色,再引入 supervisor。
常见误区
误区一:多 Agent 一定比单 Agent 强。
不一定。多 Agent 会引入调度、通信、状态膨胀和更多 LLM 调用。只有当职责隔离带来的收益大于这些成本时,才值得拆。
误区二:所有路由都交给 LLM。
能用规则判断的路由,优先用代码。比如重试次数、工具返回状态、权限级别,这些不需要模型决定。
误区三:工具返回结果必须再让 LLM 总结。
库存、价格、订单状态这类结构化结果,很多时候用代码格式化更稳。
误区四:MemorySaver 就是生产级记忆。
MemorySaver 只是内存存储。生产环境要考虑持久化、过期策略、并发、状态迁移和隐私清理。
误区五:interrupt 只是交互效果。
interrupt 本质是风险控制边界。它应该出现在高风险副作用之前,而不是事后补救。
工程落地建议
设计 LangGraph 应用时,可以按这个顺序思考:
- 先定义状态,而不是先写节点。状态字段决定系统能否恢复和调试。
- 把确定性逻辑写成普通节点,把语义判断留给 LLM。
- 给每个工具写清楚 schema 和 description,减少模型误调用。
- 工具输出尽量结构化,最好是 JSON。
- 对高风险动作加 interrupt,并保留 action summary。
- 对循环加最大次数,避免无限 Agent loop。
- 对多 Agent 明确职责,不让 worker 处理 supervisor 的事情。
- 用 stream 或日志记录节点路径,别只保存最终回答。
- 本地 demo 可以用 MemorySaver,生产要换持久化 checkpointer。
- 能用单 Agent 解决时,不要急着拆多 Agent。
总结
LangGraph 最值得学的不是 API 名字,而是一种 Agent 工程思维:把不可控的长上下文推理,拆成可控的状态流转。
在这个 demo 里,基础图说明了 state 如何在节点间传递;条件边说明了路由和循环怎么表达;MemorySaver 说明了状态如何跨调用保存;interrupt 说明了高风险流程如何暂停恢复;ToolNode 和 createAgent 说明了常见工具型 Agent loop 怎么封装;supervisor 则展示了多 Agent 如何按职责协作。
真正的工程判断在于:什么时候让模型判断,什么时候用代码控制;什么时候单 Agent 足够,什么时候必须拆多 Agent;什么时候追求灵活,什么时候必须稳定。
LangGraph 的定位可以概括成一句话:它不是让 Agent 更"神秘",而是让 Agent 更像一个可以调试、可以恢复、可以治理的工程系统。