Spring AI Alibaba Graph 快速上手:黑板、节点、边

文章来源声明: 原文作者:你顶住我先撤; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686043688786903103; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合需要多步骤 LLM 编排、人工审批与断点恢复的 Java 团队;黑板+节点+边模型清晰,三分钟可上手,生产可切换 Redis/Mongo 存档。

Spring AI Alibaba Graph 快速上手:黑板、节点、边 ------------------------------------

版本:基于 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——sizeresult 都是节点写出来的。节点之间零耦合,全靠黑板串联。

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 + 业务 + 人」编排进同一条流程。