graph-core 的 KeyStrategy 解决的是状态在节点间传递时怎么合并的问题。没有它,对话应用和并行节点都会直接失效:

  1. 对话历史丢失:默认行为是后值覆盖前值,多轮对话里上一轮的消息会被下一轮覆盖,LLM 看不到上下文
  2. 并行节点互相覆盖:多个节点同时写同一个键,只保留最后完成的那个,其他结果全丢

源码用键级策略把这两个问题一起解决——每个键关联一个合并策略,控制状态在节点间传递时如何更新。这里把读到的内容整理下来,重点在策略为什么做成键级、为什么可插拔,以及并行场景下它到底兜住了什么。

没有策略会怎样

先看最直白的对话历史丢失。没有策略时,默认行为是 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: APPENDuserId: REPLACE 语义清晰、一致 需要预先定义

graph-core 选的是键级策略,理由归结为三点:

  1. 语义与数据绑定messages 的合并语义(追加)与键本身相关,而不是与节点相关。不管哪个节点写 messages,都应该追加
  2. 全局一致性:所有节点对 messages 的理解一致,不会出现这个节点追加、那个节点覆盖
  3. 并行安全:并行节点自动使用相同策略,不需要节点自己协调

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. 并发统一:框架统一处理并发,节点不用自己加锁
  3. 可测试:节点无状态,返回纯增量,单测不用构造完整上下文

没有策略的替代方案为什么不可行

再对比几种替代方案,逐一排除:

方案 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      // 新增字段
}

总结

策略合并这套设计,从四个层面看各有意义:

  1. 技术层面:解决并行节点的数据竞争,多个节点同时写状态不冲突
  2. 架构层面:解耦节点逻辑与状态管理,节点只返回增量,合并交给框架
  3. 语义层面:让合并行为符合业务含义,对话历史追加、用户状态覆盖、配置对象合并,各走各的策略
  4. 扩展层面:策略可插拔,用户按领域模型自定义合并逻辑

整个机制的核心流转可以这样概括:

节点 → 返回增量更新(部分状态)
  ↓
策略 → 定义合并语义(如何组合)
  ↓
框架 → 自动合并状态(统一处理并发)

键级策略是这套设计的关键决策:语义与数据绑定,而不是与节点绑定,保证全局一致性和并行安全。这也是 graph-core 能支撑多步骤、多 Agent 工作流的基础设施——没有它,对话历史无法累积,并行节点互相覆盖,节点代码会被状态管理逻辑耦合进去。