跳到主要内容

内容阅读

读懂 Reducer,才算读懂 LangGraph:节点、状态与增量更新

Agent阿新聊ai

TL;DR: LangGraph 的状态不是共享变量,而是一组「每个 key 带合并规则的通道」。节点永远只返回增量更新,合并方式由 reducer 决定。理解了这一点,superstep 执行、checkpoint 边界、并行合并乱序这些现象都能自然推导出来;不理解,就只能靠背。

先看一个所有人都踩过的坑

两个节点先后写同一个 key,直觉是「后写的赢」。跑一下:

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    items: list[str]

def node_a(state: State) -> dict:
    return {"items": ["A"]}  # 我贡献了 A

def node_b(state: State) -> dict:
    return {"items": ["B"]}  # 我贡献了 B

g = StateGraph(State)
g.add_node("a", node_a)
g.add_node("b", node_b)
g.add_edge(START, "a")
g.add_edge("a", "b")
g.add_edge("b", END)
app = g.compile()

print(app.invoke({"items": []}))
# {'items': ['B']}

没有报错,但 node_a 的结果消失了。很多人的第一反应是框架有 bug,或者是「节点没按顺序执行」,往这两个方向排查一晚上都找不到答案。真相更平淡,也更重要:从 LangGraph 的视角看,两个节点各自返回了一份对 items 的更新,而 items 这个 key 没有声明合并规则,于是默认规则生效,覆盖:节点返回什么,这个 key 就变成什么。

要理解为什么默认是覆盖,得先接受一个模型。state 里的每个 key 是一条通道(channel),节点返回的 dict 不是「对共享字典的写入」,而是一张「更新单」:它只声明「我贡献了这些」,真正的合并发生在执行边界上,由每个 key 挂着的合并规则执行。节点执行时拿到的 state 是只读快照,它改不了任何东西,也看不到别的节点正在贡献什么。没有声明合并规则的 key,规则就是「直接替换」。

这个模型同时解释了为什么错误是静默的。在 LangGraph 看来每一步都符合规则:节点 a 贡献 ["A"],通道被替换成 ["A"];节点 b 贡献 ["B"],通道被替换成 ["B"]。没有任何一步违反约定,只是约定和你脑子里想的不一样。静默恰恰是它危险的地方:它不在测试环境报错,不在 CI 报错,会在生产上以「数据丢了一半」的工单形式出现。

想让它变成累加,给 key 挂一个 reducer:

from typing import Annotated, TypedDict

def merge(left: list[str], right: list[str]) -> list[str]:
    return left + right

class State(TypedDict):
    items: Annotated[list[str], merge]

其他代码不变,这次输出 {'items': ['A', 'B']}Annotated 的第二个参数告诉 LangGraph:当有新值写入这个 key 时,拿旧值和新值调用这个函数,结果才是新状态。

这个函数的惯用契约是:左参数拿通道现值,右参数拿本次更新,返回值成为通道新值。它在合并时才被调用,节点执行过程中不会跑。上面这段代码里 merge 被调用了两次:先是 merge([], ["A"]) 得到 ["A"],再是 merge(["A"], ["B"]) 得到 ["A", "B"]。初始输入 {"items": []} 就是通道的初值,reducer 从它开始一路滚下去。

如果业务要的只是「列表累加」,标准库里有现成的答案,不必自己写函数:

import operator
from typing import Annotated, TypedDict

class State(TypedDict):
    scores: Annotated[list[int], operator.add]  # 与手写 left + right 等价

顺带看一个常见的错误修法:既然节点返回会被覆盖,那我在节点里把旧值读出来拼上新值再整份返回,行不行?return {"items": state["items"] + ["A"]}。单个节点串行执行时它看起来是对的,于是这种写法到处蔓延。它的致命处在于并行:同一 superstep 里两个节点都拿同一份快照做「读、拼、写」,各自返回 快照值 + 自己的增量,合并时后到的更新整份替换先到的,先到那份的增量就丢了。这正是分布式系统里经典的丢失更新问题,只是在这里你连锁都没有。正确的姿势从来只有一种:节点只返回自己的增量,「怎么跟别人写的合」交给 reducer 声明。把合并逻辑从节点代码里拿出来放进 State,这个坑类就整体消失了。

一句话概括这一节:在 LangGraph 里定义状态,你实际在定义两件事,key 的类型,和 key 的合并规则。只写第一件,第二件就由默认的覆盖规则替你决定,而它几乎从来不是你想要的。

