尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring AI Alibaba Graph Workflow实战:构建订单售后智能Agent

Spring AI Alibaba Graph Workflow实战:构建订单售后智能Agent 在实际 Agent 项目中Spring AI Alibaba Graph Workflow 提供了一种把大模型自由度收敛进业务流程的工程化方式。Agent 不能只靠“把问题丢给大模型”来实现尤其当它要查订单、改售后、通知用户时单次模型调用既无法保证每一步都执行也无法在出错后给出准确线索。Graph 把流程拆成节点和边Workflow 再把业务约束、条件分支、状态传递和工具调用组合成可运行流程。这篇文章会从概念讲起带你搭建一个基于 Spring AI Alibaba Graph Workflow 的最小订单售后 Agent并说明如何排查、如何走向生产。这类方案的核心价值不是“用流程图替代代码”而是把 Agent 中可确定的部分和不适合确定的部分拆开订单查询走固定工具意图分类交给模型回复生成交给模型流程编排则由工作流控制。这样既保留了 LLM 的灵活性又让整个执行过程可观测、可回滚、可测试。1. 为什么 Agent 项目需要 Graph Workflow1.1 大模型自由发挥带来的失控问题早期 Agent 项目的常见做法是给模型一个 System Prompt再挂上几个工具让模型自己决定调用什么。这种设计在小 demo 里很好用但进入真实业务后会遇到几个非常现实的问题模型可能反复调用同一个工具产生多余计费。模型可能在错误的业务阶段调用不合适的工具。同一类问题的执行路径不稳定今天走 A 分支明天走 B 分支。每次调用都是黑盒用户投诉后很难定位是哪一步出了问题。流程缺少强约束比如“先验用户身份再查单”“退款前必须先关闭售后单”这类规则无法靠提示词保证。以订单售后场景为例用户说“帮我查订单 1001 到哪了”。如果完全交给模型自主决策模型可能直接返回“我已经帮你查询”但实际上没有调用订单服务也可能调用了查询服务却因为 prompt 理解偏差把订单号看错。问题不在模型而在缺少一条明确的执行链路。Graph Workflow 的思路是把流程拆成固定节点接收请求、解析意图、查询订单、生成回复。模型只负责其中“理解用户”和“组织语言”两个环节其余环节全部由代码控制。这样灵活性没有被消灭而是被放到了正确的位置。1.2 Graph 与 Workflow 的分工Graph 是一个通用执行引擎由节点和边组成。节点是函数边决定函数之间的流转关系。Workflow 可以理解为面向业务流的 Graph 规范它有明确的开始节点、结束节点、状态对象和执行目标。两者不是竞争关系而是抽象层次不同维度GraphWorkflow核心关注点状态如何流转业务步骤如何编排节点内容任意函数、模型调用、工具调用通常对应一个业务动作分支控制基于条件边或状态机基于业务意图或规则调试难度可以很小也可以很复杂更强调每一步可解释典型场景状态机、循环、通用图执行客服 Agent、工单处理、内容生成管线在 Spring AI Alibaba Graph 的语境里我们可以用 Graph API 构造一个 Workflow也可以直接使用更高层的 Workflow 构建器。重点不是类名而是你有没有把流程中的决策点和执行点分开。1.3 Spring AI Alibaba Graph 的技术定位Spring AI Alibaba 在 Spring AI 基础上补齐了阿里云模型接入、工具调用和 Graph 编排能力。Graph 模块给 Java 开发者提供了一套不依赖 Python 生态的 Agent 编排方式常用链路包括用户请求进入 WorkflowWorkflow 节点调用 ChatModelChatModel 返回结果后写入 State再由后续节点决定是否调用工具或继续生成内容。这套方案和 LangGraph 在思路上有相似之处但更贴近 Spring Boot 项目习惯。Spring 容器管理 BeanGraph 构建器负责编排Service 负责业务逻辑Controller 负责暴露接口。项目里不需要额外引入重型调度框架也不需要维护独立的任务脚本。2. 核心概念Graph、Workflow、Node、Edge 与 State2.1 Graph 的执行模型Graph 执行的本质是“状态 节点函数 边跳转”。一个节点接收当前状态执行逻辑返回新状态引擎根据节点返回结果或条件函数的返回值决定下一个进入哪个节点。一个最简单的流程图可以表示成START - receive - extractIntent - queryOrder - composeReply - END其中每个箭头就是一条边。如果某个节点后面有多条候选边则需要条件函数决定具体走哪条。比如extractIntent结束后可能进入queryOrder也可能进入policyReply判断依据是 State 中的intent字段。这种执行模型的好处是每一步都显式存在。运行时只要在节点入口和出口打日志就能还原完整调用链路不需要从大模型的对话历史里猜行为。2.2 Workflow 与普通 Graph 的差异Workflow 是在 Graph 之上的一种业务规范。Graph 允许非常自由的拓扑甚至允许循环Workflow 则倾向于让流程收敛通常要有明确的结束节点并尽量避免不可控的无限循环。工程上建议这样区分需要强收敛、可审计、可回放的流程用 Workflow。需要复杂状态转移、可能长时间运行、需要人机协同的流程可以基于 Graph 做更自由的编排。Spring AI Alibaba Graph 中Workflow 构建器通常仍然提供node、edge、conditional这类基础方法。使用上你可以把它当作“带业务约束的 Graph”。2.3 State 的传递与可变性State 是节点之间的数据载体。每个节点都能读取 State也能修改 State。修改后的 State 会继续传给下一个节点。这里有一个容易误解的点State 并不是数据库表它通常只代表“当前这一次请求的执行上下文”。例如用户输入、意图、订单号、订单快照、最终回复都放在同一个 State 对象里。如果 State 字段设计得太少节点之间传参困难设计得太多又会让图变得混乱。推荐做法是只放与本次流程相关的字段不把全局配置、用户 Token、日志对象塞进去。另外要注意 State 的并发安全。同一个 Workflow 实例会服务多个请求节点 Bean 是单例的但 State 应当是每个请求单独创建的实例。2.4 Snap Graph Builder 的使用思路在较新的 Spring AI Alibaba Graph 资料中构建器可能叫SnapGraphBuilder也可能叫Workflow.Builder。名字不同思路相同通过链式方法注册节点和边。var graph new SnapGraphBuilderTicketState() .id(after-sale-workflow) .node(receive, state - state) .edge(START, receive) .build();如果你的版本没有这个类就使用Workflow.BuilderTicketState()。为了避免版本差异导致代码复制失败下面的示例统一使用Workflow.Builder实际落地时以当前依赖中出现的类为准。3. 环境准备JDK、Maven 依赖与模型配置3.1 前置环境要求建议环境如下组件建议值说明JDK17 或更高Spring Boot 3.x 需要 JDK 17Maven3.9用于依赖管理和构建Spring Boot3.xSpring AI Alibaba 基于 Spring Boot 3模型服务DashScope API Key也可以通过 OpenAI 兼容接口接入编译工具IDEA 或 Eclipse非必需但建议用 IDEA尽量使用与自己项目一致的版本组合。Graph 模块对 Spring AI 核心版本比较敏感升级大版本时容易遇到方法签名变化所以先锁定 Spring Boot 版本再选择对应的 Spring AI Alibaba BOM。3.2 添加 Maven 依赖在pom.xml中引入 Spring AI Alibaba BOM再添加 Graph 模块和模型 Starter。下面代码中的版本号需要替换为当前稳定版本不同版本坐标可能存在差异。properties java.version17/java.version spring-ai-alibaba.version请替换为当前稳定版本/spring-ai-alibaba.version /properties dependencyManagement dependencies dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-bom/artifactId version${spring-ai-alibaba.version}/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-graph/artifactId version${spring-ai-alibaba.version}/version /dependency dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter-dashscope/artifactId version${spring-ai-alibaba.version}/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies如果不确定坐标优先去官方仓库的 release 页面查看 BOM而不是直接复制网上旧配置。Graph 模块在演进过程中出现过包名和方法名调整最容易踩的坑就是依赖版本和代码版本不匹配。3.3 配置文件在application.yml中配置 DashScope API Key 和模型参数。spring: application: name: agent-workflow-demo ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.2 max-tokens: 1024这里使用环境变量注入 API Key避免把密钥写进 git。temperature设置为 0.2适合分类、提取订单号这类偏确定性的任务。如果任务需要创造性比如写营销文案可以适当调高但 Agent 流程中的多数节点并不需要太高的随机性。3.4 验证模型连接启动项目前先做一个最小连接验证。最简单的方式是在 Spring Boot 启动类附近写一个CommandLineRunner临时输出一次模型回复。Component public class ConnectionCheckRunner implements CommandLineRunner { private final ChatClient chatClient; public ConnectionCheckRunner(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public void run(String... args) { String reply chatClient.prompt() .user(只回复连接成功) .call() .content(); System.out.println(reply); } }如果这一步能返回“连接成功”说明模型接入没有问题。之后再开始编排 Workflow排查范围会小很多。4. 最小可运行案例订单售后 Agent4.1 需求拆分与流程设计本案例实现一个最小订单售后 Agent用户输入自然语言系统识别意图然后根据意图走不同分支查询订单状态提取订单号调用订单服务生成回复。询问售后政策直接返回固定政策文案。其他问题调用模型生成兜底回复。流程设计如下START - receive - extractIntent - 条件分支 - ORDER_STATUS: queryOrder - composeReply - END - POLICY: policyReply - END - OTHER: composeReply - END这个流程足够小但已经包含 Workflow 的典型要素节点函数、状态传递、条件分支、工具调用、模型调用。4.2 创建项目结构与 State项目结构agent-workflow-demo/ ├── pom.xml └── src/main/java/com/example/agentworkflow/ ├── AgentWorkflowApplication.java ├── state/TicketState.java ├── workflow/AfterSaleWorkflowConfig.java ├── workflow/IntentExtractor.java ├── workflow/ReplyComposer.java ├── tool/OrderService.java └── web/WorkflowController.javaState 保存一次请求的全部上下文package com.example.agentworkflow.state; import java.util.ArrayList; import java.util.List; public class TicketState { private String requestId; private String query; private String intent; private String orderId; private String orderSnapshot; private String reply; private final ListString trace new ArrayList(); public void addTrace(String node) { trace.add(node); } public String getRequestId() { return requestId; } public void setRequestId(String requestId) { this.requestId requestId; } public String getQuery() { return query; } public void setQuery(String query) { this.query query; } public String getIntent() { return intent; } public void setIntent(String intent) { this.intent intent; } public String getOrderId() { return orderId; } public void setOrderId(String orderId) { this.orderId orderId; } public String getOrderSnapshot() { return orderSnapshot; } public void setOrderSnapshot(String orderSnapshot) { this.orderSnapshot orderSnapshot; } public String getReply() { return reply; } public void setReply(String reply) { this.reply reply; } public ListString getTrace() { return trace; } }这里给 State 增加了一个trace列表每个节点进入时记录节点名。它不影响业务但对调试非常有帮助。4.3 实现工具节点与模型节点订单服务不调用模型只根据订单号返回快照package com.example.agentworkflow.tool; import com.example.agentworkflow.state.TicketState; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; Service public class OrderService { private final MapString, String orderStatus new HashMap(); public OrderService() { orderStatus.put(1001, 已发货物流运输中); orderStatus.put(1002, 已签收); } public TicketState query(TicketState state) { String snapshot orderStatus.getOrDefault( state.getOrderId(), 未找到订单 ); state.setOrderSnapshot(snapshot); state.addTrace(queryOrder); return state; } }意图识别节点使用 ChatClient 调用模型然后把结果写回 Statepackage com.example.agentworkflow.workflow; import com.example.agentworkflow.state.TicketState; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Component; import java.util.regex.Matcher; import java.util.regex.Pattern; Component public class IntentExtractor { private final ChatClient chatClient; public IntentExtractor(ChatClient.Builder builder) { this.chatClient builder.build(); } public TicketState extract(TicketState state) { Matcher matcher Pattern.compile(\\d{4,}) .matcher(state.getQuery()); if (matcher.find()) { state.setOrderId(matcher.group()); } String category chatClient.prompt() .system(你是订单客服。请把用户问题分成 ORDER_STATUS、POLICY、OTHER 三类只输出一个类别。) .user(state.getQuery()) .call() .content(); state.setIntent(normalize(category)); state.addTrace(extractIntent); return state; } private String normalize(String raw) { String value raw null ? : raw.trim().toUpperCase(); if (value.contains(ORDER_STATUS)) { return ORDER_STATUS; } if (value.contains(POLICY)) { return POLICY; } return OTHER; } }这里用正则提取订单号只是一个演示思路。生产环境建议让模型输出结构化 JSON或使用工具调用的参数提取能力否则订单号格式变化时正则很容易失效。回复生成节点同样调用模型但输入不再是原始用户问题而是已经查询到的订单快照package com.example.agentworkflow.workflow; import com.example.agentworkflow.state.TicketState; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Component; Component public class ReplyComposer { private final ChatClient chatClient; public ReplyComposer(ChatClient.Builder builder) { this.chatClient builder.build(); } public TicketState compose(TicketState state) { String content chatClient.prompt() .system(根据订单快照生成给用户的中文回复不要虚构物流信息。) .user(state.getOrderSnapshot()) .call() .content(); state.setReply(content); state.addTrace(composeReply); return state; } }这段代码体现了 Workflow 的重要思想模型只在必要的位置出现。查询结果来自真实服务模型只负责改写表达不能自己生成订单状态。4.4 配置 Workflow 图接下来用 Workflow Builder 把这些节点串起来package com.example.agentworkflow.workflow; import com.example.agentworkflow.state.TicketState; import com.example.agentworkflow.tool.OrderService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class AfterSaleWorkflowConfig { private static final Logger log LoggerFactory.getLogger(AfterSaleWorkflowConfig.class); Bean public WorkflowTicketState afterSaleWorkflow( IntentExtractor intentExtractor, OrderService orderService, ReplyComposer replyComposer ) { return new Workflow.BuilderTicketState() .id(after-sale-workflow) .node(receive, state - { log.info(enter receive, requestId{}, state.getRequestId()); state.addTrace(receive); return state; }) .edge(START, receive) .node(extractIntent, intentExtractor::extract) .edge(receive, extractIntent) .node(queryOrder, orderService::query) .node(composeReply, replyComposer::compose) .node(policyReply, state - { state.setReply(目前售后政策7 天无理由退货质量问题由平台承担退货运费。); state.addTrace(policyReply); return state; }) .conditional(extractIntent, state - switch (state.getIntent()) { case ORDER_STATUS - queryOrder; case POLICY - policyReply; default - composeReply; }) .edge(extractIntent, queryOrder, ORDER_STATUS) .edge(extractIntent, policyReply, POLICY) .edge(extractIntent, composeReply, DEFAULT) .edge(queryOrder, composeReply) .edge(composeReply, END) .edge(policyReply, END) .build(); } }如果当前版本的 Workflow 类型没有泛型或者 Builder 类名不同需要以实际依赖为准。代码中的节点名和边名构成了业务语义命名时要避免随意缩写否则日志可读性会变差。4.5 暴露 HTTP 接口并验证添加一个简单的 Controllerpackage com.example.agentworkflow.web; import com.example.agentworkflow.state.TicketState; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; RestController public class WorkflowController { private final WorkflowTicketState workflow; public WorkflowController(WorkflowTicketState workflow) { this.workflow workflow; } PostMapping(/chat) public MapString, Object chat(RequestBody MapString, String body) { TicketState state new TicketState(); state.setRequestId(UUID.randomUUID().toString()); state.setQuery(body.get(message)); TicketState result workflow.execute(state); MapString, Object response new LinkedHashMap(); response.put(reply, result.getReply()); response.put(intent, result.getIntent()); response.put(orderId, result.getOrderId()); response.put(orderSnapshot, result.getOrderSnapshot()); response.put(trace, result.getTrace()); return response; } }启动项目后用 curl 测试curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {message:帮我查一下订单1001现在到哪里了}正常响应类似{ reply: 您的订单 1001 已发货物流运输中。, intent: ORDER_STATUS, orderId: 1001, orderSnapshot: 已发货物流运输中, trace: [ receive, extractIntent, queryOrder, composeReply ] }查看trace可以确认流程是否按预期执行。如果trace中没有queryOrder说明条件分支没有进入预期路径优先检查intent的返回值。5. 关键设计点详解条件分支、工具调用与状态回写5.1 条件分支的判定规则条件分支函数返回的是“下一个节点名”。这个设计容易和普通 if 混淆需要注意不是返回 true/false而是返回目标节点 ID。.conditional(extractIntent, state - switch (state.getIntent()) { case ORDER_STATUS - queryOrder; case POLICY - policyReply; default - composeReply; })后续边要通过分支名称关联.edge(extractIntent, queryOrder, ORDER_STATUS) .edge(extractIntent, policyReply, POLICY) .edge(extractIntent, composeReply, DEFAULT)如果条件函数返回了没有对应边的节点名执行时会报错或直接进入 END具体行为取决于版本。建议在条件函数里添加日志把返回值打出来排查效率会高很多。5.2 工具调用节点怎么设计工具节点是普通 Java 方法不是模型提示词。它从 State 读取参数调用订单服务、数据库或远程接口再把结果写回 State。工具节点设计要注意三点入参尽量从 State 显式读取不要在工具内部重新解析用户原始文本。返回结果先落到 State再由后续节点决定如何使用。工具异常要由节点内部捕获并写入错误字段而不是直接抛出使整条流程中断。例如OrderService.query只读取state.getOrderId()不关心用户原话是什么。这样可以避免同一个工具在不同流程里产生歧义。5.3 State 回写为什么比直接改局部变量复杂Graph 执行时一个节点结束后引擎需要把当前 State 交给下一个节点。社区实现里有些版本是直接操作同一个 State 对象有些版本会在节点返回后做快照合并。因此写节点代码时要遵守一个原则节点方法要么修改传入 State 并返回它要么返回一个新的 State 对象并完整携带原 State 的所有字段。不要这样做public TicketState extract(TicketState state) { String intent chatClient.call(); state.setIntent(intent); return new TicketState(); // 新对象丢失了 query 和 trace }一旦返回新对象前面节点写入的字段全部丢失。要避免这种问题最稳妥的方式是始终修改并返回同一个 State 实例除非你很明确当前版本的 State 合并机制。5.4 构建器参数速查方法作用常见错误id()给工作流命名名称不唯一日志难定位node()注册节点函数节点方法返回类型不兼容edge()定义边起点或终点节点不存在conditional()定义分支条件返回了未注册的节点名build()校验并生成图节点重复或边冲突这些方法的参数名在不同版本可能有调整但语义基本一致。读官方示例时重点看它如何组织节点和边而不是死记方法签名。6. 常见问题与排查链路6.1 节点没有按预期进入现象请求返回成功但trace里缺少某个节点。原因通常是conditional返回的节点名和edge里的分支名不一致。条件函数因为模型返回了带空白字符的字符串导致匹配不到。某个节点抛了异常引擎直接进入兜底逻辑。检查方式查看应用日志中的 conditional 输出建议在条件函数里打印返回值同时检查状态中的intent字段。String next ; switch (state.getIntent()) { case ORDER_STATUS - next queryOrder; case POLICY - next policyReply; default - next composeReply; } log.info(conditional result, intent{}, next{}, state.getIntent(), next); return next;修复后确认edge中的三个分支名分别等于这三个返回值。6.2 工具调用结果没有写回状态现象能看到queryOrder节点执行但最终响应里orderSnapshot为空。原因多半是节点方法返回了新对象或者写到了局部变量而不是 State。检查顺序看queryOrder节点是否调用state.setOrderSnapshot()。看节点方法是否返回了同一个 State。看后续composeReply是否读取了新 State。推荐写法是让所有节点都遵循同一个模板public TicketState query(TicketState state) { // 读取 String snapshot load(state.getOrderId()); // 写回 state.setOrderSnapshot(snapshot); // 返回同一个对象 return state; }6.3 并发请求相互串数据现象A 用户查询订单 1001B 用户查询订单 1002A 的响应里出现 1002。原因通常是节点 Bean 中保存了用户维度的临时字段。比如Component public class BadNode { private String currentOrderId; public void run(TicketState state) { this.currentOrderId state.getOrderId(); } }多个请求共享同一个 BeancurrentOrderId会被覆盖。解决方案是所有请求相关数据都放 StateBean 只保存无状态逻辑或外部服务客户端。6.4 模型超时或返回空结果现象流程长时间无响应或最终回复为空。可能原因DashScope API Key 无效或额度不足。网络访问模型服务失败。模型参数配置了过大的max-tokens导致单次调用时间过长。节点代码没有对空结果做兜底。处理方案为模型调用配置超时和重试。IntentExtractor中模型返回空字符串时默认返回OTHER。ReplyComposer中模型返回空内容时直接使用orderSnapshot作为兜底回复。为每个模型节点增加日志记录 prompt 和响应摘要。6.5 排查顺序清单当 Workflow 行为异常时按照以下顺序排查序号检查项验证方式1请求是否进入 Controller查看接口日志2State 是否创建成功检查 requestId 和 query3每个节点是否执行
返回列表