番外篇三:《ReactAgent 为什么能"想一步做一步"?扒开它的 Graph 内核》

这是专栏的番外篇。番外二里我们用 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 的上下文是强类型 beanOrderContext ctx = this.getContextBean(OrderContext.class)),取出来就是你的类,不用强转;Graph 的 OverAllStateMap<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. 状态:默认覆盖,要累积得声明策略

GraphDemoConfigevenCalc/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.0ReactAgent.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 里的常量与方法名):

  1. 节点常量__START____END___AGENT_MODEL__AGENT_TOOL_,以及钩子节点 BEFORE_AGENT / AFTER_AGENT / BEFORE_MODEL / AFTER_MODEL。也就是说,你的"思考"和"行动"就是两个被注册进 StateGraph 的普通节点。
  2. 模型 → 工具的边 makeModelToTools:它内部靠 hasToolCalls 判断"模型这次返回里有没有工具调用"。 → 走到 _AGENT_TOOL_没有 (说明模型觉得能直接作答了)→ 走向 END 出口。这就是"终止判断"的实现。
  3. 工具 → 模型的边 makeToolsToModelEdge:工具执行完,把结果作为一个 ToolResponseMessage 写回 state,然后路由回 _AGENT_MODEL_ determineLoopEntryNode / determineLoopExitNode 这两个方法名也佐证了图上确实存在一个"循环段"。------这,就是"想一步做一步"的循环边。
  4. 最妙的一笔 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()
上下文/状态 OverAllStateMap<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 封装成了标准组件,能和你的 validateOrdercheckStock 这些普通节点用 THEN/WHEN 自由编排;而 SAA Graph 的 asNode() 也能把一个 Agent 当成一个图节点嵌进去。 "Agent 即节点"已经是行业共识,只是 LiteFlow 用 EL 表达、SAA 用 Java 表达。

老说"LiteFlow 无状态、ReactAgent 有状态",根子就在这张表的最后一行。 我多啰嗦两句,免得你后面写代码踩坑:

  • LiteFlow 的"无状态"是指跨次无记忆 :一次 execute2Resp 跑完,上下文 bean 就随之消失;你下次再调,是个全新的上下文。如果你的业务是"一锤子买卖"(下个单、算个价),这完全够用,甚至更清爽、好测试。但一旦要"多轮对话""中途挂起等人"------比如让流程跑到一半记住用户上一步说了啥、甚至隔天接着办------LiteFlow 默认帮不了你,得自己把 bean 落库、下次再捞回来塞进去。
  • ReactAgent / SAA Graph 的"有状态"是基因里的 :前面第四节扒过,messagesAppendStrategy 在一轮内就不断累积,所以模型能"记住"自己前面想了啥、工具回了啥;再往上套一层检查点 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 = 一张会循环的图"刻进肌肉记忆,后面两篇才能丝滑。下一篇,动手自己画一张图。

相关推荐
颜进强2 小时前
Claude Code - 26 效率三件套:cc-switch 切模型 · codeburn 算成本 · claude-hud 看状态
前端·后端·ai编程
做前端的娜娜子3 小时前
Embedding 向量模型:从语义表示到相似度计算
langchain·openai·掘金·金石计划
答案answer3 小时前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
ai编程·three.js·vibecoding
测试_AI_一辰3 小时前
AI Agent 评测最隐蔽的坑-记忆
人工智能·算法·ai·自动化·ai编程
打呵欠的猫4 小时前
我让 AI 封装了一个 ImageUpload 组件,它设计的 5 层校验链路比我想的周全
前端·ai编程
程序员黑豆4 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
努力搬砖的咸鱼4 小时前
AI Agent测试全景图:它到底改变了什么
人工智能·python·ai·集成测试·pytest·agent·ai编程
明川4 小时前
从 Copilot 到 Harness Engineering:我在 Android 工程里的 AI 开发实践
ai编程
GitLqr5 小时前
拒绝 AI 乱写代码:手把手教你玩转 Claude Code 的工程化技能集
openai·ai编程·claude