State 是"长"出来的:Annotation、reducer 与并行安全——LangGraph 系列之二

2 阅读5分钟

一句话价值:当图的节点开始并行、循环地写同一份状态时,"谁最后写谁赢"会悄悄丢掉数据。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 的作用是给归约循环一个类型正确的起点,让第一个增量进来就有东西可合。它不是可选项,是归约安全的必要条件。

本篇小结

  1. 图一旦并行/循环,节点"读全量→改→写全量"会产生读-改-写竞态、静默丢数据。
  2. 解法是声明式归约:节点只发增量,reducer 决定怎么合,规则上移到数据结构、全图一致。
  3. 覆盖/追加/累加是同一个归约循环换算子;reducer 必须次序无关,因为执行是攒齐增量后统一归约的"超步"。
  4. 覆盖字段要保证单写者;追加/累加字段才欢迎并行。无参 Annotation() 底层会拒绝并行写。
  5. default 提供归约起点,不是可有可无。

系列预告

下一篇,让状态真正"动"起来:分支与循环——节点在状态里写下自己的判断,条件边据此决定下一跳;再把"下一跳"交给 LLM,让路由从规则进化成分类。