Spring AI智能客服多轮问答实战:从架构设计到生产环境部署
最近在做一个智能客服项目,客户反馈最集中的问题就是“机器人聊着聊着就忘了前面说过什么”。比如用户想订机票,先问了“明天北京到上海的航班”,接着问“下午的呢?”,机器人很可能就懵了,因为它丢失了“北京到上海”这个关键上下文。这种多轮对话中的上下文断裂、意图漂移问题,严重影响了用户体验。
为了解决这个问题,我们基于 Spring AI 框架进行了一次深度实战,目标是构建一个能“记住”对话历史的智能客服系统。下面就把从架构设计到上线的完整过程和踩过的坑分享给大家。
1. 为什么选择 Spring AI?技术选型的思考
在项目初期,我们对比了几个主流方案:
- Rasa:功能强大,NLU和对话管理一体,但需要学习其特定的领域语言(DSL),对纯 Java 技术栈的团队来说,集成和定制成本较高。
- Dialogflow (Google):云服务,开箱即用,但存在数据隐私、网络延迟问题,且定制能力和成本控制较弱。
- Spring AI:作为 Spring 生态的新成员,它的最大优势是“原生”。对于已经深度使用 Spring Boot 的团队,可以无缝集成,用熟悉的
@Bean、@Configuration来管理 AI 组件。它抽象了底层模型(如 OpenAI、Azure OpenAI),让我们能更专注于业务逻辑(对话状态管理),而非模型接口的适配。
最终,我们选择了 Spring AI,核心考量是:降低集成复杂度,充分利用现有 Java 技术资产(如 Redis、Spring Security),让团队能快速上手和迭代。
2. 核心架构:三招解决上下文丢失
我们的方案围绕三个核心展开:对话状态管理、意图识别优化和上下文缓存。
-
对话状态管理 (Dialogue State Management): 这是多轮对话的“大脑”。我们定义了一个
ConversationSession对象,它不仅保存原始的对话消息列表,还额外维护了几个关键状态:currentIntent:当前识别出的用户意图(如BOOK_FLIGHT,QUERY_REFUND)。slots:一个 Map,用于填充意图所需的参数。例如BOOK_FLIGHT意图的 slots 可能包含{"departureCity": "北京", "arrivalCity": "上海", "date": "2023-10-27"}。step:记录在多轮对话填充 slots 过程中的当前步骤。
每次用户输入,系统先更新
slots和step,再根据最新的完整状态生成给 AI 模型的 Prompt。这样,AI 每次回复都基于完整的对话上下文和明确的任务状态。 -
意图识别优化: 单纯依赖大语言模型(LLM)做意图分类,在特定业务场景下可能不够精确且成本高。我们的策略是“规则 + 模型”混合:
- 规则匹配:对于“你好”、“谢谢”等简单意图,或“查订单号XXX”这种有固定模式的需求,用正则表达式或关键词快速匹配,直接返回,速度快且准。
- 模型分类:对于复杂、模糊的表达,才调用 Spring AI 的
ChatClient,通过精心设计的PromptTemplate让其进行意图分类。Prompt 里会提供例子(Few-Shot Learning),显著提升准确率。这套混合方案将意图识别准确率提升到了 98% 以上。
-
上下文缓存与持久化: 这是保证“记忆”不丢失的基础。我们使用 Redis 作为会话存储。
- 每个会话有一个唯一
sessionId(通常由前端生成或基于用户ID创建)。 ConversationSession对象被序列化后,以sessionId为 Key 存入 Redis。- 设置合理的 TTL(如 30 分钟),实现会话自动超时清理,避免内存泄漏。
- 使用 Redis 的分布式特性,天然支持集群部署,用户请求打到任何服务实例都能获取到正确的上下文。
- 每个会话有一个唯一

