版本:基于 spring-ai-alibaba 1.0.0.4(配合 Spring AI 1.0.3)
环境准备
<span><<span>dependency</span>></span>
<span><<span>groupId</span>></span>com.alibaba.cloud.ai<span></<span>groupId</span>></span>
<span><<span>artifactId</span>></span>spring-ai-alibaba-graph-core<span></<span>artifactId</span>></span>
<span><<span>version</span>></span>1.0.0.4<span></<span>version</span>></span>
<span></<span>dependency</span>></span>
第 1 层 · 最小骨架
先不谈业务。下面这 40 行是一张完整可运行的图。
输入一段文本,超过 10 个字符就截断,否则原样返回。
<span>import</span> com.alibaba.cloud.ai.graph.*;
<span>import</span> com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
<span>import</span> com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
<span>import</span> com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
<span>import</span> java.util.Map;
<span>public</span> <span>class</span> <span>MinimalDemo</span> {
<span>public</span> <span>static</span> <span>void</span> <span>main</span><span>(String[] args)</span> <span>throws</span> Exception {
<span>// ① 黑板:声明有哪些 key,以及多个节点写同一个 key 时怎么合并</span>
<span>KeyStrategyFactory</span> <span>keys</span> <span>=</span> () -> Map.of(
<span>"text"</span>, <span>new</span> <span>ReplaceStrategy</span>(),
<span>"size"</span>, <span>new</span> <span>ReplaceStrategy</span>(),
<span>"result"</span>, <span>new</span> <span>ReplaceStrategy</span>());
<span>// ② 节点:只读写黑板</span>
<span>AsyncNodeAction</span> <span>measure</span> <span>=</span> AsyncNodeAction.node_async(state -> {
<span>String</span> <span>text</span> <span>=</span> state.value(<span>"text"</span>, <span>""</span>);
<span>return</span> Map.of(<span>"size"</span>, text.length() > <span>10</span> ? <span>"LONG"</span> : <span>"SHORT"</span>);
});
<span>AsyncNodeAction</span> <span>keep</span> <span>=</span> AsyncNodeAction.node_async(state -> {
<span>String</span> <span>text</span> <span>=</span> state.value(<span>"text"</span>, <span>""</span>);
<span>return</span> Map.of(<span>"result"</span>, text);
});
<span>AsyncNodeAction</span> <span>cut</span> <span>=</span> AsyncNodeAction.node_async(state -> {
<span>String</span> <span>text</span> <span>=</span> state.value(<span>"text"</span>, <span>""</span>);
<span>return</span> Map.of(<span>"result"</span>, text.substring(<span>0</span>, <span>10</span>) + <span>"..."</span>);
});
<span>// ③ 路由函数:读黑板,返回一个「标签」</span>
<span>AsyncEdgeAction</span> <span>route</span> <span>=</span> AsyncEdgeAction.edge_async(
state -> state.value(<span>"size"</span>, <span>"SHORT"</span>));
<span>// ④ 组装成图</span>
<span>CompiledGraph</span> <span>graph</span> <span>=</span> <span>new</span> <span>StateGraph</span>(<span>"demo"</span>, keys)
.addNode(<span>"measure"</span>, measure)
.addNode(<span>"keep"</span>, keep)
.addNode(<span>"cut"</span>, cut)
.addEdge(StateGraph.START, <span>"measure"</span>)
.addConditionalEdges(<span>"measure"</span>, route,
Map.of(<span>"LONG"</span>, <span>"cut"</span>,
<span>"SHORT"</span>, <span>"keep"</span>))
.addEdge(<span>"keep"</span>, StateGraph.END)
.addEdge(<span>"cut"</span>, StateGraph.END)
.compile();
<span>// ⑤ 跑两次,观察分支</span>
System.out.println(graph.call(Map.of(<span>"text"</span>, <span>"hi"</span>)).get().data());
System.out.println(graph.call(Map.of(<span>"text"</span>, <span>"Hello, Spring AI Alibaba Graph!"</span>)).get().data());
}
}
跑起来会看到两次输出,第二次走了 cut 分支:
{text=hi, size=SHORT, result=hi}
{text=Hello, Spring AI Alibaba Graph!, size=LONG, result=Hello, Spr...}
这张图长什么样
<span>START</span> ──► measure ──┬── "SHORT" ──► keep ──► <span>END</span>
└── "LONG" ──► cut ──► <span>END</span>
核心概念
| API | 在上面代码里的位置 | 一句话说明 |
|---|---|---|
| `OverAllState` | `state.value(...)` 读、`return Map.of(...)` 写 | 节点之间共享的黑板 |
| `addNode` | `.addNode("measure", measure)` | 注册一个「干活的单元」 |
| `addEdge` | `.addEdge(StateGraph.START, "measure")` | 注册一条固定跳转 |
| `addConditionalEdges` | `.addConditionalEdges("measure", route, Map.of(...))` | 按黑板内容决定走哪条路 |
第 2 层 · 逐个拆解
2.1 OverAllState:黑板
一个 Map<String, Object>,外加「每个 key 怎么合并」的规则。
节点 A 不直接调用节点 B。A 把结果写到黑板上,B 从黑板上读。节点之间互不认识
声明黑板上有哪些格子
<span>KeyStrategyFactory</span> <span>keys</span> <span>=</span> () -> Map.of(
<span>"text"</span>, <span>new</span> <span>ReplaceStrategy</span>(),
<span>"size"</span>, <span>new</span> <span>ReplaceStrategy</span>(),
<span>"result"</span>, <span>new</span> <span>ReplaceStrategy</span>());
KeyStrategyFactory 是个 @FunctionalInterface,返回 Map<String, KeyStrategy>。写成匿名内部类等价:
<span>KeyStrategyFactory</span> <span>keys</span> <span>=</span> <span>new</span> <span>KeyStrategyFactory</span>() {
<span>@Override</span>
<span>public</span> Map<String, KeyStrategy> <span>apply</span><span>()</span> {
<span>return</span> Map.of(<span>"text"</span>, <span>new</span> <span>ReplaceStrategy</span>());
}
};
框架内置三种策略:
| 策略 | 合并语义 | 典型场景 |
|---|---|---|
| `ReplaceStrategy` | 新值**覆盖**旧值 | 本文两个例子全用它 |
| `AppendStrategy` | **追加**到 List 尾部 | 对话历史、消息流水 |
| `MergeStrategy` | Map **深度合并** | 多个节点往同一个 Map 上写字段 |
ReplaceStrategy:发生在每个节点执行完、框架把该节点返回的 Map 合并进黑板的那一刻——apply(旧值, 新值) 的实现就是 return 新值。被换掉的是这个 key 上整个 value 对象。
选型口诀:中间结果用
Replace,对话历史/日志用Append,多节点共写一个 Map 用Merge。
没声明的 key 会怎样? 默认走 ReplaceStrategy
纯覆盖场景理论上可以不声明。但建议写全:显式声明等于给黑板列了一份 schema,哪个节点读哪些 key、写哪些 key,一眼就能看明白。
读:三个重载
<span>String</span> <span>text</span> <span>=</span> state.value(<span>"text"</span>, <span>""</span>); <span>// 带默认值,返回原始类型</span>
Optional<Object> v = state.value(<span>"text"</span>); <span>// 单参版本,返回 Optional</span>
Optional<String> s = state.value(<span>"text"</span>, String.class); <span>// 指定类型,返回 Optional</span>
日常用得最多的是第一个
写:不是 set,是 return
节点写黑板,不调用任何 set 方法,而是返回一个 Map。框架拿到这个 Map,按每个 key 的 KeyStrategy 合并进黑板。
看个例子——这个节点只干一件事,把 count 加一:
<span>AsyncNodeAction</span> <span>increment</span> <span>=</span> AsyncNodeAction.node_async(state -> {
<span>int</span> <span>count</span> <span>=</span> state.value(<span>"count"</span>, <span>0</span>); <span>// ← 读</span>
<span>return</span> Map.of(<span>"count"</span>, count + <span>1</span>); <span>// ← 写:返回 Map 就是写</span>
});
返回的 Map 里只需要放这次要改的 key,不用返回整个黑板。
三个必须记住的规则:
| 写法 | 含义 |
|---|---|
| `return Map.of()` | 不修改黑板(常用于「条件不满足,直接跳过」) |
| `return Map.of("k", v)` | 把 `k` 更新为 `v`(按该 key 的策略更新) |
| Map 里的 value 为 `null` | 会触发 `Map.of()` 的 NPE —— **别塞 null**,用空串 / 空集合代替 |
注意:
Map.of本身不允许 null 值,而节点里从外部拿到 null 是很常见的(比如查库返回空)。
为什么设计成「返回 Map」而不是「调用 set」?
因为节点可能并行执行。两个节点同时写黑板时,「谁覆盖谁」需要一个明确的规则——这就是 KeyStrategy 存在的理由。如果改成 set,就得在框架内部加锁,还要额外定义合并语义,反而更乱。
初始化黑板
黑板不是凭空来的,初始值由调用方传入:
<span>OverAllState</span> <span>state</span> <span>=</span> graph.call(Map.of(<span>"text"</span>, <span>"hi"</span>)).get();
graph.call(Map) 是免 RunnableConfig 的便捷重载;需要会话隔离时用 graph.call(Map, config)。
进去的只有 text,但跑完后黑板上有三个 key——size 和 result 都是节点写出来的。节点之间零耦合,全靠黑板串联。
2.2 addNode:注册一个干活的节点
.addNode(<span>"measure"</span>, measure)
两个参数:
- 节点名(
"measure"):后面addEdge/addConditionalEdges引用它时用的字符串 ID,全局唯一。 - 节点动作:一个
AsyncNodeAction实例。
node_async 是什么
框架要的是异步接口 AsyncNodeAction,它继承自 Function<OverAllState, CompletableFuture<Map<String,Object>>>。
而写业务逻辑时通常是同步的——NodeAction 就是那个同步接口,只有一个方法:
<span>public</span> <span>interface</span> <span>NodeAction</span> {
Map<String, Object> <span>apply</span><span>(OverAllState state)</span> <span>throws</span> Exception;
}
AsyncNodeAction.node_async(nodeAction) 是个适配器,把同步实现包成异步。NodeAction 是函数式接口(@FunctionalInterface,只有一个抽象方法 apply),支持lambda:
<span>AsyncNodeAction</span> <span>node</span> <span>=</span> AsyncNodeAction.node_async(state -> Map.of(<span>"k"</span>, <span>"v"</span>));
两种写法:逻辑简单就 lambda,逻辑复杂就
implements NodeAction写个类。
2.3 addEdge:固定跳转
.addEdge(StateGraph.START, <span>"measure"</span>) <span>// 入口</span>
.addEdge(<span>"keep"</span>, StateGraph.END) <span>// 出口</span>
两个特殊节点:
StateGraph.START—— 图的入口,从它出发的边定义「谁先跑」。StateGraph.END—— 图的终点,指向它的边表示「跑完就收工」。
并行:多条出边就是并行
这是 addEdge 最有价值的能力:
<span>// START 有两条出边 → 下面两个节点并行执行</span>
graph.addEdge(StateGraph.START, <span>"fetchA"</span>);
graph.addEdge(StateGraph.START, <span>"fetchB"</span>);
<span>// 两条边都汇入 merge → merge 会等两个都跑完才执行</span>
graph.addEdge(<span>"fetchA"</span>, <span>"merge"</span>);
graph.addEdge(<span>"fetchB"</span>, <span>"merge"</span>);
<span>START</span> ─┬─► fetchA ──┐
│ ├──► <span>merge</span>
└─► fetchB ──┘
两条规则:
- 一个节点有多条出边 = 这些目标并行跑。
- 多条边指向同一个目标 = 该目标等所有上游完成才跑(天然的 join / 栅栏)。
2.4 addConditionalEdges:按黑板内容分支
固定跳转不够用时用它。三个参数必须一起理解:
graph.addConditionalEdges(
<span>"measure"</span>, <span>// ① 挂在哪个节点后面</span>
AsyncEdgeAction.edge_async(state -> <span>// ② 路由函数</span>
state.value(<span>"size"</span>, <span>"SHORT"</span>)),
Map.of(<span>"LONG"</span>, <span>"cut"</span>, <span>// ③ 映射表</span>
<span>"SHORT"</span>, <span>"keep"</span>));
| 参数 | 作用 |
|---|---|
| ① 源节点 | 这个节点的动作执行完**之后**才调用路由函数 |
| ② 路由函数 | 入参 `OverAllState`,返回**字符串标签** |
| ③ 映射表 | 把标签翻译成真正的节点名 |
路由函数只读、不写
EdgeAction 也是函数式接口(@FunctionalInterface),支持 lambda:
<span>public</span> <span>interface</span> <span>EdgeAction</span> {
String <span>apply</span><span>(OverAllState state)</span> <span>throws</span> Exception;
}
它只读黑板,不写黑板。 这个分工很重要:
NodeAction读黑板 + 干活 + 写黑板;EdgeAction只读黑板、不写黑板,返回一个路由值。
映射表:两套独立的命名空间
映射表的语义是 路由函数返回值 → 目标节点名——key 和 value 是两套命名空间。
写成 Map.of("LONG", "cut", ...),因为标签恰好和节点名长得像。开发中完全可以(也应该)用更有业务含义的标签:
graph.addConditionalEdges(<span>"checkNode"</span>,
AsyncEdgeAction.edge_async(state -> {
<span>Boolean</span> <span>ok</span> <span>=</span> (Boolean) state.value(<span>"approved"</span>).orElse(<span>false</span>);
<span>return</span> ok ? <span>"ADOPT"</span> : <span>"REJECT"</span>; <span>// ← 标签</span>
}),
Map.of(<span>"ADOPT"</span>, <span>"publishNode"</span>, <span>// ← 标签 → 节点名</span>
<span>"REJECT"</span>, StateGraph.END));
这层间接性最大的价值是支持循环。 比如「起草 → 自检 → 不合格就重写」:
Map.of(<span>"retry"</span>, <span>"draftNode"</span>, <span>// 回炉,形成环</span>
<span>"done"</span>, StateGraph.END)
路由函数只管说「重写」还是「通过」,具体跳到哪个节点,映射表说了算。 。
2.5 中断与恢复:把「人」接进控制流
条件边管分支,但「停下来等人」需要另一个机制:编译期的中断点 + 存档。
<span>CompiledGraph</span> <span>graph</span> <span>=</span> stateGraph.compile(CompileConfig.builder()
.interruptBefore(<span>"approvalNode"</span>) <span>// ← 执行到该节点【之前】暂停</span>
.saverConfig(SaverConfig.builder()
.register(SaverEnum.MEMORY.getValue(), <span>new</span> <span>MemorySaver</span>()) <span>// ← 存档</span>
.build())
.build());
interruptBefore("approvalNode"):图跑到approvalNode前就停下来,把当前黑板快照存档,graph.call(...)直接返回。saverConfig:没有持久化就没有恢复。中断了但没存档,那张图就再也叫不醒了。
框架内置四种存档实现:
| 实现 | 说明 |
|---|---|
| `MemorySaver` | 存内存,**零依赖,适合本地开发和学习** |
| `FileSystemSaver` | 存文件,单机重启不丢 |
| `RedisSaver` | 存 Redis,生产多实例部署用它(需要 Redisson) |
| `MongoSaver` | 存 MongoDB |
生产换 Redis 只改一行:.register(SaverEnum.REDIS.getValue(), new RedisSaver(redissonClient))——redissonClient 就是注入的 RedissonClient Bean。
恢复:四步,一步都不能少
<span>RunnableConfig</span> <span>config</span> <span>=</span> RunnableConfig.builder()
.threadId(threadId) <span>// ← 依赖线程ID(UUID生成即可)找回暂停的图</span>
.build();
<span>StateSnapshot</span> <span>snapshot</span> <span>=</span> graph.getState(config); <span>// ① 取回存档</span>
<span>OverAllState</span> <span>state</span> <span>=</span> snapshot.state();
state.withResume(); <span>// ② 标记「这次是恢复,不是重新开始」</span>
state.withHumanFeedback(<span>new</span> <span>OverAllState</span>.HumanFeedback( <span>// ③ 塞入人的决策</span>
Map.of(<span>"approved"</span>, <span>true</span>), <span>""</span>));
graph.call(state, config); <span>// ④ 继续跑</span>
条件边 + 中断是绝配:中断让流程停下来等人,条件边按人的决策选路。而且两者是两层解耦——节点只负责把决策翻译成黑板上的标签,路由函数只负责按标签选路。
三分钟速查表
<span>// 黑板</span>
<span>new</span> <span>StateGraph</span>(<span>"名字"</span>, keyStrategyFactory) <span>// 声明 key + 合并策略</span>
state.value(<span>"key"</span>, defaultValue) <span>// 读(带默认值)</span>
<span>return</span> Map.of(<span>"key"</span>, value) <span>// 写(返回 Map)</span>
<span>return</span> Map.of() <span>// 不写</span>
<span>// 节点</span>
graph.addNode(<span>"nodeName"</span>, AsyncNodeAction.node_async(nodeAction))
<span>// nodeAction: NodeAction 或 lambda,返回 Map<String,Object></span>
<span>// 边</span>
graph.addEdge(StateGraph.START, <span>"firstNode"</span>) <span>// 入口</span>
graph.addEdge(<span>"a"</span>, <span>"b"</span>) <span>// 普通边;a 多条出边 = 并行</span>
graph.addEdge(<span>"lastNode"</span>, StateGraph.END) <span>// 终点</span>
graph.addConditionalEdges(<span>"source"</span>, AsyncEdgeAction.edge_async(edgeAction), mapping)
<span>// edgeAction: EdgeAction 或 lambda,返回「标签」字符串</span>
<span>// mapping: Map.of("标签", "真实节点名", ..., StateGraph.END, StateGraph.END)</span>
<span>// 编译与运行</span>
graph.compile(CompileConfig.builder()
.interruptBefore(<span>"approvalNode"</span>) <span>// 人工中断点</span>
.saverConfig(SaverConfig.builder()
.register(SaverEnum.MEMORY.getValue(), <span>new</span> <span>MemorySaver</span>())
.build())
.build())
graph.call(Map.of(<span>"text"</span>, <span>"hi"</span>)).get() <span>// 免 config</span>
graph.call(Map.of(<span>"ticketId"</span>, <span>"T-1001"</span>), RunnableConfig.builder().threadId(id).build())
Map<String, Object> blackboard = graph.call(Map.of(<span>"text"</span>, <span>"hi"</span>)).get().data(); <span>// 拿整块黑板</span>
<span>// 人工恢复四步</span>
<span>StateSnapshot</span> <span>snap</span> <span>=</span> graph.getState(cfg);
snap.state().withResume();
snap.state().withHumanFeedback(<span>new</span> <span>OverAllState</span>.HumanFeedback(Map.of(<span>"approved"</span>, <span>true</span>), <span>""</span>));
graph.call(snap.state(), cfg);
重点:
黑板(OverAllState)负责传数据,节点(addNode)负责干活,边(addEdge / addConditionalEdges)负责决定下一步——三者解耦,就能把「LLM + 业务 + 人」编排进同一条流程。
适合需要多步骤 LLM 编排、人工审批与断点恢复的 Java 团队;黑板+节点+边模型清晰,三分钟可上手,生产可切换 Redis/Mongo 存档。