最近在搞一个 Java 服务里的多智能体协作需求,需要在同一个进程里跑几个职责不同的 Agent,再由一个 Supervisor 统一调度、分配任务。最早我用 Python 版的 LangGraph 搭原型,逻辑很快跑通,但到了交付阶段,团队不想为一个小功能额外引一套 Python 运行时,于是转向 LangGraph4j。折腾了两周,我把 Multi-Agent Supervisor 模式完整落地在 Spring Boot 服务里,这里记录一下完整的设计思路、关键代码、踩过的坑,以及很多人纠结的“现在到底用 Spring AI 还是 LangGraph4j”这个问题。
1. 为什么在 JVM 团队里做 Multi-Agent,我最终选了 LangGraph4j
1.1 我的场景:需要一个“老板”来分活的 Agent 系统
我做的业务是一个工单助手:用户提交一个问题,系统要决定是去查知识库、还是生成一段代码、还是让用户补充信息,最后汇总成答案。最初我用一个大 prompt 把“理解意图、检索、生成、总结”全塞给一个 Agent,看起来简单,实际用起来问题很多。prompt 越长,模型越容易忽略关键指令;检索和写代码的逻辑互相干扰;出了错也很难定位到底是哪一步的问题。
后来我改成 Multi-Agent 架构,核心就是 Supervisor 模式:一个 Supervisor Agent 负责读用户请求,判断该叫哪个子 Agent 干活,所有子 Agent 干完活都把结果交回给 Supervisor,Supervisor 再决定下一步是继续派活还是收尾。就像一个小团队,主管不亲自写代码,但是负责分配任务和验收结果。
这种模式下,每个子 Agent 的职责很单一,prompt 可以写得很聚焦;Supervisor 只做决策,不淹没在具体操作细节里。问题是,Java 生态里一直没有特别顺手的编排框架,直到我注意到 LangGraph4j。
1.2 LangGraph4j 与 Python 版 LangGraph 的关系
LangGraph4j 是社区把 LangGraph 的设计思想移植到 JVM 上的实现,核心概念和 Python 版一致:StateGraph、Node、Edge、Conditional Edge、Checkpoint,甚至执行方式都尽量对齐。我最早担心它只是“照着画了个葫芦”,实际用下来,基础的状态流转、条件路由、流式输出都是可用的。
对我来说,最大的价值不是 API 完全一致,而是思想一致。我在 Python 版里验证过的 Supervisor 循环,可以在 Java 里几乎一比一复刻。团队不用重新学一套“多 Agent 设计哲学”,只需要补 Java 语法和库 API 就行。
另外,LangGraph4j 天然适合 Java 技术栈:状态能定义成强类型对象,节点能复用 Spring Service,日志链路可以用现成的 Java 日志框架,测试也更顺手。对我们这种长期维护 Spring Boot 项目的团队来说,Hybrid 架构比引入异构运行时稳妥得多。
1.3 和 Spring AI 的对比:它不是替代品,而是编排层
最近常看到有人在问“用 Spring AI 还是 LangGraph4j”,我的结论很直接:它俩不是同层的东西,别做成二选一。
Spring AI 解决的是“怎么跟模型说话”:它帮你封装了 ChatClient、Prompt、结构化输出、工具调用、Embedding 等能力,让 Java 代码可以声明式地调用大模型。但 Spring AI 本身不关心你编排几个 Agent、谁先谁后、条件路由怎么走。
LangGraph4j 解决的是“多个 Agent 怎么协作”:它负责把整个工作流描述成一张有向图,定义哪个节点运行完走哪条边,也支持暂停、恢复、保存状态。节点内部具体怎么调模型,它不关心。
所以最舒服的组合是:LangGraph4j 充当 Multi-Agent 的“骨架”,Spring AI 充当每个 Agent 的“大脑连接器”。在后面代码里你会看到,我在 LangGraph4j 的节点里面直接调用 Spring AI 的 ChatClient,二者完全可以共存。
| 维度 | Spring AI | LangGraph4j |
|---|---|---|
| 核心价值 | 模型访问、Prompt、工具调用封装 | 多 Agent 流程编排、状态管理、循环与恢复 |
| 适合场景 | 单个 Agent、简单多轮对话、RAG | Supervisor、多专家协作、人工审批、条件分支 |
| 和 LangFlow 类工具的关系 | 不冲突,可以互相配合 | 不冲突,可以互相配合 |
| 学习成本 | 较低 | 中等,需要理解图执行模型 |
如果你只是希望“给 Spring Boot 项目接一个会调用工具的聊天助手”,Spring AI 完全够,不需要引入 LangGraph4j。但当你发现一个 Agent 里塞了太多职责、代码越来越乱、开始出现“根据上一步结果决定下一步要谁来干”的复杂流程时,就是该上 LangGraph4j 的时候了。
2. Supervisor 模式到底是什么:用状态机的思路理解多 Agent 调度
2.1 三种常见的多 Agent 模式
社区里常见的设计模式有三种,搞清楚它们的区别,才知道自己到底需要什么:
- 并行扇出:一个任务拆成多个子任务,多个 Agent 同时执行,最后汇总。适合“多路搜索、对比分析”这类场景。
- 流水线:Agent 按顺序执行,上一个的输出是下一个的输入。适合“生成大纲-扩写-校对”这种严格串行流程。
- Supervisor(主管):中央控制器根据当前状态动态决定下一步执行哪个子 Agent,子 Agent 完成后把控制权交还给主管,形成循环,直到主管判定任务完成。
Supervisor 模式最大的优点是灵活:它不是一个固定流程,而是一个带“决策节点”的循环流程。每一步都可能不一样,更像是真正的团队协作。缺点是决策本身有开销,因为每轮循环都要调一次模型做路由判断,而且如果路由不稳定,可能陷入来回切换的循环。
2.2 Supervisor 循环的关键:条件边和控制权交接
理解了 Supervisor 模式,再看 LangGraph4j 实现,关键就是两个:条件边和控制权回传。
条件边是指从一个节点出发时,根据当前状态的不同,走不同的目标节点。在 Supervisor 里,就是 Supervisor 节点看完请求后,决定下一步是去 researcher 还是 reporter 还是直接结束。
控制权回传的意思是子 Agent 干完活之后,不能直接走到 END,而是必须回到 Supervisor。这样 Supervisor 才能根据子 Agent 的产出判断“任务是否完成”或者“要不要换一个 Agent 再来一次”。如果子节点直接连 END,Supervisor 就失去了最后一次决策机会。
这个模型特别像状态机:状态是当前用户的请求、对话历史、各子 Agent 的产出;事件是节点执行完毕;转移规则由条件边描述。把 Multi-Agent 当成状态机来设计,比靠感觉拼 prompt 要可靠得多。
2.3 最少可用的执行流
一个最精简的 Supervisor 执行流是这样的:
- 开始:用户请求进入 START。
- Supervisor 节点运行:调用模型,输出下一跳。
- 条件路由:如果模型输出 “research”,走 researcher 节点;输出 “report”,走 reporter 节点;输出 “finish”,走 END。
- 子 Agent 节点运行:researcher 或 reporter 干活,把结果写回状态。
- 控制权返回:子 Agent 的边指向 Supervisor,再次进入 Supervisor 节点。
- 循环直到路由结果为 “finish”。
在这个流程里,子 Agent 的数量可以任意扩展,只要在路由表里注册一个 key 和对应节点即可。Supervisor 不关心某个 Agent 内部怎么实现,只关心它返回的结果是否已经写入了共享状态。
3. 用 LangGraph4j 实现 Supervisor:状态、节点、条件路由
3.1 状态 State:所有子 Agent 共享的“共享白板”
LangGraph4j 里最重要的概念是 State。它会在整个图执行过程中传递,每个节点都能读、能改。我把 State 理解为一张放在会议桌上的白板:谁拿到笔都能写,但必须按照约定写,不能随意覆盖别人的内容。
我用的 State 是HashMap的子类,好处是扩展字段方便。LangGraph4j 官方示例里有不少是直接用HashMap的,但对于复杂工程,我建议还是定义一个语义明确的类型:
public class AgentState extends HashMap<String, Object> { public AgentState() { super(); } public String getRequest() { return (String) this.get("request"); } @SuppressWarnings("unchecked") public List<Map<String, String>> getMessages() { return (List<Map<String, String>>) this.get("messages"); } public String getNext() { return (String) this.get("next"); } public void setNext(String next) { this.put("next", next); } }我这个 project 里常用的字段包括:request用户原始请求、messages所有 Agent 产生的消息历史、next路由决策、instruction给子 Agent 的额外指令、steps循环次数、finalAnswer最终答案。状态字段越清晰,节点逻辑就越容易写。
有一点要注意:State 是可变对象,节点返回值会合并更新。别在节点里偷偷把 State 换成新对象,否则可能导致后续节点读不到之前写的数据。
3.2 Supervisor 节点:让 LLM 决定下一步该找谁
Supervisor 节点其实是整个系统里最简单也最关键的节点,它做的事情只有一件:调用模型,让模型从预定义的 Agent 集合里选一个,并输出给对应 Agent 的指令。
我让模型输出严格 JSON,这样解析方便:
public AgentState supervisorNode(AgentState state) { String request = state.getRequest(); List<Map<String, String>> history = state.getMessages(); String prompt = """ 你是 Multi-Agent 系统的 Supervisor。 根据用户请求和当前历史,从下面三个动作中选一个: - research:需要深入调研,交给 researcher 节点 - report:需要整理报告,交给 reporter 节点 - finish:任务已经完成,可以给出最终答案 只输出 JSON,不要其他解释,格式如下: {"next":"research","instruction":"给子 Agent 的指令"} """; String response = chatClient.prompt() .system(prompt) .user(request) .call() .content(); try { JsonNode node = objectMapper.readTree(response); state.setNext(node.get("next").asText()); state.put("instruction", node.get("instruction").asText()); state.put("steps", ((Integer) state.getOrDefault("steps", 0)) + 1); } catch (JsonProcessingException e) { state.setNext("finish"); } return state; }这里有几个实践要点。一是必须限制输出格式,不然路由没法做。二是我加了steps自增,后面会用来做循环上限保护。三是如果 JSON 解析失败,我宁可让它走finish也不要随便走一个子节点,因为在生产环境里,不确定的决策落到某个 Agent 上,比直接收尾危险得多。
3.3 子 Agent 节点:干完活把结果写回状态
子 Agent 节点和普通节点没有本质区别,只是职责更纯粹。比如 researcher 节点,它的任务就是根据 Supervisor 给的instruction去检索数据,并把结果写回 State:
public AgentState researcherNode(AgentState state) { String instruction = (String) state.getOrDefault("instruction", ""); String request = state.getRequest(); // 这里可以调用知识库检索、外部 API 或工具函数 String knowledge = knowledgeBaseService.search(request); String result = chatClient.prompt() .system("你是一名研究员,请基于检索内容给出客观回答。") .user(instruction + "\n检索内容:" + knowledge) .call() .content(); state.put("researchResult", result); List<Map<String, String>> messages = state.getMessages(); messages.add(Map.of("role", "researcher", "content", result)); state.put("messages", messages); return state; }reporter 节点类似,它不关注怎么调研,只关注怎么把已有的researchResult和request组合成一份结构化报告:段落、要点、结论。两个节点干完活之后,都不会自己决定结束,而是通过图定义中的边回到 supervisor,这就保证了“谁决定的开始,谁负责结束”。
3.4 主流程装配与编译运行
现在到了最核心的部分:把上面这些节点用 LangGraph4j 装配成一张可执行的图。我以我项目里锁定的 API 版本为例,整体结构如下:
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new) .addNode("supervisor", this::supervisorNode) .addNode("researcher", this::researcherNode) .addNode("reporter", this::reporterNode) .addEdge(START, "supervisor") .addConditionalEdges("supervisor", this::routeFromSupervisor, Map.of( "research", "researcher", "report", "reporter", "finish", END )) .addEdge("researcher", "supervisor") .addEdge("reporter", "supervisor"); CompiledGraph<AgentState> compiledGraph = graph.compile();注意最后两条固定边:researcher -> supervisor和reporter -> supervisor。它们实现了我前面说的“控制权回传”。没有它们,子 Agent 执行完就结束,Supervisor 就没有机会验收结果。
路由函数里我做了归一化处理,避免模型输出不一致导致路由失败:
public String routeFromSupervisor(AgentState state) { String next = state.getNext(); if (next == null) { return "finish"; } String normalized = next.trim().toLowerCase(); if (normalized.contains("research")) { return "research"; } else if (normalized.contains("report")) { return "report"; } return "finish"; }执行的时候也很简单:
Map<String, Object> input = new HashMap<>(); input.put("request", "帮我查一下最近日志里的错误原因"); input.put("messages", new ArrayList<>()); input.put("steps", 0); Map<String, Object> output = compiledGraph.invoke(Input.of(input)); System.out.println(output.get("finalAnswer"));更推荐在调试阶段用 stream 方式逐步观察:
compiledGraph.stream(Input.of(input)) .stream() .forEach(System.out::println);这样你能看到每个节点的进入和退出,排查问题时不用瞎猜。
4. 让 Supervisor 更可靠:Checkpoint、人工审批、超时与重试
4.1 Checkpoint 保存现场:Agent 中途挂了可以从头恢复
LangGraph4j 支持 Checkpoint,核心作用是保存每一步 State 的快照。一旦某个子 Agent 调用失败,我们可以从最近一个正确的快照恢复,而不是整个流程重新跑。
我的做法是为每个用户请求分配一个独立的threadId,把 threadId 和用户请求绑定。执行前经过 Checkpoint 保存,执行失败后,用同一个 threadId 再次发起调用,框架会把状态恢复到最近完成的节点,然后继续往下走。
Map<String, Object> input = new HashMap<>(); input.put("threadId", "order_10086"); input.put("request", "分析订单 10086 的交付延迟原因"); input.put("messages", new ArrayList<>()); input.put("steps", 0); compiledGraph.stream(Input.of(input, "order_10086"));Checkpoint 在生产环境里特别重要,因为多 Agent 流程通常比单 Agent 长,中途失败的概率也更高。如果没有持久化状态,用户一个问题可能要重新跑好几分钟,体验极差。
4.2 人工审批:把控制权交给“人”来确认
很多业务场景里,Agent 不能完全自主行动。比如“自动生成一封发给客户的道歉信”,Supervisor 可以写草稿,但发送前必须由人工确认。这种需求不适合硬编码成子 Agent,更适合设计成“挂起流程”。
我的通用做法是:在 State 里放一个approvalRequired字段,当 Supervisor 判断需要人工审批时,把状态设置成挂起,然后让流程自然结束。真正的人工审批接口不在 LangGraph4j 内部,而是通过外部 REST API 触发:
@PostMapping("/orders/{orderId}/approve") public void approve(@PathVariable String orderId) { Map<String, Object> input = new HashMap<>(); input.put("threadId", "order_" + orderId); input.put("approvalResult", "approved"); compiledGraph.invoke(Input.of(input)); }关键思路是:一个threadId对应一份完整的 State,人工审批只是“重新唤醒”这个 threadId,让 Supervisor 读到最新的approvalResult,再决定下一步走向。这样既实现了人工介入,又保持了图的统一性。别试图把审批按钮塞进图里的某个节点,那会把流程编排和业务接口耦合在一起,维护起来很痛苦。
4.3 超时、重试与预算控制
Multi-Agent 系统最容易被忽略的问题是“失控”。因为每个子 Agent 都在调用大模型,每一步都有成本和时间开销。Supervisor 又是一个循环,如果模型判断失误,可能一直在几个子 Agent 之间来回切换,十几轮都结束不了。
我做了三层保护:
- 第一层:循环次数上限。每次 Supervisor 执行,
steps加一,超过 5 次强制finish。 - 第二层:每个子 Agent 调用模型时,在 Spring AI 的 ChatClient 上设置最大 token 数和超时时间。
- 第三层:整个图执行外面包一层
CompletableFuture超时,超过 120 秒直接终止。
public AgentState supervisorNode(AgentState state) { int steps = (Integer) state.getOrDefault("steps", 0); if (steps >= 5) { state.setNext("finish"); state.put("finalAnswer", "已经多次尝试,为避免循环,直接给出当前已有结论:" + state.getOrDefault("researchResult", "暂无结果")); return state; } // 正常调用模型做决策 return state; }这套保护在测试阶段救过我很多次。有一回我在 prompt 里写“如果信息不足,继续调查”,模型真的就一直在 research 和 supervisor 之间循环了七八轮,直到我加上步骤上限才停下来。
5. 实测中的坑和调试技巧
5.1 条件路由的返回值不稳定,正则兜底
我最早天真地认为模型会稳定输出{"next":"research"},结果实际返回五花八门:有时候是“research”,有时候是“Research”,有时候是“next”: “researcher”,甚至带一些前后缀解释。如果你直接拿它去 Map 里查,随时会命中不了预设路由。
后来我在路由函数里做了三步归一化:先 trim,再转小写,再 contain 判断。这样 “researcher” 也能命中 “research”,“report” 和 “reporting” 都能命中 “report”。如果归一化后仍匹配不到,默认走finish,绝不让图执行在中途断裂。
5.2 节点间数据复用与并发问题
LangGraph4j 的图本身是支持多实例并发执行的,但如果你在节点里偷懒,把中间结果存在类字段里,就会出现严重串数据问题。
比如我一开始为了省事,在 Service 类里写了一个currentState字段,结果两个用户同时发任务时,后一个用户直接覆盖前一个,导致 A 的请求跑到一半拿到 B 的数据。排查了很久才意识到,State 必须通过方法的入参传递,不能塞进 Bean 的成员变量。
正确做法是:节点方法参数里接收 State,返回值也是 State,所有的临时变量都放在 State 或方法局部变量里。团队里所有人都要遵守这个约定,否则并发一上来就出事故。
5.3 如何观察图执行过程:逐步输出
排查 Multi-Agent 问题最痛苦的是“你只知道结果不对,不知道卡在哪一步”。我用stream方式输出后,明显效率提升。每个节点进入、退出、路由方向都能看到:
compiledGraph.stream(Input.of(input)) .stream() .forEach(event -> System.out.println(event));LangGraph4j 的输出里包含事件类型和状态,比如ON_NODE_START、ON_NODE_END、路由结果等。配合日志文件的 traceId,基本能还原一条完整的“用户请求 -> Supervisor 决策 -> 子 Agent 执行 -> 回到 Supervisor”的链路。
还有一个很土但很有用的技巧:在节点代码里加一个短日志,打印当前 State 里几个关键字段的摘要。实测不需要打印整个 State,因为消息历史可能很长,刷屏刷到没法看。打印steps、next、instruction就够了。
5.4 关于“Spring AI 还是 LangGraph4j”的最终判断
回到开头那个问题,我认为真正的判断标准不是看谁更火,而是看你的流程复杂度。
如果只是单一 Agent + 工具函数 + 对话补全,用 Spring AI 是最省事的。它和 Spring Boot 融合得很好,写个 ChatClient 就能干活。这时候引入 LangGraph4j,纯属给自己增加概念负担。
但如果你的业务里已经出现了“多个角色协作”、需要条件分支、需要人工确认、需要从失败现场恢复,那就应该选 LangGraph4j。你可以在它的节点里继续用 Spring AI 调模型,两者配合起来才是完整方案。
还有一点:LangGraph4j 的官方文档更新速度相当快,不同版本的 API 可能略有差异。我在项目里直接把依赖版本锁死,并且维护了一套从最小图到完整图自动跑通的集成测试。每次升级依赖,先跑一遍测试,确认行为没变再合入主干,能省掉一大半莫名其妙的线上故障。
最后分享一个很土但实用的建议:如果你也是第一次接触 LangGraph4j,不要一上来就设计十几个节点的复杂图。先把 Supervisor + 两个子 Agent 的最小循环跑通,确认路由和状态流转符合预期,再逐步加 Checkpoint、人工审批、超时控制。多 Agent 系统最怕的不是模型不行,而是编排逻辑自己先乱成一锅粥。