这是专栏的番外篇。番外三咱们扒开 ReactAgent,把 Graph 三件套(节点 / 边 / 状态)刻进了肌肉记忆,还用工程的
GraphDemoConfig跑通了一个"数字拆分并行求和"的图。但那篇藏了个没说破的真相 :GraphDemoConfig的branch节点,分支是假的------不管奇偶,两条路都跑了,它只是演示"并行分叉",不是真条件路由。今天咱们用一个真实业务场景------智能客服助手 ------把 Graph 真正值钱的两个编排能力:条件路由 和状态合并策略,彻底吃透。你会看到,客服的"工单分流",用一张图就能画得清清楚楚。
一、场景:一个会"分流"的客服助手
假设你做客服系统,用户甩过来一句话,你得分情况处理:
| 用户说的 | 问题类型 | 该走的处理流程 |
|---|---|---|
| "我要退 NO12345 的款" | 退款 | 转退款专员、给退款指引 |
| "我的快递到哪了" | 物流 | 查物流轨迹 |
| "App 闪退了" | 技术 | 查知识库、给排障步骤 |
| "我要投诉" | 投诉 | 升级人工 + 安抚 |
| 其它闲聊 / 模糊问题 | 通用 | 兜底话术 |
后端老鸟一眼看穿:这不就是工作中的工单路由 / 分流吗?根据问题类型,派给不同的处理小组。
用 Graph 怎么画?一张图就够:

关键点:classify 之后只走一条路 (命中哪个类型走哪个节点),而不是五条全跑。这正是番外三那个"假分支"欠缺的东西------图结构根据运行时状态动态选路。
二、状态设计:这张图要记什么
动手前先想清楚图里流动的"状态"有哪些:
- question(输入):用户原话,图一开始就有;
- category (中间):
classify节点判断出的问题类型; - answer(输出):最终给用户的答复;
- trace (调试):每个节点把自己的动作记一笔,用来证明"图到底走了哪条路" 。
后端老鸟类比:trace 就是工作流引擎里的"流程日志 / 审计轨迹"。生产排错时,你能一眼看出"这个工单被分到了投诉组、走了投诉节点",而不是瞎猜。
三、条件路由:用 addConditionalEdges 让图"看菜吃饭"
Spring AI Alibaba 的 StateGraph 提供 addConditionalEdges,专门干"根据状态动态选路"这件事。先看分类节点 ------这里用关键词做"极简分类",目的是不依赖大模型也能跑通,把注意力集中在路由本身:
java
import com.alibaba.cloud.ai.graph.action.NodeAction;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
// 节点1:分类。读用户问题,判断类型(极简关键词版,跑通无需 API Key)
private static final NodeAction CLASSIFY_NODE = state -> {
String question = state.value("question", String.class).orElse("");
String category;
if (question.contains("退款") || question.contains("退货")) category = "refund";
else if (question.contains("快递") || question.contains("物流")) category = "logistics";
else if (question.contains("闪退") || question.contains("报错")) category = "tech";
else if (question.contains("投诉") || question.contains("差评")) category = "complaint";
else category = "general";
Map<String, Object> update = new HashMap<>();
update.put("category", category);
update.put("trace", List.of("classify → " + category)); // 记一笔轨迹
return update;
};
然后就是核心------条件边 。addConditionalEdges 的签名很直白:
javascript
addConditionalEdges(String sourceId, AsyncEdgeAction condition, Map<String, String> mappings)
sourceNode:从哪个节点出来做判断(这里是"classify");condition:路由函数,读state,返回一个字符串;mappings:把"路由函数返回的字符串"映射到"下一个节点名"。
perl
import static com.alibaba.cloud.ai.graph.action.AsyncEdgeAction.edge_async;
// 条件路由:classify 节点算出的 category,决定走哪条路
graph.addConditionalEdges("classify",
edge_async(state ->
state.value("category", String.class).orElse("general")), // 路由函数:返回类型字符串
Map.of(
"refund", "refund", // 返回 "refund" → 走退款节点
"logistics", "logistics", // 返回 "logistics" → 走物流节点
"tech", "tech", // 返回 "tech" → 走技术节点
"complaint", "complaint", // 返回 "complaint" → 走投诉节点
"general", "general" // 返回 "general" → 走通用节点
)
);
这下图的结构 随输入变化:用户说"退款" → 只跑 refundNode;说"快递" → 只跑 logisticsNode。
真实业务里,这个路由函数读的通常不是关键词,而是 LLM 的分类结果。文末第七节我们再把关键词分类升级成 LLM 分类。
四、状态合并策略:别再自己手动 append 了
光有路由还不够。我们希望每个节点把自己的动作写进 trace,最后能拿到完整的流程轨迹 。如果不用框架策略,每个节点都得先 new ArrayList 把旧 trace 接过来、再 add、最后 put 回去------又臭又长(番外三里 GraphDemoConfig 就是这么干的)。
这活儿框架能替你干。StateGraph 构造时传入一个 KeyStrategyFactory,声明每个状态键的合并策略:
ini
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.state.strategy.AppendStrategy;
// 声明状态合并策略:trace 用"追加",answer 用默认"覆盖"
KeyStrategyFactory keyStrategy = () -> {
Map<String, KeyStrategy> strategies = new HashMap<>();
strategies.put("trace", new AppendStrategy()); // trace:追加,不覆盖
// 未声明的键(如 answer / category)默认是 ReplaceStrategy(直接覆盖)
return strategies;
};
StateGraph graph = new StateGraph(keyStrategy); // ← 把策略工厂注入
声明之后,节点写法瞬间清爽------只管写自己的那一段,框架自动把同 key 的内容追加:
dart
// 退款处理节点:只写自己的答复 + 记一笔轨迹,不用再管"接旧列表"
private static final NodeAction REFUND_NODE = state -> {
String q = state.value("question", String.class).orElse("");
String answer = "已为您转接退款专员:请在订单页点击【申请退款】,"
+ "预计 1-3 个工作日原路退回。订单号:" + extractOrderNo(q);
Map<String, Object> update = new HashMap<>();
update.put("answer", answer); // answer 默认覆盖,正合适(只命中一个节点)
update.put("trace", List.of("refundNode 处理完成")); // trace 声明了 Append,框架自动累积
return update;
};
后端老鸟类比:
ReplaceStrategy像普通成员变量赋值(覆盖),AppendStrategy像ConcurrentLinkedQueue.offer()(追加)。不声明 = 覆盖 ------所以trace这种"想累积的列表"务必显式声明AppendStrategy,否则上一个节点写的值会被下一个节点的同名 key 直接冲掉。这种 bug 不报错,只会让trace永远只有最后一条,排查时想砸键盘。
五、把图组装起来(完整配置类)
把分类节点、五个处理节点、条件路由、状态策略合起来,一个 CustomerServiceGraphConfig 长这样(关键逻辑都在,省略部分 handler 的同构代码):
java
package com.yunxi.ai.config;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.GraphRepresentation;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.alibaba.cloud.ai.graph.state.strategy.AppendStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Configuration
public class CustomerServiceGraphConfig {
// 工具:从问题中抠出 "NO12345" 形式的订单号
private static String extractOrderNo(String q) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("NO\d+").matcher(q);
return m.find() ? m.group() : "(未识别,请补充)";
}
// 节点1:分类(极简关键词版)
private static final NodeAction CLASSIFY_NODE = state -> {
String question = state.value("question", String.class).orElse("");
String category;
if (question.contains("退款") || question.contains("退货")) category = "refund";
else if (question.contains("快递") || question.contains("物流")) category = "logistics";
else if (question.contains("闪退") || question.contains("报错")) category = "tech";
else if (question.contains("投诉") || question.contains("差评")) category = "complaint";
else category = "general";
Map<String, Object> update = new HashMap<>();
update.put("category", category);
update.put("trace", List.of("classify → " + category));
return update;
};
// 节点2~6:五个处理节点(结构同构,各自写 answer + 记 trace)
private static final NodeAction REFUND_NODE = state -> {
String q = state.value("question", String.class).orElse("");
Map<String, Object> update = new HashMap<>();
update.put("answer", "已转接退款专员,请于订单页点击【申请退款】,"
+ "预计 1-3 个工作日原路退回。订单号:" + extractOrderNo(q));
update.put("trace", List.of("refundNode 处理完成"));
return update;
};
private static final NodeAction LOGISTICS_NODE = state -> {
Map<String, Object> update = new HashMap<>();
update.put("answer", "正在为您查询物流轨迹,请稍候...(演示:此处调物流 API)");
update.put("trace", List.of("logisticsNode 处理完成"));
return update;
};
private static final NodeAction TECH_NODE = state -> {
Map<String, Object> update = new HashMap<>();
update.put("answer", "请尝试:1) 清缓存重启;2) 升级到最新版;3) 仍闪退请附截图联系我们。");
update.put("trace", List.of("techNode 处理完成"));
return update;
};
private static final NodeAction COMPLAINT_NODE = state -> {
Map<String, Object> update = new HashMap<>();
update.put("answer", "非常抱歉给您带来不便,已为您升级人工专员跟进,请留下联系方式。");
update.put("trace", List.of("complaintNode 处理完成"));
return update;
};
private static final NodeAction GENERAL_NODE = state -> {
Map<String, Object> update = new HashMap<>();
update.put("answer", "您好,我是智能客服小云,请问有什么可以帮您?");
update.put("trace", List.of("generalNode 处理完成"));
return update;
};
@Bean
public CompiledGraph csCompiledGraph() throws GraphStateException {
// 1) 声明状态合并策略:trace 用追加
KeyStrategyFactory keyStrategy = () -> {
Map<String, KeyStrategy> s = new HashMap<>();
s.put("trace", new AppendStrategy());
return s;
};
StateGraph graph = new StateGraph(keyStrategy);
graph.addNode("classify", AsyncNodeAction.node_async(CLASSIFY_NODE));
graph.addNode("refund", AsyncNodeAction.node_async(REFUND_NODE));
graph.addNode("logistics", AsyncNodeAction.node_async(LOGISTICS_NODE));
graph.addNode("tech", AsyncNodeAction.node_async(TECH_NODE));
graph.addNode("complaint", AsyncNodeAction.node_async(COMPLAINT_NODE));
graph.addNode("general", AsyncNodeAction.node_async(GENERAL_NODE));
graph.addEdge(StateGraph.START, "classify");
// 2) 真条件路由:category 决定走哪条路
graph.addConditionalEdges("classify",
AsyncEdgeAction.edge_async(
state -> state.value("category", String.class).orElse("general")),
Map.of(
"refund", "refund", "logistics", "logistics", "tech", "tech",
"complaint", "complaint", "general", "general"));
// 3) 五个处理节点汇聚到 END(每个问题只命中其一)
graph.addEdge(List.of("refund", "logistics", "tech", "complaint", "general"),
StateGraph.END);
return graph.compile(); // ← 校验:条件边的所有返回值必须能在 Map 里找到目标
}
/** 导出 Mermaid,方便前端可视化调试 */
@Bean
public String csGraphMermaid(CompiledGraph csCompiledGraph) {
GraphRepresentation rep = csCompiledGraph.getGraph(GraphRepresentation.Type.MERMAID);
return rep != null ? rep.toString() : "";
}
}
配个极简 Controller 就能对外服务:
less
@GetMapping("/cs/ask")
public Map<String, Object> ask(@RequestParam String q) {
OverAllState state = csCompiledGraph.invoke(Map.of("question", q));
return Map.of(
"answer", state.value("answer", String.class).orElse(""),
"trace", state.value("trace", List.class).orElse(List.of())
);
}
六、跑起来,看它"分流"对不对
起来后,发几个不同问题:
shell
# 退款类
curl 'localhost:9999/cs/ask?q=我要退NO12345的款'
# → answer: "已转接退款专员......订单号:NO12345"
# → trace: ["classify → refund", "refundNode 处理完成"]
# 物流类
curl 'localhost:9999/cs/ask?q=我的快递到哪了'
# → trace: ["classify → logistics", "logisticsNode 处理完成"]
# 技术类
curl 'localhost:9999/cs/ask?q=App闪退了'
# → trace: ["classify → tech", "techNode 处理完成"]
最关键的证明在 trace:每条请求里,trace 只出现了"命中的那一个"处理节点(比如退款请求里没有 logisticsNode / techNode)。这说明图真的按条件走了,而不是像番外三那个假分支一样把五条路全跑一遍。可观测性,就是这么实在。
七、升级:把关键词分类换成 LLM 分类(更"AI")
上面的 CLASSIFY_NODE 用关键词,是为了让你不依赖 API Key 就能跑通路由。生产里当然该让大模型来分类------只需把 CLASSIFY_NODE 换成调 ChatModel,其余图结构一行不用动:
arduino
private final ChatModel chatModel;
public CustomerServiceGraphConfig(ChatModel chatModel) { this.chatModel = chatModel; }
private NodeAction llmClassifyNode() {
return state -> {
String question = state.value("question", String.class).orElse("");
String prompt = "将用户问题分类为以下之一,只返回英文单词:"
+ "REFUND, LOGISTICS, TECH, COMPLAINT, GENERAL, 问题:" + question;
String raw = chatModel.call(prompt); // 或 .call().content()
log.info("llm={}", raw);
String category = raw.trim().toLowerCase();
// 兜底:LLM 可能返回不在值域内的词,必须兜回 general,否则条件边运行期炸
if (!List.of("refund","logistics","tech","complaint","general").contains(category)) {
category = "general";
}
Map<String, Object> update = new HashMap<>();
update.put("category", category);
update.put("trace", List.of("classify(LLM) → " + category));
return update;
};
}
注意那个兜底 ------LLM 的输出不可控,万一返回个 refund!!!! 或中文,条件边的 Map 里找不到对应目标,框架运行到那个分支直接抛异常卡死整张图。所以"路由返回值必须落在已知值域"这件事,用 LLM 时比关键词更得绷紧这根弦。
八、收个尾,预告下一篇
番外三学会了 Graph 三件套,番外四你把它真正用活了:条件路由让图"看菜吃饭"地分流,状态策略让节点不再自己扛追加的脏活,还顺手用一个真实客服场景,把工程里那个"表演型分支"升级成了生产级真分支。
此时会发现一个规律------条件路由的尽头,往往是"人" 。当 classify 判断"这件事我(AI)做不了主,得让人工拍板"时,那个节点就不再是普通节点,而是一个中断点 。下一篇(番外五),咱们用工程的 HitlGraphConfig,把这个"等人拍板"的中断点真正做出来------那才是 Graph 最硬核、也最贴近生产的能力:HITL(Human-in-the-Loop)人机协同。