LangGraph 的 reducer 有几种模式?顺带把 Annotation 讲透
在 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>({...}) | 声明单个字段:可给 reducer、default |
reducer(cur, next) | 二元函数:当前值 + 本次更新的值 → 新值 |
TypeScript 里怎么取类型:type S = typeof State.State(State 是值,.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 —— 同 id 的 m1 被原地更新(内容从「第一条」变成「第一条被改了」),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' 字符串——预设帮你处理了消息的构造与追加。)
同目录导出里还有
messagesDeltaReducer、addMessages、pushMessage、REMOVE_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。
五、六个必须知道的坑
- reducer 会被调用很多次:别在里面做副作用(写文件、发请求、打日志噪音)。它应该是纯函数;
- 并行分支写同一个 key,reducer 要「可交换」:多个节点并行更新同一 key 时,谁先谁后由调度决定。用「累加/追加/取最大」这类顺序无关的 reducer 才安全;用「取最后一个」会得到不确定结果;
- 节点返回数组 ≠ 多个更新:数组里必须是
Command(上面的真实报错); (cur, next) => next和concat差的是语义:前者丢历史,后者留全部——消息、日志、轨迹类字段基本都要后者;- 消息要用
messagesStateReducer而不是concat:否则同 id 消息会重复堆积(第四节实测:用对 reducer 后是 2 条而不是 3 条); default忘了写函数 → 直接报initialValueFactory is not a function,这类错误信息很好认。
六、一张选型表
| 你的字段是什么 | 用什么模式 |
|---|---|
| 配置项、选中的模型、当前节点名 | 不写 reducer(覆盖) |
| 日志、轨迹、执行过的节点列表 | concat 追加 |
| 计数器、token 用量、重试次数 | cur + next 累加 |
| 多个节点各自贡献一些字段的「配置对象」 | {...cur, ...next} 浅合并 |
| 聊天消息(要按 id 更新) | messagesStateReducer 或 MessagesAnnotation |
| 复杂业务语义(如「保留最新 3 条」) | 自己写 reducer,但保证纯函数 + 可重复调用 |
七、一句话总结
- Annotation 是状态说明书:
Annotation.Root声明整张表,Annotation<T>({reducer, default})声明每个字段; - reducer 的「几种模式」其实是「一次合并函数」的无数种写法:覆盖、追加、累加、合并、去重、消息……都由你定义;
- 触发时机:每次写入调用一次、可累积调用多次(
Command多更新实测reduce(0,1)→reduce(1,2));首次写入时有没有 default 决定它是否被调用; - 两条硬规则:
default必须是函数;节点返回的数组里只能是Command; - 一条设计原则:reducer 要纯、要能重复调用、并行场景下还要顺序无关。
关联
- 前置:LangGraph 入门:状态图、条件边与记忆
- 图解版:LangGraph 图解:执行过程与状态
- 实战:用 LangGraph 写 ReAct Agent
- 对照:自己手写 agent 循环——那里「状态」就是一个你自己维护的
messages数组,reducer 就是push
参考
- LangGraph JS 文档(Graph API / State 与 reducers):langchain-ai.github.io/langgraphjs
- 本文实测环境:
@langchain/langgraph1.4.15 + Node 24;输出与报错均为真实运行结果
