基于Slickflow.NET与大模型的智能客服多轮问答系统实战
我大概有半年时间一直在折腾一个偏工程向的落地项目用 Slickflow.NET 做流程编排把 AI 大模型接进智能客服系统实现真正可上生产的 多轮问答系统。先说结论这套方案跑通之后稳定性和可控性比“直接怼 Prompt”高出好几个量级而且每次会话卡在哪、为什么回这句话、业务数据有没有收齐全部都查得到。这篇就把整个思路、设计、代码和踩坑实录完整写出来想给有同样需求的 .NET 团队一个可复用的参考。我为什么会用工作流引擎去管大模型的对话因为大模型本身是“无状态”的。你给它一段上下文它给你一段回复聊完就结束了。可智能客服不一样用户说“我要退上周买的鞋”这句话里面有意图售后、有实体商品鞋时间上周但没有订单号你得接着问问了还要能记住用户说“订单号是 12345”你再拿着去查单、生成回复。整个链路里“当前收到什么、还缺什么、下一步该干什么”就是状态。状态不交给可控的中间件去管而是全塞在模型上下文里对话一长一定出乱子。Slickflow.NET 是 .NET 生态里比较成熟的开源工作流引擎它天然自带流程定义、节点流转、运行实例、驳回跳转这些能力。把会话状态挂到流程实例上把每一步对话动作定义成节点大模型只负责两件事理解用户意图、生成自然语言回复。其余的状态推进、字段收集、业务动作全由工作流来控制。这套架构适合有 .NET 技术背景、正在做智能客服或类对话系统、并且不想只停留在 demo 层面的团队参考。1. 为什么智能客服要用“工作流引擎 大模型”1.1 纯 Prompt 方案解决不了的状态问题哪个做客服系统的开发没试过这种玩法把所有历史消息一股脑塞进 Prompt调一次大模型接口返回一段回答完事。简单真的简单三五天就能出个聊天页面。可一旦把它当真去跑业务问题马上就来了。第一个是上下文漂移跟用户聊了十二轮模型开始“忘记”用户第一轮就说过自己是会员又把刚确认过的收货地址推翻重来或者问了三遍“您要退的是哪件商品”。这不是模型笨是上下文窗口里塞了太多内容早期关键信息被淹没了。第二个是流程不可控用户本来在查订单突然问一句“你们发什么快递”模型很可能就顺着聊过去了查单流程被晾在一边聊了五六轮才想起来单还没查。第三个是状态不可观测系统回复错了你想查一下当时走到哪一步了翻日志发现只有一堆聊天记录根本看不到“订单号已收集”“业务数据已入库”这种结构化信息。核心症结就是那句老话大模型没有状态而客服对话本质上是一个状态机。让一个没有状态的东西去管需要状态的事情短期能靠 Prompt 技巧糊弄长期一定崩。1.2 工作流引擎恰好补上“状态”这块短板工作流引擎最擅长什么就是管状态。它里面天然就有“流程实例”“当前节点”“流转条件”“历史记录”这套模型。平时我们拿它做审批流、工单流本质上就是在维护一个状态机每一步走到哪、满足什么条件能走下一步被很严格地定义和执行着。把客服对话套进这个模型你会发现它意外地契合。用户每一次发言就是一次“事件”这个事件触发流程节点判断当前节点是“收集订单号”用户发来“订单号是 12345”满足条件节点往前推当前节点是“售后处理”用户还在问物流那就先走“物流查询”分支转完再回到售后主流程。整个过程中会话停在哪、收集了什么参数、调用过什么业务接口全部落在工作流的运行实例上可查询、可回溯、可重跑。我甚至觉得客服对话比审批流更适合用工作流。审批流的节点动作是“人去点按钮”客服对话的节点动作是“大模型生成一句话 可能调一个接口”两者都是同一套状态推进机制。Slickflow.NET 刚好就是 .NET 体系里能现成拿来干这件事的轮子省掉自己写状态机的大量工作。我整理过一个对比能直观看出差别对比维度纯 Prompt 大模型工作流编排 大模型状态管理靠上下文里“带一句”容易漂移落库在流程实例上稳定可靠流程控制模型自由发挥容易跑偏节点流转由条件和规则决定排障能力只有聊天记录难回溯流程日志 节点历史可回放业务动作注入代码里硬编码判断节点独立处理增删改清晰扩展性加新场景要改 Prompt加新流程节点即可开发成本前期快后期维护噩梦前期建模成本后期迭代很爽1.3 整体架构一条能落地的对话链路整个系统我按五层来拆。最外层是 API 网关或一个简单的 WebAPI 入口负责接收用户消息、返回回复第二层是会话服务层核心职责是把每个用户的 SessionId 对应到一个 Slickflow 流程实例第三层是意图识别层接收用户文本输出结构化结果意图 关键信息第四层是流程引擎层也就是 Slickflow.NET负责节点流转、节点动作执行第五层是大模型接入层负责生成自然语言回复和处理语义理解。一次完整的对话链路是这样的用户发来“我要查订单” - 会话服务拿到 SessionId找到这个会话的流程实例和当前节点 - 调用大模型做意图识别得到 intentquery_order - 流程引擎判断当前节点处于“意图分诊”满足条件后流转到“收集订单号”节点 - 该节点判断是否已拿到订单号没拿到就返回追问 - 会话服务把“当前节点 已收集信息 历史消息”组装成 Prompt让大模型生成一句追问话术返回给用户。用户再说“订单号是 8888”流程节点收到订单号字段补齐流转到“查询订单”节点调用订单接口拿到结果后再让大模型组织成自然语言回复。关键在于大模型不是流程的总控而是“意图识别器 话术生成器”。它说出来的话决定了对话质量但对话推进的方向始终由工作流把握。这个架构里的每一层都能单独替换比如意图识别可以用大模型也可以先用规则匹配兜底回复生成可以用云端大模型也可以用本地 Ollama 部署的模型。灵活还抗风险。2. 系统设计状态、意图与节点怎么编排2.1 会话 Session 与流程实例的绑定关系这是整套系统的地基我得先讲透。每个用户会话进来必须有且只有一个对应的流程实例。我设计了一个ConversationContext对象作为会话和流程之间的“翻译官”public class ConversationContext { public string SessionId { get; set; } public string ProcessInstanceId { get; set; } public string CurrentActivityId { get; set; } public string CurrentActivityName { get; set; } public Dictionarystring, object BusinessData { get; set; } new(); public DateTime StartTime { get; set; } public DateTime LastActiveTime { get; set; } }SessionId 是用户侧带过来的标识ProcessInstanceId 是 Slickflow 里的流程运行实例 IDCurrentActivityId 是当前处于哪个节点BusinessData 则是这个会话过程中收集到的全部业务字段比如订单号、商品名、用户手机号等。第一次对话时会话服务做两件事创建一个新的流程实例并把它启动到开始节点然后把SessionContext落库要么放 Redis要么放数据库表看团队的基建。之后每一轮对话来的时候先根据 SessionId 把这个上下文捞出来拿到 ProcessInstanceId 和 CurrentActivityId再继续推进流程。这里有个很重要的取舍为什么不直接把这些状态都塞在内存里、用静态字典保存因为客服系统天生要水平扩容一个请求可能落在 A 节点下一个请求落在 B 节点内存态共享不了。而且一旦服务重启所有在聊的会话全部丢失。老老实实把状态放到 Redis 或数据库里用 SessionId 做 Key既保命又方便以后做会话超时清理。流程实例本身自然是入库的Slickflow 默认就有流程实例表、节点实例表、运行日志表。会话服务这里的落库主要是把“SessionId 与 ProcessInstanceId 的映射”和“当前节点位置”缓存起来减少每次走到流程数据库里的查询开销。2.2 意图识别结果如何映射到流程节点意图识别是“用户一句话到底想干什么”的判断它能直接决定流程往哪个节点走。我在系统里维护了一套有限的意图集合不多但要覆盖业务场景用户表达示例识别意图对应流程节点我要查订单 / 订单到哪了query_order订单查询流程 - 收集订单号我要退货 / 怎么退换after_sale售后处理流程 - 收集售后信息我要找人工 / 转客服human_service转人工节点你们什么时候发货 / 发什么快递logistics_query物流咨询流程 - 收集订单号或直接回答常见问题其他闲聊/寒暄small_talk闲聊回复节点意图识别我用的是两步方案。第一路由一个本地小模型或者一套关键词规则快速判断命中就返回不命中再调用大模型做细粒度分类。这样做的好处是常见问题响应快、成本低也能在大模型服务抖动的时候兜住核心场景。大模型分类的做法也不复杂把一个意图列表和几条示例放进去做 few-shot{ instruction: 你是客服意图分类器从以下意图中选择最匹配的一个只输出 intent 和关键信息字段。, intents: [query_order, after_sale, logistics_query, human_service, small_talk], examples: [ {input: 我想退掉昨天买的裤子, output: {intent: after_sale, slots: {product: 裤子, time: 昨天}}}, {input: 订单快递到哪了, output: {intent: logistics_query, slots: {}}} ], user_input: 你们那个 8888 订单现在什么状态 }识别出的结果要保留两个东西一个是 intent 类型用于节点分流一个是 slots 字段比如订单号、商品名称、期望动作等这些字段会被塞进 BusinessData 作为流程节点的输入。有一点必须注意意图识别结果不可直接信任尤其是模型给的结果。所以流程设计里意图只是一个“路由参考”真正能否跳转还是要看当前节点的流转条件。比如系统正在“售后处理”流程中用户突然说一句“好的谢谢”意图识别可能返回 small_talk但节点条件发现业务数据还没收集完就不会跳走而是继续追问缺少的字段。这样即使意图识别偶尔不准整个对话也不会彻底跑偏。2.3 多轮问答的节点设计与流转条件节点设计是整个系统的灵魂。我第一版是按“查订单”这一条链路设计的节点拆成五类后来所有业务场景都沿用这个套路开始节点生成欢迎语并把流程推进到“意图分诊”分诊节点根据意图识别结果分流到不同业务子流程信息收集节点检查 BusinessData 缺哪些字段缺哪个就问哪个直到收齐业务处理节点调用后端接口把结果写入 BusinessData回复生成节点把业务结果 会话上下文交给大模型生成最终话术结束 / 转人工节点明确结束会话或转人工节点之间的流转条件我在 Slickflow 的流程定义里用类似这样的 XML 来表达结构以你使用的版本为准思路一致Process NameCustomerService Version1.0 StartNode GUIDstart Transitions Transition Torouter / /Transitions /StartNode TaskNode GUIDrouter Name意图分诊 Transitions Transition Tocollect_order Conditionintent query_order / Transition Tocollect_after_sale Conditionintent after_sale / Transition Tohuman_service Conditionintent human_service / Transition Tosmall_talk_reply Conditionintent small_talk / /Transitions /TaskNode TaskNode GUIDcollect_order Name收集订单号 Transitions Transition Toquery_order_detail ConditionbusinessData.orderNo ! null / Transition Tocollect_order ConditionbusinessData.orderNo null / /Transitions /TaskNode TaskNode GUIDquery_order_detail Name查询订单详情 Transitions Transition Toreply_generate / /Transitions /TaskNode TaskNode GUIDreply_generate Name大模型回复生成 / EndNode GUIDend / /Process流转条件里有个细节值得提醒收集订单号节点条件判断是“orderNo 是否存在”但如果用户一直不给订单号怎么办不能死循环让用户一直输。我在节点里加了一个字段重试计数businessData.collectRetryCount超过两次就跳转到转人工节点让真人客服接手。这个兜底逻辑一定要有因为现实中的用户不是测试脚本真的会答非所问。节点设计还有一个原则每个节点尽量只干一件事。信息收集节点只负责收集字段业务处理节点只负责调接口回复生成节点只负责组织语言。别把“查订单 退款 道歉”全塞到一个节点里否则流程编排的意义就没了一半反而退回硬编码尴尬局面。3. 核心代码实现与部署实操3.1 工程初始化与依赖引入我用的是 .NET 8创建一个 ASP.NET Core Web API 项目这是最常规的智能客服服务端骨架。Slickflow 相关的包直接 NuGet 引入包名记得以你实际用的版本为准官方迭代过好几版命名。Project SdkMicrosoft.NET.Sdk.Web PropertyGroup TargetFrameworknet8.0/TargetFramework Nullableenable/Nullable /PropertyGroup ItemGroup PackageReference IncludeSlickflow Versionx.x.x / PackageReference IncludeMicrosoft.Extensions.Http Version8.0.0 / PackageReference IncludeNewtonsoft.Json Version13.0.3 / /ItemGroup /Project大模型客户端我没用特定的 SDK直接封装 HttpClient 调 OpenAI 兼容协议。这个决定很实际不管接的是云端还是本地 Ollama 部署的模型只要兼容/v1/chat/completions一套代码全搞定后面换模型供应商零成本。在appsettings.json里把模型相关的参数抽出来{ LlmOptions: { BaseUrl: http://localhost:11434/v1, ApiKey: ollama, Model: qwen2.5:7b, Temperature: 0.2, MaxTokens: 512 }, ConnectionStrings: { SlickflowDb: Server.;DatabaseCustomerServiceWF;User Idsa;Passwordxxx; } }Temperature 设成 0.2 是我实测后的选择。客服场景要的是稳定和听话不是天马行空。Temperature 高了回答变得太“活泼”同一个问题每次说法都不一样用户会觉得客服前后不一致。低温度下话术更可控业务数字也不会瞎编。3.2 用 XML 定义一套客服流程流程定义写好后通常可以借助 Slickflow 的流程设计器可视化维护也可以直接在代码里加载 XML 文件。我建议版本化保存每次改动都留个记录方便以后回滚。我项目里的流程定义 XML 大致按第 2 章那个结构来写。定义好之后代码里加载并启动流程的步骤是这样public class WorkflowProcessService { private readonly IWorkflowService _workflowService; public WorkflowProcessService(IWorkflowService workflowService) { _workflowService workflowService; } public string StartCustomerServiceProcess(string sessionId) { var runner new WfAppRunner { AppName CustomerService, AppInstanceId sessionId, ProcessGUID customer-service-process-guid, Version 1.0, UserID sessionId, UserName CUSTOMER }; var result _workflowService.CreateRunner(runner) .Start(); if (result.Status ! WfExecutedStatus.Success) { throw new Exception($流程启动失败: {result.Message}); } return result.ProcessInstanceID; } }AppInstanceId是关键我直接塞了会话的 SessionId这样流程实例和会话在业务维度天然绑定。每次用户继续发言我通过 SessionId 反查ProcessInstanceID再从流程引擎里拿出当前节点判断当前该怎么走。启动成功后流程会停在“开始节点”之后的下一个节点。此时我拿到当前节点的信息把欢迎语和第一步引导话术拼进 Prompt交给大模型生成。3.3 大模型接入与上下文组装大模型接入层我写了一个LlmClient方法不多核心就一个ChatAsync。历史消息用ChatMessage列表维护这是对接 OpenAI 兼容协议的标准结构public class LlmClient { private readonly HttpClient _httpClient; private readonly IOptionsLlmOptions _options; public LlmClient(HttpClient httpClient, IOptionsLlmOptions options) { _httpClient httpClient; _options options; } public async Taskstring ChatAsync(IEnumerableChatMessage messages, int maxTokens 512) { var payload new { model _options.Value.Model, messages messages.Select(m new { role m.Role, content m.Content }), temperature _options.Value.Temperature, max_tokens maxTokens }; var response await _httpClient.PostAsJsonAsync(/v1/chat/completions, payload); response.EnsureSuccessStatusCode(); var json await response.Content.ReadFromJsonAsyncChatCompletionResponse(); return json.choices[0].message.content; } }上下文的组装是我最有心得的地方。不是把聊天记录全塞进去就完事而是要把当前工作流状态“翻译”成系统提示词让模型明确知道自己现在处于哪个环节、已经掌握了哪些信息。我给了一个组装函数结构大概是系统指令 流程状态摘要 最近几轮对话 用户当前输入。系统指令里我故意把所有已确认的业务字段写得很死这样模型不太容易“忘记”用户刚说过的关键信息因为这不依赖模型的短期记忆而是来自流程里持久化的事实。这里特别说明一下多轮问答最容易翻车的地方就是“历史上下文 当前节点事实”打架。比如大模型看到历史里用户说过“我要退鞋”但当前节点已经流转到“收集订单号”如果系统提示里不强制说明“当前需要收集订单号请围绕这个追问”模型就会顺着历史扯回去。我在系统提示词里直接写明当前节点名称和需要收集的字段实测能大幅降低跑题率。3.4 问答主循环一次完整的多轮对话下面是整个系统的核心方法它串起了会话上下文、流程实例、意图识别和 LLM 回复生成。我简化了异常处理和日志核心链路都在public class ConversationService { private readonly WorkflowProcessService _workflowService; private readonly LlmClient _llmClient; private readonly ILoggerConversationService _logger; public async Taskstring ProcessMessageAsync(string sessionId, string userMessage) { // 1. 获取或创建会话上下文 var context await _sessionRepository.GetOrCreateAsync(sessionId); if (string.IsNullOrEmpty(context.ProcessInstanceId)) { context.ProcessInstanceId _workflowService.StartCustomerServiceProcess(sessionId); context.CurrentActivityId router; } // 2. 意图识别 var intentResult await _llmClient.ClassifyIntentAsync(userMessage); // 3. 将用户新输入并入业务数据 MergeSlots(context.BusinessData, intentResult.Slots); // 4. 根据当前节点判断是否流转 var transitionDecision DecideNextNode(context.CurrentActivityId, context.BusinessData, intentResult.Intent); if (transitionDecision.ShouldTransition) { var nextNode _workflowService.TransitionTo(transitionDecision.NextActivityId, context); context.CurrentActivityId nextNode.ActivityId; context.CurrentActivityName nextNode.ActivityName; } // 5. 若当前节点需要执行业务动作则执行如查订单 var actionResult await ExecuteNodeActionAsync(context.CurrentActivityId, context.BusinessData); // 6. 组装 Prompt 生成回复 var messages BuildChatMessages(context, intentResult, actionResult, userMessage); var reply await _llmClient.ChatAsync(messages); // 7. 持久化会话上下文并记录流程日志 await _sessionRepository.SaveAsync(context); _logger.LogInformation(Session {SessionId} at node {Node}, sessionId, context.CurrentActivityName); return reply; } }DecideNextNode是流程编排的关键我拿它读取 XML 流程定义里的条件但代码执行的硬校验仍然保留避免出现“流程定义说能跳但缺的业务数据根本没填”这种不一致问题。ExecuteNodeActionAsync是节点动作的模板方法里面用 switch 匹配不同节点查订单、查物流、转人工等等。主循环跑通后一个用户从“我要查单”到最后看到物流信息整个状态变迁会稳如老狗。节点错了可以对着运行日志帧级回放这在纯 Prompt 时代想都不敢想。4. 上线后排障实录5 个高频问题4.1 多轮对话“失忆”用户说过的信息又被反问现象就是用户第一轮说“我要退昨天买的鞋”聊到第五轮模型突然问“您是想退货还是换货”。不是模型蠢是我上下文组装没做好。排查思路是先看工作流里的 BusinessData确认“product鞋、time昨天”有没有被正确写进去。如果写进去了那就是 Prompt 里没把这些信息强调出来。我的做法是组件系统提示词时把 BusinessData 序列化成一段“已确认信息”放在所有历史消息之前并加一句“以下信息是用户已经确认过的不要重复询问”。类似这样你是客服助手。以下是当前会话已经收集到的信息用户在后续回答中可能不会再重复请在追问和生成回复时默认这些信息已经成立 { product: 鞋子, time: 昨天 } 请开始处理用户问题。这个改动之后失忆问题基本消失。根源是我把“记忆”从模型上下文里移到了流程状态里把流程状态又变成了显式提示模型想忘都难。4.2 流程实例卡住不流转症状是用户回答完问题系统还是重复上一轮的话术。这类问题大多出在流转条件匹配不上。比如我在 XML 里写了条件是businessData.orderNo ! null但意图识别返回的 slots 字段名写成了order_no代码里 MergeSlots 只认orderNo导致字段永远为 null节点永远进不了下一步。排查方法我先看两件东西一是流程实例的运行日志Slickflow 会记录每个节点的进入和离开二是把BusinessData在每一轮对话末尾打到日志里检查字段名、字段值是否符合预期。这两种日志一交叉问题基本就定位了。之后我统一做了字段名规范Slots 输出的 key 与 BusinessData 字典的 key 完全一致再也不折腾别名转换。4.3 Token 消耗与响应延迟失控多轮对话最坑的就是历史消息越积越多每轮请求的 token 量越来越大延迟和费用双双起飞。我之前上线初期没控制最多一次一轮请求塞了 8000 多 token响应时间长到用户直接关页面。后来我做了三件事。第一历史消息只保留最近四轮超出部分做摘要压缩把关键信息提炼成简洁文本接在上下文后面。第二意图识别用本地小模型或者关键词规则处理不占主模型 token。第三业务节点完成后的结果比如查询到的物流信息直接写进 BusinessData回复生成时优先用结构化数据去组织语言而不是让模型从零“回忆”查询结果。这样一轮请求从峰值 8000 token 降到 1000 上下延迟和费用都变得健康。4.4 并发场景下会话串号会话串号是客服系统最可怕的故障用户 A 的问题用户 B 看到了回复。绝大多数原因不是流程引擎的问题而是我把会话上下文存在了静态字段里或者依赖注入生命周期配错了导致多个请求共享同一个变量。排查这个问题的关键是先看SessionId在每个环节有没有在最外层传入。我的处理办法是所有会话状态的读取和写入必须显式带上 SessionId禁止用类字段保存会话相关数据依赖注入上ConversationService和WorkflowService注册为 Scoped 或 Transient绝不注册为 Singleton最后写了一个并发压测脚本模拟 100 个会话同时发消息跑完检查每个 SessionId 的流程实例和返回话术是否一一对应。验证通过才敢放量上线。4.5 大模型输出不可控说话越界客服场景最怕模型胡说。比如明明订单接口返回“已签收”模型非要发挥一句“您放心肯定会退款的”这就出事了。我的经验是“能不让模型自由发挥的地方就不要让它自由发挥”。关键业务信息订单号、物流状态、退款金额全部在节点动作里用固定模板格式化不交给模型重新转述。模型只负责把模板内容用自然语言组织起来并基于上下文补充礼貌用语。同时在系统提示词里写死三个约束不要承诺任何未经业务接口确认的结果不要编造工单号、金额、日期无法确定的信息要引导用户转人工确认。再加上最后一层规则校验回复里如果出现订单编号格式不对或者金额和接口返回不一致就用兜底模板返回。这样上线到现在没见过一次业务口径重大事故。最后分享一个我从这个项目里带出来的判断智能客服不是一个“模型问题”而是一个“工程问题”。模型负责聪明工作流负责稳定两者拆开各干各擅长的系统才扛得住真实流量。如果你正在做类似的东西我建议先别急着追求流程复杂第一条链路——查订单、查物流、转人工——跑通再往后退货、退款、更复杂的场景扩展。Slickflow.NET 的学习成本主要在流程定义和节点流转上但只要你把这个基础模型搭对后面每加一个新场景都只是加节点和流转条件的事迭代效率是真的快。

相关新闻

最新新闻

日新闻

周新闻

月新闻