培歌行学LangGraph(1):从 LangChain 到 LangGraph 的思维跃迁
一、从问题出发:多个节点都要写同一个字段怎么办?
在 LangGraph 中,多个节点会依次执行,每个节点都可能修改 State。这就产生了一个问题:
如果节点 A 把
counter设为 1,节点 B 又把counter设为 2,那最终counter应该是多少?
有两个答案:
- 2(后一个覆盖前一个)—— 这是默认行为
- 3(1 + 2 累加起来)—— 这是 Reducer 改变后的行为
Reducer 就是用来回答这个问题的工具。
二、默认行为 vs Reducer 行为
2.1 默认行为:覆盖(Override)
如果不指定 Reducer,LangGraph 的默认行为是覆盖:
class MyState(TypedDict):
counter: int # 没有 Reducer
name: str # 没有 Reducer
执行流程:
初始状态:counter = 0
节点 A 返回:{"counter": 1}
→ 状态变为:counter = 1
节点 B 返回:{"counter": 2}
→ 状态变为:counter = 2 (覆盖了节点 A 的 1)
最终结果:counter = 2
适用于:只需要最终值的场景,比如用户名、当前状态标志等。
2.2 Reducer 行为:合并(Merge)
指定 Reducer 后,行为变为合并:
from typing import Annotated
import operator
class MyState(TypedDict):
counter: Annotated[int, operator.add] # 使用加法 Reducer
执行流程:
初始状态:counter = 0
节点 A 返回:{"counter": 1}
→ 状态变为:counter = 1
节点 B 返回:{"counter": 2}
→ 状态变为:counter = 3 (1 + 2 = 3,累加了!)
最终结果:counter = 3
适用于:需要累积的场景,比如计数器、总分、消息列表等。
三、Reducer 的本质:一个普通函数
Reducer 并没有什么魔法,它就是一个普通的 Python 函数,接收两个参数:
def reducer_func(current_value, new_value) -> final_value:
"""
current_value:当前 State 中该字段的值
new_value:节点返回的该字段的新值
return:合并后的最终值
"""
# 在这里定义你的合并逻辑
return merged_result
LangGraph 内置了几个常用的 Reducer:
| Reducer 函数 | 效果 | 等价于 |
|---|---|---|
operator.add | 数值相加 | lambda a, b: a + b |
operator.set | 集合合并 | lambda a, b: a | b |
add_messages | 消息列表追加 | 特殊处理,含 ID 去重 |
| 不指定 | 覆盖 | lambda a, b: b |
四、用生活例子彻底搞懂
场景:记账本
你和室友共用一本账本,记录每天的开销。
没有 Reducer(覆盖模式):
周一:小明记了"吃饭 50元" → 账本:[吃饭 50元]
周二:小红记了"打车 30元" → 账本:[打车 30元] ← 周一的内容被覆盖了!
周三:你们吵架了,因为周一的账找不到了
有 Reducer(追加模式):
周一:小明记了"吃饭 50元" → 账本:[吃饭 50元]
周二:小红记了"打车 30元" → 账本:[吃饭 50元, 打车 30元] ← 追加在后面
周三:月底算账,清清楚楚
在这个例子里,add就是一个 Reducer,它的规则是:新来的记录追加到旧记录的后面,而不是替换它。
五、为什么 LangGraph 需要 Reducer?
原因一:图不是线性执行的
在 LangGraph 中,图可能有分支和循环:
┌→ 节点 B ─→┐
START ─→┤ ├→ 节点 D
└→ 节点 C ─→┘
节点 B 和节点 C 都可能修改同一个字段。如果没有 Reducer,后执行的节点会抹掉前一个节点的修改。有了 Reducer,两者的贡献可以共存。
原因二:循环需要累积
节点 A → 条件判断 → 未满足 → 回到节点 A(循环)
在循环中,每次经过节点 A 都可能产生新数据。Reducer 确保这些数据是累积的,而不是每次循环都重置。
原因三:可预测的状态变化
Reducer 让状态变化变得透明和可预测。你知道每个字段的更新规则是什么,不会出现"咦,这个值怎么不见了?"的困惑。
六、实战:自定义 Reducer
内置 Reducer 不能满足需求时,可以自己写:
示例1:限制列表长度
from typing import List, Annotated
def last_n_reducer(n: int):
"""只保留最近 n 条记录"""
def reducer(old: list, new: list) -> list:
combined = old + new
return combined[-n:]
return reducer
class MyState(TypedDict):
recent_logs: Annotated[List, last_n_reducer(10)] # 只保留最近10条
示例2:字典合并
from typing import Dict, Annotated
def dict_merge(old: dict, new: dict) -> dict:
"""合并两个字典,新值覆盖旧值"""
merged = old.copy()
merged.update(new)
return merged
class MyState(TypedDict):
metadata: Annotated[Dict, dict_merge] # 字典合并
示例3:取最大值
from typing import Annotated
def max_reducer(old: float, new: float) -> float:
"""保留最大值"""
return max(old, new)
class MyState(TypedDict):
max_score: Annotated[float, max_reducer] # 保留最高分
七、Reducer 的完整工作流程
为了让你彻底明白,这里是 LangGraph 内部处理 Reducer 的完整流程:
1. 节点执行完毕,返回一个字典,比如 {"counter": 5}
2. LangGraph 遍历这个字典的每个键值对
3. 对于每个键(比如 "counter"):
a. 查找 State 定义中这个键有没有 Reducer
b. 如果有 Reducer:
- 获取当前 State 中 "counter" 的旧值(比如 3)
- 调用 Reducer 函数:reducer(旧值=3, 新值=5)
- 把 Reducer 的返回值(比如 8)写入 State
c. 如果没有 Reducer:
- 直接用新值(5)覆盖旧值(3)
4. 更新后的 State 传递给下一个节点
八、总结
| 行为 | 写法 | 效果 |
|---|---|---|
| 覆盖 | counter: int | 后写的覆盖先写的 |
| 累加 | counter: Annotated[int, operator.add] | 数值相加 |
| 追加 | messages: Annotated[List, add_messages] | 列表追加 |
| 自定义 | field: Annotated[Type, my_reducer] | 自定义合并逻辑 |
一句话记住 Reducer:
Reducer 就是告诉 LangGraph:当多个节点都想写同一个字段时,是"替换它"还是"合并它",以及怎么合并。
九、相关面试题
面试题1:如果不给字段指定 Reducer,多个节点写入同一个字段会怎样?
参考答案:
默认行为是覆盖。后执行的节点返回的值会完全替换先执行节点设置的值。
这在某些场景下是合理的(比如最终状态只需要最后一个节点的输出),但在另一些场景下会出问题(比如消息列表,旧消息会被丢掉)。
面试题2:add_messages和 operator.add有什么区别?
参考答案:
两者都能实现"追加"效果,但有重要区别:
| 对比项 | operator.add | add_messages |
|---|---|---|
| 适用类型 | 任意列表或数值 | BaseMessage消息列表 |
| 去重能力 | 无 | 有(基于消息 ID) |
| 删除能力 | 无 | 有(可标记删除) |
add_messages是专门为消息列表设计的,它不仅能追加,还能根据消息 ID 去重和删除,更适合对话场景。
面试题3:自定义 Reducer 函数的参数是什么?返回值是什么?
参考答案:
自定义 Reducer 接收两个参数:
current_value:当前 State 中该字段的值new_value:节点返回的该字段的新值
返回值是合并后的最终值,类型应与字段类型一致。
def my_reducer(current_value: int, new_value: int) -> int:
return current_value + new_value
面试题4:如果我想让一个字段"只允许写入一次,后续写入都无效",该怎么实现?
参考答案:
可以自定义一个 Reducer 来实现:
def write_once(old, new):
"""只允许第一次写入,后续写入忽略"""
return old if old else new
class MyState(TypedDict):
initialized: Annotated[bool, write_once] # 一旦设为 True,后续无法改为 False
created_at: Annotated[str, write_once] # 一旦写入,不可更改
面试题5:Reducer 在并行节点中是如何工作的?
参考答案:
当多个节点在同一个超步中并行执行时,每个节点都会返回自己的状态更新。LangGraph 会在该超步结束时,按顺序将这些更新应用到 State 上。
对于有 Reducer 的字段,每次应用更新时都会调用 Reducer 函数,将当前值与新值合并。因此,所有并行节点的贡献都会被保留(根据 Reducer 的规则)。
例如,三个并行节点都返回 {"count": 1},如果 count使用了 operator.add,最终结果是 3。