LangChain LangGraph
Module 1 Module 2 Module 3 Module 4 Module 5 Module 6
Module 2 · State & Memory
1 2-1 State Schema
2 2-2 Reducers
3 2-3 Multi Schemas
4 2-4 Trim & Filter
5 2-5 Chatbot Summary
6 2-6 Ext Memory
SUM Module Summary
HomeLangGraphModule 2 · State & Memory2-2 Reducers
📓 Notebook 📖 Explained
LANGGRAPH MODULE 2 · LESSON 2

State Reducers 状态归约器
精通 LangGraph 状态更新的核心机制

为什么节点并行执行会出错?为什么 messages 是追加而不是覆盖?
Reducer 是解决这一切问题的答案。

1 什么是 Reducer?从"默认覆盖"说起

在 LangGraph 中,每个节点执行完后都会返回一个字典,这个字典里是它想要更新的 State 字段。那么问题来了:当节点返回了新值,LangGraph 应该怎样把这个新值写入 State?

Reducer(归约器) 就是回答这个问题的机制。它定义了"对于 State 中的某个字段,当节点返回一个新值时,用什么规则来更新它"。

Reducer 函数签名:reducer(旧值, 新值) → 最终值

默认行为:直接覆盖(Last Write Wins)

如果你没有指定任何 Reducer,LangGraph 使用默认覆盖策略:节点返回的新值直接替换掉旧值,就好像做了一次赋值 state['foo'] = 新值

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

# 最简单的 State:没有任何 Reducer 注解
class State(TypedDict):
    foo: int   # 没有 Annotated,使用默认覆盖行为

def node_1(state):
    print("---Node 1---")
    return {"foo": state['foo'] + 1}  # 返回 foo + 1

builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", END)
graph = builder.compile()

graph.invoke({"foo": 1})
foo: 1
invoke 传入的初始 State
foo: 2
node_1 执行后的 State

输出是 {'foo': 2}。node_1 返回了 {"foo": 2},LangGraph 用 2 直接覆盖了旧值 1。这在单节点线性图里完全没问题。

为什么叫"Last Write Wins"?

在只有一个节点写入同一字段的情况下,"覆盖"是最直觉的行为。就像变量赋值一样:x = x + 1 让 x 变成新值。但当多个节点同时写入同一字段时,"谁的值算数"就产生了冲突,这正是下一节要讲的问题。

Reducer 的语法:Annotated 注解

给某个 State 字段指定 Reducer,需要使用 Python 的 Annotated 类型注解,格式如下:

from typing import Annotated

class State(TypedDict):
    # 格式:Annotated[字段类型, reducer函数]
    foo: Annotated[list[int], 某个reducer函数]
    #              ↑ 字段实际类型   ↑ 指定如何合并新旧值

Annotated[T, reducer_fn] 告诉 LangGraph:这个字段的类型是 T,当节点返回新值时,用 reducer_fn(旧值, 新值) 来计算最终值,而不是直接覆盖。

2 并行节点的冲突问题:为什么默认行为会报错

默认覆盖在单链路图里工作得很好,但当你的图有分支并行结构时,问题就来了。

场景:node_1 同时分叉到 node_2 和 node_3

class State(TypedDict):
    foo: int  # 没有 Reducer,默认覆盖

def node_1(state): return {"foo": state['foo'] + 1}
def node_2(state): return {"foo": state['foo'] + 1}
def node_3(state): return {"foo": state['foo'] + 1}

builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)

builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")  # node_1 → node_2
builder.add_edge("node_1", "node_3")  # node_1 → node_3(并行!)
builder.add_edge("node_2", END)
builder.add_edge("node_3", END)
graph = builder.compile()

这张图的结构

START
node_1
foo = foo + 1 → 串行
node_2
foo = foo + 1
node_3
foo = foo + 1
END

当你运行 graph.invoke({"foo": 1}) 时,会发生什么?

