一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

用 Spring AI Alibaba Graph 构建客服工单智能处理流程

时间:2026-09-19 13:38:01 编辑:袖梨 来源:一聚教程网

客服工单自动化不能只追求生成速度,还要兼顾事实准确性与人工把关。面对需要先汇总历史工单和知识库、再由模型起草回复的流程,可以借助 Spring AI Alibaba Graph 将并行采集、结构化输出、审核中断和条件发送组织成一条可恢复的工作流。

Spring AI Alibaba Graph 实战:客服工单智能处理

场景

用户提交工单,我们希望系统自动起草一条回复,但发送前必须由客服确认

  1. 收集该用户的历史工单 + 命中的知识库条目(并行
  2. 交给 LLM,起草一条回复
  3. 把草稿推给客服
  4. 客服人工审核(中断
  5. 通过才真正发送(条件边
        ┌──────────────────────────────────────────────────┐
        │ 并行采集:用户历史工单 + 知识库条目                 │
        └───────────────────────┬──────────────────────────┘
                                ▼
                      LLM 起草回复  →  解析成结构化对象
                                ▼
                        推送审核(带通过/驳回链接)
                                ▼
                        ⏸ 中断,等人点击链接
                                ▼
                条件边:通过 → 发送回复 / 驳回 → 结束

领域对象与数据源

record 定义三个最小领域对象,用内存假数据替代真实数据库。换成你自己的 Mapper / JPA Repository 。

// 历史工单
public record Ticket(int ticketId, String subject, String category,
                     String status, String createdAt) {}

// 知识库条目
public record KnowledgeArticle(String id, String title, String content) {}

// LLM 产出的回复草稿
public record ReplyDraft(String category, String replyContent, String reasoning) {}
@Component
public class DemoRepository {

    public List<Ticket> findHistory(String ticketId) {
        return List.of(
                new Ticket(1001, "登录后页面空白", "技术问题", "已解决", "2025-08-02"),
                new Ticket(1002, "订单一直未发货", "物流问题", "已解决", "2025-08-19"),
                new Ticket(1003, "优惠券无法使用", "营销活动", "已关闭", "2025-09-01"));
    }

    public List<KnowledgeArticle> searchKnowledge(String ticketId) {
        return List.of(
                new KnowledgeArticle("KB-101", "登录异常排查",
                        "请先清理浏览器缓存,仍无法解决则收集控制台报错。"),
                new KnowledgeArticle("KB-207", "订单发货时效",
                        "现货商品 48 小时内发货,预售商品以详情页标注为准。"));
    }

    public void sendReply(ReplyDraft draft, String ticketId) {
        System.out.println("已向用户发送回复:" + draft);
    }
}

黑板

KeyStrategyFactory keyStrategyFactory = () -> Map.of(
        "ticketId",      new ReplaceStrategy(),
        "historyData",   new ReplaceStrategy(),
        "knowledgeData", new ReplaceStrategy(),
        "draftText",     new ReplaceStrategy(),
        "draft",         new ReplaceStrategy(),
        "nextStep",      new ReplaceStrategy(),
        "threadId",      new ReplaceStrategy());

7 个 key,全部覆盖型——因为它们都是「一次性的中间结果」。

节点

① 并行采集(两个节点,结构对称):

public class CollectHistoryNode implements NodeAction {

    private final DemoRepository repository;

    public CollectHistoryNode(DemoRepository repository) {
        this.repository = repository;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        String ticketId = state.value("ticketId", "");
        if (ticketId.isBlank()) {
            return Map.of();                     // ← 不修改黑板
        }
        return Map.of("historyData", repository.findHistory(ticketId));
    }
}
public class CollectKnowledgeNode implements NodeAction {

    private final DemoRepository repository;

    public CollectKnowledgeNode(DemoRepository repository) {
        this.repository = repository;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        String ticketId = state.value("ticketId", "");
        if (ticketId.isBlank()) {
            return Map.of();
        }
        return Map.of("knowledgeData", repository.searchKnowledge(ticketId));
    }
}

注意 historyData 里放的是 List<Ticket> 对象,不是 JSON 字符串。黑板是 Map<String, Object>,能装任意类型;框架默认用 Jackson 序列化存档,record 和集合都能正常处理。

② LLM 起草 —— 读两个数据 key,写一个结论 key:

public class DraftReplyNode implements NodeAction {

    private static final String SYSTEM_PROMPT = """
            你是一名资深客服。根据用户的历史工单和命中的知识库条目,起草一条回复。

            硬性约束:
            - 只使用输入数据中出现过的事实,不得编造政策、时效或补偿方案
            - 语气礼貌、简洁,直接给出可执行的解决步骤
            - 若知识库中没有依据,在 reasoning 里说明「需要人工介入」

            只输出一段 JSON,不要任何解释、不要 Markdown 代码块围栏:
            {"category":"技术问题","replyContent":"回复正文","reasoning":"判断依据"}
            """;

    private final ChatClient chatClient;

    public DraftReplyNode(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        List<Ticket> history = state.value("historyData", List.of());
        List<KnowledgeArticle> knowledge = state.value("knowledgeData", List.of());

        String draft = chatClient.prompt()
                .system(SYSTEM_PROMPT)
                .user(u -> u.text("""
                        用户历史工单:{history}
                        命中知识库:{knowledge}
                        """)
                        .param("history", history)
                        .param("knowledge", knowledge))
                .call()
                .content();

        return Map.of("draftText", draft);
    }
}

state.value("historyData", List.of()) 能直接推断出 List<Ticket> —— 靠 Java 泛型方法的目标类型推断,List.of() 会跟着赋值目标一起定型,不用写强制转换。

③ 解析 —— 把 LLM 的自由文本变成结构化对象。LLM 常画蛇添足加 ```json 围栏,所以要清洗:

public class ExtractNode implements NodeAction {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        String raw = state.value("draftText", "");

        // 去掉可能出现的 markdown 代码块围栏
        String cleaned = raw.replaceAll("(?s)```json|```", "").trim();

        ReplyDraft draft = objectMapper.readValue(cleaned, ReplyDraft.class);
        return Map.of("draft", draft);
    }
}

④ 推送审核 —— 带副作用的节点,失败不能阻断主流程

public class NotifyNode implements NodeAction {

    private final Notifier notifier;

    public NotifyNode(Notifier notifier) {
        this.notifier = notifier;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        ReplyDraft draft = state.value("draft", (ReplyDraft) null);
        String threadId = state.value("threadId", "");

        try {
            notifier.sendApprovalRequest(String.valueOf(draft), threadId);
        } catch (Exception e) {
            log.error("推送审核失败,不影响主流程", e);   // ← 吞掉,不 rethrow
        }
        return Map.of();
    }
}
public interface Notifier {
    void sendApprovalRequest(String summary, String threadId);
}

@Component
public class LogNotifier implements Notifier {
    @Override
    public void sendApprovalRequest(String summary, String threadId) {
        // 真实项目里换成钉钉 / 企业微信 / 工单系统站内  / 邮箱
        System.out.printf("""
                [待审核回复] %s
                  通过:http://localhost:8080/approval?approve=true&threadId=%s
                  驳回:http://localhost:8080/approval?approve=false&threadId=%s
                """, summary, threadId, threadId);
    }
}

这里有个设计要点:通知里的两个链接把 threadId 带了出去,客服点击后就能凭它找回「暂停的那张图」。

⑤ 人工审核 —— 把人的决策翻译成黑板上的路由标签:

public class ApprovalNode implements NodeAction {

    @Override
    public Map<String, Object> apply(OverAllState state) {
        Map<String, Object> feedback = state.humanFeedback().data();
        boolean approved = Boolean.TRUE.equals(feedback.get("approved"));

        String nextStep = approved ? "sendReplyNode" : StateGraph.END;

        return Map.of("nextStep", nextStep);      // ← 写黑板,交给路由函数读
    }
}

⑥ 发送回复

public class SendReplyNode implements NodeAction {

    private final DemoRepository repository;

    public SendReplyNode(DemoRepository repository) {
        this.repository = repository;
    }

    @Override
    public Map<String, Object> apply(OverAllState state) {
        ReplyDraft draft = state.value("draft", (ReplyDraft) null);
        String ticketId = state.value("ticketId", "");

        if (draft != null) {
            repository.sendReply(draft, ticketId);
        }
        return Map.of();
    }
}

路由函数

public class ApprovalEdge implements EdgeAction {

    @Override
    public String apply(OverAllState state) {
        return state.value("nextStep", StateGraph.END);   // 直接读审核节点写的标签
    }
}

组装成图

@Configuration
public class GraphConfig {

    @Bean
    public CompiledGraph ticketGraph(ChatClient.Builder chatClientBuilder,
                                     DemoRepository repository,
                                     Notifier notifier) throws GraphStateException {

        KeyStrategyFactory keyStrategyFactory = () -> Map.of(
                "ticketId",      new ReplaceStrategy(),
                "historyData",   new ReplaceStrategy(),
                "knowledgeData", new ReplaceStrategy(),
                "draftText",     new ReplaceStrategy(),
                "draft",         new ReplaceStrategy(),
                "nextStep",      new ReplaceStrategy(),
                "threadId",      new ReplaceStrategy());

        StateGraph graph = new StateGraph("ticketGraph", keyStrategyFactory);

        // 节点
        graph.addNode("collectHistoryNode",   AsyncNodeAction.node_async(new CollectHistoryNode(repository)));
        graph.addNode("collectKnowledgeNode", AsyncNodeAction.node_async(new CollectKnowledgeNode(repository)));
        graph.addNode("draftReplyNode",       AsyncNodeAction.node_async(new DraftReplyNode(chatClientBuilder.build())));
        graph.addNode("extractNode",          AsyncNodeAction.node_async(new ExtractNode()));
        graph.addNode("notifyNode",           AsyncNodeAction.node_async(new NotifyNode(notifier)));
        graph.addNode("approvalNode",         AsyncNodeAction.node_async(new ApprovalNode()));
        graph.addNode("sendReplyNode",        AsyncNodeAction.node_async(new SendReplyNode(repository)));

        // 边:START 双出边 = 并行采集,双入 draftReplyNode = 汇合
        graph.addEdge(StateGraph.START, "collectHistoryNode");
        graph.addEdge(StateGraph.START, "collectKnowledgeNode");
        graph.addEdge("collectHistoryNode",   "draftReplyNode");
        graph.addEdge("collectKnowledgeNode", "draftReplyNode");
        graph.addEdge("draftReplyNode", "extractNode");
        graph.addEdge("extractNode",    "notifyNode");
        graph.addEdge("notifyNode",     "approvalNode");
        graph.addEdge("sendReplyNode",  StateGraph.END);

        // 条件边:审核结果决定是否发送
        graph.addConditionalEdges("approvalNode",
                AsyncEdgeAction.edge_async(new ApprovalEdge()),
                Map.of("sendReplyNode", "sendReplyNode",
                       StateGraph.END,  StateGraph.END));

        // 编译:打中断点 + 挂存档
        return graph.compile(CompileConfig.builder()
                .interruptBefore("approvalNode")
                .saverConfig(SaverConfig.builder()
                        .register(SaverEnum.MEMORY.getValue(), new MemorySaver())
                        .build())
                .build());
    }
}

触发与恢复

@RestController
public class TicketController {

    private final CompiledGraph graph;

    public TicketController(CompiledGraph graph) {
        this.graph = graph;
    }

    // 启动一次工单处理,跑到审核前会暂停
    @PostMapping("/ticket/handle")
    public String handle(@RequestParam String ticketId) {
        String threadId = UUID.randomUUID().toString();
        RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();

        graph.call(Map.of("ticketId", ticketId, "threadId", threadId), config);

        return "回复草稿已生成,等待审核。threadId=" + threadId;
    }

    // 客服审核后恢复
    @GetMapping("/approval")
    public String approval(@RequestParam boolean approve, @RequestParam String threadId) {
        RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();

        StateSnapshot snapshot = graph.getState(config);
        OverAllState state = snapshot.state();

        state.withResume();
        state.withHumanFeedback(new OverAllState.HumanFeedback(Map.of("approved", approve), ""));

        graph.call(state, config);
        return approve ? "已通过,回复已发送" : "已驳回";
    }
}

一次请求的黑板演变

阶段执行节点黑板变化
启动调用方ticketIdthreadId
并行采集collectHistoryNode+historyData
并行采集collectKnowledgeNode+knowledgeData
LLM 起草draftReplyNode+draftText(原始文本)
解析extractNode+draft(结构化对象)
推送审核notifyNode无变化(纯副作用)
中断存档,graph.call 返回
恢复approvalNode+nextStep
分支ApprovalEdge读到标签 → 选路
发送 或 结束sendReplyNode无变化(调用发送接口)

踩坑实录:一条极具误导性的报错

单独讲一个坑,因为它的报错信息和真实语义几乎是反的

现场

图跑起来后收到这条:

com.alibaba.cloud.ai.graph.exception.GraphRunnerException:
cannot find edge mapping for id: 'approvalNode'
in conditional edge with sourceId: 'sendReplyNode'

第一反应:条件边的源节点是 sendReplyNode?可这个节点明明 addNode 注册过了,怎么会「找不到」?

报错模板在 com.alibaba.cloud.ai.graph.exception.RunnableErrors(v1.0.0.4 反编译可验证):

missingNodeInEdgeMapping(
    "cannot find edge mapping for id: '%s' in conditional edge with sourceId: '%s' ")

抛出点在 CompiledGraph#nextNodeId,字节码等价于:

var command  = edgeValue.value().action().apply(derefState, config).get();
var newRoute = command.gotoNode();                            // ← EdgeAction 的返回值
String result = edgeValue.value().mappings().get(newRoute);    // ← 拿它去查映射表
if (result == null) {
    throw RunnableErrors.missingNodeInEdgeMapping.exception(nodeId, newRoute);
}

关键在最后一行传参 exception(nodeId, newRoute),对照模板的两个 %s

报错里的字段字面看像是实际是
id: 'approvalNode'缺失的映射项?条件边挂载的节点名addConditionalEdges 第一个参数)
sourceId: 'sendReplyNode'条件边的源节点?EdgeAction 的返回值(即查映射表没查到的那个 key)

sourceId 这个字段名起得极具误导性——它实际装的是「映射表里缺失的那个 key」,跟「边的源节点」没有半点关系;真正的源节点反而被塞进了 id

所以这条报错的正确读法是:

「挂在 approvalNode 后面的条件边,返回了一个映射表里不存在的值 sendReplyNode

跟「节点没注册」完全无关。

真实成因
// ApprovalNode 往黑板写的是 sendReplyNode
return Map.of("nextStep", "sendReplyNode");

// 而映射表的 key 被写成了 sendNode(漏了 Reply)
Map.of("sendNode", "sendReplyNode", END, END)

mappings.get("sendReplyNode") 返回 null → 抛异常。修复只需一行:把映射表 key 对齐 Edge 的返回值。

根因与防御

病根是同一个节点名字符串散落在三个文件五个位置

位置用在哪
GraphConfigaddNode 的节点名
GraphConfig映射表的 key
GraphConfig映射表的 value
ApprovalNode审核通过时写进黑板的值
ApprovalEdge读黑板时的默认值 / 返回值

全靠手写,一处错就是运行时才引爆的炸弹。防御:用常量

public final class GraphNodes {
    public static final String APPROVAL   = "approvalNode";
    public static final String SEND_REPLY = "sendReplyNode";

    private GraphNodes() {}
}

五处引用同一个常量,拼错直接编译不过。代价几乎为零,收益是把一整类运行时错误变成编译期错误。


避坑清单

#现象解法
1黑板 key 拼错静默拿到默认值,节点逻辑走空key 抽常量类
2节点返回 Map.of 塞 nullNullPointerException用空串 / 空集合兜底
3映射表 key 与 EdgeAction 返回值不一致GraphRunnerExceptioncannot find edge mapping for id: 'A' in conditional edge with sourceId: 'B',且 A/B 语义与字面相反逐字对齐;映射表覆盖所有可能的返回值
4中断了但没配 saverConfiggetState 取不到,图无法恢复必须配存档(学习用 MemorySaver,生产用 RedisSaver
5恢复时忘记 withResume()图从头再跑一遍,重复发送恢复固定套路:getStatewithResumewithHumanFeedbackcall
6副作用节点抛异常审核通知发不出,整条流程断掉NotifyNode 那样 try-catch 吞掉
7节点名散落在三个文件手写编译期无感,运行时才出现节点名 / 映射表 key / 映射表 value / 黑板值 统一抽常量
8忘记 compile()拿不到 CompiledGraphcompile() 才做校验(边指向的节点是否存在、图是否连通等)
9恢复时用了新的 threadId找不到存档,当成全新请求threadId 必须原样传回(本例靠审核链接携带)

热门栏目