Why does node parallel execution error? Why are messages appended instead of overwritten?
reducer is the answer to all these problems.
In LangGraph, each node will return a dictionary after execution. This dictionary contains the State field it wants to update. So here comes the question:When a node returns a new value, how should LangGraph write the new value to State?
reducerIt’s the mechanism that answers this question. It defines "what rules are used to update a field in State when the node returns a new value."
If you don't specify any reducer, LangGraph usesDefault coverage strategy: The new value returned by the node directly replaces the old value, as if an assignment was made.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})
The output is{'foo': 2}. node_1 returned{"foo": 2}, LangGraph uses2Directly overwrites the old value1. This is perfectly fine in single-node linear graphs.
In the case where only one node writes to the same field, "overwrite" is the most intuitive behavior. Just like variable assignment:x = x + 1Let x become the new value. But whenMultiple nodes write to the same field at the same timeAt this time, a conflict arises over "whose value counts", which is exactly what the next section will talk about.
To specify a reducer for a State field, you need to use Python'sAnnotatedType annotation, the format is as follows:
from typing import Annotated
class State(TypedDict):
# 格式:Annotated[字段类型, reducer函数]
foo: Annotated[list[int], 某个reducer函数]
# ↑ 字段实际类型 ↑ 指定如何合并新旧值
Annotated[T, reducer_fn]Tell LangGraph:The type of this field is T. When the node returns a new value, reducer_fn(old value, new value) is used to calculate the final value., rather than covering it directly.
The default overlay works fine in single-link graphs, but when your graph hasbranch parallelismThe problem comes when it comes to structure.
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()
When you rungraph.invoke({"foo": 1})What will happen?
node_2 and node_3 are inRun in parallel within the same execution step (step). They both try to write to the same fieldfoo, and all use "overwrite" mode. LangGraph cannot determine "which value should be retained", so it throwsInvalidUpdateError。
The execution sequence is as follows:
| Execution steps | running node | Write the value of foo | result |
|---|---|---|---|
| Step 1 | node_1 | 1 + 1 = 2 | foo = 2, no problem |
| Step 2 | node_2 andnode_3 (parallel) | node_2: 2+1=3,node_3: 2+1=3 | conflict! Which one counts? Report an error! |
When you call the same node twiceadd_edgeWhen pointing to different target nodes, LangGraph willstart simultaneouslyTwo target nodes. This is very common in scenarios such as research assistants, Map-Reduce, etc. Solution: Add a reducer to the conflicting field.
The most direct way to resolve parallel conflicts is tofoofromintChange tolist[int], and useoperator.addAs a reducer.
Python built-inoperatorIn the module,operator.addthat is+Functional form of operator:
add(1, 2) → 3(numbers added)add([1, 2], [3]) → [1, 2, 3](list splicing)add("ab", "cd") → "abcd"(String concatenation)When the State field type islisthour,operator.addwill return a new list of nodesSplicing(append/concat) to the end of the old list instead of overwriting it.
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]})
with initial input{"foo": [1]}For example, the complete execution process is as follows:
| step | node | Node returns | reducer operation | the new value of foo |
|---|---|---|---|---|
| Step 1 | node_1 | [2](Old[-1]=1, +1=2) |
[1] + [2] |
[1, 2] |
| Step 2 (parallel) | node_2 | [3](Old[-1]=2, +1=3) |
Merge two parallel results | [1, 2, 3, 3] |
| Step 2 (parallel) | node_3 | [3](Old[-1]=2, +1=3) |
[1,2] + [3] + [3] |
noticed3appears twice because both node_2 and node_3 are based onnode_1The subsequent state (foo[-1] == 2) calculation, each can get3, and then reducerAppend allCome in. This is exactly the correct behavior of the reducer: keep the output of all parallel nodes.
With reducer, LangGraph no longer needs to "select" the results of which parallel node, but insteadThe return values of all nodes are merged through the reducer. This completely solves the problem of parallel conflicts, and the semantics are clear: the contribution of each node is recorded.
If the initial State is passed inNoneGivefoo,what happens?
try:
graph.invoke({"foo": None})
except TypeError as e:
print(f"TypeError occurred: {e}")
operator.addtry to executeNone + [2], Python cannot spliceNoneandlist, so an error is reported. That's why we needCustom reducer。
A custom reducer is an ordinary Python function with a signature ofreducer(left, right) → result,in:
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] # 换成自定义函数
incoming{"foo": None}
Report an error!operator.addDon't know how to splice None
incoming{"foo": None}
success! None is treated as an empty list, and the output of node_1 is appended normally.[2]
In addition to handling None, custom reducers can also be used for:Remove duplicates(only keep unique values),Limit list length(Only keep the latest N),Dictionary merge(deep merging rather than shallow coverage),condition update(A new value is only written when a certain condition is met).
Each time the node returns a dictionary containing the field, the reducer is called once:
For examplereturn {"foo": [2]}
Read the old value of foo in the current State (left) and the new value returned by the node (right)
reduce_list(旧值, 新值)Return the merged result
If there are multiple parallel nodes, first collect the return values of all nodes, and then merge them through the reducer.
In actual use of LangGraph, the most common State field isConversation message list. LangGraph provides a built-in, powerful reducer for this purpose: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
MessagesStateis a built-in base class provided by LangGraph, which has been predefinedmessages: Annotated[list[AnyMessage], add_messages]field. After you inherit it, you directly own the message field with reducer and can add other fields freely. The two ways of writing are completely equivalent.MessagesStateIt just saves you the duplication of code.
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 条
This is exactly the desired behavior of a chatbot: new messages generated by each round of conversation areAppendinto the history, rather than replacing the entire history.
| characteristic | operator.add | add_messages |
|---|---|---|
| Basic operations | List concatenation (left + right) |
Append new messages and support ID to overwrite |
| Applicable type | any list (int, str, etc.) | Specifically for LangChain Message objects |
| Handle None | Report TypeError | Safe handling, None is treated as an empty list |
| Same ID processing | Direct splicing, resulting in duplication | New messages with the same ID will overwrite old messages |
| Message deletion | Not supported | Support RemoveMessage to delete specified messages |
add_messagesThere is a powerful hidden feature:If you pass in a new message with the same ID as an existing message, it will replace (overwrite) the old message with the new message instead of appending。
Typical scenario: A user sends a message but then wants to modify it (such as correcting a wrong question). Or the AI generated a message and you want to replace the content before saving. Message overwrite allows you to precisely modify a record in the conversation history.
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 in LangGraph are usually automatically generated by LLM or tools, and each message has a unique UUID as ID. If you want to manually overwrite a message, you needExplicitly specify the same ID. Usually you first read the ID of the message from State, then construct a new message with the same ID and pass it toadd_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_messagesAlso supports a special "delete" operation: passing inRemoveMessageObject, which can be selected from the message listRemove message with specified 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]}
RemoveMessageNot the "real message content", but aOperation instructions:whenadd_messagesreceiveRemoveMessage(id="1"), which will be found in the existing listid="1"message and delete it. This mechanism allows you to directly express the intention of "deleting a message" in the return value of Node, which is very elegant.
| Strategy | grammar | Behavior | Applicable scenarios |
|---|---|---|---|
| Default override | foo: int |
The new value directly replaces the old value | Counters, flags, fields written by a single node |
| operator.add | foo: Annotated[list[int], add] |
The new list is spliced to the end of the old list | Simple list append, parallel node collection results |
| Custom function | foo: Annotated[list, reduce_list] |
Any custom merge logic | Need to handle complex scenarios such as None, deduplication, length limit, etc. |
| add_messages | messages: Annotated[list[AnyMessage], add_messages] |
Append message + ID overwrite + RemoveMessage delete | Conversation history, Agent message status (most commonly used) |
Will this field be written to by multiple parallel nodes simultaneously?
ifno: Can be overwritten by default (linear writing to a single node is no problem).
ifyes: reducer must be used, otherwise InvalidUpdateError will be reported.
Is the field a list of conversation messages?
ifyes: Use directlyadd_messages, or inheritMessagesState, ready to use right out of the box.
The field is a normal list and the initial value is guaranteed not to be None?
ifyes:useoperator.addThat’s it, simple and efficient.
Need to handle None or have other complex merge logic?
useCustom reducer function, complete freedom of control over merge behavior.
(旧值, 新值) → 最终值functionAnnotated[T, fn]Annotations are bound to fields(left, right) → resultreducer is the core mechanism of Module 2. If you understand reducer, you will understand whymessagesFields maintain complete history across multiple rounds of dialogue, why parallel nodes don't overwrite each other's results. In subsequent courses, whether it is conversation summarization, memory management, or multi-schema design, reducer is the underlying cornerstone.As long as you add to the State fieldAnnotated[T, reducer_fn], the update behavior of this field is completely under your control.