1. 状态不是“存下来”就够了,还要知道怎么合并
上一篇我们把图跑起来了,也知道了节点返回什么、边怎么连。
但只要图稍微复杂一点,一个问题马上就会冒出来:
同一个状态字段,被不同节点更新时,到底该怎么处理?
最简单的情况,是一个节点接着一个节点顺序执行。
比如第一个节点写入 draft,第二个节点再改 draft。这种时候,后面的值覆盖前面的值,通常没什么问题。
真正麻烦的是下面两种场景:
- 你不是想覆盖,而是想追加
比如消息历史、搜索结果列表、日志列表 - 有多个节点都在写同一个字段
比如并行收集资料,最后都把结果写到results
如果没有一套明确的合并规则,状态就会变得很不稳定。
有些值会被覆盖,有些值会丢,有些值又应该累加却没累加。
这一篇要讲的就是这件事:Reducer 本质上是字段级的合并策略。
2. 先看默认行为:大多数普通字段是覆盖
先从最容易看清的问题开始。
01import { StateGraph, StateSchema, START, END } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'03import { z } from 'zod'0405const State = new StateSchema({06draft: z.string().default(''),07retryCount: z.number().default(0),08})0910const writeDraft: GraphNode<typeof State> = () => {11return {12draft: '第一版草稿',13}14}1516const rewriteDraft: GraphNode<typeof State> = () => {17return {18draft: '第二版草稿',19retryCount: 1,20}21}2223const graph = new StateGraph(State)24.addNode('writeDraft', writeDraft)25.addNode('rewriteDraft', rewriteDraft)26.addEdge(START, 'writeDraft')27.addEdge('writeDraft', 'rewriteDraft')28.addEdge('rewriteDraft', END)29.compile()3031const result = await graph.invoke({32draft: '',33retryCount: 0,34})3536console.log(result)37// {38// draft: '第二版草稿',39// retryCount: 140// }
这里的 draft 很好理解。
第一个节点写了“第一版草稿”,第二个节点又写了“第二版草稿”,最后保留下来的是后者。
这不是 bug。
对很多普通字段来说,覆盖本来就是合理的默认行为。
所以这一篇最重要的第一句话,不是“所有字段都要 reducer”,而是:
只有当默认覆盖不符合你的业务语义时,才需要额外声明合并策略。
3. Reducer 到底是什么
Reducer 可以先不想得太大。
在 LangGraph 里,它不是“整棵状态树的总 reducer”,而是:
某一个字段在收到新值时,应该怎么把旧值和新值合在一起。
可以先把它理解成这样一个函数:
1type Reducer<T> = (current: T, update: T) => T
它只关心两件事:
- 这个字段当前是什么
- 这个字段这一步新来了什么
然后返回合并后的新值。
最简单的例子就是数组追加:
1const appendReducer = (current: string[], update: string[]) => {2return [...current, ...update]3}
如果原来是:
1['A']
这一步新来的更新是:
1['B', 'C']
那 reducer 跑完以后,字段的新值就是:
1['A', 'B', 'C']
这就是 reducer 最核心的意思。
它只决定这个字段怎么合并,不负责别的字段。
放到 LangGraph 里看,最常见的就是这三种字段声明方式:
- 直接写 schema:按默认覆盖
ReducedValue:自己声明这个字段怎么合并MessagesValue:直接使用消息字段已经内置好的合并方式
4. 哪些字段最常需要 reducer
实际项目里,最常见的通常是这三类:
- 消息列表
- 普通数组
- 数字计数器
它们都不适合简单覆盖。
4.1 普通数组:把结果接到后面
先看最容易理解的普通数组。
01import { StateGraph, StateSchema, ReducedValue, START } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'03import { z } from 'zod'0405const appendResults = (current: string[], update: string[]) => {06return [...current, ...update]07}0809const State = new StateSchema({10// ReducedValue 用来给这个字段显式声明 reducer。11// 这里的字段语义是“追加到已有结果后面”。12results: new ReducedValue(13z.array(z.string()).default([]),14{ reducer: appendResults },15),16})1718const searchDocs: GraphNode<typeof State> = () => {19return {20results: ['官方文档'],21}22}2324const searchCommunity: GraphNode<typeof State> = () => {25return {26results: ['社区文章'],27}28}2930const graph = new StateGraph(State)31.addNode('searchDocs', searchDocs)32.addNode('searchCommunity', searchCommunity)33// 两个节点都从 START 出发,表示它们会在同一步里各自产生一份更新。34.addEdge(START, 'searchDocs')35.addEdge(START, 'searchCommunity')36.compile()3738const result = await graph.invoke({ results: [] })39console.log(result.results)40// ['官方文档', '社区文章']
这一段最重要的不是记住 API 细节,而是看懂语义:
- 如果不用 reducer,两个节点都写
results,后写入的会覆盖先写入的 - 加上 reducer 后,这个字段的语义变成了“把新结果追加到后面”
4.2 数字计数器:不是覆盖,而是累加
计数器也很常见。
比如:
- 走了多少步
- 调了多少次工具
- 用了多少 token
这种字段如果直接覆盖,往往就没意义了。
01import { StateGraph, StateSchema, ReducedValue, START } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'03import { z } from 'zod'0405const addNumber = (current: number, update: number) => {06return current + update07}0809const State = new StateSchema({10// 计数器字段的语义不是覆盖,而是累加。11stepCount: new ReducedValue(12z.number().default(0),13{ reducer: addNumber },14),15})1617const stepA: GraphNode<typeof State> = () => {18return { stepCount: 1 }19}2021const stepB: GraphNode<typeof State> = () => {22return { stepCount: 1 }23}2425const graph = new StateGraph(State)26.addNode('stepA', stepA)27.addNode('stepB', stepB)28// 这里同样是两路并行写入 stepCount,所以 reducer 才有意义。29.addEdge(START, 'stepA')30.addEdge(START, 'stepB')31.compile()3233const result = await graph.invoke({ stepCount: 0 })34console.log(result.stepCount)35// 2
这里如果没有 reducer,最后你拿到的往往只是 1。
但这个字段真正想表达的是“总共发生了两次更新”,所以应该累加。
4.3 消息字段:按对话语义合并
消息字段是最特殊的一类。
因为对话图里最常见的状态,不是一个普通字符串,而是一串消息。
这时候你真正想要的,不是:
- 新消息把旧消息覆盖掉
而是:
- 用户消息、模型消息、工具消息都按顺序累积起来
在后面的篇章里,我们统一把消息字段写成 MessagesValue:
01import { StateGraph, StateSchema, MessagesValue, START, END } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'0304const State = new StateSchema({05messages: MessagesValue,06})0708const callModel: GraphNode<typeof State> = async () => {09return {10messages: [11{ role: 'assistant', content: '你好,有什么想聊的?' },12],13}14}1516const graph = new StateGraph(State)17.addNode('callModel', callModel)18.addEdge(START, 'callModel')19.addEdge('callModel', END)20.compile()2122const result = await graph.invoke({23messages: [{ role: 'user', content: '你好' }],24})2526console.log(result.messages.length)27// 2
这里你可以把 MessagesValue 理解成:
- 这个字段不是普通数组
- 它代表“消息列表”这种特殊状态
- LangGraph 会按消息语义去处理它的合并
如果把 ReducedValue 看成“我自己给字段声明 reducer”,那 MessagesValue 就可以理解成 LangGraph 已经帮你预先配好的一种特殊字段。
所以这篇里最值得记住的是这句话:
Reducer 的问题,本质上就是“这个字段的业务语义是什么”。
消息字段的业务语义不是覆盖,而是形成对话历史。
普通数组的业务语义可能是追加。
计数器的业务语义可能是累加。
5. 自定义 reducer 时,先想清楚字段语义
当内置语义不够用时,再写自定义 reducer。
最常见的两个方向,是去重和对象合并。
5.1 去重合并
比如标签列表,不希望重复:
01import { StateGraph, StateSchema, ReducedValue, START } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'03import { z } from 'zod'0405const mergeUniqueTags = (current: string[], update: string[]) => {06return Array.from(new Set([...current, ...update]))07}0809const State = new StateSchema({10tags: new ReducedValue(11z.array(z.string()).default([]),12{ reducer: mergeUniqueTags },13),14})1516const fromText: GraphNode<typeof State> = () => {17return { tags: ['AI', '机器学习'] }18}1920const fromMeta: GraphNode<typeof State> = () => {21return { tags: ['Python', 'AI'] }22}2324const graph = new StateGraph(State)25.addNode('fromText', fromText)26.addNode('fromMeta', fromMeta)27.addEdge(START, 'fromText')28.addEdge(START, 'fromMeta')29.compile()3031const result = await graph.invoke({ tags: [] })32console.log(result.tags)33// ['AI', '机器学习', 'Python']
5.2 对象合并
比如配置对象,想保留已有字段,只覆盖这次更新的部分:
01import { StateGraph, StateSchema, ReducedValue, START } from '@langchain/langgraph'02import type { GraphNode } from '@langchain/langgraph'03import { z } from 'zod'0405const mergeObject = (06current: Record<string, unknown>,07update: Record<string, unknown>,08) => {09return { ...current, ...update }10}1112const State = new StateSchema({13config: new ReducedValue(14z.record(z.string(), z.unknown()).default({}),15{ reducer: mergeObject },16),17})1819const loadDefaults: GraphNode<typeof State> = () => {20return {21config: {22model: 'gpt-4.1-mini',23temperature: 0.7,24},25}26}2728const applyOverride: GraphNode<typeof State> = () => {29return {30config: {31temperature: 0.9,32},33}34}3536const graph = new StateGraph(State)37.addNode('loadDefaults', loadDefaults)38.addNode('applyOverride', applyOverride)39.addEdge(START, 'loadDefaults')40.addEdge('loadDefaults', 'applyOverride')41.compile()4243const result = await graph.invoke({ config: {} })44console.log(result.config)45// { model: 'gpt-4.1-mini', temperature: 0.9 }
这里更重要的不是“会不会写对象合并”,而是先问自己:
这个字段到底应该:
- 覆盖
- 追加
- 累加
- 去重
- 局部合并
只有这个问题先想清楚,reducer 才不会写偏。
6. 一张图里,不同字段可以有不同策略
真实项目里的状态,通常不是只有一个字段。
而且不同字段往往需要不同的合并方式。
01import { StateSchema, MessagesValue, ReducedValue } from '@langchain/langgraph'02import { z } from 'zod'0304const addNumber = (current: number, update: number) => current + update05const appendStrings = (current: string[], update: string[]) => [...current, ...update]0607const State = new StateSchema({08messages: MessagesValue,09totalTokens: new ReducedValue(10z.number().default(0),11{ reducer: addNumber },12),13intermediateResults: new ReducedValue(14z.array(z.string()).default([]),15{ reducer: appendStrings },16),17finalAnswer: z.string().default(''),18})
这个状态里其实有四种完全不同的语义:
messages:消息历史totalTokens:累加计数intermediateResults:列表追加finalAnswer:最终结果覆盖
这也是 reducer 真正有价值的地方。
它让状态不再只是“长得像对象”,而是每个字段都能表达自己的更新规则。
7. 收一下这一篇
这一篇真正要解决的,其实只有一个问题:
当多个节点都在改同一个字段时,这个字段到底该怎么合并。
如果字段只是普通字符串、普通数字,直接覆盖通常就够了。
如果字段代表的是消息历史、搜索结果、步骤计数这类会不断累积的数据,就要把更新规则提前写清楚。
放到代码里,就是三种最常见的状态字段:
- 普通 schema:保持默认覆盖
ReducedValue:自己声明这个字段的合并方式MessagesValue:直接使用消息列表已经配好的合并逻辑
后面再看到消息自动追加、计数器累加、结果列表逐步变长时,就不会觉得它们是额外的技巧了。
它们只是不同字段在按自己的语义更新。
下一篇讲 条件边。到那时,状态不只是“怎么更新”,还会开始参与“下一步往哪里走”。