reducer 是状态的一部分,不是工具函数

这是最容易被忽略的认知:reducer 声明在 State 定义里,而不是写在节点代码里。它回答的问题是「当两个写入冲突时听谁的」,这是状态定义层面的事。把它放对位置还有一个工程上的好处:任何拿到 State 类型定义的人,不读任何节点代码,就能知道每个 key 的合并语义。节点是 reducer 的使用方,State 是 reducer 的声明处,这个次序不能反。

常用内置的就三种。

默认覆盖。 不写 Annotated,后写覆盖先写。它不是错误选项,是给「全图只有一个写入者」的 key 准备的:当前阶段标记、路由目标、最终结论,这类字段一次只有一个节点在写,覆盖就是正确语义。判断标准是写入者数量,不是数据类型。多个写入者共用一个覆盖型 key,就是把开头的坑重新挖一遍。挑覆盖型 key 还有个额外好处:它的值域最好足够小(几个状态名、一个 id),小值域让「这个字段现在是什么」随时可以一眼看清,排查时它就是图的进度条。

add_messages 消息列表专用 reducer,按消息 id 去重合并,还支持 RemoveMessage 删除语义。它做的事比「append」多得多:给没有 id 的消息分配 id;id 相同的消息做「替换」而不是「追加」,所以更新一条历史消息也能表达成一次增量;删除也一样。这三个行为合起来,意味着对话记录的全部生命周期操作(追加、改写、删除)都能走同一条增量通道,这正是它能当 checkpoint 的合并底座的原因。手写一个等价物你就知道 MessagesState 是什么了:

from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]

MessagesState 这个预置类型就是「一个挂了 add_messages 的 messages key」,聊天类应用直接用它就够了。要扩展它,继承着加自己的 key:

from langgraph.graph import MessagesState

class SupportState(MessagesState):
    ticket_id: str  # 只有工单创建节点会写,默认覆盖即可

自定义函数。 合并逻辑业务化之后就要自己写,比如按分数去重、按时间排序。写自定义 reducer 有三条纪律。第一,纯函数:不改参数,只返回新值,因为你改的可能是运行时正在用的对象。第二,确定性:同样的左右输入必须得到同样的输出,否则重放历史时会得到和当时不一样的状态,第 03 篇的回放和分叉全部失效。第三,克制:它在每次合并时都会执行,别在里面调外部服务、做重计算。

一个反直觉的点:reducer 管的是「新值怎么并进来」,不管「怎么避免重复写入」。如果两个并行节点返回了内容相同的两条消息,add_messages 因为 id 不同会都保留。去重是你的业务逻辑,不是 reducer 的。最省力的做法是把去重前置到消息创建那一刻:给消息带上业务 id(工单号、事件 id),add_messages 的按 id 替换就自动帮你完成了幂等。同理,需要「更新而非追加」时,正规入口也是「用相同的 id 再发一次」,而不是想别的花招。

还有一个容易漏掉的边界:reducer 只对「有写入发生」的 key 生效。节点没返回某个 key,这个 key 的通道值原样保留,不会有什么「默认重置」。所以「本轮没写就当没改过」是这个模型免费给你的语义,别在节点里画蛇添足地把旧值抄写一遍。

reducer 出错时的行为也值得提前知道,因为它和节点出错的表象完全不同。reducer 在边界合并阶段执行,它抛异常,失败的不只是某个节点,而是这一轮推进整体以错误收场。现象很有辨识度:报错堆栈里找不到你出事的那个节点函数,错误发生在「应用更新」的阶段。看到这种堆栈,直接去查 State 里挂的自定义 reducer,重点查它对边界输入的假设,比如第一次合并时的初值形态、右参数是单个元素还是批次。

顺着纪律再划一条反面清单。三种逻辑不该写成 reducer:依赖外部状态的去重(查数据库判断是否见过),那是节点的事,reducer 必须只依赖自己的两个参数;需要业务审批或补偿的合并(合并错了要能回退),reducer 做不了事务,需要补偿语义去看第 12 篇的错误处理;以及一切带时间副作用的逻辑(顺手发通知、顺手写库),reducer 在重放时会被反复执行,这些副作用会被放大。reducer 只做一件事:两个值进,一个值出。

superstep:所有事都发生在边界上

