为什么节点并行执行会出错?为什么 messages 是追加而不是覆盖?
Reducer 是解决这一切问题的答案。
在 LangGraph 中,每个节点执行完后都会返回一个字典,这个字典里是它想要更新的 State 字段。那么问题来了:当节点返回了新值,LangGraph 应该怎样把这个新值写入 State?
Reducer(归约器) 就是回答这个问题的机制。它定义了"对于 State 中的某个字段,当节点返回一个新值时,用什么规则来更新它"。
如果你没有指定任何 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': 2}。node_1 返回了 {"foo": 2},LangGraph 用 2 直接覆盖了旧值 1。这在单节点线性图里完全没问题。
在只有一个节点写入同一字段的情况下,"覆盖"是最直觉的行为。就像变量赋值一样:x = x + 1 让 x 变成新值。但当多个节点同时写入同一字段时,"谁的值算数"就产生了冲突,这正是下一节要讲的问题。
给某个 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(旧值, 新值) 来计算最终值,而不是直接覆盖。
默认覆盖在单链路图里工作得很好,但当你的图有分支并行结构时,问题就来了。
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()
当你运行 graph.invoke({"foo": 1}) 时,会发生什么?
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。
解决并行冲突最直接的方式,是把 foo 从 int 改成 list[int],并使用 operator.add 作为 Reducer。
Python 内置的 operator 模块里,operator.add 就是 + 运算符的函数形式:
add(1, 2) → 3(数字相加)add([1, 2], [3]) → [1, 2, 3](列表拼接)add("ab", "cd") → "abcd"(字符串拼接)当 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] |
注意到 3 出现了两次,因为 node_2 和 node_3 都基于 node_1 之后的状态(foo[-1] == 2)计算,各自得出 3,然后被 Reducer 全部追加进来。这正是 Reducer 的正确行为:把所有并行节点的输出都保留下来。
有了 Reducer,LangGraph 不再需要"选择"哪个并行节点的结果,而是把所有节点的返回值都通过 Reducer 合并。这彻底解决了并行冲突问题,并且语义清晰:每个节点的贡献都被记录下来。
如果初始 State 传入 None 给 foo,会发生什么?
try:
graph.invoke({"foo": None})
except TypeError as e:
print(f"TypeError occurred: {e}")
operator.add 尝试执行 None + [2],Python 无法拼接 None 和 list,因此报错。这就是为什么我们需要自定义 Reducer。
自定义 Reducer 就是一个普通的 Python 函数,签名为 reducer(left, right) → result,其中:
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] # 换成自定义函数
传入 {"foo": None}
报错!operator.add 不知道如何拼接 None
传入 {"foo": None}
成功!None 被当作空列表,正常追加 node_1 的输出 [2]
除了处理 None,自定义 Reducer 还可以用于:去重(只保留唯一值)、限制列表长度(只保留最新的 N 个)、字典合并(深度合并而非浅层覆盖)、条件更新(只在满足某条件时才写入新值)。
每次节点返回一个包含该字段的字典,Reducer 都会被调用一次:
例如 return {"foo": [2]}
读取当前 State 中 foo 的旧值(left)和节点返回的新值(right)
reduce_list(旧值, 新值) 返回合并后的结果
如有多个并行节点,先收集所有节点的返回值,再依次经过 Reducer 合并
在 LangGraph 的实际使用中,最常见的 State 字段是对话消息列表。LangGraph 为此提供了一个内置的、功能强大的 Reducer:add_messages。
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 是 LangGraph 提供的一个内置基类,它已经预定义了 messages: Annotated[list[AnyMessage], add_messages] 字段。你继承它后,直接拥有这个带 Reducer 的消息字段,并可以自由添加其他字段。两种写法完全等价,MessagesState 只是省去了重复代码。
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 条
这正是聊天机器人所需要的行为:每轮对话产生的新消息都被追加到历史记录中,而不是替换掉整个历史。
| 特性 | operator.add | add_messages |
|---|---|---|
| 基本操作 | 列表拼接(left + right) |
追加新消息,并支持 ID 去重覆写 |
| 适用类型 | 任意列表(int、str 等) | 专门针对 LangChain Message 对象 |
| 处理 None | 报 TypeError | 安全处理,None 视为空列表 |
| 相同 ID 处理 | 直接拼接,产生重复 | 相同 ID 的新消息会覆写旧消息 |
| 消息删除 | 不支持 | 支持 RemoveMessage 删除指定消息 |
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" 的那条消息被替换成了新的内容
LangGraph 中的消息通常由 LLM 或工具自动生成,每条消息都有一个唯一的 UUID 作为 ID。如果你希望手动覆写某条消息,需要明确指定相同的 ID。通常你先从 State 中读取该消息的 ID,然后用相同的 ID 构造新消息传给 add_messages。
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 替换掉原来那条
add_messages 还支持一种特殊的"删除"操作:传入 RemoveMessage 对象,可以从消息列表中移除指定 ID 的消息。
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" 的消息
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 不是"真正的消息内容",而是一个操作指令:当 add_messages 收到 RemoveMessage(id="1"),它会在现有列表中找到 id="1" 的消息并删除它。这个机制让你在 Node 的返回值里直接表达"删除某条消息"的意图,非常优雅。
| 策略 | 语法 | 行为 | 适用场景 |
|---|---|---|---|
| 默认覆盖 | 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 消息状态(最常用) |
这个字段会被多个并行节点同时写入吗?
如果否:可以用默认覆盖(单节点线性写入完全没问题)。
如果是:必须使用 Reducer,否则会报 InvalidUpdateError。
字段是对话消息列表吗?
如果是:直接用 add_messages,或继承 MessagesState,开箱即用。
字段是普通列表,且初始值保证不为 None?
如果是:用 operator.add 即可,简洁高效。
需要处理 None 或有其他复杂合并逻辑?
用自定义 Reducer 函数,完全自由控制合并行为。
(旧值, 新值) → 最终值 的函数Annotated[T, fn] 注解绑定到字段(left, right) → resultReducer 是 Module 2 的核心机制。理解了 Reducer,你就理解了为什么 messages 字段能在多轮对话中保持完整历史,为什么并行节点不会互相覆盖结果。后续课程中,无论是对话摘要、内存管理,还是多 Schema 设计,Reducer 都是底层的基石。只要你在 State 字段上加上 Annotated[T, reducer_fn],这个字段的更新行为就完全由你掌控。