3. 手把手代码实现
下面是一些关键代码片段,基于 Spring Boot 3.x 和 Spring AI。
1. 应用配置 (application.yml)
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-3.5-turbo # 可根据精度和成本选择模型
temperature: 0.2 # 较低的温度值,使输出更确定、更专注于业务
max-tokens: 500 # 限制单次响应长度,控制成本
redis:
host: localhost
port: 6379
timeout: 2000ms
# 会话缓存配置
conversation:
ttl: 30m # 会话存活时间,根据业务调整
2. 自定义对话历史存储 (RedisConversationStore)
这是实现持久化的核心。
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@Component
public class RedisConversationStore {
private final RedisTemplate<String, ConversationSession> redisTemplate;
private final Duration ttl;
public RedisConversationStore(RedisTemplate<String, ConversationSession> redisTemplate,
@Value("${spring.redis.conversation.ttl}") Duration ttl) {
this.redisTemplate = redisTemplate;
this.ttl = ttl;
}
// 根据sessionId获取完整的会话状态
public ConversationSession getSession(String sessionId) {
return redisTemplate.opsForValue().get(buildKey(sessionId));
}
// 保存或更新整个会话状态
public void saveSession(String sessionId, ConversationSession session) {
String key = buildKey(sessionId);
redisTemplate.opsForValue().set(key, session, ttl); // 设置TTL
}
// 向指定会话中添加一条消息,并更新状态
public void addMessage(String sessionId, Message message, String recognizedIntent) {
ConversationSession session = getSession(sessionId);
if (session == null) {
session = new ConversationSession(sessionId);
}
session.addMessage(message);
session.setCurrentIntent(recognizedIntent);
// 此处可添加更复杂的slot填充逻辑
saveSession(sessionId, session);
}
private String buildKey(String sessionId) {
return "conversation:" + sessionId;
}
}
3. 对话控制器 (ChatController)
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
private final RedisConversationStore conversationStore;
private final IntentRecognizer intentRecognizer; // 自定义的意图识别器(混合策略)
@PostMapping("/{sessionId}")
public ChatResponse chat(@PathVariable String sessionId, @RequestBody UserInput userInput) {
// 1. 识别用户意图
String intent = intentRecognizer.recognize(userInput.getContent());
// 2. 从Redis恢复或创建会话状态
ConversationSession session = conversationStore.getSession(sessionId);
if (session == null) {
session = new ConversationSession(sessionId);
}
// 3. 更新会话状态(填充slots等)
session.updateWithUserInput(userInput.getContent(), intent);
// 4. 构建包含上下文的Prompt
// 这里从session中提取最近的N条消息作为上下文,避免Prompt过长
List<Message> recentMessages = session.getRecentMessages(10);
String context = convertMessagesToContext(recentMessages);
PromptTemplate promptTemplate = new PromptTemplate("""
你是一个专业的客服助手。以下是之前的对话历史:
{context}
当前用户的最新问题是:{latestQuestion}
已知用户的意图是:{intent}
请根据以上信息进行回复。
""");
Map<String, Object> model = Map.of(
"context", context,
"latestQuestion", userInput.getContent(),
"intent", intent
);
Prompt prompt = promptTemplate.create(model);
// 5. 调用AI模型
ChatResponse response = chatClient.call(prompt);
// 6. 将AI的回复也存入会话历史
conversationStore.addMessage(sessionId, new AssistantMessage(response.getResult().getOutput().getContent()), intent);
// 7. 返回响应
return response;
}
}
4. 生产环境部署与考量
系统上线前,我们重点解决了以下几个问题:
-
性能压测: 我们使用 JMeter 模拟了从 50 到 500 的并发用户。结果发现,在并发 200 以下时,平均响应时间稳定在 800ms 左右(主要耗时在 LLM API 调用)。超过 300 并发后,由于 Redis 读写竞争和线程池排队,响应时间曲线开始陡增。通过优化 Redis 连接池、调整 Web 服务器线程数,并将部分非实时的意图识别改为异步处理,最终将 300 并发的平均响应时间控制在 1.5 秒内。
-
安全与合规:
- 日志脱敏:所有对话日志在落盘前,会通过正则规则过滤手机号、身份证号、邮箱等敏感信息,替换为
***。 - 内容审核:在将用户输入拼接到 Prompt 前,会调用一个简单的关键词过滤服务,拦截明显的不当内容。AI 的回复也会经过一次审核后再返回给用户。
- Token 成本控制:监控每次对话的 Token 消耗,对上下文长度进行裁剪(只保留最近 N 轮),并设置单日、单用户调用上限,防止恶意刷取。
- 日志脱敏:所有对话日志在落盘前,会通过正则规则过滤手机号、身份证号、邮箱等敏感信息,替换为
5. 避坑指南:三个典型问题
-
内存溢出 (OOM):
- 问题:初期我们将所有会话的
Message列表都保存在内存的Map里,随着用户量增长,很快导致 OOM。 - 解决:必须将会话状态外部化存储。引入 Redis 并设置 TTL 是根本解决方案。同时,在内存中仅缓存最活跃的少量会话。
- 问题:初期我们将所有会话的
-
线程阻塞导致响应慢:
- 问题:
ChatClient.call()是同步阻塞调用,如果 LLM API 响应慢,会迅速占满 Tomcat 线程池,导致服务整体不可用。 - 解决:使用异步非阻塞模型。将
ChatClient的调用封装到@Async方法中,或者使用 WebFlux 响应式编程。更简单的做法是调整线程池大小,并设置合理的调用超时时间(如 10秒),超时后返回友好提示。
- 问题:
-
上下文混乱 (Context Pollution):
- 问题:简单地将所有历史对话都塞进 Prompt,会导致 Prompt 过长、成本激增,并且可能让 AI 关注到过于久远的不相关信息。
- 解决:实现智能上下文窗口。不是存储所有消息,而是只保留:
- 最近 5-10 轮对话。
- 本轮识别出的
intent所必需的slots信息。 - 系统关键指令(如用户身份)。这样可以精炼上下文,提升效果并降低成本。
6. 总结与思考
通过 Spring AI,我们快速构建了一个上下文感知的智能客服系统。它的价值在于,让 Java 开发者能用熟悉的方式拥抱 AI 能力,将精力集中在业务逻辑和架构设计上。
最后留一个开放问题供大家讨论: 在我们的实践中,为了提升响应速度,我们牺牲了一点意图识别的精度(优先用规则匹配)。在模型精度(用更强大的 LLM 做意图分类)和响应速度(用规则或小模型)之间,你的业务场景是如何权衡的?有没有更优雅的混合策略?
希望这篇笔记能对正在探索 Spring AI 或智能客服系统的你有所帮助。欢迎一起交流更多实战细节。
更多推荐




所有评论(0)