InvalidUpdateError occurred: At key 'foo': Can receive only one value per step. Use an Annotated key to handle multiple values.
问题的本质

node_2 和 node_3 在同一个执行步骤(step)内并行运行。它们都尝试写入同一个字段 foo,并且都用"覆盖"模式。LangGraph 无法确定"应该保留哪个值",因此直接抛出 InvalidUpdateError

执行时序如下:

执行步骤运行的节点写入 foo 的值结果
Step 1 node_1 1 + 1 = 2 foo = 2,没问题
Step 2 node_2 node_3(并行) node_2: 2+1=3,node_3: 2+1=3 冲突!哪个算数?报错!
什么时候会触发并行?

当你对同一个节点调用了两次 add_edge 指向不同的目标节点时,LangGraph 会在那一步同时启动两个目标节点。这在研究助手、Map-Reduce 等场景中非常常见。解决方案:给冲突字段添加 Reducer。

3 operator.add Reducer:用列表追加代替覆盖

解决并行冲突最直接的方式,是把 fooint 改成 list[int],并使用 operator.add 作为 Reducer。

operator.add 是什么?

Python 内置的 operator 模块里,operator.add 就是 + 运算符的函数形式:

当 State 字段类型是 list 时,operator.add 会把节点返回的新列表拼接(append/concat)到旧列表的末尾,而不是覆盖它。

from operator import add
from typing import Annotated
from typing_extensions import TypedDict

class State(TypedDict):
    # foo 现在是 list[int],使用 operator.add 来追加
    foo: Annotated[list[int], add]
    #              ↑ 列表类型        ↑ 拼接操作

def node_1(state):
    print("---Node 1---")
    return {"foo": [state['foo'][-1] + 1]}  # 返回一个列表!

def node_2(state):
    print("---Node 2---")
    return {"foo": [state['foo'][-1] + 1]}  # 同样返回一个列表

def node_3(state):
    print("---Node 3---")
    return {"foo": [state['foo'][-1] + 1]}

# 构建与之前相同的并行图
builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_1", "node_3")
builder.add_edge("node_2", END)
builder.add_edge("node_3", END)
graph = builder.compile()

graph.invoke({"foo": [1]})

执行过程拆解

以初始输入 {"foo": [1]} 为例,完整执行过程如下:

步骤节点节点返回Reducer 操作foo 的新值
Step 1 node_1 [2](旧[-1]=1,+1=2) [1] + [2] [1, 2]
Step 2(并行) node_2 [3](旧[-1]=2,+1=3) 合并两个并行结果 [1, 2, 3, 3]
Step 2(并行) node_3 [3](旧[-1]=2,+1=3) [1,2] + [3] + [3]
{'foo': [1, 2, 3, 3]}

注意到 3 出现了两次,因为 node_2 和 node_3 都基于 node_1 之后的状态(foo[-1] == 2)计算,各自得出 3,然后被 Reducer 全部追加进来。这正是 Reducer 的正确行为:把所有并行节点的输出都保留下来。

关键理解

有了 Reducer,LangGraph 不再需要"选择"哪个并行节点的结果,而是把所有节点的返回值都通过 Reducer 合并。这彻底解决了并行冲突问题,并且语义清晰:每个节点的贡献都被记录下来。

operator.add 的局限:不能处理 None

如果初始 State 传入 Nonefoo,会发生什么?

try:
    graph.invoke({"foo": None})
except TypeError as e:
    print(f"TypeError occurred: {e}")
TypeError occurred: can only concatenate list (not "NoneType") to list

operator.add 尝试执行 None + [2],Python 无法拼接 Nonelist,因此报错。这就是为什么我们需要自定义 Reducer

4 自定义 Reducer:处理 None 等边界情况

自定义 Reducer 就是一个普通的 Python 函数,签名为 reducer(left, right) → result,其中:

实现一个能处理 None 的自定义 Reducer