LangGraph 的执行模型来自 Pregel,Google 那篇批量图计算论文提出的同步推进模型:图按 superstep(超步)推进,每个 superstep 里,当前所有就绪的节点拿着同一份状态并行执行;全部执行完后,各自的增量更新经过 reducer 合并,生成下一份状态,进入下一个 superstep。

拿一个具体图走一遍时间线,模型立刻落地。图结构:START 连到 a 和 b,a、b 都连到 c。第一个 superstep:a 和 b 就绪,拿同一份输入状态并行执行,此期间谁也看不见谁。两者都结束后,两份更新单分别交给各自的通道合并,产生第二份状态。第二个 superstep:c 就绪,拿着合并后的状态执行。整张图就是这样一轮一轮推完的,每一轮的边界就是一个存档点。

这个模型推出三个必须记住的推论。

并行节点互相看不见。 同一 superstep 里的两个节点,拿到的输入状态完全相同。A 在执行中写的东西,B 看不到,要等下一个 superstep。如果你的两个节点有「先查询再汇总」的依赖,它们必须在不同的 superstep 里,也就是用边连起来。判断标准一句话:B 的输入依赖 A 的输出,它们之间就必须有边;没有边连着的并行,就是互相独立的关系,别指望运行时替你协调。

checkpoint 只在 superstep 边界生成。 快照是每个边界上的完整状态,节点执行到一半挂掉,恢复时整个节点从头再跑。所以官方反复强调:不要把五个步骤塞进一个大节点,每个步骤的失败都会连累其余四个步骤跟着重跑。反过来也不用担心拆太细,checkpoint 写入是异步的,节点粒度细不构成性能问题。粒度问题的完整账单在第 13 篇,这里先记住方向:拆细的重跑代价小,拆粗的重跑代价大。

并行分支的合并顺序不保证。 两个节点在同一个 superstep 里写同一个挂了 reducer 的 key,合并能保证发生,但先后顺序不保证["A", "B"]["B", "A"] 都可能出现,同一个图跑两次结果顺序都可能不同。对顺序敏感的场景三选一:下游消费前统一排序;每条数据带时间戳或序号,合并后按字段排;用边把并行改串行。哪种都行,就是别假装顺序存在。

为什么框架不承诺顺序?把顺序承诺收掉,运行时才能用「同步边界」这个最简单的模型同时拿到并行执行和确定性回放。这是推断,不是官方解释,但它能对上现象:你要顺序,框架就要在合并时做全局排序或者按节点定序,两者都让边界语义变复杂。你的顺序需求,应该在状态里显式表达成字段,而不是指望到达顺序。

时间线里还有个细节值得点破:节点拿到的「同一份状态」是快照,不是引用。在节点函数体里就地修改传入的 state(往 list 上 append、改 dict 的字段),LangGraph 不会收集这些修改,只收集你返回的更新单。所以「我明明改了 state,怎么没生效」的答案永远是:改了副本,没交作业。这也是函数式风格在这里被强制的体现,节点和外界只有一条契约通道,就是返回值。动态扇出(Send,第 08 篇)也是在这个边界上工作的:上一轮的更新合并完,运行时根据条件边的判断决定下一轮有哪几个节点就绪,superstep 的轮转本身不受节点内部逻辑影响。

还有一层使用上的含义:superstep 是运行时的事,你的代码里永远不会出现「超步」这个词,你只写节点和边。但它解释的恰恰是那些「没道理」的现象:为什么两个节点的效果像是同时生效的(同一轮合并),为什么下游节点看到的总是并行结果的合并态而不是中间态,为什么单测里串行调用节点全都对、图一跑就乱(单测绕过了合并边界)。系列后面每一篇的怪现象,追到最后几乎都会追到这轮转模型上。

为什么是「增量」:为了可重放

强制节点返回增量而不是直接改状态,代价是写起来多想一步,收益是整个执行变成可重放的:状态完全由「初始输入 + 逐边界的更新序列」推导出来。这句话值得展开,因为它是后面好几篇的地基。只要初始输入不变、每一步的更新不变、每个 reducer 是确定性的,那这条更新序列无论重放多少遍,每个中间状态都一模一样。checkpoint 里存的就是这套推导的中间结果,所以才能拿着历史 checkpoint 重跑(回放)、或者从历史某点分叉出新执行(第 03 篇)。

现在回头看「自定义 reducer 必须确定性」这条纪律,它不是一个风格建议,而是整条重放链的承重墙。一个依赖 random 或者当前时间的 reducer,会让「同一序列推导出同一状态」这个前提塌掉,你会在调试回放时看到灵异现象:同一张快照,重放出不同的世界线,而且无法复现。

