graph-core 的 KeyStrategy 解决的是状态在节点间传递时怎么合并的问题。没有它,对话应用和并行节点都会直接失效:
- 对话历史丢失:默认行为是后值覆盖前值,多轮对话里上一轮的消息会被下一轮覆盖,LLM 看不到上下文
- 并行节点互相覆盖:多个节点同时写同一个键,只保留最后完成的那个,其他结果全丢
源码用键级策略把这两个问题一起解决——每个键关联一个合并策略,控制状态在节点间传递时如何更新。这里把读到的内容整理下来,重点在策略为什么做成键级、为什么可插拔,以及并行场景下它到底兜住了什么。
没有策略会怎样
先看最直白的对话历史丢失。没有策略时,默认行为是 REPLACE(覆盖),会导致历史对话丢失:
StateGraph graph = new StateGraph()
.addNode("round1", state -> Map.of("messages", "你好"))
.addNode("round2", state -> Map.of("messages", "再见"))
.addEdge("round1", "round2");
// 执行结果
round1: {"messages": "你好"}
round2: {"messages": "再见"} // "你好" 丢失了
// 最终状态
{"messages": "再见"} // 只剩最后一条消息
配上 APPEND 策略后,消息累积起来:
KeyStrategyFactory factory = KeyStrategyFactoryBuilder.builder()
.addDefault("messages", NodeAggregationStrategy.APPEND) // 追加
.build();
// 执行结果
round1: {"messages": ["你好"]}
round2: {"messages": ["你好", "再见"]} // 累积了
// 最终状态
{"messages": ["你好", "再见"]} // 完整历史
对话场景里这是刚需——LLM 必须看到完整历史才能基于上下文回复。
并行节点才是策略真正的价值
对话历史丢失还只是单线程问题,并行节点才是策略真正兜底的场景。三个审批者同时审批一个文档:
nodeA: {"approvals": ["Alice: 通过"]}
nodeB: {"approvals": ["Bob: 通过"]}
nodeC: {"approvals": ["Carol: 拒绝"]}
// 没有策略:只保留最后完成的
{"approvals": ["Carol: 拒绝"]} // Alice 和 Bob 的意见丢失
// 有 APPEND 策略:合并所有结果
{"approvals": ["Alice: 通过", "Bob: 通过", "Carol: 拒绝"]}
并行执行加上无策略,本质就是数据竞争(Race Condition);并行执行加上策略,才是可预测的合并。实际代码:
// StateGraphParallelTest.java
KeyStrategyFactory factory = () -> {
Map<String, KeyStrategy> map = new HashMap<>();
map.put("messages", new AppendStrategy()); // 并行节点的消息追加
map.put("nodeId", new ReplaceStrategy()); // 节点 ID 替换
return map;
};
StateGraph graph = new StateGraph(factory)
.addNode("parallel1", makeNode("A"))
.addNode("parallel2", makeNode("B"))
.addNode("parallel3", makeNode("C"))
.addEdge(START, List.of("parallel1", "parallel2", "parallel3")) // 并行
.addEdge(List.of("parallel1", "parallel2", "parallel3"), END);
// 执行结果(三个节点并行)
// 最终状态:
// "messages": ["A", "B", "C"] 合并所有消息
// "nodeId": "C" 最后完成的节点 ID
注意这里同一个图里配了两种策略:messages 用 APPEND,nodeId 用 REPLACE。这正是策略要做成键级的原因——不同键的合并语义不同,一个要累积,一个要覆盖。
三种内置策略的语义
不同类型的数据有不同的合并语义,graph-core 内置三种策略对应三类场景:
| 数据类型 | 业务语义 | 合并策略 |
|---|---|---|
| 对话消息 | 历史累积 | APPEND(追加) |
| 用户状态 | 最新覆盖 | REPLACE(替换) |
| 配置对象 | 字段合并 | MERGE(深度合并) |
| 审批意见 | 多方收集 | APPEND(追加) |
| 计数器 | 累加求和 | 自定义策略 |
策略让合并逻辑与业务语义匹配,而不是用技术默认行为(覆盖)硬套所有场景。MERGE 策略处理嵌套对象合并:
// 节点 A 输出
{
"user": { "name": "Alice", "age": 30 }
}
// 节点 B 输出
{
"user": { "email": "alice@example.com" }
}
// MERGE 策略结果
{
"user": {
"name": "Alice",
"age": 30,
"email": "alice@example.com" // 字段合并,不是替换
}
}
实现是浅合并:
// MergeStrategy.java:47-50
if (oldValue instanceof Map && newValue instanceof Map) {
Map<Object, Object> mergedMap = new HashMap<>((Map<?, ?>) oldValue);
mergedMap.putAll((Map<?, ?>) newValue); // 浅合并
return mergedMap;
}
为什么做成键级策略
策略层级有三种可选方案:
| 方案 | 示例 | 优势 | 劣势 |
|---|---|---|---|
| 全局策略 | 所有键都 APPEND | 简单 | 无法区分不同语义 |
| 节点级策略 | 每个节点指定策略 | 灵活 | 节点间不一致 |
| 键级策略 | messages: APPEND、userId: REPLACE |
语义清晰、一致 | 需要预先定义 |
graph-core 选的是键级策略,理由归结为三点:
- 语义与数据绑定:
messages的合并语义(追加)与键本身相关,而不是与节点相关。不管哪个节点写messages,都应该追加 - 全局一致性:所有节点对
messages的理解一致,不会出现这个节点追加、那个节点覆盖 - 并行安全:并行节点自动使用相同策略,不需要节点自己协调
keyStrategyFactory 在 StateGraph 构造时传入,整个图共享一份策略定义。
为什么策略可插拔
内置三种策略覆盖不了所有场景。计数器要累加,配置要深度合并,投票要统计多数——这些是领域逻辑,框架不该预设。所以策略做成可插拔接口:
// 累加策略(计数器)
class SumStrategy implements KeyStrategy {
@Override
public Object apply(Object oldValue, Object newValue) {
int old = (Integer) (oldValue != null ? oldValue : 0);
int newVal = (Integer) newValue;
return old + newVal; // 累加
}
}
// 使用
factory.addDefault("totalScore", new SumStrategy());
可插拔的意义在于:框架只提供机制和内置实现,合并语义由用户按领域模型定义,支持加权平均、投票、过滤这类复杂场景。
节点只返回增量,合并交给框架
策略存在的另一个价值,是把节点从状态管理里解耦出来。没有策略时,节点得自己手动合并:
nodeAction.apply(state, config) {
// 节点内部需要读取旧值
List<String> oldMessages = state.value("messages");
List<String> newMessages = new ArrayList<>(oldMessages);
newMessages.add("新消息");
return Map.of("messages", newMessages); // 手动合并
}
这种写法的问题:节点代码冗长,并行时仍然有竞争,合并逻辑重复在每个节点里。有策略后,节点只返回增量:
nodeAction.apply(state, config) {
return Map.of("messages", "新消息"); // 只返回新数据
}
// 框架自动合并:["旧消息1", "旧消息2", "新消息"]
好处归结为三点:
- 职责单一:节点只管计算,合并交给框架,关注点分离
- 并发统一:框架统一处理并发,节点不用自己加锁
- 可测试:节点无状态,返回纯增量,单测不用构造完整上下文
没有策略的替代方案为什么不可行
再对比几种替代方案,逐一排除:
方案 1:让节点手动合并 节点直接修改状态里的列表。问题:并行节点会互相覆盖(并发修改),节点需要知道状态结构(耦合),无法统一审计状态变化。
方案 2:每个节点返回完整状态 节点返回所有字段。问题:节点代码冗长,并行节点无法工作(每个都返回完整状态,谁覆盖谁),状态变更不透明(diff 困难)。
方案 3:框架总是追加
所有键都 APPEND。问题:不符合所有场景的语义,单值字段(如 userId)会变成列表 ["Alice", "Bob"]。
三种方案都绕不开同一个矛盾:要么语义不对,要么并发不安全,要么节点耦合。策略合并是唯一既支持语义化合并、又能处理并行场景的方案。
AppendStrategy 的两个细节
AppendStrategy 有两个容易忽略的特性,在源码里看到才注意到。
支持去重:
// AppendStrategy.java:33-40
public AppendStrategy(boolean allowDuplicate) {
this.allowDuplicate = allowDuplicate;
}
// 使用
new AppendStrategy(false) // 去重
标签收集场景有用:
nodeA: {"tags": ["Java", "Spring"]}
nodeB: {"tags": ["Spring", "AI"]}
// 去重后: {"tags": ["Java", "Spring", "AI"]}
支持删除元素:
// AppendStrategy.java:58-61
if (newValue instanceof AppenderChannel.RemoveIdentifier<?>) {
removeFromList(result, (AppenderChannel.RemoveIdentifier) newValue);
}
撤销操作场景有用:
state: {"items": ["A", "B", "C"]}
update: {"items": RemoveIdentifier("B")}
// 结果: {"items": ["A", "C"]}
这两个特性让 APPEND 不只是单向累加,还能反向操作,覆盖了「累积 + 去重 + 删除」三类需求。
落到真实场景
把策略合并的价值落到几个实际场景里看更清楚:
多轮对话 Agent:
// 没有 APPEND:对话无法进行
round1: {"messages": [UserMessage("你好")]}
round2: {"messages": [AssistantMessage("你好!")]} // 用户消息丢失
// LLM 看到:[AssistantMessage("你好!")] 没有上下文,无法回复
// 有 APPEND:完整上下文
round1: {"messages": [UserMessage("你好")]}
round2: {"messages": [UserMessage("你好"), AssistantMessage("你好!")]}
// LLM 看到:完整对话历史 可以基于上下文回复
多 Agent 并行协作:
// 三个 Agent 并行分析文档
agentA: {"findings": ["语法错误3处"]}
agentB: {"findings": ["逻辑漏洞2处"]}
agentC: {"findings": ["性能问题1处"]}
// APPEND 策略合并
{"findings": ["语法错误3处", "逻辑漏洞2处", "性能问题1处"]}
// 如果是 REPLACE:只保留一个 Agent 的结果,其他丢失
动态配置合并:
// 全局配置
globalConfig: { "timeout": 30, "retries": 3 }
// 局部配置
localConfig: { "retries": 5, "debug": true }
// MERGE 策略结果
{
"timeout": 30, // 保留全局
"retries": 5, // 局部覆盖
"debug": true // 新增字段
}
总结
策略合并这套设计,从四个层面看各有意义:
- 技术层面:解决并行节点的数据竞争,多个节点同时写状态不冲突
- 架构层面:解耦节点逻辑与状态管理,节点只返回增量,合并交给框架
- 语义层面:让合并行为符合业务含义,对话历史追加、用户状态覆盖、配置对象合并,各走各的策略
- 扩展层面:策略可插拔,用户按领域模型自定义合并逻辑
整个机制的核心流转可以这样概括:
节点 → 返回增量更新(部分状态)
↓
策略 → 定义合并语义(如何组合)
↓
框架 → 自动合并状态(统一处理并发)
键级策略是这套设计的关键决策:语义与数据绑定,而不是与节点绑定,保证全局一致性和并行安全。这也是 graph-core 能支撑多步骤、多 Agent 工作流的基础设施——没有它,对话历史无法累积,并行节点互相覆盖,节点代码会被状态管理逻辑耦合进去。