Spring AI Graph:从0到Supervisor(一)RAG子图+Supervisor路由踩坑全记录

一个入口,自动分发到 RAG / 代码审查 / 通用对话三个子图------这就是 Supervisor 做的事。

整体架构

Supervisor 根据用户意图,把请求分发到不同子图处理。

一、RAG 子图:intent→route→search→rerank→generate

1.1 状态定义

先看状态结构,这是整个 Graph 的基石:

java

java 复制代码
public class DreamSaaSOverAllState extends BaseOverAllState {
    public static final String userInput = "userInput";
    public static final String queryIntent = "queryIntent";        // keyword / semantic / hybrid
    public static final String queryIntentSource = "queryIntentSource"; // llm / heuristic / default
    public static final String searchResult = "searchResult";
    public static final String rerankedResult = "rerankedResult";
    public static final String finalAnswer = "finalAnswer";
    public static final String errorMessage = "errorMessage";
    public static final String route = "route";                   // Supervisor 路由:ragRoute / reviewCodeRoute / chatRoute

    // ReAct 专用
    public static final String reasoningTrace = "reasoningTrace";
    public static final String reactRound = "reactRound";
    public static final String reactAction = "reactAction";       // FINAL_ANSWER / CONTINUE_THINKING
    public static final String reactDraftAnswer = "reactDraftAnswer";
}

状态键统一管理,子图之间传值不会写错 key。其中 route 是 Supervisor 路由专用,reactDraftAnswer 是 ReAct 草稿。

1.2 意图分析:keyword 还是 hybrid?

LLM 判断 + 规则兜底,两级策略:

java

typescript 复制代码
private ClassifyOutcome classify(String userInput) {
    if (!StringUtils.hasText(userInput)) {
        return new ClassifyOutcome(INTENT_HYBRID, "default");
    }
    try {
        ChatClient client = chatClientSelector.selectClient(null);
        String raw = client.prompt()
                .system(SYSTEM_PROMPT)
                .user("用户 query:\n" + userInput)
                .call()
                .content();
        String intent = normalizeIntent(raw);
        return new ClassifyOutcome(intent, "llm");
    } catch (RuntimeException ex) {
        // LLM 挂了走规则兜底
        String intent = heuristicClassify(userInput);
        return new ClassifyOutcome(intent, "heuristic");
    }
}

static String heuristicClassify(String userInput) {
    String q = userInput.trim();
    // 短词 + 非问句 → keyword
    if (q.length() <= 24 && !looksLikeQuestion(q)) {
        return INTENT_KEYWORD;
    }
    // 含疑问词/问号 → hybrid
    if (containsAny(q, "什么", "如何", "怎么", "为什么", "为何", "哪些", "是否", "吗", "?", "?")) {
        return INTENT_HYBRID;
    }
    // 含中文 → hybrid
    if (q.matches("(?s).*[\\u4e00-\\u9fff].*")) {
        return INTENT_HYBRID;
    }
    // 纯英文数字 → keyword
    if (q.matches("[A-Za-z0-9_.\\-/]+")) {
        return INTENT_KEYWORD;
    }
    return INTENT_HYBRID;
}

private record ClassifyOutcome(String intent, String source) {}

Prompt 很简洁,只输出一个词:

plaintext

diff 复制代码
你是检索路由分类器。根据用户 query 判断检索策略,只输出一个词:
- keyword:专有名词、产品名、类名、API、错误码、缩写、短术语 lookup
- hybrid:需要理解语义的一般问题、操作步骤、原因解释、对比分析
禁止输出其它文字。

1.3 条件边路由:addConditionalEdges

这是本文的核心亮点。怎么让 intent 分析完自动分流到不同的检索节点?

java