这个模型很像事件溯源,但有一个关键差别值得点破:事件溯源存事件、读时折叠,而 LangGraph 的 checkpointer 存的是每一步折叠完成后的完整快照,不是事件日志。也就是说推导已经替你做完了,任何一张快照都能直接恢复,不用从头折叠整条历史。代价是存储量更大(每一步都存全量,第 13 篇讲表膨胀怎么治),收益是恢复和分叉的读取成本是常数级的。理解了这层差别,就理解了为什么这套系统「重放快、存储涨」,两个特征是同一个设计选择的正反面。

增量模型也不是铁板一块,少数时候你就是不要合并,只想替换。节点想真正清空一个 key 怎么办?返回 Overwrite(None) 这类包装值,绕过 reducer 直接覆盖。这是少数几个「我就是不要合并」的合法出口:

from langgraph.types import Overwrite

def reset(state: State) -> dict:
    return {"items": Overwrite([])}  # 绕过 reducer,直接清空通道

把它当逃生口,不当常规操作。一个图里 Overwrite 出现得越多,说明状态设计越别扭:要么这个 key 根本不该挂 reducer,要么合并规则选错了。评审时看到它,先问一句「为什么正常合并不行」。

增量模型对调试的实际意义拿一个场景说透。客户投诉上周三那次任务跑错了参数,你要回答「当时到底发生了什么」。有了逐边界快照,这不是查日志拼时间线,而是把那次的 thread 翻出来,逐张快照看通道状态:哪个节点在哪个边界写入了错误参数,一目了然。没有增量模型,你的全部材料就是散落的日志和没人说得清的现场。第 03 篇讲快照机制本身,第 14 篇讲怎么把这套能力接到日常调试里。

还有两个高频小坑,都和这套模型直接相关:

  • 静态边和 Command 动态路由不要同时给一个节点用。两者都会生效,流量走两遍:条件边说去 b,节点返回的 Command 又说去 c,运行时把两个目标都安排上,下游被执行两次。症状是「某个下游节点的效果总是出现两份」,排查时先看这个节点是不是同时有两种出边。修法:一个节点一种出边方式,动态路由就删掉静态边。
  • recursion_limit(默认 25)写在 invoke 的 config 里,不是 compile() 参数:app.invoke(inputs, config={"recursion_limit": 50})。搜到的很多旧教程写错了位置。看到 GRAPH_RECURSION_LIMIT 报错,第一反应不该是把数字调大,而是查循环有没有出口:条件边是否可能永远为真,ReAct 循环是否缺终止条件。调大上限只是把原地打转的距离拉长,账单照付。

生产故障模式:状态不对时怎么排查

reducer 模型的一个实际好处是,故障可以按症状定位,因为每一种「状态不对」都对应模型里一个明确的缺口。下面八种是社区讨论和生产事故里反复出现的,按「先看什么」排列。

  • 结果只剩最后一个节点的贡献。 九成是这个 key 没挂 reducer。回到 State 定义看 Annotated,而不是去节点里找 bug。开头的坑换了个项目又挖了一遍,是这一类最常见的剧本。

  • 列表里出现重复条目。 并行分支同时写同一个累加型 key,而且写的是同一逻辑条目。想清楚重复从哪来:业务允许并发贡献就用业务 id 去重,不允许并发就改成串行。

  • 每次运行顺序不一样。 并行合并顺序不保证。停止依赖到达顺序,给数据加时间戳或序号字段,在消费前排序。

  • 改了节点代码,老会话恢复后行为诡异。 thread 里的旧快照还是旧 schema 存的状态,新代码读旧数据。state schema 演进要按数据库迁移的严肃程度对待,第 13 篇有专门的事故复盘。

  • GRAPH_RECURSION_LIMIT 先查图有没有出口,再考虑调大 limit。十次里九次是条件边永远为真。

  • state 越来越大,每轮调用越来越慢。 原始数据被整块堆进了 state。原始数据存引用,结论进状态,这笔 token 账第 04 篇算过。

  • 快照写入报序列化错误,或者恢复出来的状态缺字段。 不可序列化的对象(数据库连接、文件句柄、自定义类的实例)被塞进了 state。通道里的值要能被 checkpointer 落盘和读回,能放什么由第 03 篇的持久化机制决定,放到 state 之前先过一遍「这东西存进数据库再读回来还是它吗」。

  • 节点里就地改了 state,下游完全没看到。 不是 bug,是模型:运行时只收集返回值,就地修改发生在快照副本上。把修改放进返回的 dict。

