ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

多Agent工作流编排实战:Spring AI Alibaba Graph状态图指南

多Agent工作流编排实战:Spring AI Alibaba Graph状态图指南 1. 从一条链到一张图多Agent流程为什么绕不开Graph去年年底我在做一个多Agent内容生产系统最开始所有流程都是串行调用写着写着就发现不对劲。搜索Graph这个词出来的要么是图神经网络、Graph Digitizer要么是Git Graph插件跟我要解决的问题完全不搭。我要说的是Spring AI alibaba里的Graph一个用来做工作流编排的状态图引擎。先说痛点。单个Agent的调用大家都会写无非是ChatClient.prompt().user(...).call()拿到结果就完事。但真实业务不会只有一问一答而是先理解需求再检索资料再生成方案再让另一个Agent审核审核不过还要打回重写。这些动作之间有依赖、有分支、有循环用普通Java代码硬写是什么样的我在项目里见过最典型的失控现场一个Service类里堆了七八个方法用if-else连起来方法之间互相传Map跑完一趟自己都说不清状态流转了几个版本。第二次要加一个新分支时整个链路的改造成本直接劝退。Graph模块解决的就是这个问题把工作流建模成一张有向图节点是处理逻辑边是流转关系。节点之间不直接互相调用只通过一个共享的State对象读写数据。每个节点只关心我拿到什么状态我要做什么我往状态里写什么至于上一个节点是谁、下一个节点是谁全部交给图的编排逻辑决定。这种设计带来的直接好处是新增一个分支不需要改动现有节点只需要加一条边流程要不要走某条路可以在节点里根据状态动态决定。它比我之前手写的状态机更灵活比工作流引擎比如Activiti那种更轻量因为它的核心就是图和状态没有沉重的流程定义文件。对于Java系的多Agent编排来说这是目前性价比最高的路子。如果你正在被多个Agent协作、流程经常变、又不想引入重型流程引擎这三个问题同时折磨这篇文章值得看完。我会从核心概念讲起带你搭出第一个可运行的选题到初稿工作流再做条件分支最后把我实际踩过的坑全部摊开。2. 五个核心概念把Graph模块的底摸清楚Graph模块的核心元素其实就五样State状态、Node节点、Edge边、Graph有向图、执行引擎。第一次接触的人容易把它们想复杂我用一条地铁线路来打比方。2.1 State节点之间传递数据的共享背包State就是整条工作流里所有节点共享的数据容器。你可以把它理解成每个人随身背的包上一个站往包里放了什么下一站打开包就能看见。在Spring AI alibaba Graph里State通常是一个自定义类内部维护一个Map结构来存业务数据还会带一些Agent运行需要的上下文信息比如消息列表。我见过有人把State当成垃圾桶什么字段都往里塞这是第一坑。State的设计原则是只放需要被多个节点共享的数据节点内部的局部临时变量不要放进State。比如socket连接对象这种东西放进State就是给自己埋雷后面讲并发问题你会看到后果。2.2 Node和Edge最小执行单元与路线连接Node是图里的一个节点对应一段具体的处理逻辑。它的大致形态是一个方法接收当前State做一些操作往State里写结果。写LLM处理逻辑的节点我们通常叫LLM节点写普通的判断、清洗、转换逻辑的节点就是普通节点。Edge就是节点之间的有向边负责把两个节点连起来。比如从topic节点到outline节点连一条边意味着outline节点会在topic节点执行完之后被触发。边本身不携带数据数据还是走State。所以节点之间的解耦非常彻底outline节点根本不知道topic节点是谁它只知道从State里能拿到topic字段。这个设计很像水管Node是接头Edge是管道State是管子里流的水。组装接头的人决定了水往哪流但接头本身不需要知道上下游是谁。2.3 执行引擎从入口节点一步步走到END执行引擎负责把整张图跑起来。它从一个入口节点开始执行完当前节点后根据边找到下一个要执行的节点一直走到没有出边或者说走到END为止。整个过程对使用者来说基本是黑盒你只需要告诉引擎跑哪张图、入口是哪个节点、初始State是什么。引擎内部会维护当前走到哪个节点、已经执行过哪些节点还会处理一些异常情况。但有一点要特别提醒它不会帮你解决业务层面的死循环问题。如果你的图里有环比如rewrite节点可能回到review节点而没有任何条件或计数器控制引擎会一直跑下去。这是后文会重点讲的条件路由和防环设计。3. 第一个可运行的工作流选题→大纲→初稿有了概念直接上实例。我带大家搭一个最简版的内容生产工作流用户给一个模糊需求系统先提炼出明确选题再生成文章大纲最后根据大纲写出初稿。三个LLM节点串行执行跑通整条链路。3.1 依赖引入与模型配置先加依赖。Spring AI alibaba的Graph模块需要两个东西基础的Spring AI alibaba Starter和Graph模块本身。以当前Maven Central上发布的版本为准大致如下dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-starter/artifactId /dependency dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-graph/artifactId /dependency版本号我不写死因为这个项目迭代速度很快直接去Maven Central搜spring-ai-alibaba-graph拿最新稳定版就好。模型我用的是通义DashScope配置文件里把key和模型名配上spring.ai.dashscope.api-key${DASHSCOPE_API_KEY} spring.ai.dashscope.chat.options.modelqwen-plus如果你接的是其他模型把对应配置替换成你自己的就行。Graph模块本身只关心图怎么编排不关心具体用的是哪个模型这块依赖注入由Spring Boot自动配置完成。3.2 定义State别把大字段塞进共享状态这步是很多人嫌麻烦跳过导致后面维护崩溃的关键一步。我先写一个最小State类public class ArticleState { private final MapString, Object data new HashMap(); private final ListMessage messages new ArrayList(); public MapString, Object getData() { return data; } public ListMessage getMessages() { return messages; } }data用来存业务流转数据比如选题、大纲、初稿messages用来维护Agent对话上下文。你可能觉得只用一个MapString, Object就够了为什么还要包一层因为后续如果要扩展用户身份、会话ID、元数据有这层封装你加字段就行不用把所有节点的取值代码全部改一遍。3.3 三个节点每个节点只做一件事接下来是三个节点分别对应提炼选题、生成大纲、生成初稿。节点类都要实现Graph模块提供的Node接口核心是doProcess方法。第一个节点把用户模糊需求提炼成明确选题Component public class TopicNode implements NodeArticleState { private final ChatClient chatClient; public TopicNode(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public void doProcess(ArticleState state) { String task (String) state.getData().get(task); String topic chatClient.prompt() .system(你是一名资深编辑。请根据用户的要求提炼出一个明确的文章选题只输出选题本身不要多余解释。) .user(task) .call() .content(); state.getData().put(topic, topic); } }第二个节点根据选题生成大纲Component public class OutlineNode implements NodeArticleState { private final ChatClient chatClient; public OutlineNode(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public void doProcess(ArticleState state) { String topic (String) state.getData().get(topic); String outline chatClient.prompt() .system(你是一名文章大纲规划专家。输出结构清晰的三级目录大纲。) .user(topic) .call() .content(); state.getData().put(outline, outline); } }第三个节点根据大纲生成初稿Component public class DraftNode implements NodeArticleState { private final ChatClient chatClient; public DraftNode(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public void doProcess(ArticleState state) { String outline (String) state.getData().get(outline); String draft chatClient.prompt() .system(你是一名作家。根据大纲写成一篇完整的文章初稿段落通顺有标题层级。) .user(outline) .call() .content(); state.getData().put(draft, draft); } }仔细观察这三个节点每个都只做了一件事从State读自己需要的字段调用LLM处理把结果写回State。没有节点关心上一个节点是谁也没人关心执行完要去哪这就对了。3.4 装配成图并执行节点写好后在配置类里把三个节点连成一张图Configuration public class ArticleWorkflowConfig { Bean public GraphArticleState articleWorkflow( TopicNode topicNode, OutlineNode outlineNode, DraftNode draftNode) { GraphArticleState graph new Graph(); graph.addNode(topic, topicNode); graph.addNode(outline, outlineNode); graph.addNode(draft, draftNode); graph.addEdge(topic, outline); graph.addEdge(outline, draft); return graph.compile(); } }addNode的第一个参数是节点名第二个参数是节点实例addEdge负责连接。三个节点形成一条链topic → outline → draft。编译完成后这个Bean就可以注入到任何地方执行。调用方式很简单ArticleState state new ArticleState(); state.getData().put(task, 写一篇介绍Spring AI alibaba中间件生态的文章); articleWorkflow.invoke(state, topic); String draft (String) state.getData().get(draft);这里invoke的第二参数是入口节点名。不同小版本的方法签名略有差异有的版本可能只需要传state但入口节点名的语义是明确的告诉引擎从哪个节点开始跑。如果你的版本只支持单参invoke那就把入口固定命名为start通过边把后续流程接上语义是一样的。跑起来之后State里的topic、outline、draft会被三个节点依次填满。整个过程你完全不用手动控制调用顺序引擎按照边的关系自动导航。这就是Graph编排的第一个价值把流程控制权从业务代码手里收走交给图。4. 条件分支实战评审不过就重写串行链路跑通只是开胃菜Graph真正体现优势的地方是条件分支。同一个需求产出评估不达标就要走另一条路这是多Agent系统里最常见的场景。我拿初始稿→评审→重写或END来举例。4.1 条件路由工作流编排的真正核心现在加一个评审节点review它把初始稿交给一个评审Agent打分返回一个分数再准备一个重写节点rewrite分数不合格就重新生成最后是终节点end分数合格就算完成。条件路由的实现思路是节点处理完后返回下一个节点的名字引擎拿到非空返回值就把它当作路由结果。如果你的Graph模块版本里doProcess方法返回void处理办法是单独写一个Router节点做分流效果完全一样。我当时的写法是让评审节点返回节点名Component public class ReviewNode implements NodeArticleState { private final ChatClient chatClient; public ReviewNode(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public String doProcess(ArticleState state) { String draft (String) state.getData().get(draft); String review chatClient.prompt() .system(你是一名严格的审稿人。请从内容完整性、逻辑性、可读性三个维度打分满分100只输出分数。) .user(draft) .call() .content(); int score Integer.parseInt(review.trim()); state.getData().put(score, score); // 返回下一个节点名不合格去重写合格去终节点 return score 60 ? end : rewrite; } }这里我用字符串返回节点名来告诉引擎下一步去哪。分数低于60走rewrite高于60走end。你可能会问为什么不直接让评审节点内部调用重写节点因为那样两个节点就耦合了。评审节点只负责判断重写节点只负责处理谁该被触发是图的路由规则决定的不是代码调用决定的。后续如果要改成综合三次评审取平均分再决定是否重写你只需要改评审节点内部逻辑或者加一条边重写节点完全不用动。装配这组图时节点和边的定义会是graph.addNode(draft, draftNode); graph.addNode(review, reviewNode); graph.addNode(rewrite, rewriteNode); graph.addNode(end, endNode); graph.addEdge(draft, review); graph.addEdge(review, rewrite); graph.addEdge(review, end); graph.addEdge(rewrite, review);注意最后一条边rewrite回到review图上出现了一个环。这正是重写流程的本质——反复评审、反复修改直到满意或达到上限。4.2 给图加上环再用计数器防止死循环有环就有死循环风险。如果评审Agent一直打低分rewrite和review两个节点互相踢皮球整张图会跑到天荒地老。引擎层面的最大步数限制当然是一个兜底但业务层面的控制才是正道。我的做法是在State里放一个重写计数器重写节点每次执行都先看计数器是否达到上限Component public class RewriteNode implements NodeArticleState { private final ChatClient chatClient; public RewriteNode(ChatClient.Builder builder) { this.chatClient builder.build(); } Override public String doProcess(ArticleState state) { int rewriteCount (int) state.getData().getOrDefault(rewriteCount, 0); if (rewriteCount 2) { // 重写次数已用完直接结束别再绕圈 return end; } state.getData().put(rewriteCount, rewriteCount 1); String draft (String) state.getData().get(draft); String feedback (String) state.getData().get(feedback); String rewritten chatClient.prompt() .system(你是修改专家。根据审稿反馈重写文章保留原有优点解决指出的问题。) .user(原文 draft \n\n审稿反馈 feedback) .call() .content(); state.getData().put(draft, rewritten); return review; } }rewriteCount从0开始最多重写两次第三次强制走end。这样即使评审Agent的逻辑再抽风最坏情况也就是多跑一轮不会无限循环。这个计数器放State里的做法其实就是给条件路由加业务语义。Graph本身是无状态的有状态的State给了你在运行过程中动态调节流程的能力。掌握这个组合拳你就能把循环、迭代、尝试N次后放弃这类真实业务规则都搬进工作流里。5. 真实事故复盘并发串数据、LLM重复调用与调试方法前面是能跑通的部分这一节是跑起来之后出的问题。我在生产环境把Graph编排用于真实并发请求后踩了三个比较典型的坑逐个说。5.1 State被复用导致的结果串台我们线上有一个入口方法为了提升性能把State定义成类成员变量结果两个用户同时请求时A用户的大纲被B用户的选题覆盖了。原因很简单State不是线程隔离的多个请求共享同一个实例时节点A写入的数据还没被节点B读取节点B已经被另一个线程的数据修改了。解决办法没有任何花哨每次调用工作流都new一个State按请求隔离。这个State实例的生命周期严格限制在一次图执行内部。如果你的State里放了SessionId、UserId这类标识字段排查问题时会轻松很多——出问题第一件事就是开日志看State里到底是谁的数据。另外还要注意State里data这个Map也不是线程安全的容器。就算外部没有并发复用State如果节点内部自己起了多线程比如并行请求多个模型你还是需要额外同步或改用ConcurrentHashMap。5.2 重试机制带来的重复LLM调用第二个坑跟重试有关。图上某个LLM节点偶发超时我在执行引擎外面套了一个简单的try-catch重试超时就整个图重新跑一遍。逻辑上没问题但实际效果很酸爽重试的时候前面所有节点都重新执行了尤其是已经调过LLM的节点白花了好几轮token费用还把最终生成的结果变得不可复现。更隐蔽的是引擎内部可能也有自动重试机制。某些版本在节点执行异常时会对当前节点再做一次尝试如果节点本身不是幂等的就会重复调用LLM。这里的教训是区分业务重试和节点重试。如果重试的目的是临时规避网络抖动那就在LLM客户端层面做而不是在Graph引擎层面做如果一定要在引擎层面重试就得给节点设计幂等键比如State里带上requestId重试时先检查这个请求是否已经处理过。那次事故之后我把所有LLM节点的调用策略统一收敛成单个节点内部使用带超时和有限重试的ChatClient配置Graph引擎层面不再套整图重试。这样保证就算某个节点挂了也只是这个节点自己重试不会把整条链路由重跑一遍的账算在你头上。5.3 本地调试的三个实用手段Graph编排排错比普通代码困难因为执行顺序不再直观体现在调用栈里。我实际用下来比较有效的手段就三个第一节点进出日志。在每个节点的doProcess方法入口打一条日志记录当前State里关键的几个字段方法出口再打一条日志记录这个节点写了什么。这样图执行结束后看日志就能画出实际的执行路径是走了review → rewrite还是review → end一目了然。第二State快照。在关键节点执行完后把state.getData()序列化成JSON输出到日志或文件。排查为什么重写节点读到的还是旧数据这种问题时快照比断点好用得多因为你看的是数据流动的中间形态。第三超时控制。图引擎层面如果没有默认超时整张图可能因为某个LLM节点卡住而一直不返回。我早期的做法是给每个节点包一层ExecutorService用Future.get()控制超时后来发现更省事的是在模型调用的底层配置上设置合理的响应超时时间让异常尽早暴露在节点内部而不是让图引擎干等。 提示Graph编排排错的第一原则是先确认数据走到哪了再确认代码写没写对。很多问题最后发现不是节点逻辑错而是某个边没有连上、某个节点名拼写不一致。节点名拼写这个问题我也栽过一次。addEdge(rewrite, review)这行代码里的节点名如果跟addNode时定义的名字有出入引擎会在运行时才报错而且报错信息不一定直观。建议把图上所有节点名抽成常量减少手滑概率。6. 下一阶段可以扩展的点并行分支与人工确认Graph基础链路和条件分支跑通之后我的工作流编排能力算是真正入了门。但做生产级系统时还有两个方向是紧接着就要用到的。6.1 并行分支多个节点同时处理不同子任务有些业务天然适合并行。比如一篇文章初稿完成后可以同时跑事实核查风格优化SEO关键词提取三个节点互不依赖最终结果汇总到State里。在Graph里做并行编排思路是让一个入口节点连接到多个下游节点引擎按出边同时调度这些节点执行。并行给State带来了新的要求多个节点同时读写State时一定要保证写的是不同字段否则就会有竞争。我的建议是给每个分支分配独立的数据前缀比如fact_check_result、style_feedback、seo_keywords各写各的最后再用一个汇总节点统一读取。并行执行时的线程池配置也很关键我线上是把核心线程数压到跟CPU核数对应的水平避免模型调用本身是IO密集型的特性把线程池打满。6.2 人工确认节点把人接到工作流里另一个常见需求是人工审批。比如生成营销文案后需要运营人员确认才能继续下发。这个场景我目前的做法是图走到确认节点时把结果持久化到数据库然后返回一个Pending状态给前端等人工确认后再从确认节点继续往下执行。Graph本身没有内建这种语义但利用图的执行可以天然暂停在任意节点这个特性配合状态持久化就能实现半自动流程。我把这个思路完整落地成了一套草稿→人工确认→发布的工作流以后整个内容生产系统的流程灵活性才真正放开每个节点都可以被替换、被暂停、被单独调试。这也是为什么我在这个系列的第一篇就强调把流程建模成图这件事——它带来的不是某一次重构的便利而是后续所有流程变更都能以极低成本实施的能力。Graph工作流编排这条路我还在继续趟。目前这套基础已经稳定支撑了好几条生产链路后面如果再遇到有价值的新坑和新解法再单独写文章展开。你要是也在Java生态里做多Agent编排建议拿这篇文章里的最小示例跑一遍跑通了再往上加复杂度。自己动手连过一条边、打过一次节点日志之后你对Graph这套东西的理解会完全不一样。
返回列表