Spring AI Graph实战:构建集成RAG与Supervisor路由的智能体工作流

1. 项目概述:当RAG遇上Agent,一次关于“图”的探索

最近在折腾一个挺有意思的东西,叫Spring AI Graph。这玩意儿不是我们平时理解的图表或者数据结构里的图,而是一种构建AI应用的新范式。简单来说,它把复杂的AI任务拆解成一个个独立的“节点”(比如一个RAG查询、一个代码生成器、一个决策器),然后用“边”把这些节点连接起来,形成一个有向的工作流。你可以把它想象成一个高级版的流程图,只不过每个节点背后都是一个实实在在的AI模型或工具,整个图能自动执行,完成从输入到输出的复杂任务。

我这次的目标,是从零开始,构建一个包含RAG(检索增强生成)子图和Supervisor(监督者)路由的智能体。听起来有点绕?我换个说法。我想做一个能“思考”的问答系统:用户问一个问题,系统不是直接去大模型里搜答案,而是先派一个“侦察兵”(RAG子图)去知识库里检索相关资料,然后把资料和问题一起交给一个“指挥官”(Supervisor)。这个指挥官再根据问题的类型和内容,决定是让“侦察兵”再深入查查,还是调用另一个“专家”(比如代码生成节点)来处理,或者直接给出最终答案。这个“指挥官”就是Supervisor,它的决策过程就是路由。

这个组合非常实用。RAG负责提供精准、实时的外部知识,解决大模型“幻觉”和知识陈旧的问题;Supervisor则负责协调和决策,让整个系统不再是简单的单次问答,而具备了多步推理和任务分解的能力。比如,用户问“如何用Spring Boot实现一个文件上传接口并优化其性能?”,系统可以先通过RAG检索到基础的实现代码和常见的性能瓶颈,然后Supervisor判断这个问题涉及“代码实现”和“性能优化”两个子任务,从而可能路由到不同的处理节点,最终给出一个综合性的、分步骤的解决方案。

然而,理想很丰满,现实很骨感。Spring AI Graph作为一个较新的特性,官方文档更像是一个“概念展示”,很多深坑需要自己踩。尤其是在将RAG作为一个子图嵌入,并让Supervisor根据条件动态路由到它时,我遇到了不少官方指南里没写的“惊喜”。这篇文章,就是我这次从零搭建到最终跑通Supervisor路由的完整踩坑记录,希望能帮你绕过我走过的弯路。

2. 核心概念与架构设计拆解

在动手写代码之前,我们必须把几个核心概念和它们之间的关系理清楚。Spring AI Graph的模型主要借鉴了LangGraph等框架的思想,但用Spring的风格进行了封装。

2.1 图的构成要素:Node, Edge, GraphState

首先,图由三个基本要素构成:

  1. 节点(Node):这是图里的工作单元。在Spring AI Graph中,一个节点通常是一个实现了Supplier<ActionRequest>Function<ActionRequest, ActionResponse>Consumer<ActionRequest>接口的Bean。更常见的是,我们使用@Bean注解将一个方法声明为节点,这个方法接收一个GraphState,返回一个更新后的GraphState
  2. 边(Edge):边决定了执行流的方向。在Spring AI Graph中,边是通过“条件”来定义的。最常见的是ConditionalSwitchConditional用于二选一,Switch用于多路选择。边的逻辑基于GraphState中的某个值来判断下一步该去哪个节点。
  3. 图状态(GraphState):这是一个贯穿整个图执行过程的共享上下文。它是一个类似Map的结构,你可以在里面存放任何需要跨节点传递的数据,比如用户的原始问题、RAG检索到的文档、大模型的回复、中间决策结果等。每个节点读取并修改GraphState,从而推动流程前进。

我设计的这个智能体,其核心架构是一个两层图结构

  • 主图(Supervisor Graph):负责最高级别的协调和路由。它包含一个Supervisor节点(也是一个特殊的子图)和几个工具节点(如直接回答节点、代码生成节点)。Supervisor节点的作用是分析GraphState中的问题,决定下一步该调用哪个工具。
  • 子图(RAG SubGraph):这是一个封装好的、功能独立的图,专门负责接收一个问题,从向量数据库中检索相关文档,然后调用大模型生成一个基于这些文档的答案。这个子图会被主图中的Supervisor作为一个“工具”来调用。