这些排查动作有个共同点:都先看 State 定义,再看节点代码。因为状态行为由 State 声明决定,节点只是更新单的提交者。把 State 定义当数据库表结构对待,代码评审时重点看它、改它要走和改表结构一样的谨慎流程,这是团队里推 LangGraph 最值得立的一条规矩。

三种够用的状态设计模式

讲了这么多机制,落到设计上,绝大多数业务图的状态用三种模式就装下了,不需要更多发明。

单写结论型。 key 只有调度逻辑会写(当前阶段、路由目标、汇总结论),挂默认覆盖。这类 key 是图的「控制面」,值简单,语义就是「现在在哪、要到哪去」。它的合并规则是覆盖这件事反而是安全的,因为写入者唯一。

多写事实型。 key 会被多个节点贡献(命中的工具结果、产出的事件、收集的证据),挂累加型 reducer,元素带业务 id 或时间戳。这类 key 是图的「数据面」,只增不改,删除和改写通过携带业务 id 的增量表达。它天然和并行兼容,也是回放友好的:事实列表重放多少遍都长一个样。

结论与明细分离型。 同一类信息既有滚动结论又有明细时,拆成两个 key:明细进事实型列表,结论是单节点写的摘要字段。不要把结论混在明细里靠下游「取最后一条」推导,那是在状态里造视图。这个模式就是官方「状态存原始数据、节点做格式化」原则的落地形态,明细是原始数据,结论是格式化。

三种模式共同的前提是每个 key 的写入者集合是明确的。设计状态时把它当成数据库设计来做:先列写入者,再定合并规则,最后才写节点。顺序反了,就会出现「节点都写完了才发现两个节点在抢同一个 key」的返工。

权衡与建议

reducer 模型的成本是显性的:你得想清楚每个 key 的合并语义,状态里放原始数据、在节点里按需格式化(这是官方文档给出的原则,直接抄就好)。第二条原则的原因值得说破:格式化后的数据是派生数据,派生数据进状态,既撑大每张快照的体积,又制造「两处真相」的同步负担。原始数据进通道,视图在节点里现算,一致性和体积就都保住了。

成本换来的是并行、恢复、回放这些能力不需要你写一行同步代码。这三件事手写的价码在第 01 篇算过,几千行补丁起步,写完还没人敢动。

具体建议三条。新项目从 MessagesState 起步,业务状态只加必要的 key,每个 key 都能一句话说清「合并规则是什么」。说不清的 key 大概率不该存在:它对应的数据可能属于 Store(跨会话记忆,第 04 篇),可能属于节点内部局部变量(跑完就丢的中间量),也可能根本不该存在。第三条,key 的命名和合并规则写在 State 定义处,当作文档维护,新人看一遍 State 就该能猜出图怎么跑。

如果团队正从第 01 篇那个手写循环迁移过来,这里有一张心态转换表。手写版的 messages 数组,对应挂了 add_messages 的 messages 通道,两者语义几乎一致,所以迁移成本比想象低;手写版的循环变量和局部临时值,对应节点内部局部变量,不需要进状态;手写版里散落的「全局配置」(模型名、上限值),对应单写覆盖型 key,或者干脆留在节点外作为构造参数。想清楚这三组映射,旧代码变成节点就是把函数体原样搬进去,返回值从「修改 messages」改写成「返回增量」而已。

评审 State 定义时的五条检查单,照着过:每个 key 说得出唯一的写入者集合;多写 key 挂了累加型 reducer 且元素带业务 id;没有派生数据(格式化结果、拼接文本)进通道;没有不可序列化对象;key 数量一只手数得过来。五条全过,这个状态设计基本不会再给你制造惊喜。

这篇的通道模型是整个系列的底座:第 03 篇的快照存的就是通道状态,第 06 篇的 interrupt 暂停的就是通道现场,第 08 篇的 Send 并发玩的就是通道合并。后面所有现象,都能回到「key 加合并规则」这六个字上推导。读下一篇之前做一个小练习:把自己项目里的 state 声明翻出来,逐个 key 说出「谁写它、怎么合」。说不出来的那几个,就是下一篇开头你要先修的地方。

延伸阅读


原文出处:91ai / LangGraph 生产实战系列

评论

0
登录后可以参与评论和讨论。

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。