Streaming... ## 1. 为什么企业AI应用绕不开流程编排
先说个我在实际项目里遇到的场景。某公司内部有个客服工单系统,最开始他们接入大模型的方式特别直接:用户发一段文字进来,系统把这段文字连同提示词丢给大模型,让大模型直接返回处理建议。这种"一问一答"的模式在Demo阶段跑得很顺,一旦上了生产就露馅了——大模型回答不够稳定,有时候把"我要退货"识别成"我要投诉",有时候明明是需要查订单才能回答的问题,它却凭猜测直接编一个结果给你。更麻烦的是,企业流程里往往有"先判断、再查询、后决策、最终执行"这种多步骤链路,单纯靠一次大模型调用根本串不起来。
这就是Spring AI Alibaba Graph要解决的问题。它不是一个简单的"调用大模型"的工具,而是一个面向AI Agent的流程编排框架,思路类似LangGraph:把整个业务流程拆分成一个个节点,节点之间用边连接,边可以是有条件的也可以是无条件的,大模型只是其中某些节点上的"执行者",整个流程的走向由编排逻辑控制。
这篇文章适合谁看?如果你正在Java技术栈里做AI应用,试过用Spring AI Alibaba写Agent,但发现单靠ReAct模式处理不了复杂流程;或者你团队的项目已经到了"Demo能跑、生产难落"的阶段,需要一套能把AI能力嵌进企业业务流程的方案——那你应该把这篇看完。
我会从ReAct Agent讲起,因为它是理解Graph的入口;然后讲讲Graph的核心设计理念;接着用一个完整的工单异常处理案例带你把代码从零写到能跑;最后聊聊我在生产环境踩过的坑。整个内容不绕弯子,直接讲实操。
2. 从ReAct Agent说起:它解决了什么问题,又留了什么坑
2.1 ReAct模式的本质
ReAct这个词是Reasoning和Acting的合成词。它的核心思路是:让大模型在一次任务执行过程中,交替进行"推理"和"行动"。
举个例子,传统的大模型调用是这样:你问"订单A1001退款到哪了",模型只能根据训练数据里的知识回答,它根本不知道订单A1001是什么,更不知道退款状态。如果接入ReAct模式,模型会先推理——"我需要查一下这个订单的退款状态",然后调用你的查询工具,拿到结果后再推理——"退款还在处理中,预计明天到账",最后生成回答。
在Spring AI Alibaba里,实现一个ReAct Agent非常简单。我当时的做法是定义一个ChatClient,把业务工具注册成Bean,然后在提示词里声明模型可以调用这些工具:
@Service public class OrderAgentService { private final ChatClient chatClient; public OrderAgentService(ChatClient.Builder builder, OrderQueryService orderQueryService, RefundStatusService refundStatusService) { this.chatClient = builder .defaultSystem(""" 你是订单助手,帮助用户查询订单信息与退款状态。 如果需要查询订单,调用查询工具;如果需要查询退款状态,调用退款查询工具。 根据工具返回结果如实回答,不要编造数据。 """) .defaultTools(orderQueryService, refundStatusService) .build(); } public String handleUserMessage(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); } }2.2 这样写有什么问题
上面的代码在简单场景下跑得挺好,但只要业务复杂度上来,问题就一个接一个:
第一,流程不可控。ReAct给了模型决定"调什么工具、按什么顺序调"的自主权,这在简单的问答场景是优势,但在企业流程里是风险。比如你希望"先验证用户身份,再查订单,最后生成报告",这个顺序是业务规则,不应该让模型自由发挥。模型判断错了顺序,流程就错了。
第二,单次会话无法处理分支逻辑。有些流程天生带条件分支:如果用户消息包含"退款",走退款处理流程;如果包含"投诉",走升级人工流程。ReAct Agent里当然也可以用提示词约束模型去做判断,但判断结果的稳定性完全取决于模型的发挥,今天GPT-4o表现好一点,明天换个小参数模型可能就乱了。
第三,状态管理缺失。ReAct的每次工具调用都在一轮对话上下文里隐式传递,你很难显式维护"当前流程走到哪一步了""这一步累计调了几次工具""超时了怎么办"这些信息。企业级应用恰恰最看重这些。
第四,不可观测。你想知道一个请求总共调了几次大模型、每次调用的Token花了多少、在哪一步卡住了,ReAct Agent给不了你细粒度的追踪能力。
所以我的判断是:ReAct Agent适合做"轻量级任务自动化",但把它直接当企业流程引擎用,迟早要重构。Spring AI Alibaba Graph就是在这个背景下出现的替代方案。
3. Graph的核心设计理念:把流程从"模型的脑子里"搬到"代码里"
3.1 状态、节点、边——三个核心概念
Graph的思想和LangGraph一脉相承,用三个概念描述一个流程:
- State(状态):贯穿整个流程的数据载体。它是一个Java对象,每个节点都可以读取和修改它,修改后的状态传递给下一个节点。这相当于流程里的"工作台",所有信息都存在这里。
- Node(节点):流程中的一个处理单元。节点可以调用大模型,也可以只做纯代码逻辑,比如查数据库、调用外部API、做字符串处理。
- Edge(边):节点之间的关系。普通边表示"上一步完成后无条件进入下一步";条件边表示"根据当前状态判断下一步该走哪个节点"。
三个概念加起来,就能描述绝大多数业务流程。而且这个模型对业务人员也很友好:画流程图的时候,状态是图上的数据流标注,节点是方框,边是箭头,基本可以直接对照着实现。
3.2 为什么要用图而不是"硬编码流程"
有人可能会问:"这些流程我用if-else也能写啊,为什么非要引入一个Graph框架?"
这个问题我认真想过。硬编码流程的问题是:
- 流程里的每一步如果涉及大模型调用,它会阻塞,而大模型调用是慢操作、贵操作,你需要考虑重试、超时、降级,这些横切逻辑放在业务代码里会非常臃肿;
- 流程后续可能要调整,比如"插入一个新节点""把一个无条件节点改成条件分支",硬编码的话改动成本很高,而Graph是配置化的,改结构像改配置一样;
- 一个环节失败了,Graph框架可以从框架层面补偿,比如重新执行当前节点、回退到前一个节点、走兜底分支,这些都是硬编码很难优雅实现的。
Spring AI Alibaba Graph把这些能力做成了框架能力,业务代码只关心"我这个节点干什么",不关心"整个流程怎么串"。职责清晰,我实测下来可维护性高了一个台阶。
3.3 Graph的构建方式
Spring AI Alibaba Graph的编程模型是"Builder模式"。先定义节点处理器,然后用Builder把节点和边组装起来:
StateGraph<TicketState> graph = new StateGraph.Builder<TicketState>() .addNode("intent", intentNode()) .addNode("extract", extractNode()) .addNode("refund", refundNode()) .addNode("tech", techNode()) .addNode("complaint", complaintNode()) .addEdge("intent", "extract") .addConditionalEdge("extract", this::routeByIntent) .build();.addNode("intent", ...)的意思是注册一个名为"intent"的节点,第二个参数是节点执行逻辑。.addEdge是无条件边,执行完intent节点后直接进extract。.addConditionalEdge是条件边,执行完extract节点后,调用routeByIntent方法判断下一步走哪个节点。
这个编程体验和LangGraph几乎一一对应,如果你之前玩过Python的LangGraph,切过来几乎没有学习成本。就算没玩过,理解了上面的概念,上手也就一个下午的事。
4. 实战拆解:企业工单异常处理Workflow
4.1 业务场景与流程设计
选一个贴近真实生产的场景:某电商平台的客服工单系统。用户提交工单,系统需要自动完成意图识别、信息抽取、分类处理,最终生成处理建议。
整个流程我设计成6个节点:
| 节点名 | 功能 | 类型 |
|---|---|---|
| intent | 识别用户工单意图 | LLM节点 |
| extract | 抽取工单关键信息 | LLM节点 |
| refund | 退款类工单处理 | 代码节点 |
| tech | 技术支持类工单处理 | 代码节点 |
| complaint | 投诉类工单处理 | 代码节点 |
| report | 汇总生成处理建议 | LLM节点 |
流程这么走:用户工单先进入intent节点,大模型判断这是退款、技术咨询、投诉还是其他类型,结果存到State里;然后进入extract节点,抽取订单号、商品名称、问题描述等关键信息;接着走条件边,根据intent的判断结果路由到不同的处理节点;最后所有分支汇聚到report节点,生成最终的处理建议和回复文案。
这套设计的核心思路是:让大模型只做它擅长的事——理解自然语言、做分类决策——而把"下一步该干什么"交给代码逻辑决定。比如intent节点把工单判为"投诉",路由逻辑就直接走complaint节点,这中间没有商量余地,模型就算心情不好也不会把投诉工单转到退款那边去。
4.2 定义状态类
Graph的State是一个普通的POJO。我建议把所有节点需要共享的数据都放进这个类里,不要藏着掖着:
public class TicketState { private String ticketId; private String userMessage; private String intent; private String orderId; private String productName; private String problemDetail; private String processingResult; private String finalReport; // getter/setter 省略 // 为了简洁,这里只列字段,实际开发时用Lombok @Data即可 }注意一个细节:State是全程共享的,所以字段的泛型如果涉及复杂类型,比如Map、List,建议用不可变集合或者在写入时做防御性拷贝。后面会专门说并发的问题。
4.3 实现节点逻辑
每个节点实现WorkflowNode<T>接口,核心方法是执行逻辑,入参是节点上下文,通过上下文拿到当前State,处理后把结果放回State。
意图识别节点:
@Bean public WorkflowNode<TicketState> intentRecognitionNode() { return ctx -> { TicketState state = ctx.getState(); String prompt = """ 你是一个工单意图识别器。用户提交了以下工单内容: %s 请判断该工单的意图类型,只从以下四个选项中选一个输出: 退款、技术咨询、投诉、其他 不要输出任何解释,只输出意图类型。 """.formatted(state.getUserMessage()); String result = chatClient.prompt() .user(prompt) .call() .content(); // 对模型输出做一次清洗和兜底 state.setIntent(normalizeIntent(result)); return state; }; } private String normalizeIntent(String raw) { if (raw == null || raw.isBlank()) { return "其他"; } if (raw.contains("退款") || raw.contains("退货")) { return "退款"; } if (raw.contains("技术") || raw.contains("咨询") || raw.contains("怎么用")) { return "技术咨询"; } if (raw.contains("投诉") || raw.contains("不满") || raw.contains("差评")) { return "投诉"; } return "其他"; }这个normalizeIntent方法是关键。大模型的输出永远是"大概率正确但不是绝对正确",生产环境必须对输出做兜底清洗。如果不做这层处理,后面条件路由拿到一个莫名其妙的字符串,流程直接就跑了兜底分支。
信息抽取节点:
@Bean public WorkflowNode<TicketState> infoExtractNode() { return ctx -> { TicketState state = ctx.getState(); String prompt = """ 从以下工单内容中抽取结构化信息,以JSON格式返回: 工单内容: %s 要求: 1. 如果存在订单号,提取到orderId字段 2. 如果存在商品名称,提取到productName字段 3. 用一句话概括问题描述,放到problemDetail字段 4. 如果某字段不存在,填null 返回格式: {"orderId": "...", "productName": "...", "problemDetail": "..."} """.formatted(state.getUserMessage()); String jsonStr = chatClient.prompt() .user(prompt) .call() .content(); // 用Jackson解析JSON,并设置到State JsonNode json = objectMapper.readTree(jsonStr); state.setOrderId(json.get("orderId").asText()); state.setProductName(json.get("productName").asText()); state.setProblemDetail(json.get("problemDetail").asText()); return state; }; }业务处理节点这里不展开写细节了,逻辑基本就是查数据库、调内部API,拿到结果后拼接到State的processingResult字段。
4.4 组装图与条件路由
条件路由是这个示例里最值得仔细看的部分:
@Bean public StateGraph<TicketState> ticketWorkflow() { return new StateGraph.Builder<TicketState>() .addNode("intent", intentRecognitionNode()) .addNode("extract", infoExtractNode()) .addNode("refund", refundNode()) .addNode("tech", techSupportNode()) .addNode("complaint", complaintNode()) .addNode("report", reportNode()) .addEdge("intent", "extract") .addConditionalEdge("extract", this::routeByIntent) .addEdge("refund", "report") .addEdge("tech", "report") .addEdge("complaint", "report") .build(); } private String routeByIntent(TicketState state) { return switch (state.getIntent()) { case "退款" -> "refund"; case "技术咨询" -> "tech"; case "投诉" -> "complaint"; default -> "tech"; // 兜底,避免流程断掉 }; }条件边的路由方法返回String,对应下一个节点的名称。如果返回的节点名称在图中不存在,框架会报错,所以务必在兜底分支给一个合法节点。
我在生产里还做过一种做法:routeByIntent里如果识别到未知意图,不是走"tech",而是走一个"human_handle"节点,把这个工单标记为"需要人工处理",挂起流程。这样不会让未知意图的工单被模型瞎处理,安全得多。
4.5 运行Workflow
组装好图之后,运行时通过WorkflowExecutor触发:
@Component public class TicketWorkflowRunner { private final WorkflowExecutor<TicketState> executor; public TicketWorkflowRunner(StateGraph<TicketState> graph) { this.executor = graph.prepare(); } public TicketState run(TicketState initialState) { return executor.execute(initialState); } }执行完的State里,finalReport字段就是最终生成的处理建议,可以把它存库、推送给人工客服、或者直接作为回复内容。
从我这个案例可以看到,整个流程里大模型只在intent、extract、report三个节点出现,其余都是代码逻辑。相比ReAct模式下"模型主导一切",Graph的核心价值就是把主导权拿回到开发者手里。
5. 企业落地中的避坑经验与设计建议
5.1 状态管理:别把所有东西都塞进State
Graph的State设计非常自由,这既是优点也是隐患。我在项目里见过有人把一个大JSON对象整个塞进State,后续每个节点都解析一遍,既浪费Token又难以排查问题。
我的建议是:State里只放"节点之间需要传递的显式信息",临时数据一律留在节点内部处理。比如intent节点识别过程中可能临时需要调用某个工具拿到中间结果,这个中间结果如果后续节点用不到,就不放进State,省得污染全局。
另外,如果State字段非常多,可以考虑分组设计。比如:
public class TicketState { private RawInput raw; // 原始输入 private AiResult aiResult; // AI识别与抽取结果 private BizContext bizContext; // 业务处理上下文 private Output output; // 最终输出 }这样State的结构清晰,每个节点只关注自己负责的那一组字段,其他字段不感知,减少耦合。
5.2 Token成本控制:能省则省
Graph流程里的LLM节点越多,Token消耗越大,这个账必须在设计阶段就算清楚。
我常用的几个优化手段:
- 能用规则判断的节点,别用大模型。比如工单是否包含订单号,用正则判断比让大模型抽取又便宜又快;
- 把多个小任务合并成一个大任务。intent识别和信息抽取可以合并成一个节点,一次调用同时输出意图和结构化信息,省一次LLM往返;
- Prompt尽量精简。System Prompt里只放必要约束,不必要的"你是一个专家"这类废话能省就省,每天调用量上来之后这部分Token支出差别很大。
以我那个工单案例为例,如果每单走完整流程平均约8000 Token,单日1万单就是8000万Token。优化掉30%、40%的成本,在财务上是一个很可观的数据。
5.3 超时、重试与降级
大模型调用是慢操作,企业接口一般要求P95在2秒内,但一次GPT-4调用就可能1~3秒,一个流程串三个LLM节点,整体耗时轻松超过5秒。
这就带来两个问题:一是接口超时怎么处理,二是用户等待体验怎么优化。
我这边实践下来比较稳的方案:
- 给每个LLM节点设置独立的超时时间(比如8~10秒),避免单个节点卡死整个流程;
- 在条件路由的兜底分支里加入"降级处理"逻辑,比如连续两次LLM调用失败,直接走人工处理节点,不再重试;
- 异步化:工单处理这类场景其实不需要同步等待结果,可以把流程触发做成异步任务,用户先收到"您的工单已受理",处理完后再推送给用户或客服。
Graph框架本身不限制节点的执行方式是同步还是异步,但建议从架构层面把耗时长的节点做成异步,避免阻塞整个线程池。
5.4 可观测性与日志记录
Graph流程排错比单次调用排错难得多,因为一个请求会经过多个节点、多次LLM调用。不做好日志记录,出了问题根本无从查起。
我建议至少记录以下几类信息:
| 关键信息 | 说明 |
|---|---|
| requestId | 贯穿整个流程的请求ID,所有日志带上它 |
| 节点级耗时 | 每个节点执行耗时,定位瓶颈 |
| Token消耗 | 每个LLM节点的输入Token、输出Token |
| 状态快照 | 关键节点执行完之后的State核心字段 |
| 路由决策 | 条件边走了哪个分支,为什么 |
Spring AI Alibaba Graph提供了执行上下文,可以拿到当前节点名、执行耗时等信息,把这些和业务日志串起来。我习惯用一个拦截器或者装饰器模式包裹每个节点,自动打印上面的信息,业务代码里不放日志逻辑,保持干净。
6. 常见问题排查与典型报错
6.1 条件边路由报"节点不存在"
这是我最常遇到的报错。routeByIntent方法返回的字符串必须与addNode注册的节点名完全一致,包括大小写和空格。排查办法很简单:在路由方法里加日志,打印实际返回值,然后和图的节点注册列表比对。
另一个隐蔽问题是:如果路由方法返回null,框架同样会报错。务必给所有分支加兜底返回值,包括默认分支。
6.2 LLM输出非预期导致下游崩溃
大模型输出不稳定是常态。比如infoExtract节点要求只输出JSON,但模型有时候会在JSON前后加解释性文字,直接把objectMapper.readTree干崩溃。
解决思路有两个层面:一是Prompt里明确"只输出JSON,不要其他内容",并给出示例;二是在代码层面做容错,提取字符串里的JSON片段再解析,解析失败就抛异常走兜底节点。
我通常两者都做:先用正则把{...}部分截取出来,再让Jackson解析,解析失败就把当前工单标记为"人工处理"。这套组合下来,模型输出导致的流程失败率降到很低。
6.3 并发执行时State互相污染
Graph流程并发执行多个请求时,如果State对象被多个线程共享,或者节点实现里用了成员变量存临时数据,就会出现互相污染的问题。
Spring AI Alibaba Graph的设计是每个执行请求持有独立的State实例,正常使用不会有问题。怕的是节点实现里不自觉地用了共享的可变成员变量。
排查手法:看节点类里有没有非final的成员变量,或者static的可变Map/List。有的话,改成局部变量。我在写节点时给自己定了一条规矩:节点实现类不允许有可变成员变量,所有临时数据都放方法内部,彻底杜绝这类问题。
6.4 流程执行耗时过长
如果发现某个工单流程整体耗时超过预期,先定位瓶颈节点。日志里节点级耗时就是干这个用的。
实际项目中,我遇到过两次典型的耗时问题。一次是LLM节点因为重试机制连续失败重试了3次,每次浪费10秒;另一次是业务节点在同步调用一个外部RPC,那个RPC本身的超时设置是30秒,加上大模型调用的时长,整体就爆了。
解决办法分别是对重试次数设上限、对外部RPC设置更短的超时并快速失败。Graph框架本身不限制这些参数的设置,但开发者必须在设计阶段就考虑清楚。
7. 从Workflow到多Agent编排的演进方向
7.1 为什么需要多Agent
单Agent的问题在于能力边界太宽。让一个Agent既懂退款规则又懂技术方案又懂投诉处理话术,意味着它的System Prompt要写得极其冗长,模型容易"精神分裂"。
我在工单项目上做了演进:把单个大Agent拆成三个垂直Agent。退款Agent只掌握退款规则和操作工具,技术Agent只掌握产品文档和排障指南,投诉Agent只掌握安抚话术和升级机制。每个Agent的Prompt都短了,准确性反而高了。
这就是Graph带来的另一个好处:它天然支持把多个Agent编排进一个图里。每个Agent可以是一个子图,或者一个节点,主流程负责路由,子节点负责垂直领域的专注处理。
7.2 用Graph编排多Agent的实践
多Agent编排时,我建议在State里加一个activeAgent字段,标识当前激活的Agent是哪个,同时保留一个全局上下文区,用于Agent之间的信息交接。
实现上还是同一个套路:每个Agent对应一个节点或一个子图,条件边根据当前工单的类型路由到对应Agent,Agent处理完把结果写回State,流程汇聚到最终输出节点。
这套结构在多轮对话场景下尤其好用。比如用户先问"我的订单怎么还没发货",技术Agent回答了一轮;用户接着问"那我能不能退货",流程先进入intent节点,发现意图从技术咨询变成了退款,于是路由切到退款Agent。整个过程用户无感知,但背后是两个Agent在接力协作。
7.3 什么时候仍然用ReAct
Graph好归好,但不是所有场景都适合上Graph。有些任务就是很轻量的单次工具调用,比如"帮我翻译一段文字""把这个PDF转成文本",用Graph属于杀鸡用牛刀,配置成本反而高。
我的经验是:任务链路简单、只有一次工具调用、不需要状态流转的场景,继续用ReAct Agent就够了。一旦出现"多步骤""有条件分支""需要跨步骤共享状态""多个Agent协作"这四种特征里的任何一种,就值得考虑用Graph来编排。
很多团队喜欢纠结技术选型,我的观点是够用就行。ReAct和Graph不是互斥关系,它们是同一套技术栈上不同复杂度场景的两个选择,根据业务实际选型就好。
8. 一个容易被忽视的点:测试策略
8.1 单元测试测什么
Graph流程里有LLM节点,这让测试变得棘手。LLM节点的输出是不确定的,没法像普通单元测试那样断言返回的具体值。
我的做法是把LLM调用抽象成接口,单元测试时用Mock实现替换真实调用。比如intentRecognitionNode里的chatClient可以注入一个Mock,固定返回"退款",这样就能稳定测试路由分支逻辑。核心思想是:单元测试验证的是流程编排逻辑,不是大模型的能力。
8.2 集成测试怎么设计
集成测试可以把真实的LLM调用跑起来,但要注意成本控制。
我习惯的做法:
- 准备一个小样本测试集,覆盖每个分支,控制用例数量在10个以内;
- 对每个用例断言State的关键字段符合预期,比如意图识别结果在合法范围内、最终生成的报告包含必要信息;
- 跑一次集成测试记录Token消耗,如果某次改动导致Token明显上涨,大概率是Prompt或流程设计出了问题。
8.3 兜底链路测试
这是最容易被忽略的。生产环境的LLM可能出现宕机、限流、报错,你的降级链路如果从没被测试过,真出故障时大概率也是坏的。
我建议在集成测试里专门加几个"故障注入"用例:Mock掉LLM节点,让它直接抛异常,验证流程是否能正确走到兜底分支。这类用例数量不多,但价值极高,值得每位做Graph编排的开发者投入时间去写。
9. 我在这个项目里最想分享的体会
做了一段时间Spring AI Alibaba Graph项目后,我最深的感受是:AI应用的生产落地,难点从来不是模型选型或者提示词调优,而是"如何把不稳定的模型行为约束在稳定的业务流程里"。
Graph提供了一个很好的思路——让模型只承担它擅长的自然语言理解与生成,流程控制、状态流转、分支决策交给代码。这看起来是工程上的取舍,实际是把AI从"玩具"变成"生产力工具"的关键一步。
最后再分享一个小技巧:不要一上来就设计一个巨大的图。先把一个最小可用流程跑通,比如三个节点一条链路,再逐步加分支、加节点。Graph的编程模型虽然简单,但流程复杂之后,排错的难度是指数增长的。小步快跑,每次迭代只改一个节点或一条边,出问题能很快定位。这个习惯帮我省了很多排查时间,建议你也试试。