LangGraph.js 核心机制拆解:从状态管理到完整数据分析 Agent 实战
你可能已经用 LangChain 串过几个 LLM 调用,觉得"Chain 不就是管道嘛,有啥难的"。但试着让 Agent 在多个工具之间来回跳、根据中间结果走不同分支、还要在关键节点停下来等用户确认------Chain 那套线性管道就撑不住了。LangGraph.js 要解决的正是这个问题:把 Agent 的执行流程建模成一张有向图,节点是动作,边是条件,状态在图上流转。
我在一个内部数据分析平台上用 LangGraph.js(v0.2.x)搭了一套 Agent 系统。这篇文章以一个数据分析 Agent 为贯穿示例------它能查询数据库、生成图表、在敏感操作前等待人工确认------从状态定义开始,一步步拆到条件路由、人机交互断点,最后串成一个完整可运行的图。
StateGraph 的核心机制:注解状态 + 消息归约
状态定义:不只是存数据
状态通过 Annotation 定义,每个字段可以指定一个 reducer 函数来控制更新方式。这是 LangGraph.js 状态管理的核心------每个节点返回的不是完整的新状态,而是一个增量更新,框架用 reducer 把增量合并进当前状态。
typescript
import { Annotation, MessagesAnnotation } from "@langchain/langgraph";
const AgentState = Annotation.Root({
...MessagesAnnotation.spec,
currentTool: Annotation<string>({
reducer: (_, next) => next, // 简单覆盖:新值替换旧值
default: () => "",
}),
toolCallCount: Annotation<number>({
reducer: (prev, next) => prev + next, // 累加器模式
default: () => 0,
}),
});
这段定义里最 MessagesAnnotation。
我第一次用的时候没注意这个细节,自定义了一个 messages 字段但没用 MessagesAnnotation,结果每个节点的消息都在互相覆盖------最后翻了源码才发现,MessagesAnnotation 自带的归约器会根据消息 ID 做 upsert,这才是它跟普通数组字段的本质区别。
为什么用 reducer 而不是直接改状态
直接在节点里 state.messages.push(...) 可以吗?不行。LangGraph.js 的状态是不可变的。每个节点拿到的是当前状态的快照,返回的是需要变更的字段,框架负责用 reducer 合并出新状态。这样做有三个好处:
- 状态变更可追溯------每一步的输入输出都有快照
- 支持状态持久化和恢复------暂停后可以从任意节点重启
- 并行节点不会产生竞态------各自基于同一快照计算
跟 Redux 的思路如出一辙:单向数据流 + 纯函数更新。
设计权衡:LangGraph.js 不是银弹
跟纯代码编排对比
如果你的 Agent 逻辑很简单------一个工具、一次调用、没有分支------直接写个 async 函数串几个 await 就行,引入 LangGraph.js 属于过度设计。LangGraph.js 的价值在复杂度拐点之后:| 复杂度 | 纯代码 | LangGraph.js | |--------|--------|-------------| | 单工具线性调用 | 简单直接 | 杀鸡用牛刀 | | 2-3 个工具 + 条件分支 | 开始变乱 | 刚好合适 | | 多工具 + 循环 + 人机交互 | 维护噩梦 | 优势明显 | | 多 Agent 协作 | 几乎不可能 | 原生支持 |
边界与避坑指南
状态膨胀问题
消息列表会随着对话轮次不断增长。一个 20 轮的对话,messages 数组可能有 50+ 条消息,每条都包含完整的工具输入输出。这不仅增加 LLM 调用的 token 消耗(gpt-4o 按 token 计费,一次对话的成本可能翻好几倍),还会拖慢 checkpointer 的序列化。
应对策略是在图里加一个"消息压缩"节点------消息超过阈值时,用一次 LLM 调用把历史消息总结成摘要,再用 RemoveMessage 清理掉旧消息:
typescript
import { RemoveMessage } from "@langchain/langgraph";
async function compressMessages(state: typeof AgentState.State) {
if (state.messages.length < 20) return {};
const summary = await model.invoke([
new SystemMessage("请用 2-3 句话总结以下对话的关键信息和已获得的数据"),
...state.messages.slice(0, -2),
]);
const messagesToRemove = state.messages.slice(1, -2)
.map(m => new RemoveMessage({ id: m.id }));
return { messages: [...messagesToRemove, summary] };
}
注意 slice(1, -2) 的范围:第一条(通常是 SystemMessage)和最后两条(最近的上下文)保留不动,只压缩中间部分。在上面的完整图构建中,这个节点被放在入口和 agent 之间(.addEdge("__start__", "compress") → .addEdge("compress", "agent")),每次新消息进来时先检查是否需要压缩,再进入 ReAct 循环。
并行工具调用的顺序问题
LLM 有时会在一次响应中发起多个 tool_calls,而 ToolNode 默认是并行执行这些工具的。
这不是 LangGraph.js 的锅------是 LLM 自己搞错了依赖关系。解决方法是在 system prompt 里明确告诉模型"如果工具之间有依赖,分多次调用,不要一次性全部发起"(我们在上面的 callModel 节点中已经加了这条指令)。
浏览器环境的限制
LangGraph.js 虽然是 JS/TS 库,但完整功能更适合 Node.js 服务端。
正确的架构是后端跑 LangGraph.js,前端通过 API/SSE/WebSocket 通信。LangGraph 官方提供了 @langchain/langgraph-sdk,封装了客户端到 LangGraph 服务端的通信协议,可以省不少对接工作。
从状态机到通用 Agent 架构
退一步看,LangGraph.js 解决的不只是"怎么编排 LLM 调用",而是一个更底层的问题:**如何对非确定性的、需要外部交互的、可能长时间运行的流程结构化管理一下。这个问题在前端领域并不新鲜------XState 做 UI 状态机、Redux-Saga 做异步流程、React Suspense 也在解决"异步流程的暂停和恢复"。什么时候该用它?如果你的场景涉及以下任何一条,LangGraph.js 值得认真评估:
- Agent 需要在多个工具之间动态决策
- 执行流程需要人工审批节点
- 对话状态需要跨请求持久化
- 你想可视化 Agent 的执行过程(后端跑图,前端通过 SDK 获取状态流转事件来渲染)
反过来,如果只是"调一次 LLM + 格式化输出",一个简单的 API 调用就够了,别为了用而用。
写这篇文章的过程中我反复翻了 @langchain/langgraph 的 graph.ts 和 pregel/index.ts。LangGraph.js 的执行引擎底层叫 Pregel(跟 Google 那个图计算框架同名),用的是基于超步(superstep)的同步执行模型。每个节点在一个超步内执行,所有节点执行完后统一推进到下一个超步。这也就解释了为什么并行节点不会产生竞态------它们在同一个超步内基于相同的状态快照计算,结果在超步边界才合并。
理解了这层,你就能预测 LangGraph.js 在各种边界情况下的行为,而不是靠试。翻源码的价值就在这里------从"知道怎么用"到"知道为什么这样"。