为什么需要聊天记忆?
大语言模型(LLM)本质上是无状态的。这意味着每次向模型发送请求时,它都"忘记"了之前的对话内容。这在需要多轮交互的场景中是致命的------比如用户说:"我叫张三",接着问:"你能复述我的名字吗?",模型大概率会回答"我不知道"。
为了解决这个问题,Spring AI 提供了 ChatMemory
抽象,允许我们在多次与 LLM 的交互中存储和检索对话历史,从而实现"记忆"功能。
本文将带你:
- 快速上手 Spring AI 的基础聊天记忆功能;
- 深入理解
ChatMemory
的设计原理; - 实战:使用 阿里云通义千问(DashScope) 模型;
- 进阶:不使用默认的 JDBC 存储 ,而是构建一个 MySQL + Redis 双层缓存 的高性能会话存储系统。
准备工作
1. 搭建 Spring Boot 项目
创建一个标准的 Spring Boot 项目,确保使用较新的 Spring Boot 版本以兼容 Spring AI。
2. 添加 Spring AI 与通义千问依赖
由于我们使用的是阿里云的通义千问模型,需引入对应的 Starter:
xml
<properties>
<java.version>21</java.version>
<spring.ai.version>1.0.0</spring.ai.version>
<spring.ai.alibaba.version>1.0.0.3</spring.ai.alibaba.version>
</properties>
<!-- Spring AI 阿里云通义千问 Starter -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring.ai.alibaba.version}</version>
</dependency>
<!-- 聊天记忆 JDBC 支持 -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-jdbc</artifactId>
<version>${spring.ai.alibaba.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
<!-- Redis 缓存支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
3. 配置通义千问 API Key
yaml
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
💡 提示 :请将
DASHSCOPE_API_KEY
设置为环境变量,避免密钥泄露。
Spring AI 聊天记忆基础
Spring AI 会自动注册一个 ChatMemory
的默认实现------MessageWindowChatMemory
。它使用内存中的 ConcurrentHashMap
(通过 InMemoryChatMemoryRepository
)存储消息,默认保留最近的 20 条消息。
聊天记忆功能通过 Advisor(顾问/拦截器) 实现。MessageChatMemoryAdvisor
是 Spring AI 提供的默认顾问,负责在请求前后与 ChatMemory
交互。
java
@RestController
@RequestMapping("/chat")
public class ChatController {
private final ChatClient chatClient;
private final ChatMemory chatMemory; // Spring AI 自动注入默认实现
public ChatController(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
this.chatClient = chatClientBuilder.build();
this.chatMemory = chatMemory;
}
@GetMapping("/memory")
public String chatWithMemory(String userInput) {
return chatClient.prompt()
.advisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) // 启用记忆功能
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "session-007")) // 指定会话ID
.user(userInput)
.call()
.content();
}
}
测试:
- 访问
http://localhost:8080/chat/memory?userInput=你好,我叫李雷
- 再访问
http://localhost:8080/chat/memory?userInput=请告诉我你的名字
如果第二次请求模型能正确回答"你叫李雷",说明聊天记忆已生效!
ChatMemory 框架设计
Spring AI 的 ChatMemory
设计非常优雅,采用分层架构:
+------------------+
| ChatMemory | <- 接口,定义 add/get/clear 行为
+------------------+
|
v
+--------------------------+
| MessageWindowChatMemory | <- 默认实现,管理消息窗口(如保留最近20条)
+--------------------------+
|
v
+---------------------------+
| ChatMemoryRepository | <- 接口,定义数据持久化行为
+---------------------------+
|
+------+------+
| |
v v
+-----------+ +------------------+
| InMemory | | JdbcChatMemory |
| Repository| | Repository |
+-----------+ +------------------+
|
v
+-------------+
| Database |
+-------------+
ChatMemory
: 顶层接口,定义了add
,get
,clear
等核心方法。MessageWindowChatMemory
: 默认实现,内部持有ChatMemoryRepository
实例,负责管理消息的窗口大小。ChatMemoryRepository
: 数据存储抽象层。InMemoryChatMemoryRepository
: 使用ConcurrentHashMap
存储在内存中(默认)。JdbcChatMemoryRepository
: 使用关系型数据库存储。
关键点 :当项目中引入了
spring-ai-starter-model-chat-memory-repository-jdbc
依赖时,Spring AI 会自动将JdbcChatMemoryRepository
注册为ChatMemoryRepository
的 Bean,从而替代内存实现。
构建 MySQL + Redis 双层缓存架构
虽然 JdbcChatMemoryRepository
可以直接将数据存入 MySQL,但在高并发场景下,频繁的数据库读写会影响性能。
我们不满足于此,目标是:使用 Redis 作为高速缓存,MySQL 作为持久化存储,实现读写分离的双层架构。
设计思路
- 写入流程 :新消息 → 同时写入 Redis 和 MySQL。
- 读取流程 :优先从 Redis 读取;若未命中,则从 MySQL 读取,并回填到 Redis。
- 容量控制 :使用 Redis 的
LIST
结构 +TRIM
命令,确保每个会话只保留最近 N 条消息。
实现步骤
1. 创建自定义 ChatMemory
实现
java
@Service
public class CachedChatMemoryService implements ChatMemory {
private final JdbcTemplate jdbcTemplate;
private final RedisTemplate<String, Object> redisTemplate;
private final int messageWindowSize;
private static final String KEY_PREFIX = "chat:history:";
public CachedChatMemoryService(JdbcTemplate jdbcTemplate,
RedisTemplate<String, Object> redisTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.redisTemplate = redisTemplate;
this.messageWindowSize = 20; // 可配置
}
@Override
public void add(String conversationId, List<Message> messages) {
String key = KEY_PREFIX + conversationId;
// 1. 写入 Redis
messages.forEach(msg -> {
redisTemplate.opsForList().rightPush(key, new ChatMemoryEntity(msg.getText(), msg.getMessageType().name()));
});
// 2. 修剪 Redis 列表,只保留最近 messageWindowSize 条
redisTemplate.opsForList().trim(key, -messageWindowSize, -1);
// 3. 批量写入 MySQL
batchSaveToDatabase(conversationId, messages);
}
@Override
public List<Message> get(String conversationId) {
String key = KEY_PREFIX + conversationId;
// 1. 先查 Redis
List<Object> cached = redisTemplate.opsForList().range(key, 0, -1);
if (cached != null && !cached.isEmpty()) {
return convertToMessages(cached);
}
// 2. Redis 无数据,查 MySQL
List<Message> dbMessages = jdbcTemplate.query(
"SELECT content, type FROM ai_chat_memory WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?",
new MessageRowMapper(),
conversationId, messageWindowSize
);
// 3. 回填 Redis
if (!dbMessages.isEmpty()) {
dbMessages.forEach(msg -> redisTemplate.opsForList().rightPush(key, new ChatMemoryEntity(msg.getText(), msg.getMessageType().name())));
redisTemplate.opsForList().trim(key, -messageWindowSize, -1);
}
return dbMessages;
}
@Override
public void clear(String conversationId) {
String key = KEY_PREFIX + conversationId;
redisTemplate.delete(key);
jdbcTemplate.update("DELETE FROM ai_chat_memory WHERE conversation_id = ?", conversationId);
}
private void batchSaveToDatabase(String conversationId, List<Message> messages) {
String sql = "INSERT INTO ai_chat_memory (conversation_id, content, type, timestamp) VALUES (?, ?, ?, ?)";
List<Object[]> batchArgs = new ArrayList<>();
long baseTimestamp = Instant.now().toEpochMilli();
for (int i = 0; i < messages.size(); i++) {
Message message = messages.get(i);
Object[] args = new Object[] {
conversationId,
message.getText(),
message.getMessageType().getValue().toUpperCase(),
new Timestamp(baseTimestamp + i) // 确保每条消息时间戳不同
};
batchArgs.add(args);
}
jdbcTemplate.batchUpdate(sql, batchArgs);
}
/**
* 数据库行到 Message 对象的映射器
*/
private static class MessageRowMapper implements RowMapper<Message> {
@Nullable
public Message mapRow(ResultSet rs, int i) throws SQLException {
String content = rs.getString(1);
MessageType type = MessageType.valueOf(rs.getString(2));
Message message;
switch (type) {
case USER -> message = new UserMessage(content);
case ASSISTANT -> message = new AssistantMessage(content);
case SYSTEM -> message = new SystemMessage(content);
case TOOL -> message = new ToolResponseMessage(List.of());
default -> throw new IncompatibleClassChangeError();
}
return message;
}
}
}
2. 配置 ChatClient
使用自定义 ChatMemory
java
@Configuration
public class ChatClientConfig {
private static final String DEFAULT_PROMPT = "你是一个博学的智能聊天助手,请根据用户提问回答!";
@Bean
public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultSystem(DEFAULT_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new SimpleLoggerAdvisor()
)
.build();
}
@Bean
public ChatMemory chatMemory(CachedChatMemoryService service) {
return service; // 使用我们自定义的服务
}
}
3.数据库表结构
sql
CREATE TABLE ai_chat_memory (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(50) NOT NULL, -- USER, ASSISTANT, SYSTEM
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation (conversation_id)
);
继续测试,查看Redis中的数据:
Spring AI 的扩展性极强,它不仅提供了开箱即用的解决方案,更鼓励开发者根据业务需求进行深度定制。掌握其设计思想,是构建企业级 AI 应用的关键。