less 复制代码
stateGraph.addConditionalEdges(
    RagQaGraphNames.INTENT_ANALYZE,
    AsyncEdgeAction.edge_async(retrievalRouteNode),  // 条件边决策器
    Map.of(
        IntentAnalyzeNode.INTENT_KEYWORD, RagQaGraphNames.KEYWORD_SEARCH,
        IntentAnalyzeNode.INTENT_HYBRID, RagQaGraphNames.HYBRID_SEARCH));

addConditionalEdges 接收三参数:

  • 第一个:当前节点名(INTENT_ANALYZE)
  • 第二个:EdgeAction 实现,返回目标节点标识字符串
  • 第三个:Map<String, String>,key 是 EdgeAction 返回值,value 是实际节点名

java

typescript 复制代码
public String apply(OverAllState state) {
    String intent =
            Objects.toString(
                    state.value(DreamSaaSOverAllState.queryIntent, IntentAnalyzeNode.INTENT_HYBRID), "")
                    .trim();
    // 返回值匹配 addConditionalEdges 的 Map key,决定下一步去哪
    return IntentAnalyzeNode.INTENT_KEYWORD.equals(intent)
            ? IntentAnalyzeNode.INTENT_KEYWORD
            : IntentAnalyzeNode.INTENT_HYBRID;
}

简单说:节点执行后,条件边决定下家是谁。这就是 Graph 编排的核心能力。

1.4 完整 RAG 子图配置

java

scss 复制代码
@Bean
public CompiledGraph ragQaSubGraph() throws GraphStateException {
    // 1. 定义状态策略(Replace 覆盖,不是累加)
    KeyStrategyFactory keyStrategyFactory = () -> {
        HashMap<String, KeyStrategy> strategies = new HashMap<>();
        strategies.put(userInput, new ReplaceStrategy());
        strategies.put(queryIntent, new ReplaceStrategy());
        strategies.put(searchResult, new ReplaceStrategy());
        // ... 其他字段
        return strategies;
    };

    StateGraph stateGraph = new StateGraph(keyStrategyFactory);
    
    // 2. 注册节点
    stateGraph.addNode(INTENT_ANALYZE, AsyncNodeAction.node_async(intentAnalyzeNode));
    stateGraph.addNode(KEYWORD_SEARCH, AsyncNodeAction.node_async(keywordSearchNode));
    stateGraph.addNode(HYBRID_SEARCH, AsyncNodeAction.node_async(hybridSearchNode));
    stateGraph.addNode(RERANK, AsyncNodeAction.node_async(rerankNode));
    stateGraph.addNode(GENERATE, AsyncNodeAction.node_async(generateNode));

    // 3. 定义边
    stateGraph.addEdge(START, INTENT_ANALYZE);
    
    // 4. 条件边:intent 分析结果决定走哪条检索路径
    stateGraph.addConditionalEdges(
        INTENT_ANALYZE,
        AsyncEdgeAction.edge_async(retrievalRouteNode),
        Map.of(INTENT_KEYWORD, KEYWORD_SEARCH, INTENT_HYBRID, HYBRID_SEARCH));
    
    // 5. 后续固定路径
    stateGraph.addEdge(KEYWORD_SEARCH, RERANK);
    stateGraph.addEdge(HYBRID_SEARCH, RERANK);
    stateGraph.addEdge(RERANK, GENERATE);
    stateGraph.addEdge(GENERATE, END);

    return stateGraph.compile();
}

实际数据流:

plaintext

css 复制代码
START → [intentAnalyze] → 
        ├─ keyword → [keywordSearch] → [rerank] → [generate] → END
        └─ hybrid → [hybridSearch] → [rerank] → [generate] → END

二、Supervisor 主图:意图分发到子图

2.1 IntentRouterNode:比 RAG 更粗粒度的分类

Supervisor 的第一步也是意图识别,但粒度不同:

  • RAG子图:keyword vs hybrid(检索策略)
  • Supervisor:ragRoute vs reviewCodeRoute vs chatRoute(任务类型)

java

