☰
基于Spring AI Alibaba Graph的客服工单智能处理实战
2026/9/28 13:24:13 网站建设 项目流程

1. 为什么客服工单场景值得用图工作流重做一遍

客服工单系统大概是所有中后台系统里最"拧巴"的一类:业务方希望它越智能越好,最好用户一句话进来就自动分类、自动派单、自动回复;但真正落地过的人都知道,工单处理本质上是一条带分支、带循环、带人工兜底的状态机,用一条直线式的 LLM 调用链去硬扛,最后一定会被现实打脸。

我最早做客服智能化的时候,走的就是最朴素的路线:用户提交工单 → 拼一段 prompt → 调一次大模型 → 拿到分类和回复 → 写回数据库。Demo 阶段效果惊艳,上线两周就崩了。问题出在哪?举几个真实场景你就明白了:

  • 用户描述里同时包含"退款"和"物流投诉",单次分类只能给一个标签,派单派错了;
  • 模型判断需要补充信息,但系统没有"回问用户"这个环节,直接给了个驴唇不对马嘴的回复;
  • 高优先级工单(比如涉及资金安全)需要人工复核,但直线流程里没有"中断—等待人工—继续"的机制;
  • 有些工单需要查订单库、查知识库、查历史工单,多次工具调用之间还要共享上下文。

这些需求翻译成工程语言,就是条件分支、循环重试、人工中断(Human-in-the-loop)、状态持久化、多节点共享上下文。这正好是Spring AI Alibaba Graph这类图编排框架的主场。它把工单处理从"一次函数调用"变成"一张可执行的状态图",每个节点干一件事,边决定下一步往哪走,状态在节点之间流转。

这篇文章我会把一套完整的客服工单智能处理方案拆开讲透:从图结构怎么设计、每个节点内部怎么实现、条件边怎么判断、人工中断怎么接、状态怎么持久化,一直讲到线上踩过的坑。代码基于 Spring AI Alibaba Graph 的常见 API 风格来写,如果你用的是相近版本,思路完全可以直接抄。适合已经了解 Spring AI 基础、想把它用到真实业务里的后端同学,也适合正在评估"要不要上图编排"的技术负责人。

2. 图结构整体设计与节点职责拆解

2.1 为什么是"图"而不是"链"

先说清楚一个概念,避免后面混淆。链(Chain)是线性的:A 完了必须走 B,B 完了必须走 C。图(Graph)是网状的:A 完了可以根据状态走 B 或 C,B 和 C 还能汇合到 D,D 甚至能回到 A 形成循环。

工单处理的真实形态天然是图。我画一下核心流转(用文字描述,你脑补成节点和箭头):

用户提交工单 →意图识别节点→ 判断分支:

  • 如果是"咨询类" → 走知识库检索节点→回复生成节点→ 结束;
  • 如果是"投诉/退款类" → 走信息补全节点(检查必填字段是否齐全)→ 不齐就回问用户(中断,等用户补充)→ 齐了走工单派发节点;
  • 如果是"高风险类"(资金、账号安全)→ 直接进人工复核节点(中断,等人工处理)→ 人工给出结论后继续;
  • 任何节点如果模型置信度低于阈值 → 回到意图识别节点重试,最多重试 N 次。

你看,这里面有分支、有循环、有中断、有汇合。用链去实现,代码会变成一堆 if-else 嵌套,状态靠方法参数传来传去,改一个环节牵一发动全身。用图来实现,每个节点是独立的 Bean,边是显式声明的,状态是统一的对象,改流程就是改图的连边,清爽太多。

2.2 状态对象设计:整张图的"共享内存"

图编排里最关键的设计决策不是节点,而是状态(State)。所有节点读写同一个状态对象,它相当于整张图的共享内存。设计得好,节点之间零耦合;设计得烂,状态字段爆炸,谁都能改,最后没人知道某个字段是谁写的。

我的经验是:状态字段按"生命周期"分层,而不是按"业务模块"堆。下面是我在工单场景里用的状态结构(用 Java 类示意,实际用框架提供的状态 Schema 或 POJO 都行):

