Spring AI 实战系列 | 进阶篇 第 1 篇
智能客服系统:多轮对话 + 工具调用 + 人工兜底
系列说明:本文为《Spring AI 实战系列 进阶篇》第 1 篇
前置知识:完成入门篇第 2 篇(Tool Calling)和第 5 篇(Advisors)
预计阅读时间:14 分钟
💻 开发环境说明
重要说明:本文章是基于本地模型进行讲解,不依赖任何云端 API Key,真正零成本、可离线运行。(Ps但是电脑配置确实带不动“高智商”的大模型,后面项目源码是换了智谱的大模型去跑的)
| 项目 | 配置 |
|---|---|
| 电脑型号 | MagicBook Pro 14 2025,14.6 吋 |
| CPU | Intel Core Ultra 5(MTL Ultra5) |
| 核显 | Intel UMA |
| 内存 | 24GB |
| 硬盘 | 1TB SSD |
| 本地模型 | Ollama + qwen2.5:7b(通义千问) |
| Embedding 模型 | qwen3-embedding:0.6b |
为什么选择本地模型?
| 对比项 | 云端 API(OpenAI 等) | 本地模型(Ollama) |
|---|---|---|
| 费用 | 按 Token 计费 | 免费 |
| 网络 | 需要联网 | 完全离线可用 |
| 数据安全 | 数据上传到第三方 | 数据不出本地 |
| 响应速度 | 依赖网络延迟 | 本地推理,无网络延迟 |
| 适用场景 | 生产环境、高并发 | 开发测试、个人项目 |
模型下载命令
# 安装 Ollama(macOS/Linux)
curl -fsSL https://ollama.ai/install.sh | sh
# 下载 Chat 模型
ollama pull qwen2.5:7b
# 下载 Embedding 模型(用于 RAG)
ollama pull qwen3-embedding:0.6b
# 验证
ollama list
📖 目录
一、业务场景与核心挑战
1.1 场景描述
搭建一个电商平台的智能客服系统,用户可以通过对话完成:
| 功能 | 示例对话 |
|---|---|
| 订单查询 | "我的订单 B-12345 到哪里了?" |
| 退换货 | "我想退货,订单号是 R-98765" |
| 产品咨询 | "这款手机的电池容量是多少?" |
| 投诉建议 | "你们的发货太慢了,我要投诉" |
| 转人工 | "我要找人工客服" |
1.2 核心挑战
| 挑战 | 解决方案 |
|---|---|
| 用户意图多样 | 意图识别 + 路由分发 |
| 需要查询实时数据 | Tool Calling 连接后端服务 |
| 多轮对话上下文 | ChatMemory + 会话状态 |
| 复杂问题处理不了 | 满意度评分 → 人工接管 |
| 系统稳定性 | 降级策略 + 异常处理 |
二、系统架构设计
2.1 整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 前端 (Vue3) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 聊天界面 │ │ 满意度评分 │ │ 人工客服接入(WebSocket)│ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Spring Boot 后端 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ IntentRouter(意图识别 + 路由分发) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ 订单查询 │ │ 退换货 │ │ 产品咨询 │ │ 转人工 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 各场景 ChatClient(配置不同 System Prompt + Tools) │ │
│ │ - OrderQueryClient(订单查询工具) │ │
│ │ - RefundClient(退换货工具) │ │
│ │ - ProductClient(知识库 RAG) │ │
│ │ - HumanHandoffClient(转人工逻辑) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SessionManager(会话状态管理) │ │
│ │ - ChatMemory 存储(Redis 分布式会话) │ │
│ │ - 满意度评分 │ │
│ │ - 人工接管标记 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌──────────────────┬──────────────────┐
↓ ↓ ↓
┌─────────────────┐ ┌──────────────┐ ┌─────────────────────────┐
│ Redis │ │ MySQL │ │ 外部服务 │
│ 用户登录会话 │ │ 订单数据 │ │ 库存/知识库/人工客服 │
│ ChatMemory缓存 │ │ 退换货记录 │ │ │
└─────────────────┘ └──────────────┘ └─────────────────────────┘
2.2 核心组件说明
| 组件 | 职责 | 技术选型 |
|---|---|---|
IntentRouter | 识别用户意图,路由到对应处理器 | 结构化输出 + 策略模式 |
ChatClient 实例 | 各场景独立的 AI 客户端 | Spring AI ChatClient |
SessionManager | 管理多轮对话上下文 | Redis 分布式 ChatMemory |
Tool 方法 | 连接后端业务系统(MySQL 订单数据) | @Tool 注解 |
HumanHandoffService | 监控满意度,触发人工接管 | WebSocket + MySQL 记录 |
三、意图识别:AI 路由分发
3.1 为什么需要意图识别?
用户的一句话可能对应多种处理方式:
用户:"我的订单在哪里?"
→ 意图:ORDER_QUERY → 调用订单查询工具
用户:"这款手机支持快充吗?"
→ 意图:PRODUCT_QA → 查询知识库 RAG
用户:"我要找人工"
→ 意图:HUMAN_HANDOFF → 转人工客服
3.2 意图识别实现
使用结构化输出让 AI 直接返回意图分类:
public enum Intent {
ORDER_QUERY, // 订单查询
REFUND_REQUEST, // 退换货
PRODUCT_QA, // 产品咨询
COMPLAINT, // 投诉建议
HUMAN_HANDOFF, // 转人工
GREETING, // 问候
UNKNOWN // 未知意图
}
public record IntentResult(
Intent intent, // 识别到的意图
double confidence, // 置信度 0.0-1.0
String extractedParams // 提取的参数(如订单号)
) {}
IntentRecognizer 服务:
@Service
public class IntentRecognizer {
private final ChatClient chatClient;
public IntentRecognizer(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
你是智能客服的意图识别专家。
分析用户输入,判断属于以下哪种意图:
- ORDER_QUERY: 查询订单状态、物流信息
- REFUND_REQUEST: 申请退货、换货、退款
- PRODUCT_QA: 询问产品规格、功能、价格
- COMPLAINT: 投诉、抱怨、建议
- HUMAN_HANDOFF: 明确要求人工客服
- GREETING: 打招呼、问候
- UNKNOWN: 无法判断的意图
同时提取关键参数(如订单号、产品名称)。
confidence 是 0.0-1.0 的置信度。
""")
.build();
}
public IntentResult recognize(String userInput) {
return chatClient.prompt()
.user(userInput)
.call()
.entity(IntentResult.class);
}
}
3.3 路由分发器
@Service
public class IntentRouter {
private final IntentRecognizer recognizer;
private final Map<Intent, ChatHandler> handlers;
public IntentRouter(IntentRecognizer recognizer,
OrderQueryHandler orderHandler,
RefundHandler refundHandler,
ProductQAHandler productHandler,
HumanHandoffHandler humanHandler) {
this.recognizer = recognizer;
this.handlers = Map.of(
Intent.ORDER_QUERY, orderHandler,
Intent.REFUND_REQUEST, refundHandler,
Intent.PRODUCT_QA, productHandler,
Intent.HUMAN_HANDOFF, humanHandler
);
}
public ChatResponse handle(String sessionId, String userInput) {
// 1. 识别意图
IntentResult intent = recognizer.recognize(userInput);
// 2. 置信度低时,用通用回复
if (intent.confidence() < 0.6) {
return ChatResponse.builder()
.reply("抱歉,我不太理解您的问题。您可以问:订单查询、退换货、产品咨询,或输入'人工'转接客服。")
.intent(Intent.UNKNOWN)
.build();
}
// 3. 路由到对应处理器
ChatHandler handler = handlers.getOrDefault(
intent.intent(),
handlers.get(Intent.PRODUCT_QA) // 默认走产品咨询
);
return handler.handle(sessionId, userInput, intent);
}
}
四、多轮对话与工具调用
4.1 订单查询场景
工具定义:
@Component
public class OrderTools {
private final OrderService orderService; // 真实订单服务
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(description = "根据订单号查询订单详情和物流状态")
public String queryOrder(@ToolParam(description = "订单号,如 B-12345") String orderNo) {
Order order = orderService.findByOrderNo(orderNo);
if (order == null) {
return "未找到订单:" + orderNo + ",请检查订单号是否正确。";
}
return String.format(
"订单号:%s\n状态:%s\n商品:%s\n物流:%s",
order.getOrderNo(),
order.getStatus(),
order.getProductName(),
order.getLogisticsInfo()
);
}
@Tool(description = "查询用户最近的订单列表")
public String queryRecentOrders(@ToolParam(description = "用户ID") String userId) {
List<Order> orders = orderService.findRecentByUserId(userId, 5);
// 格式化返回...
return formatOrderList(orders);
}
}
订单查询处理器:
@Component
public class OrderQueryHandler implements ChatHandler {
private final ChatClient chatClient;
private final OrderTools orderTools;
private final ChatMemoryStore memoryStore;
public OrderQueryHandler(ChatClient.Builder builder,
OrderTools orderTools,
ChatMemoryStore memoryStore) {
this.orderTools = orderTools;
this.memoryStore = memoryStore;
this.chatClient = builder
.defaultSystem("""
你是订单查询助手,帮助用户查询订单状态和物流信息。
如果用户没有提供订单号,询问用户订单号。
回复要简洁友好,关键信息(状态、物流)要突出显示。
""")
.defaultTools(orderTools)
.build();
}
@Override
public ChatResponse handle(String sessionId, String userInput, IntentResult intent) {
// 获取或创建该会话的 ChatMemory
ChatMemory chatMemory = memoryStore.getOrCreate(sessionId);
String reply = chatClient.prompt()
.user(userInput)
.advisors(new MessageChatMemoryAdvisor(chatMemory))
.call()
.content();
return ChatResponse.builder()
.reply(reply)
.intent(Intent.ORDER_QUERY)
.build();
}
}
4.2 退换货场景(带状态机)
退换货流程较长,需要会话状态机管理:
public enum RefundState {
INIT, // 初始状态
ORDER_CHECKED, // 已确认订单
REASON_COLLECTED, // 已收集退货原因
CONFIRMED, // 用户确认
COMPLETED // 完成
}
@Component
public class RefundHandler implements ChatHandler {
private final Map<String, RefundSession> sessions = new ConcurrentHashMap<>();
@Override
public ChatResponse handle(String sessionId, String userInput, IntentResult intent) {
RefundSession session = sessions.computeIfAbsent(sessionId,
k -> new RefundSession(sessionId));
return switch (session.getState()) {
case INIT -> handleInit(session, userInput, intent);
case ORDER_CHECKED -> handleReasonCollect(session, userInput);
case REASON_COLLECTED -> handleConfirm(session, userInput);
case CONFIRMED -> handleSubmit(session);
default -> ChatResponse.builder()
.reply("您的退换货申请已提交,客服将在 24 小时内处理。")
.build();
};
}
private ChatResponse handleInit(RefundSession session, String input, IntentResult intent) {
// 提取订单号,查询订单
String orderNo = extractOrderNo(input, intent.extractedParams());
Order order = orderService.findByOrderNo(orderNo);
if (order == null) {
return ChatResponse.builder()
.reply("未找到订单,请提供正确的订单号。")
.build();
}
session.setOrderNo(orderNo);
session.setOrder(order);
session.setState(RefundState.ORDER_CHECKED);
return ChatResponse.builder()
.reply(String.format(
"已找到订单:%s(%s)\n请问退货原因是什么?",
order.getProductName(),
order.getOrderNo()
))
.build();
}
// ... 其他状态处理
}
五、人工接管机制
5.1 触发条件
| 触发方式 | 说明 |
|---|---|
| 用户主动 | 用户输入"人工"、"找客服"等关键词 |
| 满意度低 | 用户对 AI 回复评分 ≤ 2 星 |
| 多次失败 | 同一意图识别失败 3 次以上 |
| 系统异常 | AI 服务异常或工具调用失败 |
5.2 实现代码
@Service
public class HumanHandoffService {
private final SimpMessagingTemplate messagingTemplate; // WebSocket
private final HumanAgentQueue agentQueue;
/**
* 触发人工接管
*/
public void handoffToHuman(String sessionId, String reason) {
// 1. 标记会话状态
SessionManager.markAsHumanHandoff(sessionId);
// 2. 获取会话历史
List<Message> history = ChatMemoryStore.getHistory(sessionId);
// 3. 加入人工客服队列
HandoffRequest request = HandoffRequest.builder()
.sessionId(sessionId)
.reason(reason)
.chatHistory(history)
.timestamp(LocalDateTime.now())
.build();
agentQueue.enqueue(request);
// 4. 通知前端切换界面
messagingTemplate.convertAndSend(
"/topic/handoff/" + sessionId,
Map.of("status", "HANDOFF", "reason", reason)
);
}
/**
* 满意度评分监控
*/
public void recordSatisfaction(String sessionId, int score) {
if (score <= 2) {
handoffToHuman(sessionId, "用户满意度低(" + score + "星)");
}
}
}
5.3 WebSocket 实时通信
@Controller
public class ChatWebSocketController {
private final IntentRouter router;
private final HumanHandoffService handoffService;
@MessageMapping("/chat/{sessionId}")
@SendTo("/topic/chat/{sessionId}")
public ChatMessage handleChat(@DestinationVariable String sessionId,
ChatMessage message) {
// 检查是否已转人工
if (SessionManager.isHumanHandoff(sessionId)) {
// 直接转发给人工客服系统
return forwardToHumanAgent(sessionId, message);
}
// AI 处理
ChatResponse response = router.handle(sessionId, message.getContent());
// 检查是否需要转人工(如 AI 无法处理)
if (response.getIntent() == Intent.HUMAN_HANDOFF) {
handoffService.handoffToHuman(sessionId, "用户要求转人工");
}
return ChatMessage.builder()
.sender("AI")
.content(response.getReply())
.timestamp(LocalDateTime.now())
.build();
}
}
关注公众号「AI日撰」,点击菜单「获取源码」获取完整代码(Gitee 仓库)。