这种设计的优势在于解耦和复用。RAG子图可以独立开发、测试和优化。主图无需关心RAG内部复杂的检索和生成逻辑,只需把它当做一个黑盒工具来调用。同时,Supervisor可以根据情况灵活决定是否调用、以及如何调用这个工具。

2.2 Supervisor与工具节点的交互模式

这里有一个关键点需要理解:在Spring AI Graph的语境下,Supervisor本身通常是一个Agent节点,它内置了一个大语言模型(LLM)。这个LLM的职责是进行“思考”和“规划”。

其工作流程通常是这样的:

  1. GraphState中包含了用户的input(问题)。
  2. Supervisor节点被激活,它内部的LLM会审视GraphState,分析问题。
  3. LLM根据预定义的“工具列表”(Tool List)进行判断。每个工具都有一个名称和描述。LLM会想:“用户这个问题,我该用哪个工具来处理?”
  4. LLM输出一个决策,格式通常是类似{"action": "tool_name", "action_input": "..."}。这个决策会被Spring AI Graph框架解析。
  5. 框架根据action的值,将执行流路由到对应的工具节点,并将action_input作为参数传递给该工具节点。
  6. 工具节点执行完毕,将结果写回GraphState
  7. 执行流通常会再次回到Supervisor节点(通过边连接),让它根据工具执行的结果,决定下一步是继续调用工具,还是结束流程并给出最终答案。

在这个交互中,我们的RAG子图就需要被包装成一个Tool,并注册到Supervisor的上下文中,这样Supervisor内部的LLM才知道有这么一个工具可用。

2.3 技术栈选型与前期准备

为了完成这个项目,我选择了以下技术栈,并说明了理由:

  • Spring Boot 3.x + Spring AI: 基础框架。Spring AI提供了对主流大模型和向量数据库的统一抽象,是构建AI应用的首选。
  • OpenAI GPT-4o / Anthropic Claude 3 Haiku: 作为Supervisor和RAG生成答案的LLM。选择它们是因为API稳定、能力强大,且Spring AI原生支持。对于Supervisor,需要较强的推理和规划能力,GPT-4o是优选;对于RAG的答案生成,性价比高的Haiku也足够。
  • PGVector + Spring AI VectorStore: 作为知识库的存储和检索后端。PGVector是PostgreSQL的扩展,部署简单,与Spring生态集成好,适合中小规模的知识库。Spring AI的VectorStore接口让切换后端变得容易。
  • Spring AI Graph (Experimental): 核心库。需要注意的是,截至我实践时,Graph模块仍处于experimental(实验性)阶段,API可能会有变动,这也是坑多的原因之一。

注意:实验性功能意味着你可能需要引入特定的快照版本(Snapshot)仓库,并且需要仔细查看对应版本Spring AI的官方文档或源码示例,因为主版本文档可能更新不及时。

在开始编码前,请确保你的pom.xmlbuild.gradle中正确引入了spring-ai-graph依赖,并配置好了AI模型(如OpenAI)的API密钥。

3. 构建RAG子图:封装一个独立的检索增强生成单元

我们的第一步,是先打造一个可靠、可复用的RAG子图。这个子图的功能是:输入一个查询字符串,输出一个基于知识库的答案。

3.1 定义子图的状态(State)

子图也需要自己的GraphState,用于在子图内部传递数据。我们定义一个RagState记录类:

import java.util.List; import java.util.Map; public record RagState( String userQuery, // 用户输入的问题 List<Document> retrievedDocuments, // 检索到的文档列表 String aiResponse, // AI生成的最终答案 Map<String, Object> metadata // 可扩展的元数据,如检索耗时、模型使用情况等 ) { // 提供一个便捷的构造方法,用于初始化 public static RagState fromQuery(String query) { return new RagState(query, List.of(), null, Map.of()); } }

这里使用record是为了不可变性和简洁性。Document是Spring AI中表示文档的标准类,通常包含文本内容和元数据。

3.2 实现核心节点:检索与生成

接下来,我们创建两个节点,并将它们组装成图。

节点1:检索节点(RetrieveNode)这个节点的职责是调用VectorStore进行相似性搜索。

import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.graph.api.GraphNode; import org.springframework.ai.graph.api.GraphState; import org.springframework.stereotype.Component; import java.util.List; @Component public class RetrieveNode implements GraphNode<RagState> { private final VectorStore vectorStore; public RetrieveNode(VectorStore vectorStore) { this.vectorStore = vectorStore; } @Override public RagState apply(GraphState<RagState> graphState) { RagState currentState = graphState.getState(); String query = currentState.userQuery(); // 执行相似性检索,这里假设返回前5个最相关的文档 List<Document> documents = vectorStore.similaritySearch(query, 5); // 创建新的状态,更新检索到的文档 RagState newState = new RagState( currentState.userQuery(), documents, currentState.aiResponse(), currentState.metadata() ); return newState; } }

节点2:生成节点(GenerateNode)这个节点利用检索到的文档和原始问题,调用大模型生成答案。

import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.SystemPromptTemplate; import org.springframework.ai.graph.api.GraphNode; import org.springframework.ai.graph.api.GraphState; import org.springframework.stereotype.Component; import java.util.Map; @Component public class GenerateNode implements GraphNode<RagState> { private final ChatClient chatClient; private final SystemPromptTemplate systemPromptTemplate; public GenerateNode(ChatClient chatClient) { this.chatClient = chatClient; // 定义一个系统提示词模板,指导AI基于上下文回答 this.systemPromptTemplate = new SystemPromptTemplate(""" 你是一个专业的助手,请严格根据以下提供的上下文信息来回答问题。 如果上下文信息不足以回答问题,请如实告知“根据现有资料无法回答该问题”。 上下文信息: {context} 用户问题:{question} """); } @Override public RagState apply(GraphState<RagState> graphState) { RagState currentState = graphState.getState(); List<Document> documents = currentState.retrievedDocuments(); String question = currentState.userQuery(); // 将检索到的文档合并成上下文字符串 String context = documents.stream() .map(Document::getContent) .collect(Collectors.joining("\n\n")); // 构造Prompt Prompt prompt = systemPromptTemplate.create(Map.of( "context", context, "question", question )); // 调用大模型 String aiResponse = chatClient.call(prompt).getResult().getOutput().getContent(); // 创建新的状态,更新AI回复 RagState newState = new RagState( currentState.userQuery(), currentState.retrievedDocuments(), aiResponse, currentState.metadata() ); return newState; } }

3.3 组装子图并暴露为Tool

这是将子图封装成可调用工具的关键步骤。我们需要定义一个Graph,并将其包装成一个Spring Bean,同时还要实现Tool接口,以便Supervisor识别。

import org.springframework.ai.graph.api.*; import org.springframework.ai.graph.api.builder.GraphBuilder; import org.springframework.ai.tool.Tool; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RagGraphConfig { @Bean public Graph<RagState> ragGraph(RetrieveNode retrieveNode, GenerateNode generateNode) { // 使用GraphBuilder构建一个简单的线性图:检索 -> 生成 return GraphBuilder.<RagState>builder() .startNode(retrieveNode) // 起始节点是检索 .next(generateNode) // 检索完成后执行生成 .build(); } @Bean public Tool ragTool(Graph<RagState> ragGraph) { return new Tool() { @Override public String getName() { return "query_knowledge_base"; // 工具名称,Supervisor将根据这个名称来调用 } @Override public String getDescription() { return "查询内部知识库,获取与问题相关的权威信息。当用户的问题涉及内部文档、产品手册、历史记录等时使用此工具。"; } @Override public Object execute(Map<String, Object> inputs) { // Tool的execute方法被Supervisor调用。 // inputs 参数包含了Supervisor LLM认为应该传递给工具的参数。 // 通常,Supervisor会把`action_input`作为`question`键的值传过来。 String question = (String) inputs.get("question"); if (question == null || question.isBlank()) { throw new IllegalArgumentException("Tool 'query_knowledge_base' requires a 'question' input."); } // 1. 初始化子图状态 RagState initialState = RagState.fromQuery(question); // 2. 执行子图 GraphState<RagState> resultState = ragGraph.execute(initialState); // 3. 返回子图的最终结果(AI生成的答案) return resultState.getState().aiResponse(); } }; } }

关键点与踩坑记录1:Tool的输入输出这里是我遇到的第一个坑。Tool.execute(Map inputs)方法的inputs参数内容,完全依赖于Supervisor内部LLM的理解。你需要确保在Supervisor的提示词(或工具描述)中清晰地说明这个工具需要什么参数。我最初没有明确说明,导致LLM有时传一个Map过来,有时只传一个字符串,造成类型转换错误。后来在工具描述中明确写“接受一个名为question的字符串参数”,才稳定下来。

另外,execute方法的返回值会成为GraphState的一部分,供Supervisor后续判断。这里我直接返回了字符串答案,你也可以返回一个更结构化的对象。

4. 构建主图与Supervisor:实现智能路由决策

有了RAG工具,接下来我们构建主图,核心是创建一个具备路由能力的Supervisor节点。

4.1 定义主图状态(MainState)

主图的状态需要包含更丰富的信息,以支持多轮对话和工具调用。

import java.util.List; import java.util.Map; public record MainState( String userInput, // 用户最新输入 String conversationHistory, // 简化的对话历史(实际项目可用更复杂结构) String latestToolResponse, // 上一个工具调用的结果 String supervisorThought, // Supervisor的“思考”过程(便于调试) String finalAnswer, // 最终给用户的答案 List<String> usedTools // 记录已使用的工具,防止循环调用 ) { public static MainState initial(String input) { return new MainState(input, "", "", "", "", List.of()); } }

4.2 配置Supervisor(Agent节点)

在Spring AI Graph中,我们通常通过配置一个ChatClient(即LLM)并为其提供Tool列表来创建一个Agent,这个Agent就可以作为Supervisor节点。

import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.SystemPromptTemplate; import org.springframework.ai.graph.api.GraphNode; import org.springframework.ai.graph.api.GraphState; import org.springframework.ai.tool.Tool; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Component public class SupervisorNode implements GraphNode<MainState> { private final ChatClient chatClient; // 用于推理的LLM private final List<Tool> tools; // 可用的工具列表,包括我们的RAG Tool private final SystemPromptTemplate systemPrompt; public SupervisorNode(ChatClient chatClient, List<Tool> tools) { this.chatClient = chatClient; this.tools = tools; // 构建Supervisor的系统提示词,这是路由决策的核心! String toolDescriptions = tools.stream() .map(tool -> "- " + tool.getName() + ": " + tool.getDescription()) .collect(Collectors.joining("\n")); this.systemPrompt = new SystemPromptTemplate(""" 你是一个任务调度员(Supervisor)。你的职责是分析用户的问题,并决定使用哪个工具来解决问题,或者直接给出答案。 你可以使用的工具如下: %s 请遵循以下规则: 1. 仔细分析用户的问题和对话历史。 2. 如果问题需要查询内部知识、文档、数据,请使用`query_knowledge_base`工具。 3. 如果问题是简单的问候、感谢或无需工具就能回答的常识问题,请直接给出友好、简洁的回答。 4. 如果上一个工具调用返回了结果,请结合该结果和用户原始问题,判断是否需要继续使用其他工具,或可以给出最终答案。 5. 你的输出必须是严格的JSON格式,且只包含以下两个字段: - `thought`: 你的思考过程,解释你为什么做出这个决定。 - `action`: 决定执行的动作。如果是使用工具,值为工具名称(如`query_knowledge_base`);如果是直接回答,值为`final_answer`。 - `action_input`: 传递给工具或作为最终答案的输入内容。如果是`final_answer`,这里就是你的回答文本。 当前对话历史:{history} 上一个工具结果:{last_tool_result} 用户当前问题:{input} """.formatted(toolDescriptions)); } @Override public MainState apply(GraphState<MainState> graphState) { MainState currentState = graphState.getState(); // 构建Prompt Prompt prompt = systemPrompt.create(Map.of( "history", currentState.conversationHistory(), "last_tool_result", currentState.latestToolResponse() != null ? currentState.latestToolResponse() : "无", "input", currentState.userInput() )); // 调用LLM进行决策 String llmResponse = chatClient.call(prompt).getResult().getOutput().getContent(); // 解析LLM的JSON输出(这里需要简单的JSON解析,实际可使用Jackson) // 假设我们有一个简单的解析方法 parseLlmResponse(llmResponse),返回一个决策对象Decision Decision decision = parseLlmResponse(llmResponse); // 更新状态,记录Supervisor的“思考” MainState newState = new MainState( currentState.userInput(), currentState.conversationHistory(), currentState.latestToolResponse(), decision.thought(), // 记录思考过程 decision.action().equals("final_answer") ? decision.actionInput() : currentState.finalAnswer(), currentState.usedTools() ); return newState; } // 简化的决策解析逻辑 private Decision parseLlmResponse(String response) { // 实际情况中,你需要一个健壮的JSON解析器来处理LLM可能的不稳定输出。 // 这里为演示,进行简单字符串匹配。 if (response.contains("\"action\": \"final_answer\"")) { // 提取action_input... return new Decision("直接回答的思考过程", "final_answer", "这是最终答案..."); } else if (response.contains("\"action\": \"query_knowledge_base\"")) { // 提取action_input作为question... return new Decision("需要查询知识库", "query_knowledge_base", "用户的具体问题..."); } // 默认回退 return new Decision("无法解析,默认直接回答", "final_answer", "我暂时无法处理这个问题。"); } record Decision(String thought, String action, String actionInput) {} }

关键点与踩坑记录2:提示词工程与输出解析这是最核心也最容易出问题的地方

  1. 提示词必须极其清晰:你必须明确告诉LLM输出格式(JSON),并定义好action字段的可能值(你的工具名称和final_answer)。模糊的指令会导致解析失败。
  2. LLM输出不稳定:即使指令清晰,LLM偶尔也会输出格式不正确、包含额外解释文字的JSON。因此,parseLlmResponse方法必须非常健壮,要能处理各种边缘情况,比如使用正则表达式提取JSON块,或者使用JacksonJsonNode进行宽松解析。
  3. 工具描述要准确ToolgetDescription()方法内容至关重要。Supervisor的LLM主要靠这个描述来判断何时调用该工具。描述应简洁说明工具的用途、适用场景和输入参数。

4.3 实现工具执行节点与最终回答节点

主图中还需要两个节点:

  1. 工具执行节点(ToolExecutorNode):根据Supervisor的决策(action),调用对应的Tool
  2. 最终回答节点(FinalAnswerNode):当Supervisor决定actionfinal_answer时,将答案整理并结束流程(或返回给用户)。
// ToolExecutorNode @Component public class ToolExecutorNode implements GraphNode<MainState> { private final Map<String, Tool> toolMap; // 工具名称到Tool实例的映射 public ToolExecutorNode(List<Tool> tools) { this.toolMap = tools.stream() .collect(Collectors.toMap(Tool::getName, tool -> tool)); } @Override public MainState apply(GraphState<MainState> graphState) { MainState state = graphState.getState(); // 假设上一个节点(Supervisor)已将决策信息存入state的某个字段,这里简化处理。 // 实际中,可能需要一个单独的字段如 `pendingAction` 来传递。 String action = "query_knowledge_base"; // 假设从state中获取 String actionInput = state.userInput(); // 假设从state中获取 Tool tool = toolMap.get(action); if (tool == null) { throw new IllegalStateException("Unknown tool: " + action); } Object toolResult = tool.execute(Map.of("question", actionInput)); // 更新状态,记录工具调用结果和已使用工具 List<String> newUsedTools = new ArrayList<>(state.usedTools()); newUsedTools.add(action); return new MainState( state.userInput(), state.conversationHistory(), toolResult.toString(), // 工具执行结果 state.supervisorThought(), state.finalAnswer(), newUsedTools ); } } // FinalAnswerNode @Component public class FinalAnswerNode implements GraphNode<MainState> { @Override public MainState apply(GraphState<MainState> graphState) { MainState state = graphState.getState(); // 这个节点可能只是简单地将最终答案标记为就绪,或者进行最后的格式化。 // 在我们的简单流程中,Supervisor节点已经将最终答案写入了state.finalAnswer()。 // 所以这个节点可以什么都不做,或者记录日志。 System.out.println("最终答案已生成: " + state.finalAnswer()); return state; // 返回未修改的状态,或进行最终处理 } }

4.4 组装主图:连接Supervisor、工具与决策边

最后,我们用GraphBuilder将所有这些节点连接起来,形成完整的工作流。这里的边(路由逻辑)是核心。

import org.springframework.ai.graph.api.builder.GraphBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MainGraphConfig { @Bean public Graph<MainState> mainSupervisorGraph( SupervisorNode supervisorNode, ToolExecutorNode toolExecutorNode, FinalAnswerNode finalAnswerNode) { return GraphBuilder.<MainState>builder() .startNode(supervisorNode) // 1. 首先进入Supervisor进行决策 // 2. 根据Supervisor决策的结果(需体现在state中)进行条件路由 .conditional( state -> { // 这里需要根据state中的决策信息来判断 // 例如,检查state中某个字段是否为`final_answer` return shouldGoToFinalAnswer(state); }, finalAnswerNode, // 如果为true,前往最终答案节点 toolExecutorNode // 如果为false,前往工具执行节点 ) // 3. 从工具执行节点出来后,应该再次回到Supervisor,让它评估工具结果 .from(toolExecutorNode).next(supervisorNode) // 4. 构建图 .build(); } private boolean shouldGoToFinalAnswer(MainState state) { // 实现你的判断逻辑,例如: // return “final_answer”.equals(state.supervisorDecisionAction()); // 这里需要你有一个方法从state中提取出Supervisor的决策动作。 // 这是一个关键设计点:决策信息如何存储在state中并在节点间传递? return false; // 示例 } }

关键点与踩坑记录3:状态管理与路由条件这是第二个大坑。在图中,节点之间通过GraphState通信。Supervisor的决策(去A工具还是B工具还是直接回答)必须被写入GraphState,后续的Conditional边才能读取这个决策并正确路由。

我最初的设计是让SupervisorNodestate里设置一个nextAction字段。但问题来了:Conditional边的判断函数Predicate<MainState>是在当前节点执行前就被评估,用于决定当前节点执行完后该去哪。这意味着,从SupervisorNodeConditional边之间,state还没有被SupervisorNode更新!

解决方案有两种:

  1. 使用Switch边而非ConditionalSwitch边允许根据state的值进行多路分发,但其判断逻辑可能仍需仔细设计。
  2. 将决策作为节点输出的一部分,并通过特殊的边类型处理:更常见和灵活的模式是,让SupervisorNode的输出不仅包含更新后的state,还包含一个“建议的下一个节点”的标识。Spring AI Graph的@Branch注解或更底层的API支持这种模式。这需要更深入地研究框架的进阶用法。在我的实践中,我暂时采用了一种简化方案:在SupervisorNode中,根据决策直接修改state中的一个routeTo字段,然后在Conditional边中判断这个字段。虽然理论上存在上述时序问题,但在线性执行且SupervisorNode是唯一修改者的简单场景下,可以工作。但这并非最佳实践。

5. 调试、常见问题与优化实录

将整个图跑起来之后,才是真正挑战的开始。下面是我遇到的一些典型问题及解决思路。

5.1 问题一:Spring AI Graph版本兼容性与Bean注入错误

症状:启动应用时报错,提示GraphBuilder类找不到,或者@GraphNode注解无法识别,或者Tool注入失败。排查

  1. 检查pom.xml,确认spring-ai-graph的版本与spring-ai-core等其它模块版本匹配。实验性模块的版本号可能比较特殊(如1.0.0-SNAPSHOT)。
  2. 确保你添加了正确的Spring Snapshot仓库地址(如果需要)。
  3. 检查所有GraphNode的实现类是否都被Spring管理(即加了@Component等注解)。
  4. ToolBean必须被正确注入到SupervisorNodeToolExecutorNode中。使用List<Tool>进行集合注入时,确保所有Tool都是Spring Bean。

解决

  • 锁定一个经过测试的Spring AI版本组合。例如,我当时使用的是spring-ai:1.0.0-M5的一系列模块。
  • SupervisorNode和配置类中使用@Autowired或构造器注入List<Tool>
  • 如果仍有问题,尝试将Graph的构建移到@Configuration类中,并使用@Bean方法显式创建节点和边,而不是完全依赖类路径扫描。

5.2 问题二:Supervisor LLM不按预期调用工具

症状Supervisor总是输出final_answer,即使明显应该使用query_knowledge_base工具。排查

  1. 检查工具描述Tool.getDescription()是否足够清晰?LLM是否理解这个工具的用途?尝试将描述写得更具体,例如:“当用户的问题涉及到公司内部的产品文档、技术规范、历史案例、政策制度等非公开通用知识时,使用此工具进行查询。”
  2. 检查系统提示词:给Supervisor的指令是否足够强硬?在提示词中明确优先级:“首先考虑是否可以使用query_knowledge_base工具。如果问题可能涉及任何内部信息,必须先使用该工具。”
  3. 检查LLM的思考过程:将SupervisorNode中解析出的thought字段打印出来或记录到日志。看看LLM到底是怎么“想”的。也许它认为问题太简单,或者它误解了“内部知识”的范围。
  4. 提供少量示例(Few-Shot):在系统提示词中加入一两个例子,展示什么样的问题该调用工具,什么样的问题直接回答。

解决

  • 优化提示词工程。这是Agent类应用的核心。我最终的提示词包含了更明确的规则和例子。
  • 考虑使用能力更强的LLM作为Supervisor(如从Haiku切换到GPT-4o),推理能力有显著提升。
  • Tool.execute方法开头加日志,确认它是否被调用。

5.3 问题三:子图(RAG)执行结果未正确返回主图

症状ToolExecutorNode调用了ragTool.execute(),但主图state中的latestToolResponse为空或不是预期的答案。排查

  1. ragTool.execute()方法内部加日志,确认子图ragGraph.execute()是否被成功调用,以及返回的resultState.getState().aiResponse()是什么。
  2. 检查RagState在子图各个节点间的传递是否正确。确保GenerateNode确实将生成的答案写入了state
  3. 检查ToolExecutorNode中,是否正确地将工具执行结果(toolResult.toString())设置到了MainState的对应字段中。

解决

  • 添加详细的日志记录,跟踪GraphState在每个节点执行前后的变化。
  • 确保MainState是一个record,每次更新都要创建新实例,避免状态污染。
  • 考虑使用一个共享的上下文对象或ThreadLocal来辅助调试(仅用于调试,生产环境慎用)。

5.4 问题四:图陷入无限循环

症状:应用不停执行,日志显示在Supervisor->ToolExecutor->Supervisor之间循环,无法结束。排查

  1. 检查结束条件Supervisor在什么情况下应该输出final_answer?你的提示词和解析逻辑是否确保了这一点?例如,当工具返回“根据资料无法回答”时,Supervisor是否应该直接给出最终答案而不是再次尝试?
  2. 记录已使用工具:我在MainState中设计了usedTools列表。在SupervisorNode的思考中,可以加入一条规则:“如果同一个工具已被使用过,且其返回结果没有提供新信息,则倾向于给出最终答案或尝试其他路径。”
  3. 设置最大迭代次数:在图的外部调用层(比如你的Controller或Service),设置一个循环执行图的计数器,超过一定次数(如10次)后强制跳出并返回超时错误,这是一个重要的安全防护。

解决

  • Supervisor的系统提示词中增加防循环指令:“注意避免重复调用同一工具处理相同的问题。如果工具未能提供新信息,请尝试给出基于现有信息的最佳答案,或告知用户能力限制。”
  • MainState中维护usedTools,并在SupervisorNode的提示词模板中传入这个信息。
  • 务必在调用图的入口处设置最大步数限制。

5.5 性能优化与经验心得

  1. 向量检索优化:RAG子图的性能瓶颈通常在检索。确保你的向量数据库有合适的索引,并且检索时限制返回数量(topK)。对于简单问题,topK=3可能就够了。
  2. 提示词模板化:将Supervisor和RAGGenerateNode的系统提示词放在配置文件(如application.yml)或数据库中,便于随时调整而无需重新部署。
  3. 异步执行:如果图中某些节点是IO密集型(如调用外部API、查询数据库),考虑将其改为异步节点,可以提高整体吞吐量。Spring AI Graph对异步有一定的支持。
  4. 状态序列化:如果你的应用需要持久化工作流状态(例如,支持长时间运行的多轮对话),GraphState中的对象需要是可序列化的。record类型通常可以,但要注意其中包含的复杂对象。
  5. 测试策略:为每个GraphNode编写单元测试,模拟输入GraphState,验证输出。为整个Graph编写集成测试,使用真实的LLM和向量数据库(或它们的Mock),测试端到端的流程。

构建Spring AI Graph应用是一个既需要软件工程思维,又需要提示词工程技巧的过程。它不像传统的微服务开发那样有固定的套路,很多设计需要根据具体的业务逻辑和LLM的特性进行反复调整和测试。这次从0到1集成RAG子图和Supervisor路由的经历,让我深刻体会到,在AI工程化的道路上,清晰的架构设计、严谨的状态管理和耐心的调试优化,与算法模型本身同样重要。