番外篇四:《一个图搞定智能客服:用 StateGraph 把"条件路由"和"状态合并"玩明白》

这是专栏的番外篇。番外三咱们扒开 ReactAgent,把 Graph 三件套(节点 / 边 / 状态)刻进了肌肉记忆,还用工程的 GraphDemoConfig 跑通了一个"数字拆分并行求和"的图。但那篇藏了个没说破的真相GraphDemoConfigbranch 节点,分支是假的------不管奇偶,两条路都跑了,它只是演示"并行分叉",不是真条件路由。

今天咱们用一个真实业务场景------智能客服助手 ------把 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 像普通成员变量赋值(覆盖),AppendStrategyConcurrentLinkedQueue.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)人机协同

相关推荐
全栈弄潮儿2 小时前
第一次让 AI 帮你写代码:用 JavaScript 完成一个待办事项清单
chatgpt·openai·ai编程
颜进强2 小时前
Claude Code - 26 效率三件套:cc-switch 切模型 · codeburn 算成本 · claude-hud 看状态
前端·后端·ai编程
东小西2 小时前
番外篇三:《ReactAgent 为什么能"想一步做一步"?扒开它的 Graph 内核》
openai·ai编程
做前端的娜娜子3 小时前
Embedding 向量模型:从语义表示到相似度计算
langchain·openai·掘金·金石计划
答案answer3 小时前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
ai编程·three.js·vibecoding
测试_AI_一辰4 小时前
AI Agent 评测最隐蔽的坑-记忆
人工智能·算法·ai·自动化·ai编程
打呵欠的猫4 小时前
我让 AI 封装了一个 ImageUpload 组件,它设计的 5 层校验链路比我想的周全
前端·ai编程
程序员黑豆4 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
努力搬砖的咸鱼4 小时前
AI Agent测试全景图:它到底改变了什么
人工智能·python·ai·集成测试·pytest·agent·ai编程