
1. 工单系统为什么需要图结构编排客服工单处理这件事表面上看是用户提问、客服回答的简单循环但真正做过企业级客服系统的人都知道一张工单从创建到关闭中间要经过意图识别、分类路由、知识检索、答案生成、人工兜底、满意度回访等一连串环节。传统做法是用一堆 if-else 或者状态机把这些环节串起来代码写到最后就是一团意大利面——加一个环节要改三处判断调一个顺序要重新测一整条链路。Spring AI Alibaba Graph 解决的正是这个问题。它把工单处理流程抽象成一张有向图每个处理环节是一个节点Node节点之间的流转由边Edge和条件边Conditional Edge控制。你可以把它理解成给 LLM 应用画了一张流程图代码就是这张图的直接映射。相比链式调用Chain图结构最大的好处是支持分支、循环、并行和状态共享——这四样东西恰好是工单场景的刚需。举个具体的例子。用户提交一张工单我上周买的手机屏幕有坏点想退货但订单页面显示已超过七天。这句话里至少包含三个意图售后咨询、退货政策查询、订单状态核实。链式调用只能一条路走到黑而图结构可以先把工单路由到售后分类节点再根据分类结果并行触发政策检索和订单查询两个节点最后在答案聚合节点汇合。这种编排能力是纯 Prompt 工程做不到的。关键词里的NodeAction是这套框架的核心接口。每个节点都要实现NodeAction它的apply方法接收一个OverAllState全局状态对象返回一个MapString, Object作为状态更新。这个设计很关键——状态在节点之间显式传递而不是靠隐式上下文调试的时候你能清楚看到每个节点往状态里写了什么。ChatClient则是 Spring AI 提供的对话客户端负责和底层大模型交互在节点里调用它来生成回答或做意图判断。提示如果你的团队之前用的是 LangChain4j 或者自己封装的 HTTP 调用迁移到 Graph 编排时不要一次性重写全部逻辑。先把最复杂的那条分支通常是意图识别→知识检索→生成用图重写跑通后再逐步替换其余环节。适合读这篇内容的人有三类一是正在做客服/工单类 LLM 应用的 Java 后端二是想从链式调用升级到图编排的 Spring AI 使用者三是需要给团队做技术选型、想搞清楚 Graph 到底比 Chain 强在哪的架构同学。下面我会从环境搭建讲到状态设计再到条件路由和人工兜底把踩过的坑一并交代清楚。2. 环境搭建与依赖版本的那些坑2.1 版本矩阵必须先对齐Spring AI Alibaba Graph 目前还在快速迭代版本兼容性是第一个拦路虎。我实测下来最稳的组合是 Spring Boot 3.3.x Spring AI 1.0.0-M 系列 Spring AI Alibaba Graph 对应版本。如果你用的是 Spring Boot 2.x直接放弃这套框架依赖 JDK 17 的新特性强上只会浪费时间。Maven 依赖大致是这样dependency groupIdcom.alibaba.cloud.ai/groupId artifactIdspring-ai-alibaba-graph-core/artifactId version1.0.0-M3.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0-M3/version /dependency这里有个坑要提前说spring-ai-alibaba-graph-core 和 spring-ai-core 的版本必须匹配否则会出现NoSuchMethodError这种运行时才暴露的问题。我建议在dependencyManagement里统一锁定版本别让 Maven 自己解析。2.2 模型配置别写死在代码里ChatClient 的构建依赖ChatModel而 ChatModel 的配置建议全部走application.ymlspring: ai: openai: api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL} chat: options: model: qwen-plus temperature: 0.3 max-tokens: 2048温度设 0.3 是有讲究的。工单场景要求回答稳定、可复现温度太高会导致同一个问题两次回答不一致客服主管看到会疯。max-tokens 设 2048 是因为工单回答通常不需要长篇大论超过这个长度大概率是模型在胡扯截断反而更安全。注意api-key 和 base-url 一定要用环境变量注入不要提交到 Git。我见过有团队把 key 硬编码在application.yml里推到公开仓库第二天就被刷爆了额度。2.3 一个容易被忽略的初始化顺序问题Graph 的构建依赖 ChatClientChatClient 依赖 ChatModelChatModel 依赖配置。如果你在PostConstruct里构建图可能会遇到 ChatModel 还没初始化完的情况。稳妥的做法是把图的构建放在一个Configuration类里用Bean的方式暴露让 Spring 自己管理依赖顺序Configuration public class GraphConfig { Bean public StateGraph ticketGraph(ChatClient chatClient) { StateGraph graph new StateGraph(); // 注册节点和边 return graph; } }这样写还有个好处图对象是单例的多个请求共享同一张图定义只有OverAllState是每次请求独立的。这一点很重要图定义本身是无状态的别把请求相关的数据塞到图对象里。3. 工单状态设计OverAllState 里到底该放什么3.1 状态字段的取舍原则OverAllState是贯穿整张图的数据载体设计得好不好直接决定后续节点的复杂度。我的原则是只放跨节点需要共享的数据节点内部的临时变量不要往状态里塞。工单场景下我通常会定义这几类字段字段名类型用途写入节点ticketIdString工单唯一标识入口节点userQueryString用户原始问题入口节点intentString识别出的意图分类意图识别节点retrievedDocsList检索到的知识片段知识检索节点draftAnswerString生成的草稿回答生成节点needHumanBoolean是否需要人工介入置信度评估节点finalAnswerString最终返回给用户的答案聚合节点这张表看着简单但每一行都是踩坑换来的。比如retrievedDocs用 List 而不是 String是因为后续生成节点需要把多个知识片段拼成 Prompt如果检索节点直接拼成字符串生成节点就没法控制拼接格式了。再比如needHuman单独用一个布尔字段而不是靠draftAnswer是否为空来判断是因为空回答和需要人工是两回事——模型可能生成了一个低质量回答这时候也需要人工。3.2 状态更新的合并策略Graph 框架里节点返回的 Map 会合并到全局状态。这里有个细节默认是覆盖不是追加。如果你在检索节点返回Map.of(retrievedDocs, docs)它会直接替换掉状态里原有的retrievedDocs。如果多个节点都要往同一个 List 里加东西你得自己先取出旧值、合并、再放回去。我一般会封装一个工具方法private MapString, Object appendDocs(OverAllState state, ListDocument newDocs) { ListDocument existing state.value(retrievedDocs, new ArrayList()); ListDocument merged new ArrayList(existing); merged.addAll(newDocs); return Map.of(retrievedDocs, merged); }别小看这几行代码工单场景里知识检索和历史工单检索是两个节点都要往文档列表里加内容不处理合并就会丢数据。3.3 状态里的敏感信息处理工单里经常包含手机号、订单号、地址这类敏感信息。我的做法是在入口节点做一次脱敏把原始 query 存到rawQuery脱敏后的存到userQuery后续节点只用userQuery。这样即使日志打印了状态也不会泄露用户隐私。String masked userQuery.replaceAll(1[3-9]\\d{9}, 1**********);脱敏规则要根据业务定但原则是能进 LLM 的信息必须是脱敏后的。别指望模型帮你保密它只会把看到的东西原样吐出来。4. 节点实现从意图识别到答案生成4.1 意图识别节点分类 Prompt 怎么写才准意图识别是整个流程的分叉点分错了后面全错。我试过三种方案纯关键词匹配、小模型分类、大模型 Prompt 分类。实测下来大模型 Prompt 分类 关键词兜底的组合最稳。Prompt 模板大概长这样String prompt 你是一个客服工单分类助手。请将用户问题归类到以下类别之一 - 售后咨询退换货、维修、保修相关 - 订单问题订单状态、物流、支付相关 - 产品咨询功能、参数、使用方法相关 - 投诉建议服务态度、质量投诉相关 - 其他无法归类的 用户问题%s 只输出类别名称不要输出任何其他内容。 .formatted(userQuery);关键在最后一句只输出类别名称。不加这句模型会给你来一段根据用户的问题我认为这属于售后咨询类别因为……解析起来全是麻烦。加了之后输出就是干净的一个词。但模型偶尔还是会不听话所以我在节点里加了一层校验String intent chatClient.prompt(prompt).call().content().trim(); SetString validIntents Set.of(售后咨询, 订单问题, 产品咨询, 投诉建议, 其他); if (!validIntents.contains(intent)) { intent keywordFallback(userQuery); // 关键词兜底 }keywordFallback就是一个简单的关键词映射表退货换货归售后物流发货归订单。这层兜底能挡住 90% 的模型抽风。4.2 知识检索节点RAG 的检索策略工单回答的质量八成取决于检索到的知识准不准。我用的是向量检索 关键词检索的混合方案。向量检索负责语义匹配关键词检索负责精确匹配比如产品型号、政策编号。检索节点的核心逻辑public MapString, Object apply(OverAllState state) { String query state.value(userQuery, String.class); ListDocument vectorDocs vectorStore.similaritySearch( SearchRequest.query(query).withTopK(5)); ListDocument keywordDocs keywordSearch(query, 3); ListDocument merged deduplicate(vectorDocs, keywordDocs); return Map.of(retrievedDocs, merged); }deduplicate不能省。向量检索和关键词检索经常返回同一篇文档不去重的话生成节点的 Prompt 里会出现重复内容浪费 token 还干扰模型判断。去重我一般按文档 ID 做。提示topK 不要设太大。我一开始设 10结果生成节点拿到的上下文太长模型反而抓不住重点。实测 5 个向量结果 3 个关键词结果是比较平衡的。4.3 答案生成节点如何让模型别瞎编生成节点的 Prompt 要明确约束模型基于检索内容回答String prompt 你是客服助手。请严格基于以下知识库内容回答用户问题。 如果知识库中没有相关信息请回答这个问题我需要为您转接人工客服。 不要编造任何知识库中没有的信息。 知识库内容 %s 用户问题%s .formatted(formatDocs(docs), userQuery);不要编造这句话必须加。我做过对比测试不加这句模型在知识库没有相关内容时有 30% 的概率会自己编一个答案出来。加了之后这个比例降到 5% 以下。formatDocs也有讲究我一般给每篇文档加上编号和来源[文档1] 来源退换货政策V2.1 内容自签收之日起7天内商品存在质量问题可申请退货…… [文档2] 来源订单FAQ 内容订单状态显示已完成表示……带来源的好处是如果用户追问你这个政策哪来的客服能快速定位。4.4 置信度评估节点什么时候该转人工这个节点是工单系统的安全阀。我的评估逻辑是三个信号加权检索文档的最高相似度分数低于阈值说明知识库没覆盖生成回答的长度过短可能是敷衍回答中是否包含转接人工字样double maxScore docs.stream().mapToDouble(Document::getScore).max().orElse(0); boolean lowConfidence maxScore 0.7 || answer.length() 20 || answer.contains(转接人工); return Map.of(needHuman, lowConfidence);阈值 0.7 是调出来的不同向量模型不一样你得用自己的数据测。别照搬。5. 条件边与人工兜底让流程真正跑起来5.1 条件边的判断函数Graph 的条件边靠一个返回 String 的函数决定走哪条路。意图识别节点之后我定义了一个路由函数public String routeByIntent(OverAllState state) { String intent state.value(intent, String.class); return switch (intent) { case 售后咨询 - afterSale; case 订单问题 - order; case 投诉建议 - complaint; default - general; }; }返回值对应的是节点名称。这里有个坑返回的字符串必须和注册的节点名完全一致大小写、空格都不能差。我因为把afterSale写成after_sale排查了半小时才发现是命名不一致。5.2 人工兜底节点的设计needHuman为 true 时流程走到人工节点。这个节点不调用 LLM只做两件事把工单标记为待人工处理把草稿回答存起来供人工参考。public MapString, Object apply(OverAllState state) { String ticketId state.value(ticketId, String.class); String draft state.value(draftAnswer, String.class); ticketService.markForHuman(ticketId, draft); return Map.of(finalAnswer, 您的问题已转接人工客服请稍候。); }草稿回答一定要存。人工客服看到模型已经生成的草稿改一改就能发效率比从零写高得多。这是我在实际项目里验证过的人工处理时长平均缩短了 40%。5.3 循环与重试检索不到怎么办有些工单第一次检索不到相关知识但换个查询词就能找到。我在图里加了一个查询改写节点当检索结果为空时让模型把用户问题改写成更规范的查询词再走一次检索。这就形成了一个循环。循环必须有退出条件否则会死循环。我的做法是加一个retryCount字段超过 2 次就强制走人工int retry state.value(retryCount, 0); if (retry 2) { return Map.of(needHuman, true); } return Map.of(retryCount, retry 1, userQuery, rewritten);注意循环边在 Graph 里是允许的但一定要有计数器。我见过有人忘了加测试环境跑了一晚上token 烧了几百块。6. 实测中的性能与稳定性问题6.1 节点串行导致的响应慢最初我把所有节点串成一条线一个工单处理要 8-10 秒。后来发现知识检索和历史工单检索两个节点没有依赖关系可以并行。Graph 框架支持并行边改完之后响应时间降到 5 秒左右。并行的写法是给同一个源节点注册多条边指向不同的目标节点框架会自动并行执行然后在汇合节点等待。6.2 大模型超时的处理LLM 调用偶尔会超时尤其是高峰期。我在 ChatClient 调用外面包了一层超时控制try { return chatClient.prompt(prompt).call().content(); } catch (Exception e) { log.warn(LLM call failed, fallback to template, e); return templateFallback(state); }templateFallback是一个基于规则的模板回答虽然不如模型生成的自然但至少不会让用户等半天然后报错。降级方案是生产系统的必备品。6.3 状态膨胀问题跑了一段时间后发现内存占用偏高排查下来是retrievedDocs里存了完整的 Document 对象包括向量数据。后来改成只存文档 ID 和内容摘要需要全文时再查一次。内存占用降了 60%。这个坑的教训是状态里别存大对象。向量是 1536 维的 float 数组一个文档就占好几 KB几百个并发请求下来内存就爆了。7. 一些实战心得意图识别的 Prompt 里类别名称最好用业务方熟悉的词。售后咨询比after_sale_inquiry好因为客服主管看日志时能直接看懂不用查映射表。知识检索的 topK 和相似度阈值一定要用真实工单数据调。我拿 500 条历史工单做测试集网格搜索出来的最优参数和拍脑袋设的差了一大截。人工兜底的触发条件宁可宽松一点。漏转人工的代价是用户收到错误回答然后投诉误转人工的代价只是客服多看一眼。两害相权取其轻。Graph 的调试建议开 DEBUG 日志每个节点的输入输出都会打出来。虽然日志量大但排查问题时能省很多时间。生产环境可以关掉或者只对特定 ticketId 开。最后说个扩展方向这套图结构很容易加节点。比如加一个情绪识别节点检测到用户情绪激动时直接转人工或者加一个多轮澄清节点信息不全时反问用户。图编排的好处就是加节点不用动已有逻辑注册一下、连条边就行。