客服工单自动化不能只追求生成速度,还要兼顾事实准确性与人工把关。面对需要先汇总历史工单和知识库、再由模型起草回复的流程,可以借助 Spring AI Alibaba Graph 将并行采集、结构化输出、审核中断和条件发送组织成一条可恢复的工作流。
用户提交工单,我们希望系统自动起草一条回复,但发送前必须由客服确认:
┌──────────────────────────────────────────────────┐
│ 并行采集:用户历史工单 + 知识库条目 │
└───────────────────────┬──────────────────────────┘
▼
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 ? "已通过,回复已发送" : "已驳回";
}
}
| 阶段 | 执行节点 | 黑板变化 |
|---|---|---|
| 启动 | 调用方 | ticketId、threadId |
| 并行采集 | 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 的返回值。
病根是同一个节点名字符串散落在三个文件五个位置:
| 位置 | 用在哪 |
|---|---|
GraphConfig | addNode 的节点名 |
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 塞 null | NullPointerException | 用空串 / 空集合兜底 |
| 3 | 映射表 key 与 EdgeAction 返回值不一致 | GraphRunnerException:cannot find edge mapping for id: 'A' in conditional edge with sourceId: 'B',且 A/B 语义与字面相反 | 逐字对齐;映射表覆盖所有可能的返回值 |
| 4 | 中断了但没配 saverConfig | getState 取不到,图无法恢复 | 必须配存档(学习用 MemorySaver,生产用 RedisSaver) |
| 5 | 恢复时忘记 withResume() | 图从头再跑一遍,重复发送 | 恢复固定套路:getState → withResume → withHumanFeedback → call |
| 6 | 副作用节点抛异常 | 审核通知发不出,整条流程断掉 | 像 NotifyNode 那样 try-catch 吞掉 |
| 7 | 节点名散落在三个文件手写 | 编译期无感,运行时才出现 | 节点名 / 映射表 key / 映射表 value / 黑板值 统一抽常量 |
| 8 | 忘记 compile() | 拿不到 CompiledGraph | compile() 才做校验(边指向的节点是否存在、图是否连通等) |
| 9 | 恢复时用了新的 threadId | 找不到存档,当成全新请求 | threadId 必须原样传回(本例靠审核链接携带) |
从 ZCode 迁移到 DeepSeek Harness:GitHub Actions 自建 Windows 安装包实战
部署大模型先别急着挑GPU:运维责任才是选型起点
AI逐次提交查漏洞:升级关键在于把证明链嵌入评审
.NET Core 分布式任务调度ScheduleMaster详解
中小企业 GEO 落地:用五大平台体检 AI 推荐表现
关于WPF WriteableBitmap类直接操作像素点的问题