Spring AI Alibaba Graph实战:从ReAct Agent到Workflow,企业AI复杂流程该如何编排?
一年前我还在为一个多步骤Agent调参调到头疼,一年多之后我在生产环境里把Agent拆成了Graph。这不是什么炫技,纯粹是被现实逼的:单靠ReAct范式,Agent一旦遇到分支判断、并行处理、人工确认,脑子里那点上下文根本扛不住,结果就是失控。今天想聊聊我用Spring AI Alibaba Graph做复杂流程编排的完整过程,从最原始的ReAct思路说起,到真正把工作流跑成一张有向图,这条路走通了,企业级AI落地才算是有了抓手。
先说清楚这篇东西适合谁:如果你正在用Java写AI应用,需要Agent处理多步骤任务,或者你已经发现ReAct模式在公司内部飘逸不定、难以控制,那这篇实战笔记能帮你在Spring AI和Spring AI Alibaba这套生态里找到一种更可控的状态机式方案。不需要你有多深的AI理论底子,但最好写过Spring Boot项目,对依赖注入、AOP这些基础概念有感觉。文里所有代码片段我都按真实项目的写法简化过,能跑,但不是玩具。
1. 为什么ReAct Agent撑不住企业级流程
1.1 ReAct范式到底解决什么问题
ReAct的核心思想是交替执行Reasoning和Acting:模型先想一步,然后调用工具,看到工具结果,再决定下一步干什么。这个思路确实比纯LLM硬答要靠谱太多,因为它把工具调用、观察结果、推理决策串成了一条链,模型不再是凭空发挥,而是贴着真实数据走。比如一个“查库存再生成订单”的Agent,模型先调用库存查询工具,看到“缺货”结果,就不会继续走下单流程,而是停在合理的地方。
但说白了,ReAct把流程控制权完全交给了模型。单轮任务还好,一旦任务变成多步骤、多分支、需要并行、需要人参与的复合流程,这个“自由发挥”就成了漏洞。我自己踩过最典型的坑是:模型在思考步骤中反复调用同一个只读工具三次,不是因为他需要三次,而是因为他忘了刚才调过;另一个更麻烦的坑是模型在异常分支里自作主张改了订单状态,规则上没有这个授权。
1.2 为什么企业流程不喜欢模型自行决策
企业级AI应用不是聊天机器人,他背后是业务约束、权限边界、审计日志、异常兜底。订单流转、工单分发、慢查询治理,每一步都有明确的状态转移条件,这些条件不能交给模型靠prompt“悟”,必须写死在流程里。实践中我们发现:用ReAct写出来的复杂任务,只要工具超过五六个,模型开始频繁出现工具选择错误。不是模型能力差,而是开放式决策空间太大,概率上必然翻车。
这就是Graph出现的理由:把流程步骤做成有向图节点,把流转条件做成边,模型只在局部节点做子决策,流程骨架由代码控制。人负责设计流程,模型负责单点智能,两者各司其职,整个系统才是可预测、可审计、可回滚的。
1.3 Spring AI Alibaba Graph在这个生态里的位置
Spring AI Alibaba是阿里巴巴在Spring AI生态上的落地实现,Graph模块对标LangGraph那套思路,但天然服务于Java技术栈和Spring Boot基础设施。他让你在已有的Spring工程里,无缝引入状态图、节点、条件边的抽象,配合原有的Service、Mapper、MQ等组件,不用重新发明一套流程引擎。
一句话总结:ReAct适合探索型任务,Graph适合生产型任务。接下来的实战,我会把同一个业务场景分别用两种方式演一遍,你就会直观感受到Graph带来的控制感。
2. Graph的状态模型与核心概念拆解
2.1 State、Node、Edge:先忘掉“Promt调用”,记住这三个
Graph编程最忌讳上来就写代码。你先在脑子里建立三个概念:
- State是全局流转的数据载体,任何节点都可以读写
- Node是流程中的一个执行单元,可以是LLM调用、工具调用、普通Java方法
- Edge是状态转移规则,决定了执行完一个节点后,下一个进入哪个节点
我把State理解成“一摞共享的便签”,所有节点都能往上面贴新便签,也能看看别人贴了什么。Node是流水线上的工位,每个工位只干一件事:要么问模型要一个结果,要么调一个服务,要么做一次数据清洗。Edge像流水线上的分流闸,根据产品合格还是不合格,送到不同的下一条线。
Spring AI Alibaba Graph用法类似,我用代码说明。先建状态类:
public class OrderState extends HashMap<String, Object> { public static final String ORDER_ID = "order_id"; public static final String CATEGORY = "category"; public static final String NEED_MANUAL = "need_manual"; public static final String ORDER_DATA = "order_data"; }State最简单的方式就是继承HashMap,省得定义一堆getter/setter。我建议在企业项目里还是做成POJO,方便序列化和后续版本兼容。
2.2 为什么用状态机思维替代直接发消息
在传统微服务里,服务间通过消息传递状态,调用链长了以后很难追踪。Graph这套玩法本质是单机状态机:所有状态都存在一份State里,节点只是状态转换器。好处是调试简单——你随时可以打出一个节点开始前和结束后的State diff,一眼看出谁改了数据;另一个好处是天然适合日志审计,每个节点进出都留痕。
可以说Graph是“用代码写死主干,把决策点交给模型”的折中方案。这比“各节点独立发消息”可控得多。
2.3 关键API:条件边、并行分支和递归
Spring AI Alibaba Graph里最核心的是ConditionalEdge和ParallelNode。前者根据State里的某个字段决定流转方向,后者把任务拆成多路并行。我实际项目里最常用的组合是:
Graph<OrderState> graph = new Graph<>(OrderState.class) .addNode("classify", new MyNode<>("classify", ctx -> { OrderState state = ctx.getState(); String text = state.getOrDefault("user_input", "").toString(); state.put(OrderState.CATEGORY, classify(text)); })) .addNode("auto_process", new MyNode<>("autoProcess", ctx -> { // 自动审批分支 })) .addNode("manual_review", new MyNode<>("manualReview", ctx -> { // 人工审核分支 })) .addEdge("classify", "auto_process", (state) -> { return "auto".equals(state.get(OrderState.CATEGORY)); }) .addEdge("classify", "manual_review", (state) -> { return !"auto".equals(state.get(OrderState.CATEGORY)); });条件边是Graph和普通流程引擎最大的区别:普通引擎的分支条件是程序员在写流程时定死的,Graph的条件边则是一个Java函数,可以读State里模型写的字段,再决定走向。这样模型就有了把球踢到左路还是右路的局部决策权,但路线本身还是人画的。
3. 实战:把请假审批流改成“Agent+Graph”混合编排
3.1 最简单的ReAct Agent实现
先看一个最简单的ReAct式Agent长什么样。我用的是Spring AI的ChatClient,配合ToolCalling:
@Service public class OldAgentService { private final ChatClient chatClient; private final ToolCallbackProvider toolCallbackProvider; public OldAgentService(ChatClient.Builder builder, ToolCallbackProvider toolCallbackProvider) { this.chatClient = builder.build(); this.toolCallbackProvider = toolCallbackProvider; } @Bean public ToolCallbackProvider myTools() { return MethodToolCallbackProvider.builder() .toolObjects(new LeaveTools(new FakeLeaveRepository())) .build(); } public String handle(String userMessage) { return chatClient.prompt() .user(userMessage) .tools(toolCallbackProvider) .call() .content(); } }这个Demo能跑通“帮我查一下请假记录然后提交年假申请”这种复合任务,模型会自动调用Tool。但一旦加入“请假天数大于3天需要部门主管审批,大于5天需要HR复核”这种业务规则,你就得开始祈祷模型能正确记忆前置判断结果。实践证明,这个祈祷经常落空。
3.2 用Graph重构:步骤拆解与State设计
我把同一个请假流程改成Graph。先定义状态字段:
public class LeaveState extends HashMap<String, Object> { public static final String USER_INPUT = "user_input"; public static final String LEAVE_TYPE = "leave_type"; public static final String LEAVE_DAYS = "leave_days"; public static final String NEED_MANAGER_APPROVAL = "need_manager_approval"; public static final String NEED_HR_APPROVAL = "need_hr_approval"; public static final String APPROVAL_RESULT = "approval_result"; }Node划分成四个:
- 语义解析节点:从user_input里抽出请假类型和天数
- 规则校验节点:根据天数判断是否需要主管审批、HR审批
- 审批执行节点:模拟调用审批服务
- 结果汇总节点:把审批结果拼装成给人看的输出
这四个节点和LLM的关系各有不同:解析节点必须用LLM,规则校验节点用纯代码,审批执行节点用工具调用。
3.3 核心代码:完整Graph组装
@Configuration public class LeaveGraphConfig { @Bean public Graph<LeaveState> leaveGraph(LeaveTools leaveTools) { // 节点1:语义解析(模型判断) MyNode<LeaveState> parser = new MyNode<>("parser", ctx -> { LeaveState state = ctx.getState(); String userInput = (String) state.get(LeaveState.USER_INPUT); ChatClient client = aiClient(); // 注入你的ChatClient String response = client.prompt() .user("从以下请假请求中提取请假天数和请假类型,返回JSON:\n" + userInput) .call() .content(); JsonNode json = objectMapper.readTree(response.substring(response.indexOf("{"), response.lastIndexOf("}") + 1)); state.put(LeaveState.LEAVE_DAYS, json.get("days").asInt()); state.put(LeaveState.LEAVE_TYPE, json.get("type").asText()); }); // 节点2:规则判断(代码控制,不依赖模型) MyNode<LeaveState> rule = new MyNode<>("rule", ctx -> { LeaveState state = ctx.getState(); int days = (int) state.get(LeaveState.LEAVE_DAYS); boolean needManager = days > 3; boolean needHr = days > 5; state.put(LeaveState.NEED_MANAGER_APPROVAL, needManager); state.put(LeaveState.NEED_HR_APPROVAL, needHr); }); // 节点3:审批(调用外部服务/工具) MyNode<LeaveState> approval = new MyNode<>("approval", ctx -> { LeaveState state = ctx.getState(); boolean needManager = (boolean) state.get(LeaveState.NEED_MANAGER_APPROVAL); boolean needHr = (boolean) state.get(LeaveState.NEED_HR_APPROVAL); String result = leaveTools.submitApproval(needManager, needHr); state.put(LeaveState.APPROVAL_RESULT, result); }); // 节点4:汇总输出 MyNode<LeaveState> summary = new MyNode<>("summary", ctx -> { LeaveState state = ctx.getState(); String result = (String) state.get(LeaveState.APPROVAL_RESULT); String type = (String) state.get(LeaveState.LEAVE_TYPE); int days = (int) state.get(LeaveState.LEAVE_DAYS); state.put("answer", String.format("您的%s假期申请(%d天)已处理,审批结果:%s", type, days, result)); }); return Graph.<LeaveState>builder() .addNode("parser", parser) .addNode("rule", rule) .addNode("approval", approval) .addNode("summary", summary) .addEdge("parser", "rule") .addEdge("rule", "approval") .addEdge("approval", "summary") .build(); } }业务流程上唯一的控制点就是Node内部的Java代码和固定的边。模型不负责“接下来干什么”,他只负责解析用户输入。这意味着即使模型的解析结果出现偏差,也只会影响某个字段值,绝对不会跳跃到别的流程分支上。
3.4 运行时效果对比
我把同一个测试案例跑了两遍。老代码里,模型每次执行工具的顺序和次数都不一样,有一回甚至跳过请假天数判断直接提交了申请。Graph版本这边,四次执行日志几乎一模一样:parser节点完成解析、rule节点完成规则判断、approval节点调用审批服务、summary节点拼装答案,顺序固定,差异只在解析出的JSON字段值上。这种“骨架稳定、子步骤智能”的表现,正是企业环境最看重的东西。
4. 边界情况、异常处理与企业落地的关键设计
4.1 模型解析失败时怎么兜底
即使把决策点压缩到局部,模型依然可能翻车。我在parser节点里加了兜底逻辑:
MyNode<LeaveState> parser = new MyNode<>("parser", ctx -> { LeaveState state = ctx.getState(); String userInput = (String) state.get(LeaveState.USER_INPUT); String response = callModel(userInput); try { JsonNode json = parse(response); state.put(LeaveState.LEAVE_DAYS, json.get("days").asInt()); state.put(LeaveState.LEAVE_TYPE, json.get("type").asText()); } catch (Exception ex) { state.put(LeaveState.LEAVE_DAYS, 0); state.put(LeaveState.LEAVE_TYPE, "unknown"); state.put("parse_error", ex.getMessage()); } });宁可解析出无效值让后续规则节点走“默认拒绝”分支,也不让异常从Graph里炸出去。因为Graph框架里的异常处理往往会影响整个状态快照,导致审计日志不完整。我的原则是:模型节点永远不抛异常,只写错误标记;规则节点看到错误标记就走兜底分支。
4.2 状态快照与审计日志的价值
企业环境里每步操作都要能追溯。Graph方案天然适合做这件事——每个节点执行前后,State对象都是一份快照。我在实际工程里给节点包了一层切面:
@Aspect @Component public class GraphAuditAspect { @Around("@annotation(com.example.audit.GraphAudit)") public Object audit(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); // 记录before state Object result = pjp.proceed(); // 记录after state return result; } }有了这份审计日志,客户合规部门再问“这个审批决策是怎么做出来的”时,你直接把Graph执行轨迹一摆,所有节点和状态变化清清楚楚。这在ReAct模式下很难做到:模型中间调用了哪些工具、哪些没调,只有聊天消息记录,但消息记录和状态变更之间缺乏结构化绑定。
4.3 超时与重试机制怎么设计
Graph节点往外调服务时,每个Node最好设置自己的超时时间。我把整个Graph执行看作一条调用链,只要有一个节点卡死,整条链就堵住了。框架可以配置全局timeout,但我更推荐单个节点层级的超时控制:
TimeLimiter limiter = TimeLimiter.of(Duration.ofMillis(3000)); node.setTimeout(limiter);在重试策略上有个反直觉的坑:LLM节点不要把“模型本身的重试”和“Graph节点的重试”混在一起。模型调用失败重试一次是合理的,但是Graph节点级别的重试会把整条链的状态都重放一遍,如果状态里已经写了脏数据,重试只会放大问题。我的经验是:模型节点用指数退避重试一次,业务节点的重试交给业务方自己在代码里控制。
4.4 Graph和人工确认节点的组合
真实企业流程还有个ReAct完全搞不定的需求:人工确认。比如“系统自动生成退款单,需要财务审核后执行”。Graph里实现人工确认节点就是做一个异步等待:
MyNode<LeaveState> manualCheck = new MyNode<>("manualCheck", ctx -> { LeaveState state = ctx.getState(); // 把审批请求写入数据库,标记为PENDING String approvalId = approvalRepo.createApproval(state); // 调用一个阻塞接口等待外部回调 ApprovalResult result = approvalService.waitForManualApproval(approvalId, Duration.ofHours(24)); if (result.approved()) { state.put(LeaveState.APPROVAL_RESULT, "approved"); } else { state.put(LeaveState.APPROVAL_RESULT, "rejected"); } });这个节点在图上和其他节点一样,唯一的区别是执行时间长了点。状态机会一直把这根链挂在那边,直到人工处理完再继续往下走。这在ReAct模式里无法想象——模型的上下文窗口和token预算根本撑不住几小时的等待状态。
5. 并行编排实战:审批节点拆成多路执行
5.1 ParallelNode的适用场景
企业流程里“并行”是高发需求。比如数据处理场景,一个上游节点分析完数据格式后,需要同时执行“格式转换”“质量校验”“敏感信息脱敏”,三个任务互相不依赖,串行做太浪费时间。
Spring AI Alibaba Graph里可以用ParallelNode:
ParallelNode<LeaveState> parallel = new ParallelNode<>("parallel", branches -> { return branches .addSubGraph(b -> b.addNode("convert", new MyNode<>(...))) .addSubGraph(b -> b.addNode("quality", new MyNode<>(...))) .addSubGraph(b -> b.addNode("mask", new MyNode<>(...))) .build(); });并行节点内部的子节点跑完后,会把结果合并回主State。这里有一个关键点:并行分支里各子节点不要同时修改同一个State字段,否则会有覆盖问题。我的做法是并行分支写入独立命名空间,比如convert_result、quality_result、mask_result,最后汇总节点再统一读取和融合。
5.2 并行分支的失败处理策略
并行分支最怕的是“一个失败导致整条主流程失败”。我建议先调整心态:并行分支失败不一定要回滚整条链,针对不同失败类型做不同兜底。比如质量校验失败时,可以标记quality_passed=false而不抛异常,让汇总节点根据这个标记决定最终结果。把异常尽量“折叠”成标记,而不是“抛出”,State的容错性会好很多。
5.3 并行度和资源控制
实际跑并发节点时,要特别注意线程池配置。Graph框架的并行节点默认会起线程池,但线程池大小如果没有限制,高峰期会打爆下游服务的连接池。我通常把并行节点的线程池大小限制在CPU核数的一半以下:
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix("graph-parallel-");这个配置看起来常规,但相当多踩坑案例都是因为并行节点没有独立线程池,直接吃满了公共线程池,把其他服务的响应拖垮了。
6. 常见问题速查与避坑清单
6.1 问题排查表
我在生产环境里总结了一张排查表,Graph跑不动的时候先按这个顺序查:
| 问题现象 | 可能原因 | 排查办法 |
|---|---|---|
| Graph执行卡在某个节点不动 | 外部服务调超时,节点未配置超时 | 看线程dump,检查该节点timeout配置 |
| 模型节点返回的内容解析失败 | 模型输出格式不稳定 | 加JSON schema约束,加解析兜底try-catch |
| 并行分支结果互相覆盖 | 多个子节点写同一个State字段 | 改用独立命名字段,最后汇总 |
| 状态里出现null导致条件边NPE | State字段初始化不完整 | 节点入口统一做空值检查 |
| 重试导致脏数据被放大 | 节点级重试放大了副作用 | 模型节点只做单次退避重试,业务节点自己控制幂等 |
6.2 最容易忽略的三个坑
第一个坑是State序列化问题。Graph中间状态如果要做持久化,或者跨节点传递时经过消息队列,HashMap<String,Object>会被序列化成JSON,其中嵌套对象可能因为类型信息丢失导致反序列化后ClassCastException。我的建议是State里不要存自定义对象本身,只存他Id,需要完整对象时节点内再从仓储查一遍。这是很多早期Graph项目最常见的诡异报错来源。
第二个坑是Graph的节点ID不能是匿名函数。调试时如果每个节点叫lambda$new$0这种东西,日志几乎没法看。一定给每个节点起可读名称,比如send_notification、validate_address,这能省下你后期排查日志时的大量时间。
第三个坑是条件边的优先级。同一个起点连出多条条件边时,框架如何判定走哪条?有些实现按“第一条匹配”,有些按“最后一条匹配”,如果你没搞清楚具体语义,分支很容易走到意料之外的路径上。我在Spring AI Alibaba的实际使用中,都是把条件边设计成互斥条件,并且会在每条边前打印日志,确认到底走了哪条。
7. 真实项目落地中的架构配合经验
7.1 不要让Graph直接暴露给Web层
我在落地时坚决不在Controller里直接调用Graph,而是封装了一层Service。Controller只需要接收请求参数,然后构建初始State,Service启动Graph执行,拿到最终State后再组装HTTP响应。这样Graph的输入、输出都是State对象,和前端协议彻底解耦。后续如果流程改动,只动Graph内部节点,Controller层完全不需要感知。
7.2 状态持久化与恢复
有些企业流程执行时间特别长,比如人工审批流程挂起几小时。如果进程重启,未完成的Graph状态就会丢失。我的方案是把State快照持久化到数据库,节点每次变更后就更新一次快照。Graph在启动的时候读取这条快照,接着从上次断点继续执行。Spring AI Alibaba Graph本身可能不直接提供这种持久化机制,但我建议优先级很高,别等出事再补。
7.3 从单体Graph拆成分段Graph
流程特别复杂时,把全流程画成一整张图会很大,难以维护。我更倾向按业务边界拆成多段Graph:比如“解析段Graph”、“审批段Graph”、“执行段Graph”,每段Graph有自己的State上下文,段与段之间通过消息队列传递精简后的结果。这和微服务拆分是同一个思路,目的是控制每张图的可理解性和故障爆炸半径。
8. 踩过坑之后的一些体会
最后聊点个人感觉。
Graph这套东西最大的好处不是“更聪明”,而是“更稳”,它把LLM从“流程控制者”降级成了“流程里的节点执行者”,这个角色转换才是企业AI能在生产环境活下去的关键。以前我总担心模型哪一步脑洞大开,现在我只用担心模型在局部节点里解析JSON的质量,这种担忧量级完全不一样。
如果你想上手,建议第一步不是写Graph代码,而是拿一个现存ReAct流程,在纸上画出它的状态流转图:哪些步骤其实可以并行,哪些步骤必须串行,哪些步骤需要人工确认,哪些步骤是规则校验。画出这张图,代码实现只是时间问题。
第二个建议是在小范围验证框架的边界:先跑通偶数个节点的线性链,再加条件分支,再加并行节点。一步到位写十几二十个节点的图,调试时你会疯的。确认每一步都打印State变化,Graph最忌讳的就是“看起来没动,其实动了”的隐藏状态变更。
这个框架和我用过的其他编排方案相比,最大优势是和Spring生态无缝衔接,Service、Mapper、MQ这些已有的东西直接复用,AOP审计、配置中心、链路追踪都不用推翻重来。对有Spring Boot底子的团队来说,学习成本主要在思维转换——把“让模型发挥”的习惯收回来,改成“让模型在约束里发挥”。
如果你已经在ReAct模式里被折磨过,给Graph一次机会,你会发现流程从“玄学”变成了“工程”。