def reduce_list(left: list | None, right: list | None) -> list:
    """安全地合并两个列表,处理其中任意一个或两个为 None 的情况。

    Args:
        left (list | None): 第一个列表,或 None
        right (list | None): 第二个列表,或 None

    Returns:
        list: 包含两个输入列表所有元素的新列表。
              如果输入为 None,视作空列表处理。
    """
    if not left:
        left = []   # None → 空列表
    if not right:
        right = []  # None → 空列表
    return left + right  # 安全拼接

# 用 operator.add:不能处理 None
class DefaultState(TypedDict):
    foo: Annotated[list[int], add]

# 用自定义 reduce_list:能处理 None
class CustomReducerState(TypedDict):
    foo: Annotated[list[int], reduce_list]  # 换成自定义函数

对比:传入 None 时的表现差异

DefaultState(operator.add)

传入 {"foo": None}

TypeError: can only concatenate list (not "NoneType") to list

报错!operator.add 不知道如何拼接 None

CustomReducerState(reduce_list)

传入 {"foo": None}

{'foo': [2]}

成功!None 被当作空列表,正常追加 node_1 的输出 [2]

自定义 Reducer 的常见使用场景

除了处理 None,自定义 Reducer 还可以用于:去重(只保留唯一值)、限制列表长度(只保留最新的 N 个)、字典合并(深度合并而非浅层覆盖)、条件更新(只在满足某条件时才写入新值)。

Reducer 函数的完整调用时机

每次节点返回一个包含该字段的字典,Reducer 都会被调用一次:

5 add_messages Reducer:专为消息列表设计

在 LangGraph 的实际使用中,最常见的 State 字段是对话消息列表。LangGraph 为此提供了一个内置的、功能强大的 Reducer:add_messages

MessagesState 与自定义 State 的等价写法

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import MessagesState
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages

# 方式一:手动定义(完整控制)
class CustomMessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    added_key_1: str   # 可以继续添加其他字段
    added_key_2: str

# 方式二:继承 MessagesState(推荐,更简洁)
class ExtendedMessagesState(MessagesState):
    # MessagesState 已内置:messages: Annotated[list[AnyMessage], add_messages]
    added_key_1: str   # 只需添加额外字段即可
    added_key_2: str
MessagesState 是什么?

MessagesState 是 LangGraph 提供的一个内置基类,它已经预定义了 messages: Annotated[list[AnyMessage], add_messages] 字段。你继承它后,直接拥有这个带 Reducer 的消息字段,并可以自由添加其他字段。两种写法完全等价,MessagesState 只是省去了重复代码。

add_messages 的基本行为:追加消息

from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage, HumanMessage

# 模拟当前 State 中已有的消息列表
initial_messages = [
    AIMessage(content="Hello! How can I assist you?", name="Model"),
    HumanMessage(content="I'm looking for information on marine biology.", name="Lance")
]

# 节点返回了一条新消息
new_message = AIMessage(
    content="Sure, I can help with that. What specifically are you interested in?",
    name="Model"
)

# add_messages(旧列表, 新消息) → 追加后的列表
result = add_messages(initial_messages, new_message)
# result 包含 3 条消息:原来 2 条 + 新的 1 条
messages: [
AIMessage("Hello!")
HumanMessage("marine biology")
]
initial_messages(旧值)
messages: [
AIMessage("Hello!")
HumanMessage("marine biology")
AIMessage("Sure, I can help...")
]
add_messages 后(追加了新消息)

这正是聊天机器人所需要的行为:每轮对话产生的新消息都被追加到历史记录中,而不是替换掉整个历史。

add_messages 与 operator.add 的对比

