Nodejs也能写Agent - 15.LangGraph篇 - State与Graph API
上一篇我们搭好了 LangGraph 的骨架:State / Node / Edge、compile() 铁律,以及「跟 LangChain 怎么分工」。但说句实话——如果你还不懂 State 更新是怎么合进去的,后面一写对话 Agent,messages 要么叠成重复灾难,要么被整份覆盖成一张白纸。
State 才是 LangGraph 的心脏。这一篇我们就把它掰开:StateSchema 怎么声明、reducer 管合并、MessagesValue 为什么不能用裸 concat 凑合,再把 addNode / addEdge / addSequence 这套 Graph API 摸熟。
老规矩,本文 API 均以官网最新文档核对过(Graph API overview、Use the graph API)。网上不少教程仍在教
Annotation.Root/MessagesAnnotation——那是 Legacy 写法,本篇主路径跟官网走StateSchema。

一、StateSchema:每个 key 是一条 channel
构图第一件事:定义 State。官网现在的主推是 StateSchema——每个字段对应一条 channel(状态通道),各自有独立的「收到更新后怎么合」规则。
import {
StateSchema,
MessagesValue,
ReducedValue,
UntrackedValue,
} from "@langchain/langgraph";
import * as z from "zod";
const State = new StateSchema({
// 对话历史:内置消息感知 reducer
messages: MessagesValue,
// 简单字段:Zod schema = last-value(新值直接覆盖)
status: z.string(),
userId: z.string(),
// 带默认值的 last-value
retryCount: z.number().default(0),
// 需要自定义合并逻辑:ReducedValue
llmCalls: new ReducedValue(z.number().default(0), {
reducer: (current, update) => current + update,
}),
// 临时缓存:默认不进 checkpoint(别拿它存对话!)
tempCache: new UntrackedValue(z.record(z.string(), z.unknown())),
});
type FullState = typeof State.State; // 完整状态
type StateUpdate = typeof State.Update; // 节点可返回的 Partial
四种字段速查:
| 字段类型 | 更新行为 | 典型用途 |
|---|---|---|
Zod(如 z.string()) |
Last-value:新值覆盖旧值 | userId、status、中间结果 |
ReducedValue |
自定义 (current, update) => next |
计数累加、列表追加 |
MessagesValue |
内置消息 reducer(append / 按 ID 替换 / 删除) | 几乎所有聊天 Agent |
UntrackedValue |
临时值,默认不持久化进 checkpoint | 一次性缓存;别存对话历史 |
TypeScript 里节点参数用
typeof State.State,或直接上官方的GraphNode<typeof State>,少踩类型坑。
二、Reducer:更新怎么合进黑板
节点不会(也不该)亲手改全局对象。它只返回一份 Partial Update;框架拿着这份更新,去找对应 channel 的 reducer,算出新值写回。
自定义 reducer 的心智模型:
reducer(current, update) => next
↑ ↑
黑板上已有值 节点这次交回来的值
最典型的累加:
llmCalls: new ReducedValue(z.number().default(0), {
reducer: (current, update) => current + update,
}),
// 节点返回 { llmCalls: 1 } → 0 + 1 = 1
// 再返回 { llmCalls: 1 } → 1 + 1 = 2
铁律再强调一遍:Node 只返回要改的字段。返回整份 State,碰上带 reducer 的 channel,等于把「更新值」理解成「全量替换输入」,轻则重复消息,重则逻辑全歪。
没配 reducer 的 Zod 字段呢?默认就是 覆盖:你返回 { status: "done" },旧的 "running" 直接被拍掉——这对 userId / status 是对的,对 messages 则是灾难。所以对话列表绝不能用裸 z.array(...) 糊弄过去。
三、MessagesValue:对话列表的正确姿势
聊天 Agent 里,messages 几乎永远是 State 的主角。官网给了现成的 MessagesValue,内部就是消息感知 reducer,帮你搞定:
- 新消息:追加到列表末尾
- 同 ID 消息:替换已有那条(HITL 改稿、流式落盘很常用)
RemoveMessage:按 ID 删掉指定消息- 短格式输入:也可以丢
{ role: "user", content: "hi" },框架会尽量归一成 Message 对象
节点写法极其克制——只返回新增的那几条:
import { GraphNode, StateSchema, MessagesValue } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
const State = new StateSchema({
messages: MessagesValue,
});
const agentNode: GraphNode<typeof State> = async (state) => {
// ... 调模型拿到 response
return { messages: [response] }; // 只交新增;MessagesValue 负责 append
// return { messages: [...state.messages, response] }; // 别这么干,容易重复
};
为啥别自己写个 concat 凑合?
老教程里常见这种手写 reducer:
reducer: (left, right) => left.concat(Array.isArray(right) ? right : [right])
追加是能跑。但人机协作、流式、按 ID 修正已有 AIMessage 时,裸 concat 不会替换同 ID,只会越积越多。MessagesValue 就是为这些场景准备的,新项目直接用它,别发明轮子。
旧世界对照(看懂就行,别当主路径)
你在旧博客 / 旧笔记里可能看到:
| Legacy | 现在等价 |
|---|---|
Annotation.Root({ messages: Annotation({ reducer: messagesStateReducer, default: () => [] }) }) |
messages: MessagesValue |
MessagesAnnotation / ...MessagesAnnotation.spec |
new StateSchema({ messages: MessagesValue, ...其它字段 }) |
手写 Annotation + 自定义 concat |
优先 MessagesValue;非消息字段用 ReducedValue |
官网对照表里
Annotation.Root明确标成 Legacy。还能用,但新代码请写StateSchema。
四、StateGraph API 速查
State 定义好了,就轮到拼图。
addNode:挂上干活的函数
.addNode("chat", chatNode)
- 名字必须图内唯一,后面
addEdge全靠这个字符串——typo 会在 compile 或运行时报复你 - 函数签名:
(state) => PartialUpdate,支持 async - 推荐类型:
const chatNode: GraphNode<typeof State> = async (state) => { ... }
addEdge:固定下一步
.addEdge(START, "chat") // 入口;也可用 "__start__"
.addEdge("chat", END) // 出口;也可用 "__end__"
.addEdge("a", "b") // a 完一定去 b
线性图:从 START 出发,最终都要能走到 END。
addSequence:线性多节点偷懒写法
一连串「A→B→C」可以一次加完。注意官方签名是 [名称, 函数] 元组数组,不是旧笔记里「名字数组 + 函数数组」那种双参数写法:
const graph = new StateGraph(State)
.addSequence([
["step1", step1Node],
["step2", step2Node],
["step3", step3Node],
])
.addEdge(START, "step1")
.addEdge("step3", END)
.compile();
等价于分别 addNode 三次,再加上 step1→step2→step3 的边。分支、回环还是老老实实 addEdge / 条件边(下一篇)。
五、完整示例:MessagesValue + ReducedValue
一个最小「聊天节点」:对话走 MessagesValue 追加,顺手用 ReducedValue 统计调了几次模型——跟官网 Quickstart 同款结构,模型换成我们系列的 ChatOllama:
import {
StateGraph,
StateSchema,
MessagesValue,
ReducedValue,
GraphNode,
START,
END,
} from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage } from "@langchain/core/messages";
import * as z from "zod";
const State = new StateSchema({
messages: MessagesValue,
llmCalls: new ReducedValue(z.number().default(0), {
reducer: (current, update) => current + update,
}),
});
const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const chatNode: GraphNode<typeof State> = async (state) => {
const response = await llm.invoke(state.messages);
return {
messages: [response], // MessagesValue → append
llmCalls: 1, // ReducedValue → 累加 1
};
};
const graph = new StateGraph(State)
.addNode("chat", chatNode)
.addEdge(START, "chat")
.addEdge("chat", END)
.compile();
const result = await graph.invoke({
messages: [new HumanMessage("什么是 reducer?用一句话解释。")],
});
console.log(result.llmCalls); // 1
console.log(result.messages.length); // 2:Human + AI
跑通之后你可以改成两节点线性流(比如 chat → format),用 addSequence 串起来练手——先把「只返回增量」练成肌肉记忆,再去碰条件边。
常见坑
- Node 返回全量
messages:应只返回[newMessage]。[...state.messages, newMessage]在 MessagesValue 下等于再 append 一整份历史,消息会爆炸式重复。 - 标识字段误用累加 reducer:
userId、status用 Zod last-value 即可。给字符串加concat式 reducer,你会得到奇奇怪怪的拼接结果。 ReducedValue忘记 default:首次更新时current可能是undefined,(a, b) => a + b直接 NaN 或报错。数字累加记得z.number().default(0)。- 被
Annotation.Root/MessagesAnnotation旧教程带偏:能跑,但是 Legacy;新代码统一StateSchema+MessagesValue。 addSequence抄错签名:正确是.addSequence([["a", fnA], ["b", fnB]]),不是.addSequence(["a","b"], [fnA, fnB])。- 节点名和边字符串不一致:
"chat"建成了,边却写成"Chat"——compile / 运行阶段找打灯笼。
更多推荐



所有评论(0)