typescript 复制代码
private static final String system_prompt = """
    你是一个专业的意图分析助手,根据用户输入的问题分析用户的意图
    输出下面枚举值的其中一个,不能包含其他内容
    ragRoute、reviewCodeRoute、chatRoute,分别对应:文档检索、代码审查、大模型对话
    必须严格遵守要求,只能输出 ragRoute、reviewCodeRoute、chatRoute 这三个值的其中一个
    """;

@Override
public Map<String, Object> apply(OverAllState state) {
    // 规则优先,避免 LLM 波动
    String ruleRoute = ruleBasedRoute(userInput);
    if (ruleRoute != null) {
        return Map.of(DreamSaaSOverAllState.route, ruleRoute);
    }
    // LLM 兜底
    String content = client.prompt().system(system_prompt).user(userInput).call().content();
    return Map.of(DreamSaaSOverAllState.route, normalizeIntent(content));
}

static String ruleBasedRoute(String userInput) {
    if (!StringUtils.hasText(userInput)) {
        return null;
    }
    String q = userInput.trim();
    if (containsAny(q, "查文档", "检索", "知识库", "RAG", "rag", "文档搜索", "搜文档", "查资料")) {
        return SupervisorGraphNames.RAG_ROUTE;
    }
    if (containsAny(q, "代码审查", "code review", "review code", "审查代码", "CR ", " cr")) {
        return SupervisorGraphNames.REVIEW_CODE_ROUTE;
    }
    return null; // 走 LLM
}

2.2 IntentRouteDispatcher:条件边决定去哪个子图

java

typescript 复制代码
@Override
public String apply(OverAllState state) {
    String route = state.value(DreamSaaSOverAllState.route, "");
    if (route.contains(SupervisorGraphNames.RAG_ROUTE)) return SupervisorGraphNames.RAG_ROUTE;
    if (route.contains(SupervisorGraphNames.REVIEW_CODE_ROUTE)) return SupervisorGraphNames.REVIEW_CODE_ROUTE;
    return SupervisorGraphNames.CHAT_ROUTE; // 默认走通用对话
}

2.3 Supervisor 图配置

java

less 复制代码
@Bean
public CompiledGraph supervisorGraph(
        IntentRouterNode intentRouterNode,
        IntentRouteDispatcher intentRouteDispatcher,
        CodeReviewSubGraphStubNode codeReviewSubGraphStubNode,
        @Qualifier("ragQaSubGraph") CompiledGraph ragQaSubGraph,
        @Qualifier("generalChatSubGraph") CompiledGraph generalChatSubGraph) {

    StateGraph stateGraph = new StateGraph(keyStrategyFactory);
    
    // 注册节点(子图作为节点嵌入)
    stateGraph.addNode(DreamSaaSOverAllState.route, AsyncNodeAction.node_async(intentRouterNode));
    stateGraph.addNode(SupervisorGraphNames.RAG_ROUTE, ragQaSubGraph);        // RAG子图
    stateGraph.addNode(SupervisorGraphNames.REVIEW_CODE_ROUTE, AsyncNodeAction.node_async(codeReviewSubGraphStubNode)); // 暂用占位
    stateGraph.addNode(SupervisorGraphNames.CHAT_ROUTE, generalChatSubGraph); // Chat子图

    // 定义边
    stateGraph.addEdge(START, DreamSaaSOverAllState.route);
    
    // 条件边:路由结果决定去哪个子图
    stateGraph.addConditionalEdges(
        DreamSaaSOverAllState.route,
        AsyncEdgeAction.edge_async(intentRouteDispatcher),
        Map.of(
            SupervisorGraphNames.RAG_ROUTE, SupervisorGraphNames.RAG_ROUTE,
            SupervisorGraphNames.REVIEW_CODE_ROUTE, SupervisorGraphNames.REVIEW_CODE_ROUTE,
            SupervisorGraphNames.CHAT_ROUTE, SupervisorGraphNames.CHAT_ROUTE));

    // 每个子图结束后统一到 END
    stateGraph.addEdge(SupervisorGraphNames.RAG_ROUTE, END);
    stateGraph.addEdge(SupervisorGraphNames.REVIEW_CODE_ROUTE, END);
    stateGraph.addEdge(SupervisorGraphNames.CHAT_ROUTE, END);

    return stateGraph.compile();
}