public class TicketState { // 输入层:工单进来就固定,全程只读 private String ticketId; private String userId; private String rawContent; // 用户原始描述 private long createdAt; // 推理层:节点逐步填充,会被覆盖更新 private String intent; // 意图分类结果 private double intentConfidence; // 置信度 private List<String> missingFields; // 缺失的必填信息 private String knowledgeSnippet; // 知识库检索到的片段 private String draftReply; // 草稿回复 // 控制层:驱动流程走向 private int retryCount; // 意图识别重试次数 private String route; // 当前路由决策 private boolean needHuman; // 是否需要人工 private String humanDecision; // 人工结论 // 输出层:最终结果 private String finalReply; private String assignedGroup; private String status; }

这里有个实操心得:控制层字段一定要和业务字段分开。我见过有人把retryCount和intent混在一起,结果做状态快照回放的时候,分不清哪些是业务数据、哪些是流程控制数据,调试极其痛苦。分层之后,序列化、持久化、回放都清晰。

注意:状态对象不要设计成"万能 Map"。用强类型 POJO,字段有明确含义和类型,编译期就能挡掉一堆低级错误。Map 看着灵活,线上出问题时你连字段名拼错了都发现不了。

2.3 节点粒度:一个节点只干一件事

节点粒度是新手最容易踩的坑。太粗,一个节点里塞了分类+检索+生成,等于把链又塞回图里,白折腾;太细,每个字段判断都拆一个节点,图变成蜘蛛网,维护成本爆炸。

我的划分原则是:一个节点对应一个"可独立测试的语义动作"。判断标准很简单——这个节点的输入输出能不能用一句话说清楚?能,就合格。

按这个原则,工单场景我拆出这几个核心节点:

节点名职责输入输出
IntentNode意图分类rawContentintent, confidence
InfoCheckNode必填字段校验intent, rawContentmissingFields
AskUserNode回问用户(中断)missingFields用户补充内容
KnowledgeNode知识库检索intent, rawContentknowledgeSnippet
ReplyNode生成回复上述所有draftReply
DispatchNode派单intent, 优先级assignedGroup
HumanReviewNode人工复核(中断)全量状态humanDecision

每个节点都是独立的 Spring Bean,实现框架的节点接口,apply(state)进去、返回更新后的 state。这样单测的时候,我直接构造一个 state 丢进去,断言输出字段,不需要启动整张图。

2.4 条件边:流程的"交通警察"

节点之间的连线分两种:普通边(无条件,A 完了必走 B)和条件边(根据状态决定走哪条)。条件边是图编排的灵魂,工单场景里几乎所有分支都靠它。

条件边的实现通常是一个函数:输入当前 state,返回下一个节点的名字。比如意图识别之后的分支:

public String routeAfterIntent(TicketState state) { if (state.getIntentConfidence() < 0.6) { return state.getRetryCount() >= 3 ? "humanReview" : "intent"; } switch (state.getIntent()) { case "consult": return "knowledge"; case "complaint": case "refund": return "infoCheck"; case "high_risk": return "humanReview"; default: return "humanReview"; } }

这段逻辑看着简单,但有几个关键设计点值得说:

第一,低置信度优先于业务分类。置信度不够,说明模型自己都没把握,这时候按业务标签硬走分支,错误会被放大。我的做法是低于阈值先重试,重试到上限还不行就转人工,绝不硬猜。

第二,重试上限必须硬编码兜底。我见过有人忘了设上限,模型因为 prompt 问题一直返回低置信度,图就在 intent 节点里无限循环,直接把线程池打满。retryCount >= 3这个判断是保命的。

第三,default 分支永远指向人工。所有没匹配上的情况,兜底给人工,而不是随便给个默认分类。工单场景里,错误派单的代价远高于多一次人工介入。

3. 核心节点实现与关键参数调优

3.1 意图识别节点:分类不是越细越好

意图识别是整个流程的入口,它的准确率直接决定后面所有环节的质量。这里最大的误区是分类体系设计得过于精细。我一开始设计了 20 多个意图标签,结果模型准确率惨不忍睹,因为很多标签之间语义边界模糊,模型自己都分不清。

后来我砍到 6 个大类:咨询、投诉、退款、物流、账号、高风险。准确率立刻上了一个台阶。分类体系的设计原则是:类间距离要大,类内可以粗。细分需求放到下游节点去处理,比如"退款"下面再分"仅退款""退货退款",那是派单节点的事,不是意图识别的事。

节点实现上,我用的是结构化输出(让模型返回 JSON),而不是让它自由发挥。prompt 大致长这样:

你是客服工单分类助手。请将用户工单归入以下类别之一: consult(咨询)、complaint(投诉)、refund(退款)、 logistics(物流)、account(账号)、high_risk(高风险)。 同时给出 0 到 1 之间的置信度。 用户工单内容: {rawContent} 只返回 JSON,格式: {"intent": "...", "confidence": 0.0}

参数调优方面,意图识别节点我建议temperature设成 0 或接近 0。分类任务要的是稳定,不是创意。我实测过 temperature=0.7 和 0 的对比,前者同一个工单两次调用可能给出不同分类,后者基本稳定。这个节点不需要"发挥",需要"复现"。

还有一个容易忽略的点:rawContent 要不要做预处理?我的经验是轻度清洗即可。去掉首尾空白、把连续换行压成一个、超长内容截断到模型上下文限制的 80%。但不要做"智能摘要",因为摘要本身可能丢信息,而分类恰恰依赖细节。我踩过的坑是:为了省 token 把工单摘要后再分类,结果"我要投诉你们客服态度差"被摘要成"用户有投诉",高风险信号丢了。

3.2 信息补全与回问:中断机制怎么接

信息补全节点负责检查"这个工单要往下走,还缺哪些必填信息"。比如退款工单必须有订单号,物流投诉必须有运单号。缺了怎么办?不能瞎编,得回问用户。

这就涉及图编排里一个高级特性:中断(Interrupt)。节点执行到一半,需要外部输入(用户补充、人工决策),此时图暂停,把当前状态持久化,等外部输入回来后再从断点恢复。

实现上,回问节点大致是这样:

public class AskUserNode implements Node<TicketState> { @Override public TicketState apply(TicketState state) { String question = buildQuestion(state.getMissingFields()); // 触发中断,等待用户输入 interrupt(question); // 恢复后,用户输入会写回 state return state; } }

这里有几个实操要点,都是血泪教训:

第一,中断前必须持久化状态。图暂停了,进程可能重启,状态不落库就丢了。我用的是把 state 序列化后存数据库,恢复时反序列化。序列化要注意:状态里别放不可序列化的对象(比如数据库连接、文件句柄)。

第二,回问的问题要具体。别问"请补充信息",要问"请提供您的订单号,通常是 16 位数字,在订单详情页可以看到"。问题越具体,用户补充的有效率越高。我做过 A/B 测试,具体问题相比笼统问题,用户一次补充到位的比例从 40% 提到了 75%。

第三,回问次数要设上限。用户可能就是不配合,或者补充的还是不全。设个上限(比如 2 次),超了直接转人工,别跟用户死磕。

提示:中断恢复的幂等性要特别注意。用户可能重复提交补充信息,恢复逻辑要能识别"这个中断已经恢复过了",避免同一份补充被处理两次,导致状态错乱。

3.3 知识库检索节点:RAG 在工单里的正确姿势

咨询类工单需要查知识库,这是典型的 RAG(检索增强生成)场景。但工单场景的 RAG 和通用问答的 RAG 有个关键区别:工单往往需要"精确匹配"而非"语义泛化"。

举个例子,用户问"我的会员什么时候到期",通用 RAG 可能检索出一堆会员权益介绍,但用户真正要的是"查我的到期时间"这个动作。所以工单场景的检索,我做了两件事:

一是混合检索。向量检索负责语义相似,关键词检索(BM25 之类)负责精确匹配。两者结果融合排序。纯向量检索在工单场景容易"跑偏",因为它太擅长找"意思相近"的内容,而工单经常需要"字面精确"的内容。

二是检索结果带元数据。每个知识片段带上来源、更新时间、适用场景。生成回复时,如果检索到的内容更新时间超过半年,我会在 prompt 里提示模型"该信息可能过时,建议引导用户联系人工确认"。这个细节能显著降低"用过期政策回复用户"的投诉。

检索节点的参数里,topK 我一般设 3 到 5。设太大,噪声多,模型容易被无关内容带偏;设太小,可能漏掉关键信息。这个值需要根据你的知识库规模实测,没有万能值。相似度阈值我设 0.7 左右,低于这个值的检索结果直接丢弃,宁可让模型说"没找到相关信息",也不要让它基于弱相关内容硬编。

3.4 回复生成节点:把"人设"和"边界"写进 prompt

回复生成是用户直接看到的部分,质量要求最高。我的 prompt 结构分四块:角色设定、上下文注入、约束条件、输出格式。

角色设定决定语气。工单回复要专业、克制、有同理心,但不能过度热情显得假。我一般写"你是 XX 公司的客服专员,语气专业友好,简洁明了,不承诺无法兑现的事情"。

上下文注入就是把意图、检索片段、用户历史都塞进去。这里有个技巧:把最关键的约束放在 prompt 的最后,因为模型对末尾内容的注意力更高。比如"如果知识库没有相关信息,直接回复需要转人工,不要编造"这句话,我一定放在最后。

约束条件里必须明确几条红线:不编造政策、不承诺具体时间(除非知识库明确写了)、不透露内部流程。这些是客服场景的合规底线,写进 prompt 只是第一层,后面还要有输出校验。

输出格式我要求模型返回 JSON,包含reply和needHuman两个字段。needHuman是模型自己判断"这个问题我搞不定",给个信号。这个自评机制很有用,能捞回一部分本该转人工但被硬答的工单。

参数上,回复生成节点的 temperature 可以稍微高一点(0.3 到 0.5),让语言自然些,但别太高,否则容易"发挥"。maxTokens 要留够,工单回复一般 200 到 500 字,设太小会被截断。

4. 完整实操流程与状态持久化落地

4.1 图的组装:把节点和边拼起来

前面讲了各个节点,现在把它们组装成一张完整的图。组装代码大致长这样(API 风格按 Spring AI Alibaba Graph 的常见写法):

StateGraph<TicketState> graph = new StateGraph<>(TicketState.class) .addNode("intent", new IntentNode()) .addNode("infoCheck", new InfoCheckNode()) .addNode("askUser", new AskUserNode()) .addNode("knowledge", new KnowledgeNode()) .addNode("reply", new ReplyNode()) .addNode("dispatch", new DispatchNode()) .addNode("humanReview", new HumanReviewNode()) .addEdge("intent", routeAfterIntent) // 条件边 .addEdge("infoCheck", routeAfterInfoCheck) // 条件边 .addEdge("askUser", "infoCheck") // 补充后回到校验 .addEdge("knowledge", "reply") .addEdge("reply", "dispatch") .addEdge("dispatch", END) .addEdge("humanReview", END) .setEntryPoint("intent");

组装时有几个顺序和结构上的讲究:

第一,入口节点要"轻"。入口节点做最少的事,快速分流。我把 intent 作为入口,因为它决定了后面所有走向。如果入口节点很重(比如要查一堆外部系统),整个图的启动延迟会很高。

第二,循环边要能收敛。askUser → infoCheck这条边是循环,用户补充后重新校验。前面说的回问次数上限,就是保证这个循环能退出。任何循环边都要问自己:什么条件下它会停?

第三,汇合点要明确。reply 和 humanReview 都指向 END,但它们的语义不同。我在状态里用status字段区分最终状态是"自动回复"还是"人工处理",方便后续统计。

4.2 状态持久化:让图能"断点续跑"

图编排在生产环境必须解决持久化,否则进程一重启,所有进行中的工单全丢。持久化分两个层面:检查点(Checkpoint)和事件日志。

检查点是每个节点执行完后,把当前 state 存一份快照。这样恢复时,从最后一个检查点继续,不用从头跑。存储我用的是关系型数据库,一张表搞定:

CREATE TABLE graph_checkpoint ( ticket_id VARCHAR(64) PRIMARY KEY, node_name VARCHAR(64), state_json TEXT, updated_at TIMESTAMP, status VARCHAR(32) );

state_json存序列化后的状态,node_name记录当前停在哪个节点,status标记是"运行中""等待用户""等待人工"还是"已完成"。

这里有个性能坑:如果每个节点都全量序列化整个 state,state 大了之后写库会很慢。我的优化是增量存——只存变化的字段,恢复时先加载基线再合并增量。不过增量存实现复杂,工单场景 state 一般不大(几 KB),全量存也扛得住。我建议先全量存跑通,真有性能问题再优化,别过早复杂化。

事件日志则是记录"谁在什么时候改了状态",用于审计和回放。工单场景对审计要求高,我建议事件日志一定要有。每次状态变更追加一条记录,包含节点名、变更前后、时间戳。出问题时,把事件日志按时间排开,整个处理过程一目了然。

4.3 人工中断的完整闭环

人工复核是工单场景绕不开的环节。完整闭环是这样的:

  1. 图执行到 humanReview 节点,触发中断,状态标记为"等待人工",持久化;
  2. 系统通知人工工作台,把工单和当前状态推给客服;
  3. 客服在工作台处理,给出结论(比如"同意退款""转技术组");
  4. 结论写回状态,图从 humanReview 节点恢复执行;
  5. 后续节点基于人工结论继续跑。

关键实现细节:恢复执行时,要能精确定位到中断的那个节点,并且把人工输入注入到正确的状态字段。我见过有人恢复时从入口重跑,结果前面所有节点又执行一遍,重复调用了模型和外部系统,浪费资源还可能产生副作用。

恢复的代码大致是:

public void resume(String ticketId, String humanDecision) { TicketState state = checkpointRepo.load(ticketId); state.setHumanDecision(humanDecision); state.setNeedHuman(false); // 从 humanReview 节点之后继续 graph.resumeFrom("humanReview", state); }

超时处理也要考虑。人工可能一直不处理,工单卡在那。我设了个超时(比如 24 小时),超时后自动升级到上级或触发提醒。这个逻辑放在图外面,用定时任务扫"等待人工"超时的工单。

4.4 一次完整工单的流转实录

光讲结构太抽象,我拿一个真实工单走一遍,你能看到状态是怎么一步步变化的。

用户提交:"我上周买的那个耳机,收到就是坏的,左耳没声音,我要退款,订单号我忘了。"

第一步,intent 节点:模型分类为 refund,置信度 0.85。状态更新:intent=refund, confidence=0.85。路由到 infoCheck。

第二步,infoCheck 节点:退款需要订单号,rawContent 里没有,missingFields=[orderId]。路由到 askUser。

第三步,askUser 节点:触发中断,回问"请提供您的订单号,在订单详情页可以找到"。状态标记"等待用户",持久化。图暂停。

第四步,用户补充:"订单号是 20240512001"。恢复执行,状态更新rawContent追加补充内容,missingFields清空。回到 infoCheck,这次校验通过,路由到 dispatch。

第五步,dispatch 节点:根据 intent=refund 和商品类别,派单到"退款处理组"。状态更新assignedGroup=退款处理组。

第六步,reply 节点:生成回复"您好,已收到您的退款申请,订单 20240512001 我们已受理,将在 1-3 个工作日处理,请留意短信通知。"状态更新finalReply。

第七步,结束:状态标记"已完成",写回工单系统。

整个过程,模型被调用了两次(分类、生成回复),中断了一次,状态持久化了三次。每个环节都可追溯、可回放。这就是图编排相比直线调用的价值。

5. 常见问题排查与避坑经验实录

5.1 模型输出不稳定导致路由错乱

现象:同一个工单,有时分类成 refund,有时分类成 complaint,导致派单组别飘忽不定。

排查思路:先看 temperature,如果大于 0,先降到 0 试试。如果还飘,看 prompt 里分类定义是不是有重叠。我遇到过"退款"和"投诉"定义里都写了"用户不满",模型自然分不清。

解决:分类定义要互斥。退款强调"要求退回款项",投诉强调"对服务或商品不满但未明确要求退款"。边界写清楚,准确率立竿见影。另外,可以在 prompt 里加几个 few-shot 例子,尤其是容易混淆的边界案例。

5.2 中断恢复后状态错乱

现象:用户补充信息后,图恢复执行,但发现之前的一些字段被重置了。

排查思路:检查恢复时加载的 state 是不是最新的检查点。常见错误是加载了旧检查点,或者恢复时新建了一个空 state 只填了部分字段。

解决:恢复必须从"最后一次持久化的完整 state"加载,然后只覆盖人工/用户输入的那部分字段。我建议在恢复逻辑里加断言:关键字段(ticketId、intent)不能为空,为空直接报错,别让它带着残缺状态往下跑。

5.3 循环边不收敛打满线程

现象:服务 CPU 飙高,日志里 intent 节点疯狂重复执行。

排查思路:看 retryCount 有没有正确递增,以及条件边里有没有判断上限。

解决:所有循环边必须有"计数器 + 上限"双保险。计数器在节点里递增,上限在条件边里判断。我还会加一个全局的"单工单最大节点执行次数"限制,超过就强制转人工,作为最后一道防线。

5.4 知识库检索召回率低

现象:咨询类工单经常回复"未找到相关信息",但知识库里明明有。

排查思路:先看检索的 topK 和相似度阈值。阈值太高会漏,太低会引入噪声。再看知识库的切分粒度,切得太碎,单块信息不完整,检索到了也没用。

解决:阈值从 0.7 往下调到 0.6 试试,topK 从 3 加到 5。切分粒度我建议按"语义段落"切,而不是按固定字数切。一个完整的政策说明切成一块,别拦腰截断。另外,给知识块加上标题和摘要,检索时把标题也纳入匹配,召回率会明显提升。

5.5 常见问题速查表

问题现象可能原因排查动作解决方向
分类结果飘忽temperature 高 / 定义重叠降 temperature,查 prompt定义互斥,加 few-shot
恢复后状态错乱加载旧检查点 / 空 state查检查点加载逻辑从最新完整 state 恢复
循环不收敛缺计数器或上限查 retryCount 递增计数器+上限双保险
检索召回低阈值高 / 切分碎调阈值,看切分降阈值,语义切分
回复编造政策prompt 约束弱查约束位置约束放末尾,加输出校验
人工中断丢失未持久化查中断前是否落库中断前强制持久化

5.6 几条压箱底的经验

第一,先跑通直线,再上图。别一上来就设计复杂图结构。先用最简单的链把业务跑通,明确每个环节的输入输出,再把它重构成图。我见过团队直接上图,结果节点职责都没想清楚,图画得一团糟,还不如链。

第二,给每个节点加可观测性。节点执行时间、输入输出、模型 token 消耗,全部打点。工单量大之后,你会发现某些节点是性能瓶颈,没有数据你根本不知道优化谁。我用的是在每个节点包一层切面,统一记录。

第三,模型调用要能降级。大模型不是永远可用的。我做了个降级策略:模型超时或报错时,意图识别降级为关键词匹配,回复生成降级为模板回复,同时标记"需人工复核"。宁可给个不那么智能但正确的回复,也不要让工单卡死。

第四,状态字段宁少勿多。每加一个字段,就多一份序列化开销、多一处可能出错的地方。我定期 review 状态对象,把没人读的字段删掉。状态对象应该像数据库表一样,定期做"字段清理"。

第五,人工兜底不是失败,是设计。别追求 100% 自动化。工单场景里,把 70% 的简单工单自动化掉,剩下 30% 转人工,整体效率提升已经非常可观。强行追求全自动,最后一定是用户投诉和返工。人工环节设计得好,反而是系统可靠性的保障。

这套方案我在实际项目里跑了大半年,日均处理工单量从几千到几万,自动处理率稳定在 65% 到 75% 之间,剩下的转人工,整体客服响应时间缩短了一半以上。图编排带来的最大价值不是"更智能",而是"更可控"——每个环节的决策都显式可见,出问题能定位、能回放、能改。这一点,是直线调用永远给不了的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询