特性operator.addadd_messages
基本操作 列表拼接(left + right 追加新消息,并支持 ID 去重覆写
适用类型 任意列表(int、str 等) 专门针对 LangChain Message 对象
处理 None 报 TypeError 安全处理,None 视为空列表
相同 ID 处理 直接拼接,产生重复 相同 ID 的新消息会覆写旧消息
消息删除 不支持 支持 RemoveMessage 删除指定消息

6 消息覆写:用相同 ID 替换已有消息

add_messages 有一个强大的隐藏特性:如果你传入的新消息与现有消息有相同的 ID,它会用新消息替换(覆写)旧消息,而不是追加

为什么需要消息覆写?

典型场景:用户发出了一条消息,但随后想要修改它(比如纠正一个错误的问题)。或者 AI 生成了一条消息,你想在保存前替换掉其中的内容。消息覆写让你能够精准地修改对话历史中的某一条记录。

from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage, HumanMessage

# 当前 State 中的消息(注意:都有显式的 id)
initial_messages = [
    AIMessage(content="Hello! How can I assist you?", name="Model", id="1"),
    HumanMessage(content="I'm looking for information on marine biology.", name="Lance", id="2")
]

# 新消息的 id="2",与上面第二条消息的 id 相同!
new_message = HumanMessage(
    content="I'm looking for information on whales, specifically",
    name="Lance",
    id="2"   # ← 关键:相同的 ID
)

result = add_messages(initial_messages, new_message)
# 结果:id="2" 的那条消息被替换成了新的内容
messages: [
id="1" AIMessage("Hello!")
id="2" HumanMessage("marine biology")
]
initial_messages(原始列表)
messages: [
id="1" AIMessage("Hello!")
id="2" HumanMessage("whales, specifically")
]
覆写后(id="2" 的内容被替换)
ID 自动生成 vs 手动指定

LangGraph 中的消息通常由 LLM 或工具自动生成,每条消息都有一个唯一的 UUID 作为 ID。如果你希望手动覆写某条消息,需要明确指定相同的 ID。通常你先从 State 中读取该消息的 ID,然后用相同的 ID 构造新消息传给 add_messages

实际应用:在节点中修正 LLM 的输出

def fix_last_message(state):
    messages = state["messages"]
    last_msg = messages[-1]  # 拿到最后一条 AI 消息

    # 构造一条相同 ID 的新消息,内容做了修正
    corrected = AIMessage(
        content=last_msg.content + " [已审核]",
        id=last_msg.id  # 相同 ID → 触发覆写
    )
    return {"messages": [corrected]}
    # add_messages 看到相同 ID,用 corrected 替换掉原来那条

7 消息删除:用 RemoveMessage 精准移除

add_messages 还支持一种特殊的"删除"操作:传入 RemoveMessage 对象,可以从消息列表中移除指定 ID 的消息

RemoveMessage 的工作原理

from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage

# 当前消息列表(4 条消息)
messages = [
    AIMessage("Hi.", name="Bot", id="1"),
    HumanMessage("Hi.", name="Lance", id="2"),
    AIMessage("So you said you were researching ocean mammals?", name="Bot", id="3"),
    HumanMessage("Yes, I know about whales. But what others should I learn about?", name="Lance", id="4")
]

# 构造删除指令:删除前两条消息(id="1" 和 id="2")
delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]]
print(delete_messages)
# [RemoveMessage(id="1"), RemoveMessage(id="2")]

# 执行删除
result = add_messages(messages, delete_messages)
# result 中只剩 id="3" 和 id="4" 的消息
messages: [
id="1" AIMessage("Hi.")
id="2" HumanMessage("Hi.")
id="3" AIMessage("ocean mammals?")
id="4" HumanMessage("whales...")
]
原始消息(id 1 和 2 将被删除)
messages: [
id="3" AIMessage("ocean mammals?")
id="4" HumanMessage("whales...")
]
删除后(只剩最近 2 条)

删除消息的典型使用场景

场景一:对话历史裁剪(节省 token)

