上季度我在给一家电商平台的订单履约系统做AI化改造,最初架构非常简单:一个ReAct Agent,把订单查询、退款校验、地址修改几个工具全部交给模型自由调用。演示阶段一切都好,一到真实流量和压测就原形毕露——模型在工具之间反复横跳、退款流程被重复触发、关键决策完全无迹可循。也就是从那个时候起,我开始系统研究Spring AI Alibaba里的Graph模块,试着把“自由发挥”的Agent改造成“按图索骥”的Workflow。这篇文章就把这轮改造的完整思路、核心代码和踩坑记录梳理出来,给正在纠结“企业AI流程到底怎么编排”的同学一个参考。
如果你也在做企业级AI应用,大概率会遇到和一样的困惑:单纯的ReAct Agent太不可控,传统的硬编码Workflow又太死板,到底有没有中间路线?Spring AI Alibaba Graph给出的答案是:把Agent和Workflow统一到一张有向图上,让每条边都代表一个明确的流转规则,让每个节点自己去选择调用模型还是调用工具。下面我从问题出发,一步步讲清楚它是怎么做到的。
1. ReAct Agent在真实业务里的“失控时刻”
1.1 三个让我决心改造的线上问题
第一个问题出在退款流程重复执行。用户在客服对话里说了一句“刚才那个订单帮我退一下”,Agent识别到意图后调用了退款接口,但接口响应稍微慢了一点,模型在等待中又重复调用了一次。结果就是同一笔订单被发起了两笔退款申请,财务侧直接报警。事后排查时发现,ReAct模式下的Agent对“这个动作是不是已经做过了”完全没有概念,上下文里的工具调用记录被模型当成新信息重新读取,轻飘飘地又执行了一遍。
第二个问题是关键决策没有规则约束。我们的售后策略里有一条硬规定:金额超过5000元的退款必须走人工审核。但Agent在处理一笔6300元的退款申请时,因为看到了用户“商品有质量问题”的描述,就自动进入了快速退款通道。从模型的角度看,它觉得这很合理;从业务角度看,这属于明显违规操作。原因很简单:业务规则存在于文档和代码里,而模型的知识库里只有常识和推理能力,它根本不知道“5000元”这个阈值的存在。
第三个问题最磨人——高并发下的不可控串联。用户在对话框里问了一句“我上次买的东西还在保修期吗?能退吗?”,Agent先查了订单列表,又查了商品类目,再去翻售后政策文档,来来回回调用了8次工具,耗时将近12秒。每一步看起来都有道理,但整体路径完全是随机的。日志里只能看到模型的思考过程,却看不到业务层面的状态流转,出了问题连复现都很困难。
这三个问题共同指向一件事:ReAct擅长“想”,不擅长“做”。它在开放问答、复杂推理场景里确实很强大,可一旦进入有顺序、有状态、有强约束的业务流程,它的灵活性反而成了最大的风险源。
1.2 ReAct的底层逻辑:模型即路由
ReAct的核心是Reasoning and Acting的交替循环。每一步,模型先根据当前的观察结果进行推理,然后决定下一步调用哪个工具,再根据工具返回结果继续推理,直到得出最终答案。这个循环本身没有错,它模仿了人类的“思考-行动-再思考”过程,特别适合需要探索和推断的开放任务。
但它有一个隐含假设:模型对“下一步该做什么”拥有完全的控制权。在聊天问答场景里,这个假设是合理的——问题开放,答案不唯一,模型需要自由探索。在业务流程场景里,这个假设就很危险了——流程的先后顺序、分支条件、审批链条是业务强规则,不是靠模型“推理”出来的。
用个生活化的类比:ReAct Agent像是一个特别聪明但完全没学过公司SOP的新员工。你给他一堆工具、一份产品手册,他能把事情办成,但你永远不知道他会按什么顺序办、会不会跳过某个审批环节、会不会因为心情好给你发两次退款。企业要的不是“聪明的可能”,而是“稳定的确定”。
1.3 为什么“能答对题”不等于“能走完流程”
这里我想说破一个很多团队都会踩的误区:我们往往用“回答准确率”来衡量Agent的能力,但在企业场景里,真正的KPI是“流程完成率”。一个Agent能把用户的问题回答得再漂亮,只要它无法稳定地走完“校验——审批——执行——通知”这条链路,那它在生产环境里就是不可用的。
原因在于业务顺序不是模型推理出来的,而是流程定义出来的。拿订单退款来说,“先校验订单状态,再校验金额,再走审批,最后执行退款”,这个顺序不是逻辑推理的结果,是业务契约。模型可能觉得“既然用户都申请退款了,那我直接退不就行了?”,于是跳过了状态校验,对一个已经取消的订单发起了退款操作。你说模型错了吗?从对话角度看,它确实理解了用户意图并执行了动作;从业务角度看,它彻底破坏了流程。
还有状态一致性问题。企业流程天生有状态:待审核、审核中、已通过、已驳回、执行中、已完成。这些状态之间的迁移必须严格可控,而ReAct Agent天然没有“当前状态”的概念,它只有上下文里的一堆文本。当状态和上下文不一致时,模型就会做出与业务背道而驰的决策。这就是为什么我后来铁了心要把流程拆成Graph——我需要的是“模型在轨道上跑”,而不是“模型拿着方向盘乱开”。
2. Spring AI Alibaba Graph的编排模型:把AI流程画成有向图
2.1 先建立Graph的心智模型
接触Spring AI Alibaba Graph之前,我最早用过Python生态里的LangGraph,所以对“用有向图编排Agent流程”这个思路并不陌生。它的心智模型可以归结为一句话:把一个复杂的AI业务流拆成节点和边。
节点代表一个具体的处理步骤。这个步骤可以是纯粹的规则代码,比如“检查订单金额是否大于5000元”;也可以是LLM调用,比如“分析用户投诉情绪并生成处理建议”;还可以是工具调用,比如“调用售后系统执行退款操作”。
边代表节点之间的流转关系。最简单的边是无条件边,A执行完直接进B;更实用的是条件边,根据State里的某个字段选择去B还是去C。比如“如果审核结果是通过,走自动退款节点;否则走人工复核节点”。
和普通流程引擎不同,Graph里有几个关键设计:
- 共享状态(State)贯穿整个图,每个节点都能读取和修改,这相当于流程的“公共黑板”;
- 允许循环边,可以根据条件反复执行某个节点,直到满足退出条件;
- START和END是两个特殊节点,分别代表流程的入口和出口;
- 节点内部既可以放LLM调用,也可以放工具函数,甚至能嵌套一个完整的Agent循环。
这个概念本身不复杂,但它带来的思维转变非常大:你不再把Agent看成一个“会自己思考的对话实体”,而是把它看成流程图里的一个环节。什么时候让模型说话,什么时候必须走固定规则,全由你在图里画死。
2.2 Spring AI Alibaba Graph的工程骨架
Spring AI Alibaba Graph最大的价值不是发明了新的编排理论,而是把Graph编排能力完整地搬进了Spring生态。对于用Java做后端的企业团队来说,这直接省掉了一层跨语言的胶水代码——不需要再单独部署一个Python服务来跑LangGraph,也不需要自己在Java里重造一个状态机轮子。
它提供了一套构建器风格的API,核心是基于StateGraph来搭建流程。我手头这个版本的大致写法是:
StateGraph<OrderState> graph = new StateGraph.Builder<OrderState>() .addNode("verifyOrder", new VerifyOrderNode()) .addNode("checkAmount", new LLMNode<>(chatClient, "请根据订单信息判断退款金额是否合理")) .addNode("autoApprove", new AutoApproveNode()) .addNode("manualReview", new ManualReviewNode()) .addNode("executeRefund", new ExecuteRefundNode()) .addEdge("start", "verifyOrder") .addEdge("verifyOrder", "checkAmount") .addConditionalEdge("checkAmount", state -> state.getAmount().compareTo(new BigDecimal("5000")) >= 0 ? "manualReview" : "autoApprove") .addEdge("autoApprove", "executeRefund") .addEdge("manualReview", "executeRefund") .addEdge("executeRefund", "end") .build();和LangGraph的思路一致,但它深度绑定了Spring AI的ChatClient、Tool Calling等能力。这意味着你不需要自己管理消息历史、工具描述和调用结果的回填,这些Spring AI都帮你处理好了。团队里熟悉Spring的Java工程师基本没有学习门槛,看图说话而已。
2.3 预置节点与就地改造:最少代码迁移路径
我调研时发现一个对存量项目非常友好的特性:Graph模块里内置了AgentNode、LLMNode、WorkflowNode这几个预置节点。
LLMNode用来跑一次性的模型调用,适合“判断、分类、抽取、摘要”这类轻决策;AgentNode把整个ReAct循环封装成了一个节点,适合流程中确实需要自由探索的子任务;WorkflowNode可以直接嵌套一个已有的工作流,方便把老系统里的流程原样搬进来。
这意味着迁移路径很平滑:你先画一遍现有ReAct Agent的图,找出它实际走过的所有工具调用路径,然后把其中固定不变的步骤画成普通节点,把必须靠模型判断的点画成LLMNode,把真正需要开放推理的子任务包进AgentNode。原来的工具函数不需要改,直接注册成Tool交给节点调用就行。
我自己做下来的体感是:80%的流程根本不需要Agent,20%真正需要Agent的地方,用AgentNode包一层就足够了。
3. 一个可落地的案例:售后退款审核流程从Agent改造成Graph
3.1 业务场景与状态定义
为了不空谈理论,我拿一个典型的售后退款审核场景来拆解。它的完整链路是:接收用户的退款申请——校验订单状态(是否已发货、是否在售后期)——校验退款金额和退款原因——如果金额超过阈值走人工审核——自动通过或人工通过后执行退款——通知用户处理结果。
改造Graph之前,我先把状态类定义出来。这个类是整个流程的公共黑板,每个节点读写它:
public class OrderState { private String orderId; private String userId; private String orderStatus; private BigDecimal refundAmount; private String refundReason; private String auditResult; // 自动通过 / 转人工 / 驳回 private String refundStatus; // 未执行 / 执行中 / 已完成 / 失败 private LocalDateTime createTime; private String errorMessage; }关键点是:不要把对话历史塞进State。State只应该承载业务流转需要的数据,模型思考过程让它留在上下文里,和业务状态解耦。后面我会专门讲这个坑有多深。
3.2 改造前:自由调用的ReAct写法
改造前的代码逻辑很直接:给Agent注册5个工具——queryOrderDetail、validateRefund、checkAfterSalePolicy、executeRefund、sendNotify,然后加上一段提示词:“你是售后客服,根据用户请求一步步操作。”
跑单次测试时效果不错,但仔细看日志就会发现路径千奇百怪。有一次模型先查了售后政策,再查订单,再做校验,再执行退款;另一次模型跳过校验直接执行了退款,理由是“用户已经明确要求退款”。提示词里明明写了“必须先校验”,但模型对“必须”的理解经常被用户话语里的情绪词汇覆盖。
更麻烦的是分支逻辑没法写死。如果金额超过5000元需要人工审核,ReAct模式下模型确实会在工具列表里选择“转人工待办”这个工具,但遇到金额恰好等于5000元时,不同次请求的决策就分叉了。有的判断“等于5000不需要”,有的判断“等于5000需要”,完全随机。规则逗号模型化,结果是规则失效。
3.3 改造后:节点化编排的Graph写法
改造的核心思路:让规则回归规则,让模型回归模型。固定顺序全部画成边,阈值判断写进条件路由,只有“审核未通过时给用户生成解释话术”这种开放任务才交给LLM。
完整构建代码如下:
StateGraph<OrderState> graph = new StateGraph.Builder<OrderState>() // 1. 固定顺序:先查订单、再校验售后资格 .addNode("loadOrder", context -> { OrderState state = context.getState(); OrderDetail detail = orderService.getOrderDetail(state.getOrderId()); state.setOrderStatus(detail.getStatus()); state.setRefundAmount(detail.getPayAmount()); return state; }) // 2. 用规则做校验,不经过模型 .addNode("validateAfterSale", context -> { OrderState state = context.getState(); if (!"PAID".equals(state.getOrderStatus())) { state.setAuditResult("REJECT"); } else if (orderService.isOutsidePolicy(state.getOrderId())) { state.setAuditResult("REJECT"); } else { state.setAuditResult("PENDING"); } return state; }) // 3. 规则失败直接走拒绝分支,不再询问模型 .addConditionalEdge("validateAfterSale", state -> "REJECT".equals(state.getAuditResult()) ? "rejectNotify" : "amountGate") .addNode("amountGate", context -> { ... }) // 4. 金额阈值走条件边,大于5000转人工 .addConditionalEdge("amountGate", state -> state.getRefundAmount().compareTo(new BigDecimal("5000")) >= 0 ? "manualReview" : "autoApprove") // 5. 人工审核节点,嵌入一个AgentNode做原因分析和建议 .addNode("manualReview", new AgentNode<>(chatClient, reviewAgentTools)) // 6. 自动通过节点,纯规则执行 .addNode("autoApprove", context -> { state.setAuditResult("APPROVED"); return state; }) // 7. 执行退款,带幂等控制 .addNode("executeRefund", ExecuteRefundNode.builder() .refundService(refundService) .idempotencyStore(redisTemplate) .build()) // 8. 通知用户 .addNode("notifyUser", context -> { state.setRefundStatus("NOTIFIED"); return state; }) .addNode("rejectNotify", ...) .addEdge("start", "loadOrder") .addEdge("loadOrder", "validateAfterSale") .addEdge("autoApprove", "executeRefund") .addEdge("manualReview", "executeRefund") .addEdge("executeRefund", "notifyUser") .addEdge("notifyUser", "end") .addEdge("rejectNotify", "end") .build();相比之前的ReAct版本,有几个明显的差别:
- 顺序是编译期确定的,模型没有权限改变“先查订单再校验资格”这个顺序;
- 分支条件是代码写死的,5000元阈值不再依赖模型对数字的敏感度;
- 只有
manualReview节点里嵌了AgentNode,用于分析退货原因、生成给用户回复的建议文案,这是它该干的活。
还有一个细节值得说:执行退款节点我做了幂等控制。用Redis存了一个refund:index:orderId的标记,只有第一次执行能拿到锁,后面的重试直接返回“已处理”。这个在ReAct模式里根本没法做,因为模型的行为不可预测,你没办法在提示词里约束它“同一个退款只能调一次接口”。
3.4 压测对比:稳定性和成本的真实变化
改造完成后我跑了一轮为期两周的对比例试,数据是我自己压出来的,不代表任何官方基准,但趋势很明显:
| 指标 | ReAct Agent | Graph编排 |
|---|---|---|
| 完整流程成功率 | 约71%(100次里29次走错分支或漏步骤) | 96%以上 |
| 单次平均工具调用次数 | 7.2次 | 3.1次 |
| 平均处理耗时 | 约6.5秒 | 约2.4秒 |
| Token消耗(单次均值) | 约4200 | 约1600 |
| 可审计性 | 只有模型思考日志,无状态记录 | 每个节点前后都有状态快照 |
| 重复执行风险 | 高(同一个退款被触发多次) | 低(幂等控制兜底) |
区别最明显的是成功率。ReAct模式失败的原因五花八门:有跳过校验的,有分支判断错误的,也有模型在复杂对话里直接说“我已经帮您退款了”但实际根本没调用工具的。Graph模式把这类问题基本消灭了,剩下4%的失败集中在外部接口超时和退款服务本身报错,这些属于基础设施问题,靠编排也解决不了,只能加重试和告警。
成本下降也很直观。原来模型每轮都要看一遍所有工具的描述,再自己决定路径,现在大部分节点是纯代码,只有两三个节点真正调用模型,Token消耗直接降了一个量级。
4. 企业流程编排的选型取舍:何时继续用Agent,何时必须上Workflow
4.1 判断流程“可确定性”的三个标准
被Graph的效果打动之后,我有一段时间差点走上另一个极端:把所有流程都改成Graph。结果在做一个资料收集类需求时发现完全行不通——那个任务本身就是开放式的,用户要什么文档、去哪里找、找到后怎么组织,每一步都没有固定规则。硬编成Graph只会得到一堆fake的条件边,等于用代码硬模拟模型的自由决策,既蠢又难维护。
后来我总结出三条判断标准,用来决定一个流程到底该用ReAct Agent还是该上Graph/Workflow:
- 流程是否存在明确的业务顺序。比如“必须先校验再执行”“必须走审批再放款”,如果有,顺序应该由代码固定而非模型决定。
- 流程是否需要跨节点共享状态。企业流程基本都要——订单状态要传给审核节点,审核结果要传给执行节点。ReAct在长流程里维护这种状态很容易丢失。
- 流程是否必须可审计。财务、售后、风控这类带审计要求的流程,每一步都必须有迹可循。ReAct模型思考日志无法满足这种要求。
三条里有两条命中,我就倾向Graph;一条都不中,那就老实让Agent自由跑。混合型需求最常见——整体流程确定,但局部环节需要模型发挥,这种就用Graph套AgentNode。
4.2 混合编排:在Graph节点里嵌入Agent子流程
混合编排是Spring AI Alibaba Graph里最实用的一种用法。它解决的核心问题是:大方向上要可控,细节上要灵活。
举个例子,我们的售后流程里有一个环节是“给用户生成驳回通知”。这个环节如果只写模板,显得太生硬;如果完全交给模型自由发挥,又怕它乱承诺。我的做法是在Graph里加一个专门的AgentNode,给它两个工具:一个是“查询驳回原因对应的售后政策条目”,一个是“查询同类问题的常见处理结果”,然后让它根据这些事实生成一封委婉但不失明确的通知文案。
这样做的好处是:Agent的灵活性被关在了一个狭窄的笼子里。它可以写错一句话,但不可能改变退款流程的状态,不可能直接调用退款接口,不可能绕过审核。风险被隔离了,价值被保留了。对生产系统来说,“不出错”比“出彩”重要得多。
4.3 一张选型对照表
我最后整理了一张表,给团队内部当选型参考:
| 场景特征 | 推荐方案 | 典型例子 |
|---|---|---|
| 开放问答、资料整理、内容生成 | ReAct Agent | 知识库问答、写作辅助 |
| 路径完全固定、无状态流转 | 传统代码/规则引擎 | 报表生成、定时任务 |
| 流程固定但带分支、需要状态同步 | Graph/Workflow编排 | 订单履约、审批流、售后处理 |
| 整体确定但局部需要模型参与 | Graph + 局部AgentNode | 审核流里带原因分析、政策解读 |
| 需要严格审计和条件回滚 | Graph(节点可追踪、状态快照) | 风控链路、资金操作 |
核心原则只有一句:业务流程的骨架必须用代码锁死,模型的创造力应该用在内容生成和局部决策上,而不是用在决定业务怎么流转上。
5. 实测踩坑记录:超时、循环、状态隔离与编译期问题
5.1 循环逃逸:模型自己给自己加戏
Graph支持循环边,这在“反复校验直到通过”这类场景里很好用,但代价是如果你不设防,模型会让流程永远跑不出去。
我遇到的情况是:在一个审核节点里,模型会被要求“检查申请材料是否齐全,不齐全就返回补充材料节点”。理论上补完材料再审一轮就行,但实际操作中模型经常判定“材料仍然不全”,哪怕新提交的材料已经完全符合要求。它的思考过程是“为了保险起见,再让用户补充一次身份证明”,然后流程陷入了一个永远循环的检查链路。
解决方法是双层保险。第一层是硬编码最大迭代次数,在构建图的配置里设置maxIterations(5),任何节点一旦循环超过5次直接走异常出口。第二层是在State里加一个int visitCount字段,每次进入循环节点先自增,超过阈值就不再询问模型判断,直接转人工处理。模型可以“觉得”还需要检查,业务规则不允许它无限制地检查下去。
5.2 状态脏读:多会话共用一个State
这个坑非常隐蔽。Graph的State类一开始我定义成了Spring的单例Bean,想着一个流程的状态跑完就结束了,无所谓共享不共享。结果上线第一天就出了问题:用户A的退款申请流转到人工审核节点时,审核人看到的却是一些鬼畜数据,下拉列表里混着用户B的订单号。
排查之后发现,问题出在一个AgentNode内部调用了会写State的Tool,而State对象因为被定义成单例,在多个会话之间被并发修改了。用户A和用户B的请求同时进图,后写的字段把先写的覆盖掉了。
修复方案清清楚楚:State必须是每次请求单独创建的,绝不能做成单例。我用一个StateFactory在每次进入Graph时new一个空State,节点之间通过上下文传递数据,而不是通过静态字段共享。之后还在State里加了sessionId和requestId两个审计字段,每个节点写状态前检查requestId是否匹配,不匹配直接终止并告警。
5.3 超时引发的连锁失败
Graph的节点里如果包了LLM调用,超时设置非常讲究。我第一次配置时用的是Spring AI ChatClient的默认超时,大概60秒。这个时长在对话Demo里没问题,但在生产流程里是个灾难:用户发起一个退款审核,流程跑到“分析退款原因”这个LLM节点时,模型响应慢,60秒后超时,整个Graph线程被卡住,后续节点全部排队,数据库连接池都快被打满了。
后面我做了三件事:
- 给LLM节点单独设置较短的调用超时,比如15到20秒,超过就走“降级节点”,用规则模板生成默认分析结果,不阻塞主流程;
- 把Graph的整体执行也设了超时,比如30秒,超过就直接把流程标记为“待人工处理”;
- 重试策略区分幂等和非幂等节点。读操作节点可以安全重试,写操作节点一律不自动重试,只记录失败状态并告警,防止重复执行带来的副作用。
经过这轮调整,整个流程的最长耗时被控制在了25秒以内,基本不会因为单个环节慢而拖垮全链路。
5.4 版本升级带来的API差异
Spring AI Alibaba这个项目迭代速度很快,Graph模块的API在不同小版本之间是有过变动的。我在从早期版本升级到另一个版本时,发现StateGraph.Builder里原来叫addConditionalEdge的接口签名变了,节点执行上下文从GraphState改成了带范型的GraphState<T>,结果编译期直接报了一堆错。
这其实不是坏事,反而是好事。Graph编排最怕的是“图结构在运行时才能发现问题”——节点名字拼错了、两条边指向了不存在的节点,如果等到线上流量到了才暴露,排查成本极高。我在项目里加了一个启动自检机制:应用启动时扫描所有的Graph定义,校验每个节点名称在注册表里有对应实现、每条边指向的节点真实存在、每个条件边的返回值都能匹配到目标节点,校验不通过直接抛异常不让应用启动。
经过这次经验,我建议所有用Graph做编排的项目都把“Graph结构自检”放进CI流程里。一个在编译期就能发现的图结构错误,比在凌晨三点被监控系统叫醒要可爱得多。
6. 生产落地前,我认为比写代码更重要的三件事
把Graph落到生产环境之后,回头看得失,真正决定这个方案成败的其实不是图怎么画、节点怎么写,而是工程配套有没有跟上。
第一件是幂等。只要流程里涉及写操作——退款、改单、发送通知——就必须在节点入口处做幂等控制。我用的是“业务单号+动作类型”做唯一键,先查后写,再配合Redis分布式锁,保证同一个动作只有一次能真正执行。上面那个退款重复执行的问题,就是在加了这套幂等机制之后才彻底根治的。
第二件是可观测性。Graph给了我们一个得天独厚的机会:每个节点的输入和输出都是结构化数据。我把每个节点执行前后的State快照都打了一份日志,字段包括requestId、orderId、节点名、耗时、状态字段变化。出了问题,按requestId一查整条链路,哪个节点改坏了字段一目了然。这种排查体验在ReAct时代完全不敢想。
第三件是人机边界。Graph把流程控制权交给了代码,但在关键决策上我依然保留了人工兜底。具体做法是:所有自动通过节点都带一个“灰度开关”,刚上线的流程前两周强制转人工,跑稳定了再逐步放开自动通过的比例;任何异常退出都会生成一个半成品的State,直接落入人工处理队列。让AI去提效,让人去兜底,这不是保守,是对用户负责。
如果把Graph当成一个“把Agent装进轨道”的工具,那它的价值就不只是稳定流程,而是让团队重新审视了业务的本质:哪些环节需要模型的智能,哪些环节只是流程的惯性。把这两个问题想清楚,比任何编排框架都重要。