关键点:子图可以作为节点嵌入主图。这样 Supervisor 不需要知道 RAG 子图的内部细节,只负责分发。

三、Chat 子图:ReAct 模式

简单介绍下 Chat 子图,采用经典的 ReAct 模式:

plaintext

scss 复制代码
START → [think] → [act] → [route] → 
                        ├─ CONTINUE → [think] (循环,最多2轮)
                        └─ FINAL_ANSWER → [format] → END

java

scss 复制代码
// Chat 子图配置
stateGraph.addNode(ChatSubGraphNames.THINK, AsyncNodeAction.node_async(chatReActThinkNode));
stateGraph.addNode(ChatSubGraphNames.ACT, AsyncNodeAction.node_async(chatReActActNode));
stateGraph.addNode(ChatSubGraphNames.FORMAT, AsyncNodeAction.node_async(chatFormatOutputNode));

stateGraph.addEdge(START, ChatSubGraphNames.THINK);
stateGraph.addEdge(ChatSubGraphNames.THINK, ChatSubGraphNames.ACT);

// 条件边:继续思考 or 输出格式
stateGraph.addConditionalEdges(
    ChatSubGraphNames.ACT,
    AsyncEdgeAction.edge_async(chatReActRouteNode),
    Map.of(ChatSubGraphNames.THINK, ChatSubGraphNames.THINK,
           ChatSubGraphNames.FORMAT, ChatSubGraphNames.FORMAT));

四、踩坑记录:状态污染问题

这是让我折腾最久的问题。

4.1 问题现象

连续请求时,Chat 子图会混入上次推理的 reasoningTrace,越积越多。第一次正常,第二次就开始乱了。

4.2 根因分析

看这段代码:

java

arduino 复制代码
// ❌ 错误写法
graph.invoke(inputs);  // 不传 RunnableConfig

graph.invoke() 不传 RunnableConfig 时:

  1. threadId 复用:Spring AI Graph 默认用固定 threadId
  2. checkpoint 残留:上一个请求的状态还在
  3. reasoningTrace 累加:节点的追加逻辑不断累积

java

typescript 复制代码
// 某节点的累加操作
public Map<String, Object> apply(OverAllState state) {
    String trace = state.value(reasoningTrace, "");
    String newTrace = trace + "\n" + newContent;  // 追加,不是覆盖!
    return Map.of(reasoningTrace, newTrace);
}

4.3 解决方案:每请求独立 thread + 清空 state

java

typescript 复制代码
public final class GraphInvokeSupport {
    
    // 关键1:每请求生成新 threadId
    public static RunnableConfig freshRunConfig() {
        return RunnableConfig.builder()
            .threadId(UUID.randomUUID().toString())
            .build();
    }

    // 关键2:清空所有状态键
    public static Map<String, Object> chatInputs(String userInput) {
        Map<String, Object> inputs = new HashMap<>();
        inputs.put(userInput, userInput.trim());
        inputs.put(reasoningTrace, "");   // 清空
        inputs.put(reactRound, 0);          // 重置
        inputs.put(reactAction, "");
        inputs.put(reactDraftAnswer, "");
        inputs.put(finalAnswer, "");
        return Map.copyOf(inputs);
    }
}

调用时:

java

typescript 复制代码
public Map<String, Object> runSupervisor(String query) {
    // 构建干净输入
    Map<String, Object> inputs = GraphInvokeSupport.supervisorInputs(query);
    // 独立 threadId
    Optional<OverAllState> finished = 
        supervisorGraph.invoke(inputs, GraphInvokeSupport.freshRunConfig());
    return extractResult(finished.get());
}

