一句话价值:当图的节点开始并行、循环地写同一份状态时,"谁最后写谁赢"会悄悄丢掉数据。LangGraph 的解法是把"状态怎么合并"的规则提前声明在字段上,让节点只发增量、永不整包覆盖——这是复杂图不写乱的根基。
上一篇埋下的问题
上一篇里那张最小图只有一条直线:节点做完,交给下一个。顺序执行时,手工维护一个"总状态对象"并不难——谁后写,谁生效。
但图的真正威力——并行、分支、循环——会立刻戳破"后写覆盖"的天真假设。
想象同一轮里两个节点并行执行,都要往 messages 里追加:
// 节点 A
const nodeA = (state) => {
const all = [...state.messages, msgA] // 读到旧值 []
return { messages: all } // 整体写回 [A]
}
// 节点 B(同一轮并行)
const nodeB = (state) => {
const all = [...state.messages, msgB] // 也读到旧值 []
return { messages: all } // 整体写回 [B]
}
两个节点都基于同一个旧值 [] 各自拼出新数组,谁后整体写回,谁的 [B] 就把另一个的 [A] 整包覆盖——一条消息丢了,而且不报错、很难查。这就是分布式系统里最经典的读-改-写竞态。
LangGraph 的解法:合并规则上移到数据结构
LangGraph 不让你在节点里自己"读全量 → 改 → 写全量"。它让节点永远只发增量,而"这些增量怎么合"由 State 字段上声明的规则决定。
字段的样子,就是 Annotation:
const State = Annotation.Root({
messages: Annotation({ // 追加:只声明一次,全图生效
reducer: (prev, next) => prev.concat(next), // 旧值数组 + 新消息 = 新数组
default: () => [],
}),
retries: Annotation({ // 累加:节点发 1,它负责加
reducer: (prev, next) => prev + next,
default: () => 0,
}),
answer: Annotation(), // 无参数 = 覆盖:存最近一次写入
})
回到上面的竞态场景,节点只需发增量:
const nodeA = (state) => ({ messages: [msgA] }) // 只管"A 的消息到了"
const nodeB = (state) => ({ messages: [msgB] }) // 只管"B 的消息到了"
至于谁先谁后、两条怎么合,是 messages 字段的 reducer 说了算——它把两条增量都并进同一个数组。合并规则从"藏在每个节点里的隐式逻辑",上移成了"声明在数据上、全图一致"的显式规则。不依赖执行顺序,所以并行安全。
"覆盖 / 追加 / 累加"其实是同一件事
很多初学者以为这是三种不同机制。真相是:它们是同一个归约循环,只是换了算子函数。
新值 = reducer(旧值, 这条增量)
| 你想要的语义 | 写的 reducer | 它实际干的 |
|---|---|---|
| 覆盖 | (_prev, next) => next | 丢弃旧值,留新值 |
| 追加 | (prev, next) => prev.concat(next) | 返回"旧数组 + 新元素"的新数组 |
| 累加 | (prev, next) => prev + next | 数学加法 |
所以 Annotation 的完整形态是 { reducer, default }:reducer 决定"旧的怎么变成新的",default 提供"归约的起点"。
两个必须记住的机制细节
① 为什么 reducer 不能依赖"谁先来"?
因为 LangGraph 的执行是"超步"批处理的:一轮里被激活的节点并行跑完,所有增量攒齐后统一归约。节点永远只看到上一个超步结束时的完整状态,看不到本轮别的节点正在写的中间值。
这就要求 reducer 是次序无关的(追加、累加、取最大值这类规则天然满足)——它跟你熟悉的 [].reduce() 有本质差别:数组 reduce 从左到右固定顺序,图的 reducer 面对的是可能乱序到达的更新。凡是依赖"先到者"的 reducer(比如"只保留第一条"),在并行时都是错的。
② 覆盖字段有个隐藏的并发约束。
不带 reducer 的 Annotation() 底层是"最近值通道":一个执行轮里只允许一条写入,两个节点并行写同一个无参字段会直接抛错 INVALID_CONCURRENT_GRAPH_UPDATE。
而显式写 reducer: (_prev, next) => next 的字段能容忍并行多条写入,但结果取决于归约顺序。所以工程上的铁律是:
语义是"覆盖"的字段(question、finalAnswer…),要保证一个执行轮里只有一个写入方;语义是"追加/累加"的字段(messages、retries…),才天生欢迎多节点并行写。
这解释了为什么 Agent 里聊天记录 messages 几乎总是配追加 reducer,而"最终答案"通常只有一个节点写。
顺带解决一个困惑:"默认值"不是装饰
messages 若少了 default: () => [],图一开始没有这个字段,第一个节点发增量时归约的"旧值"是 undefined,undefined.concat(...) 直接崩。default 的作用是给归约循环一个类型正确的起点,让第一个增量进来就有东西可合。它不是可选项,是归约安全的必要条件。
本篇小结
- 图一旦并行/循环,节点"读全量→改→写全量"会产生读-改-写竞态、静默丢数据。
- 解法是声明式归约:节点只发增量,
reducer决定怎么合,规则上移到数据结构、全图一致。 - 覆盖/追加/累加是同一个归约循环换算子;reducer 必须次序无关,因为执行是攒齐增量后统一归约的"超步"。
- 覆盖字段要保证单写者;追加/累加字段才欢迎并行。无参
Annotation()底层会拒绝并行写。 default提供归约起点,不是可有可无。
系列预告
下一篇,让状态真正"动"起来:分支与循环——节点在状态里写下自己的判断,条件边据此决定下一跳;再把"下一跳"交给 LLM,让路由从规则进化成分类。