智能客服的核心价值:AI无法应对时的优雅交接机制
项目github仓库坐标: https://github.com/liulangjietou/customer_work 目前main分支已基于AgentScope2.0基座发布release2.0 欢迎fork和真实体验与应用,以此解决你的实际问题。
前文展示了智能客服后台的实际应用场景,模型、Skill、MCP、智能体如何在一行数据库配置里被现场组装成一个可运行的Agent。本文聚焦用户与智能体客服、人工客服交互的真实场景:从用户自助查询,到AI无法处理,再到人工坐席介入,最后回传给用户确认,这条完整链路在代码层面是如何一步步实现的。
评估一个AI智能体客服,多数人关注它能正确回答多少问题。我完成这套系统后的心得恰恰相反:正确回答只是基础,当无法回答时如何优雅地将用户转接进来、保留完整上下文、留下可追溯记录,才是这个项目真正需要落地的核心价值。用户不会因为AI答对十个问题而满意,却可能因为第十一个问题卡在机器人那里无法脱身而愤怒投诉。
文末提供项目启动指南,直接阅读项目文档即可运行。入口位于订单详情页右上角,用户进入智能客服时直接携带订单号进行咨询和投诉。
AI能够处理的部分由它独立完成。用户询问订单和物流信息时,Agent直接调用MCP工具查询,将订单状态、金额、下单时间、物流轨迹一次性返回。
这里的工具并非硬编码的if-else判断。系统将能力划分为七个ToolGroup,订单、售后、售前、会员、投诉、知识库、人工坐席各占一组,统一注册到同一个Toolkit中。
每个工具背后都对应一层tool.backend.*接口。对接你真实场景的订单系统只需实现接口或覆盖Bean,业务代码无需任何修改,基座已经完善。
假设用户输入一串数字1234567890,但这并非系统内的有效订单号。我们来看看系统如何处理这种情况。
AI没有随意编造答案。它明确表示这串数字看起来不像有效订单号,请用户核实。这种行为并非模型自觉产生,而是由两层约束共同作用的结果。第一层在系统提示词,运行期注入一条硬约束。
第二层在工具后端,查询不到结果时老实返回空结果,既不抛异常也不编造内容。
提示词约束Agent不臆断,工具约束数据不造假。一个AI客服敢不敢说“我不知道”,往往比它回答得多漂亮更能决定用户的信任。到这一步,用户很直接地选择了转人工客服。
用户触发转人工有三条路径。说「转人工」这类关键词走快车道,不经过模型直接建单;在对话框主动点击转人工按钮走WebSocket请求;还有一条是AI自己判断用户情绪激动或涉及大额资金,调用转接工具主动升级。
三条路径看似分散,代码层面全部收敛到同一个方法TicketService.requestHandoff。关键词落点在这里。
这个"用户关键词触发转人工"常量,就是后续坐席在工单详情里看到的那行转人工原因。前端一个动作,后台留下一条可解释的信息作为记录,中间不会丢失任何信息。
先看这张图,它是整条链路的骨架。后面四到七节讲解的每一步,都是在这张图上走一条边。
先看这里的枚举定义。
状态如何流转,没有散落在各个Service中,而是全部收进Ticket这个充血实体自己身上。请求转人工的方法如下,注意它具备幂等特性。
用户手抖连点两次转人工,第二次进来命中中间那个分支,直接返回false空转,不会重复建单、不会重复发事件。而一个已关闭的工单想再转人工,直接抛异常快速失败。合法的流转发让它走,非法的流转当场拦死,状态机的边界就是业务的边界。
把状态和状态迁移的规则放进实体自己身上,而不是散在一堆Service里,改动时才能清楚知道哪些流转是允许的。
配合状态机的是一份事件流水。每次流转都追加一条不可变的TicketEvent,记录从哪个状态到哪个状态、谁操作的、什么原因。坐席在工单详情右侧看到的那条时间线,CREATE到REQUEST_HANDOFF到CLAIM,就是这些事件按发生顺序排列出来的。
转人工之后,工单进入坐席的接单池。后台工单列表里,这条单的状态是待人工接入,右侧有一个接入按钮。
多个坐席同时在线,同一条单可能被两个人同时点击接入。这种并发场景,如果采用先查后改的方式,两个人都会查到待接入状态、都以为自己抢到了。这套系统没有在应用层加锁,而是把抢单操作交给数据库处理,用一句带条件的UPDATE解决,当然,你也可以引入其他技术和方案解决抢单问题,这里采用乐观锁。
关键在于最后那个status = 'WAITING_AGENT'。数据库行锁保证两个并发UPDATE串行执行,第一个将状态改为PROCESSING,第二个的WHERE条件不再成立,影响行数为0。服务层据此判断抢单成败,后到的那个坐席直接收到失败响应,翻译成HTTP 409。
分布式环境下抢资源,与其应用层堆锁,不如让数据库的行锁替你做仲裁,一个条件UPDATE就足够了。
坐席接单之后,用户端和坐席端需要能够实时通信。这套系统采用WebFlux原生响应式WebSocket,用户端连接/ws/user,坐席端连接/ws/agent,两条独立端点共用一套帧协议。所有消息都包装进一个统一信封,前端按类型分派。
坐席发送消息后,后端校验发送者确实是这条单的受理坐席,落库后定向推送给工单归属的用户。用户端立刻收到,对话就接上了。
这里有个设计细节值得分享。用户端那条状态,从等待人工接入变成人工服务中,不是前端轮询出来的,而是坐席接单那一下,状态机流转触发了一条ticket_event帧,推送到用户端。用户端拿到新状态就地一改,状态条跟着变。同一条事件通道,顺便把新工单广播给所有在线坐席去抢单。状态机流转一次,该通知的两端一次都不少。
前端两端都封装了带心跳和指数退避重连的WebSocket客户端。坐席端还留了一手降级方案:WebSocket断开后,发送消息自动退回HTTP接口,本地先回显,不让坐席干等。
坐席处理完毕后,点击标记已解决。这一下并非直接把工单关掉,而是将状态推到WAITING_CONFIRM,等待用户确认。用户端随即弹出确认卡片。
用户点击确认解决,工单才真正进入RESOLVED状态;点击仍有问题,工单打回坐席继续处理。解决与否的最终裁量权,交回给用户,而不是坐席单方面说了算。这一步看似小,却是把服务是否到位这件事,从内部指标变成了用户亲自决定。
前面都是问答和沟通,退款不一样,它涉及资金。这套系统对涉资金动作的态度很明确:AI可以受理,但绝不自动打款。
用户申请全额退款,AI收齐订单号和原因后,生成一张退款工单,同时挂起一张待人工审批单,状态停留在待人工坐席复核。
代码里这道红线画得很清楚。退款工具执行后只落一张PENDING工单,不触碰打款操作。
真正的打款,要等坐席在审批端点击approve,才会触发下游回调。审批单本身也是个带终态的小状态机,PENDING到APPROVED或DENIED,决策一次落定不可逆,重复决策直接拒绝。这就是HITL,Human in the Loop,把人工审批固化成流程里一道跳不过去的闸门,而不是靠人自觉。
投诉走的是类似的路径。用户说要投诉,AI先追问投诉类型登记内容,再转人工升级给专人处理,工单原因和上下文一起带过去。
把上面这条链路拆开来看,真正花心思的不是让AI多能答,而是几处边界处理。
还有一层没在图里但一直在底下托着的东西:所有存储都走SPI接口。工单、审批、会话都是一个接口加内存默认实现加MyBatis实现,配置一行从memory切换到jdbc,业务代码零改动。这让整套东西开箱就能单机跑起来看效果,要上生产再接数据库,不用重写。
一套好的智能客服,AI和人不是替代关系,是接力关系。AI负责把能标准化的部分吃干净,人负责兜住AI兜不住的长尾和涉资金的红线,中间那根交接棒,就是这条不丢上下文、可追溯、有闸门的状态机链路。
代码在GitHub开源,基于AgentScope Java 2.0.0 GA,包名com.richard.fyoung.customerwork,感兴趣的可以clone下来自己跑一遍这条链路。
模块说明(文档详细查看即可):
组件依赖(我用docker,详细查看文档即可):
欢迎fork尝试、解决实际生产问题。问题与改进留言区讨论