4.4 问题总结

  • 连续请求混入旧数据:默认 threadId 复用 → 每请求 UUID
  • reasoningTrace 累加:未初始化 → chatInputs 清空
  • 累加型操作越积越多:Replace vs Append 策略混用 → 统一 ReplaceStrategy

五、实测结果

RAG 全链路

意图识别日志:

plaintext

ini 复制代码
IntentRouterNode userInput:Spring AI Graph
IntentRouterNode route=ragRoute source=rule
retrievalRoute intent=keyword targetNode=keywordSearchNode
keywordSearch hitCount=5

完整请求走通:用户输入 → Supervisor 路由识别为 ragRoute → RAG 子图 intent 分析为 keyword → 关键词检索命中5条 → 重排序 → 生成回答。

通用对话

json

json 复制代码
{
  "route": "chatRoute",
  "reactRound": 1,
  "reactAction": "FINAL_ANSWER"
}

Chat 子图走 ReAct 模式,1轮就输出 FINAL_ANSWER,符合预期。

六、代码结构

plaintext

bash 复制代码
src/main/java/com/zhu/dream/ai/graph/
├── state/
│   └── DreamSaaSOverAllState.java      # 状态键定义
├── support/
│   └── GraphInvokeSupport.java         # 调用工具(防状态污染)
├── supervisor/
│   ├── SupervisorGraphConfiguration.java  # 主图
│   ├── IntentRouterNode.java           # 意图识别
│   ├── IntentRouteDispatcher.java      # 条件边
│   └── SupervisorGraphNames.java       # 常量
├── subgraph/
│   ├── rag/
│   │   ├── RagQaSubGraphConfiguration.java
│   │   ├── IntentAnalyzeNode.java      # 检索意图分析
│   │   ├── RetrievalRouteNode.java     # 条件边(分流)
│   │   ├── KeywordSearchNode.java
│   │   ├── HybridSearchNode.java
│   │   ├── RerankNode.java
│   │   └── GenerateNode.java
│   ├── chat/
│   │   ├── GeneralChatSubGraphConfiguration.java
│   │   ├── ChatReActThinkNode.java
│   │   ├── ChatReActActNode.java
│   │   └── ChatReGraphNames.java
│   └── codereview/
│       └── CodeReviewSubGraphConfiguration.java
└── controller/
    └── GraphController.java             # HTTP 入口

七、下一步

  • CodeReview 子图完整实现(目前是占位)
  • 流式输出支持(目前是同步)
  • 多轮对话上下文传递

版本信息:spring-ai-bom 1.1.6 + spring-ai-alibaba 1.1.2.2

相关推荐
嘻嘻仙人5 小时前
从原理到代码,拆解AgentScope框架开发实践
agent
Mahir085 小时前
MyBatis 深度解密:从执行流程到底层原理全解
java·后端·面试·mybatis
菜菜的顾清寒5 小时前
力扣hot100(37)栈-有效的括号
java·开发语言
罗超驿5 小时前
9.LeetCode 209. 长度最小的子数组 | 滑动窗口专题详解
java·算法·leetcode·面试
孟林洁6 小时前
Java转AI应用开发速成(3)—— 第一个 SpringAI 聊天应用
java·spring boot·后端·ai·机器人
Simon523146 小时前
Spring AOP 五大通知类型
java·前端·spring
早睡身体真不戳6 小时前
【无标题】
java·服务器·windows
布吉岛的石头6 小时前
Java 程序员第 38 阶段:Embedding 向量缓存实战,减少重复向量化计算开销
java·缓存·embedding
Circ.6 小时前
Java 远程调用 NX 11 完整实战:参数读取、修改、STP 文件导出(附环境配置 + 源码)
java·开发语言·nx11