跳到主要内容

LangGraph 的 reducer 有几种模式?顺带把 Annotation 讲透

· 阅读需 8 分钟

LangGraph 入门 那篇里,我把 reducer 一笔带过:「(a,b)=>b 是覆盖,a.concat(b) 是追加」。但那只是两种写法,不是全部模式——真正的问题是:

同一个 key 被写入多次时,状态该怎么合?

这就是 reducer 要回答的唯一问题。这篇把它讲全:Annotation 的完整用法、reducer 的几种模式、它什么时候被调用、调用几次、以及我把每种行为都真实跑出来的结果(含两个报错,都是实测)。

声明:以下行为全部在 @langchain/langgraph 1.4.15(Node 24)跑出来的,输出是真实的。不同版本行为可能微调,以你的实际运行为准。

一、先搞懂 Annotation:状态说明书

LangGraph 的状态不是「一个对象」,而是一张「字段 → 说明」的表Annotation 就是写这张表的工具:

import { Annotation } from '@langchain/langgraph';

const State = Annotation.Root({
// 每个 key 一条说明:用什么类型、怎么合并、初始值是什么
count: Annotation({ reducer: (cur, next) => cur + next, default: () => 0 }),
logs: Annotation({ reducer: (cur, next) => cur.concat(next), default: () => [] }),
title: Annotation({ default: () => '' }), // 不写 reducer → 覆盖模式
});

三个要点:

说明
Annotation.Root({...})唯一的静态方法(本机实测 Object.keys(Annotation) 只有 Root),用来声明整张状态表
Annotation<T>({...})声明单个字段:可给 reducerdefault
reducer(cur, next)二元函数:当前值 + 本次更新的值 → 新值

TypeScript 里怎么取类型type S = typeof State.StateState 是值,.State 才是类型)。另外 LangGraph 还提供现成预设,比如 MessagesAnnotation(聊天消息专用,见第五节)。

二、reducer 的几种模式(含实测)

模式 1:不写 reducer = 覆盖(最后一次写入赢)

const S = Annotation.Root({ v: Annotation({ default: () => 'init' }) });
// a 节点写 'from-a',b 节点写 'from-b'

实测输出:

① 默认(覆盖): {"v":"from-b"}

a 写的值被 b 覆盖了。这是默认语义,也是绝大多数「配置类字段」想要的。

模式 2:追加 / 累加(最常用)

logs: Annotation({ reducer: (cur, next) => cur.concat(next), default: () => [] })
count: Annotation({ reducer: (cur, next) => cur + next, default: () => 0 })

实测输出:

② 自定义(追加): {"log":["a","b"]}

模式 3:任意语义——合并、取最大、去重……

reducer 就是个普通函数,所以「几种模式」本质上没有上限

想要的语义reducer 写法
覆盖(默认)(cur, next) => next
追加(cur, next) => cur.concat(next)
数值累加(cur, next) => cur + next
对象浅合并(cur, next) => ({ ...cur, ...next })
取最大(cur, next) => Math.max(cur, next)
去重追加(cur, next) => [...new Set([...cur, ...next])]
自定义业务语义随便写,但必须能重复调用(见第五节)

模式 4:消息专用——messagesStateReducer

聊天场景「追加消息」有个特殊需求:同一条消息被更新时应该替换,而不是新增。LangGraph 内置了它:

messages: Annotation({ reducer: messagesStateReducer, default: () => [] })

实测(m1 先写入,随后同 id 再写一次):

④ 消息 reducer: [{ id:"m1", content:"第一条被改了" }, { id:"m2", content:"第二条" }] | 条数 = 2

注意:条数是 2 不是 3 —— 同 idm1原地更新(内容从「第一条」变成「第一条被改了」),m2 才被追加。这就是消息 reducer 和「朴素 concat」的本质区别:它按 id 去重

模式 5:预设 MessagesAnnotation

不想自己写 reducer,可以直接用预设:

import { MessagesAnnotation } from '@langchain/langgraph';
const graph = new StateGraph(MessagesAnnotation)...

实测输出:

⑤ MessagesAnnotation: ["human:hi","ai:收到 1 条"]

(注意这里的消息 type 就是 'human' / 'ai' 字符串——预设帮你处理了消息的构造与追加。)

同目录导出里还有 messagesDeltaReduceraddMessagespushMessageREMOVE_ALL_MESSAGES 等消息相关工具(本机导出的真实名字),做「消息流/增量」时值得翻一眼。

三、reducer 什么时候被调用、调用几次

