这是专栏的番外篇。番外二里我们用
ReactAgent.builder()十行代码搭了个会自己调工具的 Agent,reactAgent.call("北京和上海哪个温度高")一调,它就自动"想一步、做一步、再想一步"直到出答案。当时我留了个扣子没解:call()凭什么会自己多轮循环?后端老鸟最怕这种黑盒。今天咱们把这层壳扒开------你会发现里面跑的,是一张 Graph(计算图) 。而且我会拿我熟悉的 LiteFlow 做个对照,最后扒一段
ReactAgent底层建图的源码。三件事做完,Graph 这玩意儿就算真正进门了。
一、先别急着看代码,把"Graph"这颗钉子钉进脑子
很多人(包括写代码时的我)一听 Graph,脑子里蹦的是"图论/拓扑排序/算法题"。但 Spring AI Alibaba 里说的 Graph,不是写算法,而是一套"把一串步骤编排成流程图"的引擎。
它有三件套,我拿后端老鸟闭眼都懂的东西类比:
| Graph 概念 | 后端老鸟眼里的等价物 | 作用 |
|---|---|---|
| Node(节点) | 一个 service 方法 | 干一件具体的事 |
| Edge(边) | 流程跳转 / 状态机 transition | 决定"下一步去哪" |
| State(状态) | ThreadLocal / 流程上下文 flowScope |
节点之间传数据的中转站 |
为什么 AI 编排非得用 Graph? 因为大模型调用天然不是"一条线走到底":
- 它要循环:想一步 → 调工具 → 看结果 → 再想一步,直到能回答(ReAct);
- 它要分支:根据问题类型,走退款 / 物流 / 技术不同的处理流;
- 它要并行:同时调好几个工具,谁先回来算谁的;
- 它要等人:某些不可逆动作,跑到一半得挂起等人工拍板。
把这四种控制流用 if/for/CompletableFuture 手写,代码又臭又长还难调试。Graph 把它们声明式 地画出来------这就是它存在的意义:给 AI 编排用的"轻量 BPMN" 。
如果你写过 LiteFlow,这一段你能秒懂。 LiteFlow 里一个 @LiteflowComponent("a") 组件 ≈ Graph 的 Node;EL 规则里的 THEN/WHEN/SWITCH/IF/FOR ≈ Graph 的 Edge(控制流);那个 getContextBean(OrderContext.class) 的上下文 bean ≈ Graph 的 State。区别只在于:LiteFlow 用一份 XML/JSON 的 EL 规则"画"流程图,Spring AI Alibaba 用 Java 代码 addNode/addEdge "画"流程图。
先把这句话刻下来:ReactAgent 不是比
@Tool多了超能力,它只是把"循环 + 累积 + 终止判断"提前画成了一张图。
二、三件套上手:写一个"最小可跑样例"
实现一个不调大模型、纯演示 Graph 三件套 的样例:GraphDemoConfig(把一个整数拆成数字、按奇偶并行求和)。它 compile() 之后导出的真实结构是:
sql
START ──► analyze ──► branch
├─► evenCalc ──┐
├─► oddCalc ──┤
└─────────────► merge ──► summary ──► END
1. 节点:靠"状态"在彼此之间传话
vbnet
private static final NodeAction ANALYZE_NODE = state -> {
Integer input = state.value("input", Integer.class).orElse(0);
// ... 拆解数字、算奇偶 ...
Map<String, Object> update = new HashMap<>();
update.put("digits", digits);
update.put("isEven", isEven);
update.put("history", List.of("analyze(input=" + input + ")"));
return update; // ← 把结果"写回"状态,下一个节点就能读到了
};
后端老鸟一眼看懂:NodeAction 是个函数式接口,入参 state 是全局上下文,state.value("input") 取数、 return update 写数 。节点之间不靠方法调用传参,全靠这个共享 state 通信。
对比 LiteFlow:LiteFlow 的上下文是强类型 bean (OrderContext ctx = this.getContextBean(OrderContext.class)),取出来就是你的类,不用强转;Graph 的 OverAllState 是 Map<String, Object>,取出是 Object,要自己 .orElse(默认) 兜底再强转。
2. 边:声明式地"连"出并行与汇聚
less
@Bean
public CompiledGraph demoCompiledGraph() throws GraphStateException {
StateGraph graph = new StateGraph();
graph.addNode("analyze", AsyncNodeAction.node_async(ANALYZE_NODE));
graph.addNode("branch", AsyncNodeAction.node_async(BRANCH_NODE));
graph.addNode("evenCalc", AsyncNodeAction.node_async(EVEN_CALC_NODE));
graph.addNode("oddCalc", AsyncNodeAction.node_async(ODD_CALC_NODE));
graph.addNode("merge", AsyncNodeAction.node_async(MERGE_NODE));
graph.addNode("summary", AsyncNodeAction.node_async(SUMMARY_NODE));
graph.addEdge(StateGraph.START, "analyze");
graph.addEdge("analyze", "branch");
// ← 一个节点扇出到两个:框架自动【并行】跑,你不用 CompletableFuture.allOf
graph.addEdge("branch", List.of("evenCalc", "oddCalc"));
// ← 两个节点汇聚到一个:框架会【等两边都跑完】才往下走
graph.addEdge(List.of("evenCalc", "oddCalc"), "merge");
graph.addEdge("merge", "summary");
graph.addEdge("summary", StateGraph.END);
return graph.compile(); // ← 编译期校验:孤儿节点、环、起点终点
}
这两行我标了注释的,就是 Graph 比手写 for 循环强的地方:
addEdge("branch", List.of("evenCalc", "oddCalc"))------ 扇出两个,框架并行跑;addEdge(List.of("evenCalc", "oddCalc"), "merge")------ 两个汇聚,框架等两边都完成才继续。
对应 LiteFlow 的 EL,这两行等价于 WHEN(evenCalc, oddCalc)(并行 + 隐式汇聚)。一句话:SAA Graph 用 Java 画,LiteFlow 用 WHEN 关键字画,表达的控制流是同一回事。
3. 状态:默认覆盖,要累积得声明策略
GraphDemoConfig 里 evenCalc/oddCalc 每个节点都先 new ArrayList 把旧 history 接过来再 add------为什么这么啰嗦?因为 OverAllState 对同一个 key 默认是"覆盖"(Replace) ,不声明的话你上一个节点写的值会被下一个同名 key 冲掉。番外四我们会用 KeyStrategyFactory + AppendStrategy 让框架替你累积。这点 LiteFlow 不一样------它的上下文 bean 是大家共享同一个可变对象,天然累积,但也要小心并发。
三、揭晓悬念:ReactAgent 内部就是这么一张图
番外二打印出的 Thought / Action / Observation 日志,本质就是一张图在循环。它内部大致固化成:
scss
START ──► __BEFORE_AGENT__ ──► _AGENT_MODEL_ ──(有工具调用?)──► _AGENT_TOOL_ ──┐
▲ │
└──────────── (观察结果写回,再想) ──────┘
│
(无工具调用,已能作答?)
└──► __AFTER_AGENT__ ──► END
_AGENT_MODEL_节点:调一次 LLM,让它决定"下一步是调工具,还是直接给答案";_AGENT_TOOL_节点 :执行它选中的@Tool方法;- 那条绕回
_AGENT_MODEL_的边 :把工具结果写回 state,下一轮thought才能"看到"上一步结果------这就是循环; - 终止判断 :模型不再发工具调用时,这条边直接走向
END。
"想一步做一步" = 一条指向自己的循环边 + 状态(messages)不断累积。 糖衣而已。
四、源码简读:ReactAgent 是怎么把这张图"建"出来的
光说"它内部是张图"不够,咱们扒一层。对照工程实际依赖的 1.1.2.0 的 ReactAgent.class它的骨架清晰得让人安心:
scala
// ReactAgent 继承自 BaseAgent,内部持有两个核心节点
public class ReactAgent extends BaseAgent {
private final AgentLlmNode llmNode; // 思考节点:调模型
private final AgentToolNode toolNode; // 工具节点:执行 @Tool
private final Boolean hasTools; // 有没有挂工具
// ...
public StateGraph getStateGraph(); // ← 直接把这个内部图暴露出来
protected StateGraph initGraph(); // ← 建图的地方
}
initGraph() 里干的事,和我们上面画的图一一对应。几个关键证据(均来自 1.1.2.0 里的常量与方法名):
- 节点常量 :
__START__、__END__、_AGENT_MODEL_、_AGENT_TOOL_,以及钩子节点BEFORE_AGENT / AFTER_AGENT / BEFORE_MODEL / AFTER_MODEL。也就是说,你的"思考"和"行动"就是两个被注册进StateGraph的普通节点。 - 模型 → 工具的边
makeModelToTools:它内部靠hasToolCalls判断"模型这次返回里有没有工具调用"。有 → 走到_AGENT_TOOL_;没有 (说明模型觉得能直接作答了)→ 走向END出口。这就是"终止判断"的实现。 - 工具 → 模型的边
makeToolsToModelEdge:工具执行完,把结果作为一个ToolResponseMessage写回 state,然后路由回_AGENT_MODEL_。determineLoopEntryNode / determineLoopExitNode这两个方法名也佐证了图上确实存在一个"循环段"。------这,就是"想一步做一步"的循环边。 - 最妙的一笔
buildMessagesKeyStrategyFactory:它给 state 里的messages这个 key 注册了AppendStrategy。意思是:每轮工具结果回来,不是覆盖历史,而是追加 到 messages 列表里。于是下一轮_AGENT_MODEL_调 LLM 时,messages里已经带着前面所有的"思考 + 工具结果",模型才能连续推理。所谓"observation 累积",在源码层就是这一行策略声明。
把四件事串起来,ReactAgent 的"魔法"彻底祛魅:
它
new出来的不是什么智能生命,而是一个StateGraph:_AGENT_MODEL_(调 LLM)→ 有工具调用就走_AGENT_TOOL_(执行工具)→ 结果以 Append 写回 messages → 边把它路由回_AGENT_MODEL_→ 循环,直到模型不再调工具 →END。和你番外四自己写的classify → 条件路由 → 处理节点是同构的,只不过节点里装的是 LLM 和工具。
五、LiteFlow vs Spring AI Alibaba Graph:一张表说清
大家如果会 LiteFlow,我把两者摊开比一比------它们解决的是同一类问题(流程编排) ,但出发点和生态完全不同:
| 维度 | Spring AI Alibaba StateGraph |
LiteFlow |
|---|---|---|
| 画流程图的方式 | Java 流式 API:addNode / addEdge / addConditionalEdges |
EL 规则:XML/JSON/YML 里的 THEN/WHEN/SWITCH/IF/FOR |
| 节点是什么 | NodeAction / AsyncNodeAction(函数式接口) |
@LiteflowComponent("id") 继承 NodeComponent,重写 process() |
| 上下文/状态 | OverAllState(Map<String,Object>,靠 KeyStrategy 决定覆盖/追加) |
强类型上下文 bean(getContextBean(XxxContext.class)),天然共享可变 |
| 并行 | addEdge("a", List.of("b","c")) |
WHEN(b, c) |
| 条件分支 | addConditionalEdges(...) |
SWITCH(x).to(b,c,d) / IF(cond,a,b) |
| 循环 | 靠"回指自己的边" | FOR(a).loop(3) / WHILE / BREAK |
| 有没有"记忆"(跨次调用) | OverAllState 在一轮推理内就累积 messages(番外三源码里那行 AppendStrategy);更狠的是支持检查点 Saver :用同一个 threadId 能把状态持久化、半路挂起后接着跑。 |
上下文 bean 只在单次执行 内有效;两次 execute2Resp 之间是全新上下文,默认无跨轮记忆,要留存得自己把 bean 塞进 DB、下次再捞回来 |
| AI 原生能力 | 一等公民:messages 累积策略、HITL 中断(InterruptableAction)、Agent 可作为节点 |
2.16 起也能把 ReAct Agent 封装成标准组件,和普通业务节点用 EL 混编排 |
| 动态热刷/规则存储 | 图在代码里,改了要重新编译 | 规则可存 Nacos/ZK/DB,支持热刷不改代码 |
| 成熟度/定位 | AI Agent 编排专用,轻量 | 成熟业务规则引擎,2000+ 测试用例,偏业务流程/规则 |
最值得你品的一点 :两个框架最近都"长到了对方身上"------LiteFlow 2.16 把 ReAct Agent 封装成了标准组件,能和你的 validateOrder、checkStock 这些普通节点用 THEN/WHEN 自由编排;而 SAA Graph 的 asNode() 也能把一个 Agent 当成一个图节点嵌进去。 "Agent 即节点"已经是行业共识,只是 LiteFlow 用 EL 表达、SAA 用 Java 表达。
老说"LiteFlow 无状态、ReactAgent 有状态",根子就在这张表的最后一行。 我多啰嗦两句,免得你后面写代码踩坑:
- LiteFlow 的"无状态"是指跨次无记忆 :一次
execute2Resp跑完,上下文 bean 就随之消失;你下次再调,是个全新的上下文。如果你的业务是"一锤子买卖"(下个单、算个价),这完全够用,甚至更清爽、好测试。但一旦要"多轮对话""中途挂起等人"------比如让流程跑到一半记住用户上一步说了啥、甚至隔天接着办------LiteFlow 默认帮不了你,得自己把 bean 落库、下次再捞回来塞进去。 - ReactAgent / SAA Graph 的"有状态"是基因里的 :前面第四节扒过,
messages用AppendStrategy在一轮内就不断累积,所以模型能"记住"自己前面想了啥、工具回了啥;再往上套一层检查点 Saver,threadId这个"流程实例 ID"就能把状态持久化------番外五 HITL 那个"订单金额过大自动挂起、人工confirm后用同一threadId接着往下跑",底层靠的就是这个状态能力。换句话说:Graph 天生就是为"会思考、会等待、会续上"的 Agent 准备的。
怎么选:
- 流程是业务逻辑/规则 为主(下单、风控、审批里的业务判断),而且想要 EL 那种"产品运营都能改"的热刷能力 → LiteFlow;
- 流程是AI 推理为主 (模型多轮思考、调工具、半路等人拍板)→ SAA Graph;
- 更香的是组合 :用 LiteFlow 兜业务流程,把"SAA 的 ReAct Agent"作为一个
@LiteflowComponent节点塞进去------大模型智能体就这样自然融进了你的老系统。
六、跑起来,看 Graph 的"执行轨迹"
GraphDemoConfig 对外暴露了 /graph/run,正好可以看到 Graph 跑完后的完整审计轨迹------比 ReactAgent 的黑盒日志友好一万倍:
arduino
curl 'localhost:9999/graph/run?input=12345'
css
{
"summary": "输入数字 12345 是一个5位数(正数),奇数。各位数字为 [1, 2, 3, 4, 5];其中偶数数字之和=6,奇数数字之和=9,各位总和=15。",
"history": [
"analyze(input=12345)",
"branch(isEven=false → 并行 evenCalc + oddCalc)",
"evenCalc(sum=6)",
"oddCalc(sum=9)",
"merge(even=6, odd=9, total=15)",
"summary(✓)"
]
}
注意 history 的顺序:analyze → branch → evenCalc → oddCalc → merge → summary。每个节点干了啥、状态怎么变的,全摊在 JSON 里。 这种"可观测性"是 Graph 的天然优势------生产排查问题,比在 ReactAgent 黑盒里猜强太多。
七、收个尾,预告下一篇
番外二以为自己在"用 Agent",番外三扒开一看------你其实在跑一张 Graph ,而且这张图和我们熟悉的 LiteFlow 规则是"同父异母"的亲兄弟。ReactAgent 那层糖衣剥掉,底下全是 Node + Edge + State,而它底层 _AGENT_MODEL_→_AGENT_TOOL_→绕回的循环边 + messages 的 Append 策略,正是"想一步做一步"的全部秘密。
关键转折来了:ReactAgent 帮你固化的那张图,你现在能自己画了。 接下来真正的硬核才开始------
- 番外四 :不靠 ReactAgent,自己用
StateGraph搭一个会按问题类型分流的客服图(条件路由 + 状态合并策略),把 Graph 三件套真正用到 AI 编排上; - 番外五 :在图里加一个"等人拍板 "的节点(HITL 中断),让 Agent 在关键时刻自动挂起等人工------你工程里
HitlGraphConfig最出彩的部分。
先把 Graph 三件套和"ReactAgent = 一张会循环的图"刻进肌肉记忆,后面两篇才能丝滑。下一篇,动手自己画一张图。