State 与 Reducer:一本谁都能写的账本,怎么不打架
上一篇我们把 20 行 while 循环和 LangGraph 版逐行对了一遍,结尾留了个问题: 节点只
return { messages: [新消息] },为什么最后历史里是 7 条而不是 1 条?谁把它们拼起来的? 这一篇拆 L1 状态层。主角两个:State (账本长什么样)和 Reducer(新写的内容怎么记进账本)。
兄弟们,先回忆一下 while 循环里的状态是怎么管的:
typescript
const res = await model.invoke(messages);
messages.push(res);
// ...
messages.push(...results);
一个数组,谁要写就 push。简单粗暴,而且从来没出过问题。
为什么没出问题?因为整个循环只有一个人在写。
到了 LangGraph,情况变了。图里有多个节点,每个节点都要读状态、写状态,有些节点还会同时跑。这时候"一个数组谁都能 push"就不灵了。
LangGraph 的做法是把状态拆成两件事:
State 规定账本里有哪些栏目。Reducer 规定往每个栏目里记账的规矩。
本篇所有代码我都在 @langchain/langgraph 1.6.0 上实际跑过,输出原样贴出来。开整!🚀
第一章:节点不 push,节点只交"增量"
先看 LangGraph 里节点长什么样:
typescript
const State = new StateSchema({
logs: z.array(z.string()),
});
const graph = new StateGraph(State)
.addNode("readFile", () => ({ logs: ["读了 src/index.ts"] }))
.addNode("runTests", () => ({ logs: ["跑了测试"] }))
.addEdge(START, "readFile")
.addEdge("readFile", "runTests")
.addEdge("runTests", END)
.compile();
await graph.invoke({ logs: ["用户:修一下测试"] });
两个节点按顺序跑,各自返回一条日志。注意节点没碰 state.logs,只是 return 了一个对象。
这个对象叫更新(update)。节点的职责到此为止:我干完了,这是我要交的东西。至于怎么记进账本,节点不管。
猜猜最后 logs 是什么?
css
{ logs: [ '跑了测试' ] }
只剩一条。 用户那句话没了,readFile 那条也没了。😱
这不是 bug。字段上没写 reducer,LangGraph 就用默认规矩:后写的覆盖先写的。官方文档原话:默认 reducer 忽略旧值,直接用新值替换。
所以 readFile 写进去的 ["读了 src/index.ts"] 替换了初始输入,runTests 写进去的 ["跑了测试"] 又替换了它。
这条规矩对大多数字段是对的。比如 currentStep: "running"、retryCount: 3,你要的就是最新值。但对一个日志列表、一个消息历史,覆盖就是丢数据。
第二章:Reducer------记账的规矩
给字段配一个 reducer,就能把"覆盖"改成"追加":
typescript
const State = new StateSchema({
logs: new ReducedValue(z.array(z.string()).default(() => []), {
reducer: (left: string[], right: string[]) => left.concat(right),
}),
});
ReducedValue 接两个东西:
- 第一个参数是存什么:一个字符串数组,默认空数组
- 第二个参数里的
reducer是怎么合并 :旧值left和新值right拼起来
每次有节点交了更新,LangGraph 就调一次:
typescript
新值 = reducer(账本里的旧值, 节点交上来的更新);
把这个 State 换进第一章的图,结果变成:
css
{ logs: [ '用户:修一下测试', '读了 src/index.ts', '跑了测试' ] }
三条都在了。
这里有个容易混的点,单独拎出来:
Reducer 管的是"怎么合并",不是"存什么"。
存什么由 Zod schema 决定(z.array(z.string()))。同一个字符串数组,可以配"追加"的 reducer,可以配"去重追加"的 reducer,也可以配"只保留最后 10 条"的 reducer。账本栏目一样,记账规矩不同。
第三章:名场面------两个节点同时写
前两章都是顺序执行,覆盖最多丢数据。真正要命的是并行。
把第一章的图改一下,让 readFile 和 runTests 都从 START 出发,同时跑:
typescript
const graph = new StateGraph(State) // State 还是没有 reducer 的版本
.addNode("readFile", () => ({ logs: ["读了 src/index.ts"] }))
.addNode("runTests", () => ({ logs: ["跑了测试"] }))
.addEdge(START, "readFile")
.addEdge(START, "runTests")
.compile();
这就是第 6 篇名场面二"一轮并行发五路"的缩小版。跑一下:
sql
InvalidUpdateError: Invalid update for channel "logs" with values
[["读了 src/index.ts"],["跑了测试"]]: LastValue can only receive one value per step.
错误码是 INVALID_CONCURRENT_GRAPH_UPDATE。
LangGraph 没有让后写的覆盖先写的,而是直接拒绝执行。 👏
这个设计值得夸一句。如果它默默覆盖,你会得到一个"有时候少一条日志"的 bug,取决于哪个节点先跑完,复现全看运气。现在它在第一次并行写的时候就炸给你看,告诉你:这个字段同一步收到了两个值,我不知道该听谁的,你给我一个规矩。
报错里那个 LastValue 是底层 channel 的名字,意思是"只保留最后一个值"。channel 是第 3 篇 Pregel 的内容,这里先记住:没配 reducer 的字段,同一步只能有一个节点写。
换成带 reducer 的 State,再跑:
css
[ '用户:修一下测试', '读了 src/index.ts', '跑了测试' ]
不报错了。
合并顺序听谁的?
并行就有一个新问题:readFile 和 runTests 谁的日志排前面?
我故意让 runTests 慢 30 毫秒,连跑 5 次,结果都一样。再做一个更狠的实验:两个节点 alpha 和 zeta,换着让谁先 addNode、谁跑得慢:
less
alpha 先加且更慢: [ 'alpha', 'zeta' ]
zeta 先加且更慢: [ 'alpha', 'zeta' ]
顺序跟谁先跑完无关,跟谁先 addNode 也无关。 我翻了一下源码,LangGraph 在合并一步内的写入之前,会按任务路径排序(_applyWrites 里的 tasks.sort),在这种简单图里效果就是按节点名排。
这条规矩的意义是确定性 :同样的输入,每次跑出来的状态顺序一样。while 循环里 Promise.all 也能保证顺序(按数组下标),但如果你自己用回调往一个数组里 push,顺序就是谁先回来谁在前,每次都可能不同。
第四章:MessagesValue------专门给消息列表的 reducer
回到上一篇的问题。Agent 的 State 是这么写的:
typescript
const State = new StateSchema({ messages: MessagesValue });
MessagesValue 是什么?官方文档说得很清楚:它是 ReducedValue 的一个特例,预配置了一个专门处理消息的 reducer(messagesStateReducer)。
它比 left.concat(right) 多做了一件事:按消息 id 合并。
- 新 id:追加到末尾
- 已有 id:原地替换
- 收到
RemoveMessage:按 id 删除
我写了个三节点的图验证这三条:
typescript
const graph = new StateGraph(State)
.addNode("draft", () => ({
messages: [new AIMessage({ id: "a1", content: "初稿:原因是时区" })],
}))
.addNode("revise", () => ({
messages: [new AIMessage({ id: "a1", content: "终稿:原因是漏了 DST" })],
}))
.addNode("cleanup", () => ({
messages: [new RemoveMessage({ id: "h1" })],
}))
// draft -> revise -> cleanup 顺序连起来
输入一条 id 为 h1 的用户消息,用 streamMode: "values" 看每一步之后的完整状态:
css
[ 'h1:修一下测试' ]
[ 'h1:修一下测试', 'a1:初稿:原因是时区' ]
[ 'h1:修一下测试', 'a1:终稿:原因是漏了 DST' ]
[ 'a1:终稿:原因是漏了 DST' ]
逐行看:
- 初始输入
draft写入新 ida1,追加revise写入同一个 ida1,原地替换,消息条数没变cleanup用RemoveMessage删掉h1
那上一篇里那些消息根本没写 id 啊?
ini
human id=5c757343-7ea5-4709-b9dc-81e26f45b583
ai id=43098413-f0c5-4eb0-b702-1a1235e569e6
没 id 的消息,进账本时会被自动分配一个 UUID。 所以每条新消息 id 都不同,表现出来就是一直追加。这就是上一篇"节点只返回 1 条,历史里却有 7 条"的答案。
按 id 替换有什么用?
while 循环里,想改一条旧消息,你得自己 findIndex 再赋值。想删消息,得 filter。消息一多、节点一多,谁改了哪条就很难追了。
按 id 合并之后,改和删也变成了"交一个更新",跟追加走同一条路。这让几件事变简单了:
- 上下文压缩 :把前 20 条消息总结成 1 条,然后用
RemoveMessage删掉原来的 20 条。第 5 篇上下文工程"四把刀"里的"压",在这里就是一个节点 - 人工修改:审批时用户改了模型的工具参数,用同一个 id 写回去,覆盖原来那条
- 流式输出:模型边生成边更新同一条消息,最后的版本覆盖前面的半成品
一个坑:删不存在的 id 会直接炸
javascript
Attempting to delete a message with an ID that doesn't exist ('nope')
RemoveMessage 指向的 id 不在列表里,reducer 直接抛错,不会静默跳过。写压缩节点时,要删的 id 一定从当前 state.messages 里取,别从别处缓存的列表里拿。
第五章:两个反直觉的坑
坑一:返回空数组,清不掉累加字段
场景:一个 errors 字段配了追加 reducer,重试成功后想清空它。
typescript
// fail 节点返回 { errors: ["路径不存在"] }
// retry 节点想清空:
.addNode("retry", () => ({ errors: [] }))
结果:
less
返回 []: [ '路径不存在' ]
没清掉。 道理一想就通:["路径不存在"].concat([]) 还是 ["路径不存在"]。reducer 只会合并,不会清空。
官方给的解法是 Overwrite,用它包一层,这一次绕过 reducer 直接写入:
typescript
.addNode("retry", () => ({ errors: new Overwrite([]) }))
ini
返回 Overwrite([]): []
清掉了。其他节点照常返回普通数组,还是追加。
注意:官方文档特别说明,同一步里只能有一个节点 对同一个字段用 Overwrite,否则也会抛 InvalidUpdateError。两个节点同时说"以我为准",LangGraph 一样不知道听谁的。
坑二:写一个没定义的字段,不报错,直接丢掉
typescript
const State = new StateSchema({ logs: z.array(z.string()).default(() => []) });
.addNode("n", () => ({ logs: ["ok"], typo: 1 }))
结果:
css
{ logs: [ 'ok' ] }
typo 字段静默消失,没有任何报错或警告。
这个坑在你拼错字段名的时候最阴:节点里写 { mesages: [...] }(少个 s),图照常跑完,只是消息怎么都加不进去。用 TypeScript 的话,给节点加上 GraphNode<typeof State> 类型标注,编译期就能抓到;用 as any 糊过去的,运行时就只能靠你自己查了。
第六章:账本也分"内部"和"对外"
最后说一个工程上很实用的能力:输入输出 schema。
Agent 内部会产生很多中间状态:草稿、检索结果、计数器。调用方只关心问题和答案。LangGraph 允许你给图定三个 schema:
typescript
const Input = new StateSchema({ question: z.string() });
const Output = new StateSchema({ answer: z.string() });
const Overall = new StateSchema({
question: z.string(),
scratch: z.string(), // 中间草稿
answer: z.string(),
});
const graph = new StateGraph({ state: Overall, input: Input, output: Output })
.addNode("think", (s) => ({ scratch: `先查 ${s.question}` }))
.addNode("reply", (s) => ({ answer: `${s.scratch},结论:漏了 DST` }))
// think -> reply 顺序连起来
css
invoke: { answer: '先查 测试为啥挂,结论:漏了 DST' }
invoke 只返回 answer,scratch 被挡在里面。
但官方文档有个警告,我照搬过来:输入输出 schema 只过滤 invoke 的返回值,不过滤流式输出。 用 streamMode: "values" 流式拿状态时,内部字段一样会推出来。如果中间状态里有敏感信息,别指望 output schema 帮你挡住,流式输出要单独限制(文档给的办法是传 outputKeys)。
第七章:收拢一下
| 你想要 | 写法 | 并行时 |
|---|---|---|
| 只要最新值 | z.string() 等普通 Zod 字段 |
同一步两个节点写,抛 InvalidUpdateError |
| 累加 / 自定义合并 | new ReducedValue(schema, { reducer }) |
按任务路径排序后依次合并,结果稳定 |
| 消息历史 | MessagesValue |
按 id 追加、替换、删除 |
| 这一次强制覆盖 | 返回 new Overwrite(值) |
同一步只能有一个节点用 |
| 调用方只看部分字段 | new StateGraph({ state, input, output }) |
只影响 invoke 返回值,不影响流式 |
再把它跟 while 循环对照一下:
while 循环里,状态是一块谁都能改的内存。
LangGraph 里,状态是一本只能通过 reducer 记账的账本。节点不能直接改账本,只能交更新;更新怎么合并,由字段自己的 reducer 决定。
这看起来比 push 啰嗦,但它换来了三样 while 循环没有的东西:
- 并行安全:没规矩的字段被并行写,直接报错,不会悄悄丢数据
- 合并确定:同样的输入,状态顺序每次一样
- 改删统一:追加、替换、删除都是"交一个更新",可以被记录、被回放
第 3 条现在看着不起眼,到第 4 篇 Checkpointer 就要靠它了:每一步都是"旧状态 + 一批更新",存档只要记下这一步交了哪些更新就行。
尾声
while 循环里,状态是一块谁都能改的内存。 LangGraph 里,状态是一本只能通过 reducer 记账的账本。
留个问题:
你的 Agent 状态里,有没有哪个字段既需要累加、又偶尔需要清空?你现在是怎么处理的?
评论区聊聊。👇
觉得有用的话点赞收藏,下一篇继续往上拆。
下期预告 :这一篇的图,我都是用 addEdge 一条一条手连的,而且只用了顺序边和并行边。
但 Agent 的核心是判断 :模型要调工具就去 tools,不调就结束。上一篇那个 route 函数,到底是怎么接进图里的?START 和 END 是两个真节点吗?compile() 到底编译了什么?
下一篇,StateGraph:把 while + if 画成地铁图。
P.S. 版本:@langchain/langgraph 1.6.0,2026-11-03 实测。本篇提到的合并顺序是我读源码加实验得出的,官方文档没有把它写成保证。业务逻辑如果依赖并行写入的先后,最好在 reducer 里自己排序。
用 Python 的兄弟:概念一样。ReducedValue 对应 Annotated[类型, reducer函数],MessagesValue 对应 Annotated[list[AnyMessage], add_messages],文档在 docs.langchain.com/oss/python/langgraph/graph-api。