读 graph-core 源码的时候,我一开始把边(Edge)当成流程图里的箭头——连两个节点而已。直到自己写一个文本分类的路由:分类完要根据结果跳到不同节点,可分类结果本身(类别、置信度)该往哪放?如果只让边返回「下一个节点 ID」,这些数据就丢了,要么在节点里重算一次,要么自己想办法塞回状态。
带着这个问题回去看源码,才发现 graph-core 的边设计比我想的重得多。边本身就是一个容器,把路由、循环、状态更新三件事打包在一起。这里把我读到的整理下来。
边的结构:一个容器,不是一根线
先看 Edge 的定义:
public record Edge(String sourceId, List<EdgeValue> targets) {
// sourceId: 起始节点 ID
// targets: 目标节点列表(支持并行)
}
注意 targets 是个 List。一条边可以指向多个目标,这就是并行边的来源——边本身就允许多个出口,不需要专门的「并行网关」节点。Edge.isParallel() 的判断也很直白:
public boolean isParallel() {
return targets.size() > 1; // 多个目标 = 并行
}
再看每个目标 EdgeValue,它有双重形态:
public record EdgeValue(String id, EdgeCondition value) {
// id: 固定目标节点 ID(静态路由)
// value: 条件对象(动态路由)
}
| 类型 | id |
value |
用途 |
|---|---|---|---|
| 固定边 | 非空 | null |
直连下一节点 |
| 条件边 | null |
非空 | 运行时动态决策 |
固定边就是 addEdge("step1", "step2"),内部存成 EdgeValue(id="step2", value=null),执行时直接跳。条件边是 addConditionalEdges(...),id 留空,value 里装路由函数和映射表,运行时才算去哪。
sequenceDiagram
participant Executor
participant EdgeAction
participant Mappings
participant Command
Executor->>EdgeAction: apply(state, config)
EdgeAction->>EdgeAction: 计算:score > 80?
EdgeAction-->>Executor: Command("high")
Executor->>Mappings: 查找 "high"
Mappings-->>Executor: "nodeA"
Executor->>Executor: 跳转到 nodeA
这一层看明白,边是一个「可能指向多个目标、每个目标可以是静态或动态的容器」,比一根箭头重得多。
Command 模式:路由和状态更新打包
回到我前面撞墙的问题:分类结果往哪放。答案在 Command 这个对象上:
public record Command(String gotoNode, Map<String, Object> update) {
// gotoNode: 下一个节点 ID
// update: 状态更新数据
}
关键在 update 这个字段。边执行完返回的是一个 Command——既告诉框架下一跳去哪,又把这次路由算出来的副产物写回状态,而不是单纯的节点 ID。
对比一下两种设计就清楚差距在哪:
// 传统设计:边只返回节点 ID
interface EdgeAction {
String apply(State state);
}
// 问题:分类结果、置信度这些副产物没地方放,要么丢,要么节点里重算
// Command 模式:边返回 Command
interface EdgeAction {
Command apply(State state);
}
// gotoNode 控制跳转,update 带数据,一次计算结果不丢
我那个文本分类的场景,写出来是这样:
graph.addConditionalEdges("classify",
(state, config) -> {
String text = state.value("text");
// 分类逻辑(耗时操作)
String category = expensiveClassify(text);
double confidence = calculateConfidence(text, category);
// 返回命令:路由 + 数据
return new Command(
category, // 下一节点(根据分类结果路由)
Map.of(
"category", category, // 分类结果写入状态
"confidence", confidence // 置信度写入状态
)
);
},
Map.of(
"news", "newsHandler",
"spam", "spamHandler",
"urgent", "urgentHandler"
)
);
框架拿到 Command 后做三件事,看 GraphRunnerContext 的执行逻辑:
var command = singleAction.apply(this.overallState, config).get();
String routeKey = command.gotoNode(); // "news"
String result = route.value().mappings().get(routeKey); // "newsHandler"
// 关键:应用 Command 携带的状态更新
this.mergeIntoCurrentState(command.update());
return new Command(result, state);
- 执行路由函数,拿到
category="news"和confidence=0.95 - 查映射表,
"news"→"newsHandler" - 把
update里的分类结果、置信度合并进状态,再跳到 newsHandler
Command 模式的好处归结为三点:
- 数据不丢失:路由计算的副产物直接写状态,不用在节点里重算
- 控制流和数据流分离:
gotoNode管跳转,update管状态,职责清楚 - 可测试:Command 是纯数据对象,路由函数好不好验证一下就清楚
循环图:边可以回指
读到这我又冒出一个问题:边能指回自己吗?也就是图能不能有环。一开始我以为 graph-core 是 DAG(有向无环图),看完发现不是——它支持循环。
证据在 CompileConfig:
private int recursionLimit = 100; // 循环限制(默认 100 次迭代)
如果只支持 DAG,没必要设迭代上限。graph-core 本质是有向图,允许回环边。定义循环最自然的方式是条件边回指自己:
StateGraph graph = new StateGraph(factory)
.addNode("process", (state, config) -> {
int count = state.value("count", 0);
return Map.of("count", count + 1);
})
.addEdge(START, "process")
.addConditionalEdges("process",
(state, config) -> {
int count = state.value("count");
if (count < 5) {
return new Command("continue"); // 继续循环
} else {
return new Command("done"); // 退出循环
}
},
Map.of(
"continue", "process", // 回到自己
"done", END
)
);
graph LR
START --> process
process -->|count < 5| process
process -->|count >= 5| END
光写 addEdge("nodeA","nodeB").addEdge("nodeB","nodeA") 会死循环,所以循环必须配条件边做退出。框架也兜了底,递归限制防住无限循环:
public boolean isMaxIterationsReached() {
return iteration.incrementAndGet() > compiledGraph.getMaxIterations();
}
循环图最典型的两个用法:
1. Agent 自我反思:生成 → 评估 → 质量不够就回到生成重写。
StateGraph dialog = new StateGraph(factory)
.addNode("generate", generateResponse)
.addNode("critique", critiqueResponse)
.addEdge(START, "generate")
.addEdge("generate", "critique")
.addConditionalEdges("critique",
(state, config) -> {
boolean isGood = state.value("quality") > 0.8;
return new Command(isGood ? "accept" : "retry");
},
Map.of(
"retry", "generate", // 重新生成
"accept", END
)
);
2. 批处理管道:取一批 → 处理 → 还有数据就回到取数。
StateGraph pipeline = new StateGraph(factory)
.addNode("fetch_batch", fetchData)
.addNode("process", processData)
.addEdge(START, "fetch_batch")
.addEdge("fetch_batch", "process")
.addConditionalEdges("process",
(state, config) -> {
boolean hasMore = state.value("hasMore");
return new Command(hasMore ? "next" : "done");
},
Map.of(
"next", "fetch_batch", // 获取下一批
"done", END
)
);
三个容易踩的坑
读完源码也对照着自己写,发现三个坑最容易踩:
- 循环没退出条件:
addEdge("A","B").addEdge("B","A")看着像循环,其实是死循环。回环边必须配条件边判断退出,光靠recursionLimit兜底等于把 bug 拖到运行时炸。 - 路由映射漏键:路由函数返回了一个映射表里没有的 key,运行时会直接抛
missingNodeInEdgeMapping。映射表要覆盖所有可能的路由键,或者留个"default" -> "fallbackNode"兜底。 - 自己写路由时忘记应用
update:如果不用框架的执行器、自己实现路由逻辑,容易只取cmd.gotoNode()查映射,忘了调mergeIntoCurrentState(cmd.update()),结果路由算出来的数据全丢。这是 Command 模式能不能生效的关键一步。
边设计到底好在哪
把边设计的几种模式汇总一下:
| 模式 | 实现方式 |
|---|---|
| 线性流程 | 固定边 |
| 条件分支 | 条件边 + 映射 |
| 并行执行 | 多目标边 |
| 循环迭代 | 条件边回环 |
| 嵌套子图 | SubGraph 节点 |
和传统工作流引擎对比,graph-core 的边设计区别主要在三处:
| 特性 | 传统工作流 | graph-core |
|---|---|---|
| 循环 | 不支持或需特殊节点 | 条件边原生支持回环 |
| 并行 | 需显式并行网关 | 边自然支持多目标 |
| 路由数据 | 决策与数据分离 | Command 携带状态更新 |
graph TB
A[边的设计] --> B[路由机制]
A --> C[命令模式]
A --> D[循环支持]
B --> B1[固定边: 直连]
B --> B2[条件边: 动态决策]
B --> B3[并行边: 多目标]
C --> C1[gotoNode: 控制流]
C --> C2[update: 状态更新]
C --> C3[避免重复计算]
D --> D1[条件边回环]
D --> D2[递归限制保护]
D --> D3[退出条件检查]
style A fill:#e1f5ff
style B fill:#fff4e6
style C fill:#f3e5f5
style D fill:#e8f5e9
总结
回头看,graph-core 边设计的核心就三句话:
- 边是容器不是箭头:一条边可以指向多个目标(并行),每个目标可以是静态 ID(固定边)或动态条件(条件边)。
- Command 把路由和数据打包:边返回的不只是下一跳,还有这次路由算出的副产物,一次计算结果不丢,控制流和数据流一起走。
- 循环靠条件边回环:边可以回指自己或前驱,配合退出条件和递归限制,图就不再只是 DAG。
边的设计把「路由跳转」和「状态更新」合并到了 Command 这一层。这正是手写路由最容易出错、最容易丢失中间数据的环节,也是边设计区别于传统工作流引擎的核心所在。