1. 为什么客服工单场景值得用图工作流重做一遍客服工单系统大概是所有中后台系统里最拧巴的一类业务方希望它越智能越好最好用户一句话进来就自动分类、自动派单、自动回复但真正落地过的人都知道工单处理本质上是一条带分支、带循环、带人工兜底的状态机用一条直线式的 LLM 调用链去硬扛最后一定会被现实打脸。我最早做客服智能化的时候走的就是最朴素的路线用户提交工单 → 拼一段 prompt → 调一次大模型 → 拿到分类和回复 → 写回数据库。Demo 阶段效果惊艳上线两周就崩了。问题出在哪举几个真实场景你就明白了用户描述里同时包含退款和物流投诉单次分类只能给一个标签派单派错了模型判断需要补充信息但系统没有回问用户这个环节直接给了个驴唇不对马嘴的回复高优先级工单比如涉及资金安全需要人工复核但直线流程里没有中断—等待人工—继续的机制有些工单需要查订单库、查知识库、查历史工单多次工具调用之间还要共享上下文。这些需求翻译成工程语言就是条件分支、循环重试、人工中断Human-in-the-loop、状态持久化、多节点共享上下文。这正好是Spring AI Alibaba Graph这类图编排框架的主场。它把工单处理从一次函数调用变成一张可执行的状态图每个节点干一件事边决定下一步往哪走状态在节点之间流转。这篇文章我会把一套完整的客服工单智能处理方案拆开讲透从图结构怎么设计、每个节点内部怎么实现、条件边怎么判断、人工中断怎么接、状态怎么持久化一直讲到线上踩过的坑。代码基于 Spring AI Alibaba Graph 的常见 API 风格来写如果你用的是相近版本思路完全可以直接抄。适合已经了解 Spring AI 基础、想把它用到真实业务里的后端同学也适合正在评估要不要上图编排的技术负责人。2. 图结构整体设计与节点职责拆解2.1 为什么是图而不是链先说清楚一个概念避免后面混淆。链Chain是线性的A 完了必须走 BB 完了必须走 C。图Graph是网状的A 完了可以根据状态走 B 或 CB 和 C 还能汇合到 DD 甚至能回到 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 ListString 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, confidenceInfoCheckNode必填字段校验intent, rawContentmissingFieldsAskUserNode回问用户中断missingFields用户补充内容KnowledgeNode知识库检索intent, rawContentknowledgeSnippetReplyNode生成回复上述所有draftReplyDispatchNode派单intent, 优先级assignedGroupHumanReviewNode人工复核中断全量状态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。分类任务要的是稳定不是创意。我实测过 temperature0.7 和 0 的对比前者同一个工单两次调用可能给出不同分类后者基本稳定。这个节点不需要发挥需要复现。还有一个容易忽略的点rawContent 要不要做预处理我的经验是轻度清洗即可。去掉首尾空白、把连续换行压成一个、超长内容截断到模型上下文限制的 80%。但不要做智能摘要因为摘要本身可能丢信息而分类恰恰依赖细节。我踩过的坑是为了省 token 把工单摘要后再分类结果我要投诉你们客服态度差被摘要成用户有投诉高风险信号丢了。3.2 信息补全与回问中断机制怎么接信息补全节点负责检查这个工单要往下走还缺哪些必填信息。比如退款工单必须有订单号物流投诉必须有运单号。缺了怎么办不能瞎编得回问用户。这就涉及图编排里一个高级特性中断Interrupt。节点执行到一半需要外部输入用户补充、人工决策此时图暂停把当前状态持久化等外部输入回来后再从断点恢复。实现上回问节点大致是这样public class AskUserNode implements NodeTicketState { 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 的常见写法StateGraphTicketState 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标记是运行中等待用户等待人工还是已完成。这里有个性能坑如果每个节点都全量序列化整个 statestate 大了之后写库会很慢。我的优化是增量存——只存变化的字段恢复时先加载基线再合并增量。不过增量存实现复杂工单场景 state 一般不大几 KB全量存也扛得住。我建议先全量存跑通真有性能问题再优化别过早复杂化。事件日志则是记录谁在什么时候改了状态用于审计和回放。工单场景对审计要求高我建议事件日志一定要有。每次状态变更追加一条记录包含节点名、变更前后、时间戳。出问题时把事件日志按时间排开整个处理过程一目了然。4.3 人工中断的完整闭环人工复核是工单场景绕不开的环节。完整闭环是这样的图执行到 humanReview 节点触发中断状态标记为等待人工持久化系统通知人工工作台把工单和当前状态推给客服客服在工作台处理给出结论比如同意退款转技术组结论写回状态图从 humanReview 节点恢复执行后续节点基于人工结论继续跑。关键实现细节恢复执行时要能精确定位到中断的那个节点并且把人工输入注入到正确的状态字段。我见过有人恢复时从入口重跑结果前面所有节点又执行一遍重复调用了模型和外部系统浪费资源还可能产生副作用。恢复的代码大致是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。状态更新intentrefund, confidence0.85。路由到 infoCheck。第二步infoCheck 节点退款需要订单号rawContent 里没有missingFields[orderId]。路由到 askUser。第三步askUser 节点触发中断回问请提供您的订单号在订单详情页可以找到。状态标记等待用户持久化。图暂停。第四步用户补充订单号是 20240512001。恢复执行状态更新rawContent追加补充内容missingFields清空。回到 infoCheck这次校验通过路由到 dispatch。第五步dispatch 节点根据 intentrefund 和商品类别派单到退款处理组。状态更新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% 之间剩下的转人工整体客服响应时间缩短了一半以上。图编排带来的最大价值不是更智能而是更可控——每个环节的决策都显式可见出问题能定位、能回放、能改。这一点是直线调用永远给不了的。