State 与 Reducer:一本谁都能写的账本,怎么不打架

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' ]

逐行看:

  1. 初始输入
  2. draft 写入新 id a1,追加
  3. revise 写入同一个 id a1,原地替换,消息条数没变
  4. 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 循环对照一下:

flowchart LR P1[while 循环 谁都能 push] --> A1[一个数组] N1[节点 A 交更新] --> R[Reducer] N2[节点 B 交更新] --> R R --> S[State] style A1 fill:#f8cecc,stroke:#b85450 style R fill:#fff2cc,stroke:#d6b656 style S fill:#d5e8d4,stroke:#82b366

while 循环里,状态是一块谁都能改的内存。

LangGraph 里,状态是一本只能通过 reducer 记账的账本。节点不能直接改账本,只能交更新;更新怎么合并,由字段自己的 reducer 决定。

这看起来比 push 啰嗦,但它换来了三样 while 循环没有的东西:

  1. 并行安全:没规矩的字段被并行写,直接报错,不会悄悄丢数据
  2. 合并确定:同样的输入,状态顺序每次一样
  3. 改删统一:追加、替换、删除都是"交一个更新",可以被记录、被回放

第 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。

相关推荐
superj21 分钟前
答题卡智能批改系统:选择题 OMR 秒出,主观题交给一次大模型调用
人工智能
海螺_AIHCC21 分钟前
AGENTS.md 别手写,让它自己从仓库里长出来
人工智能
用户241015057020825 分钟前
给 100 块的 AI 玩具写了一套英语启蒙 App:100 个词、看图选词、开口打分
人工智能
黄啊码38 分钟前
从 0 到 1 做 FDE:如何获客、成交、交付?百万级 AI 项目实战复盘
人工智能
玩美数据1 小时前
海外市场数据服务常用术语与名词解释权威指南
大数据·人工智能
Lansonli1 小时前
Claude Code 写 Shell 脚本翻车实录:一份避坑指南
人工智能
IT_陈寒1 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
东离与糖宝2 小时前
Spring AI 大模型容易混淆概念一次性讲透
人工智能
复杂网络与机器学习3 小时前
全双工 160 毫秒一格:李沐让模型不再「一问一答」
人工智能