def trim_old_messages(state):
    messages = state["messages"]
    if len(messages) > 10:  # 超过 10 条则删除最旧的
        to_delete = messages[:-10]
        return {"messages": [RemoveMessage(id=m.id) for m in to_delete]}
    return {}

场景二:删除工具调用中间消息(只保留最终回答)

from langchain_core.messages import ToolMessage

def clean_tool_messages(state):
    messages = state["messages"]
    # 删除所有工具调用相关的中间消息
    tool_msgs = [m for m in messages if isinstance(m, ToolMessage)]
    return {"messages": [RemoveMessage(id=m.id) for m in tool_msgs]}
RemoveMessage 的本质

RemoveMessage 不是"真正的消息内容",而是一个操作指令:当 add_messages 收到 RemoveMessage(id="1"),它会在现有列表中找到 id="1" 的消息并删除它。这个机制让你在 Node 的返回值里直接表达"删除某条消息"的意图,非常优雅。

8 总结:Reducer 选型指南与最佳实践

四种 Reducer 策略对比

策略 语法 行为 适用场景
默认覆盖 foo: int 新值直接替换旧值 计数器、标志位、单节点写入的字段
operator.add foo: Annotated[list[int], add] 新列表拼接到旧列表末尾 简单列表追加、并行节点收集结果
自定义函数 foo: Annotated[list, reduce_list] 任意自定义合并逻辑 需要处理 None、去重、限长等复杂场景
add_messages messages: Annotated[list[AnyMessage], add_messages] 追加消息 + ID 覆写 + RemoveMessage 删除 对话历史、Agent 消息状态(最常用)

选型决策树

Q1

这个字段会被多个并行节点同时写入吗?

如果:可以用默认覆盖(单节点线性写入完全没问题)。
如果:必须使用 Reducer,否则会报 InvalidUpdateError。

Q2

字段是对话消息列表吗?

如果:直接用 add_messages,或继承 MessagesState,开箱即用。

Q3

字段是普通列表,且初始值保证不为 None?

如果:用 operator.add 即可,简洁高效。

Q4

需要处理 None 或有其他复杂合并逻辑?

自定义 Reducer 函数,完全自由控制合并行为。

关键概念总结

Reducer 的本质
  • • 一个 (旧值, 新值) → 最终值 的函数
  • • 通过 Annotated[T, fn] 注解绑定到字段
  • • 每次节点写入该字段时自动调用
  • • 解决并行节点写入冲突的唯一官方方式
add_messages 的能力
  • • 追加新消息到消息列表
  • • 相同 ID 的消息触发覆写
  • • RemoveMessage 触发删除
  • • MessagesState 内置此 Reducer
并行节点的注意事项
  • • 同一步骤内的所有节点并行执行
  • • 写同一字段必须有 Reducer
  • • Reducer 按节点返回顺序依次合并
  • • 没有 Reducer → InvalidUpdateError
自定义 Reducer 的设计原则
  • • 函数签名:(left, right) → result
  • • 处理所有可能的输入(包括 None)
  • • 保持幂等性(无副作用)
  • • 可以任意复杂,只要最终返回正确类型
Module 2 的整体学习路径

Reducer 是 Module 2 的核心机制。理解了 Reducer,你就理解了为什么 messages 字段能在多轮对话中保持完整历史,为什么并行节点不会互相覆盖结果。后续课程中,无论是对话摘要、内存管理,还是多 Schema 设计,Reducer 都是底层的基石。只要你在 State 字段上加上 Annotated[T, reducer_fn],这个字段的更新行为就完全由你掌控。

一张图看懂 Reducer 的作用

无 Reducer vs 有 Reducer(并行节点写同一字段)

无 Reducer(默认覆盖)
node_1 → foo=2
node_2
foo=3
node_3
foo=3
InvalidUpdateError
冲突!谁的 foo 算数?
有 Reducer(operator.add)
node_1 → [2]
node_2
[3]
node_3
[3]
foo = [1, 2, 3, 3]
全部追加,无冲突!