一个入口,自动分发到 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 时:
- threadId 复用:Spring AI Graph 默认用固定 threadId
- checkpoint 残留:上一个请求的状态还在
- 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