这是最容易搞错的地方。结论:每次「向某个 key 写入」都会调用一次 reducer,而且一次节点执行可能产生多次写入

实测:一个节点里通过 Command 连续写同一个 key 三次:

.addNode('multi', () => [
new Command({ update: { sum: 1 } }),
new Command({ update: { sum: 2 } }),
])
reduce(0, 1)
reduce(1, 2)
③ Command 多更新 → {"sum":3}

reducer 被调用了两次,且是顺序累积的:先 reduce(0,1) 得 1,再 reduce(1,2) 得 3。

⚠️ 实测踩到的坑:节点返回「数组」不行

我一开始想当然地写 () => [{ sum: 1 }, { sum: 2 }],直接报错(真实报错文本):

InvalidUpdateError: Expected node "multi" to return an object or an array containing at
least one Command object, received array
(lc_error_code: INVALID_GRAPH_NODE_RETURN_VALUE)

在这个版本里,节点返回的数组必须包含 Command 对象,不能是「一串普通的部分状态」。想在单个节点里写同一个 key 多次 → Command,或者干脆拆成多个节点(更常见、更清晰)。

四、default 的三条规则(实测)

规则 1:首次写入时,有 default 才会调用 reducer

我特意在 reducer 里打了日志:没有 default 的字段,首次写入不会调用 reducer——直接落值(实测输出里没有出现 reducer 日志,最终状态是 {"x":"first"})。

而第三节的例子有 default: () => 0,于是第一次写入就是 reduce(0, 1)

直觉理解:reducer 是「合并器」,没有旧值就没什么可合并的

规则 2:default 必须是函数

default: () => [] // ✅ 正确
default: [] // ❌ 实测报错:initialValueFactory is not a function

为什么必须是函数:函数保证每次 invoke 得到全新对象,否则多个请求会共享同一个数组/对象(脏数据)。实测验证:

② 两次调用: {"list":["x"]} {"list":["x"]} | 互相独立 = true

规则 3:不写 default,字段初始就是 undefined

需要「一开始就有值」的字段(尤其是要 concat 的数组、要相加的数字),请务必给 default,否则第一次 reduce 会拿到 undefined

五、六个必须知道的坑

  1. reducer 会被调用很多次:别在里面做副作用(写文件、发请求、打日志噪音)。它应该是纯函数
  2. 并行分支写同一个 key,reducer 要「可交换」:多个节点并行更新同一 key 时,谁先谁后由调度决定。用「累加/追加/取最大」这类顺序无关的 reducer 才安全;用「取最后一个」会得到不确定结果;
  3. 节点返回数组 ≠ 多个更新:数组里必须是 Command(上面的真实报错);
  4. (cur, next) => nextconcat 差的是语义:前者丢历史,后者留全部——消息、日志、轨迹类字段基本都要后者;
  5. 消息要用 messagesStateReducer 而不是 concat:否则同 id 消息会重复堆积(第四节实测:用对 reducer 后是 2 条而不是 3 条);
  6. default 忘了写函数 → 直接报 initialValueFactory is not a function,这类错误信息很好认。

六、一张选型表

你的字段是什么用什么模式
配置项、选中的模型、当前节点名不写 reducer(覆盖)
日志、轨迹、执行过的节点列表concat 追加
计数器、token 用量、重试次数cur + next 累加
多个节点各自贡献一些字段的「配置对象」{...cur, ...next} 浅合并
聊天消息(要按 id 更新)messagesStateReducerMessagesAnnotation
复杂业务语义(如「保留最新 3 条」)自己写 reducer,但保证纯函数 + 可重复调用

七、一句话总结

  • Annotation 是状态说明书Annotation.Root 声明整张表,Annotation<T>({reducer, default}) 声明每个字段;
  • reducer 的「几种模式」其实是「一次合并函数」的无数种写法:覆盖、追加、累加、合并、去重、消息……都由你定义;
  • 触发时机:每次写入调用一次、可累积调用多次(Command 多更新实测 reduce(0,1)reduce(1,2));首次写入时有没有 default 决定它是否被调用
  • 两条硬规则default 必须是函数;节点返回的数组里只能是 Command
  • 一条设计原则:reducer 要、要能重复调用、并行场景下还要顺序无关

关联

参考

  • LangGraph JS 文档(Graph API / State 与 reducers):langchain-ai.github.io/langgraphjs
  • 本文实测环境:@langchain/langgraph 1.4.15 + Node 24;输出与报错均为真实运行结果