Spring AI 实战:用 JdbcChatMemory + MySQL 给 AI 接上「长期记忆」
环境:Spring Boot 3.4.5 · Spring AI 1.1.6 · MySQL 8.x · Java 17
一、为什么需要对话记忆?
默认情况下,ChatClient 每次调用都是无状态的。你问 AI「我叫什么名字?」,它永远回答「我不知道」------因为上一句「我叫张三」已经消失了。
要实现真正的多轮对话,就需要把历史消息随每次请求一起发送给模型。Spring AI 提供了 ChatMemory 体系来解决这个问题,而 JdbcChatMemory 则把记忆持久化到关系型数据库,让服务重启后历史不丢失。
二、核心概念
在动手之前,先理清三个核心类的职责:
scss
┌─────────────────────────────────────────────────────────┐
│ ChatClient │
│ │
│ prompt().user("...").advisors(...).call().content() │
└──────────────────────┬──────────────────────────────────┘
│ 挂载
▼
┌─────────────────────────────────────────────────────────┐
│ MessageChatMemoryAdvisor │
│ │
│ • 请求前:从 ChatMemory 读取历史消息,拼入本次请求 │
│ • 响应后:将本轮 USER + ASSISTANT 消息写回 ChatMemory │
└──────────────────────┬──────────────────────────────────┘
│ 依赖
▼
┌─────────────────────────────────────────────────────────┐
│ ChatMemory │
│ (Spring AI 自动配置的 Bean) │
└──────────────────────┬──────────────────────────────────┘
│ 使用
▼
┌─────────────────────────────────────────────────────────┐
│ JdbcChatMemoryRepository │
│ │
│ • 底层通过 JdbcTemplate 读写 MySQL │
│ • 表:SPRING_AI_CHAT_MEMORY │
└─────────────────────────────────────────────────────────┘
| 类 | 职责 |
|---|---|
MessageChatMemoryAdvisor |
AOP 式拦截,自动注入/保存历史 |
ChatMemory |
记忆抽象接口,由 Spring AI 自动配置 |
JdbcChatMemoryRepository |
实现 ChatMemoryRepository,持久化到 DB |
三、完整实现
3.1 Maven 依赖
xml
<properties>
<spring-ai.version>1.1.6</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring AI OpenAI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Spring AI ChatMemory 自动配置(提供 ChatMemory Bean) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>
<!-- Spring AI JDBC 记忆存储实现 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<!-- Spring Boot JDBC(提供 JdbcTemplate) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
注意 :
spring-ai-model-chat-memory-repository-jdbc没有内置 Spring Boot 自动配置,需要手动注册 Bean(见 3.4 节)。
3.2 application.yaml
yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/your_db?useUnicode=true&characterEncoding=utf-8&useSSL=false&createDatabaseIfNotExist=true
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
sql:
init:
mode: always # 启动时自动执行 schema.sql 建表,生产改为 never
ai:
openai:
api-key: your-api-key
base-url: https://api.openai.com/
chat:
options:
model: gpt-4o
temperature: 0.7
3.3 建表 SQL(schema.sql)
放在 src/main/resources/schema.sql,Spring Boot 启动时自动执行:
sql
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
`conversation_id` VARCHAR(36) NOT NULL,
`content` TEXT NOT NULL,
`type` ENUM('USER','ASSISTANT','SYSTEM','TOOL') NOT NULL,
`timestamp` TIMESTAMP NOT NULL,
INDEX `SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX`
(`conversation_id`, `timestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
conversation_id |
VARCHAR(36) | 会话唯一标识,推荐用 UUID(恰好 36 位) |
content |
TEXT | 消息内容 |
type |
ENUM | 消息角色:USER / ASSISTANT / SYSTEM / TOOL |
timestamp |
TIMESTAMP | 消息时间戳,按会话+时间联合索引 |
3.4 注册 JdbcChatMemoryRepository Bean
spring-ai-model-chat-memory-repository-jdbc 没有内置自动配置,需要手动声明。ChatMemoryAutoConfiguration 检测到 ChatMemoryRepository Bean 后会自动组装 ChatMemory。
java
// src/main/java/.../config/ChatMemoryConfig.java
@Configuration
public class ChatMemoryConfig {
@Bean
public JdbcChatMemoryRepository chatMemoryRepository(JdbcTemplate jdbcTemplate) {
return JdbcChatMemoryRepository.builder()
.jdbcTemplate(jdbcTemplate)
.build();
}
}
3.5 改造 Controller
java
// src/main/java/.../controller/MyController.java
@RestController
class MyController {
private final ChatClient chatClient;
public MyController(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
this.chatClient = chatClientBuilder
// 挂载记忆 Advisor,每次请求自动读写历史
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
@GetMapping("/ai")
String generation(
@RequestParam String userInput,
@RequestParam(defaultValue = "default-session") String conversationId) {
return this.chatClient.prompt()
.user(userInput)
// 指定本次请求属于哪个会话
.advisors(a -> a.param("chat_memory_conversation_id", conversationId))
.call()
.content();
}
}
关键点解读:
ChatMemory由 Spring AI 自动注入(底层使用我们注册的JdbcChatMemoryRepository)defaultAdvisors(...)在 ChatClient 级别全局挂载,所有请求都会经过记忆读写"chat_memory_conversation_id"是 Spring AI 内部约定的 Key(1.1.x 未作为公共常量暴露)- 不同的
conversationId对应完全独立的历史记录
四、完整调用链路
scss
GET /ai?userInput=我叫张三&conversationId=user-001
│
▼
MyController.generation()
│
▼
ChatClient.prompt().user("我叫张三").advisors(conversationId="user-001")
│
▼ ← MessageChatMemoryAdvisor (before)
│ 从 DB 读取 user-001 的历史消息,拼入本次请求
▼
调用 AI 模型 API
│
▼ ← MessageChatMemoryAdvisor (after)
│ 将本轮 USER("我叫张三") + ASSISTANT("好的,我记住了") 写入 DB
▼
返回 AI 响应内容
---
GET /ai?userInput=我叫什么名字&conversationId=user-001
│
▼
MessageChatMemoryAdvisor (before)
│ 从 DB 读取历史:["我叫张三", "好的,我记住了"]
│ 拼成:[历史消息..., "我叫什么名字"]
▼
AI 模型收到完整上下文 → 回复「你叫张三」
五、集成测试
测试直接使用真实的 MySQL 和 AI 接口,不 Mock,每个用例结束后自动清理测试数据。
java
@SpringBootTest
class MyControllerTest {
@Autowired
private MyController controller;
@Autowired
private JdbcTemplate jdbcTemplate;
private String conversationId;
@AfterEach
void cleanUp() {
if (conversationId != null) {
jdbcTemplate.update(
"DELETE FROM SPRING_AI_CHAT_MEMORY WHERE CONVERSATION_ID = ?",
conversationId);
}
}
@Test
@DisplayName("正常返回 AI 非空响应")
void generation_returnsNonEmptyResponse() {
conversationId = UUID.randomUUID().toString();
String response = controller.generation("你好", conversationId);
assertThat(response).isNotBlank();
}
@Test
@DisplayName("同一会话内 AI 能记住上下文")
void generation_remembersContextWithinSameConversation() {
conversationId = UUID.randomUUID().toString();
controller.generation("我的名字是张三,请记住我的名字", conversationId);
String response = controller.generation("我叫什么名字?", conversationId);
assertThat(response).containsIgnoringCase("张三");
}
@Test
@DisplayName("不同会话 ID 的记忆相互隔离")
void generation_isolatesMemoryBetweenConversations() {
String sessionA = UUID.randomUUID().toString();
String sessionB = UUID.randomUUID().toString();
controller.generation("我的名字是李四", sessionA);
String response = controller.generation("我叫什么名字?", sessionB);
// sessionB 从未存过名字,AI 不应知道李四
assertThat(response).doesNotContain("李四");
jdbcTemplate.update("DELETE FROM SPRING_AI_CHAT_MEMORY WHERE CONVERSATION_ID = ?", sessionA);
jdbcTemplate.update("DELETE FROM SPRING_AI_CHAT_MEMORY WHERE CONVERSATION_ID = ?", sessionB);
}
@Test
@DisplayName("对话记录写入数据库")
void generation_persistsMessageToDatabase() {
conversationId = UUID.randomUUID().toString();
controller.generation("记录这条消息", conversationId);
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM SPRING_AI_CHAT_MEMORY WHERE CONVERSATION_ID = ?",
Integer.class, conversationId);
// USER 消息 + ASSISTANT 消息,至少 2 条
assertThat(count).isGreaterThanOrEqualTo(2);
}
}
运行结果:
yaml
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
六、踩坑记录
实际接入过程中遇到了几个问题,记录下来供参考。
坑 1:artifact 名称写错
xml
<!-- ❌ 错误:BOM 中不存在这个 artifact -->
<artifactId>spring-ai-autoconfigure-model-memory-jdbc</artifactId>
<!-- ✅ 正确:两个独立 artifact -->
<artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
<artifactId>spring-ai-model-chat-memory-repository-jdbc</artifactId>
坑 2:常量 CONVERSATION_ID_KEY 不存在
java
// ❌ 1.1.6 中该常量未对外暴露
.advisors(a -> a.param(MessageChatMemoryAdvisor.CONVERSATION_ID_KEY, conversationId))
// ✅ 直接用字符串字面量
.advisors(a -> a.param("chat_memory_conversation_id", conversationId))
坑 3:JDBC 记忆模块没有自动配置
spring-ai-model-chat-memory-repository-jdbc 的 jar 里没有 AutoConfiguration.imports,不会自动注册 JdbcChatMemoryRepository Bean。必须手动用 @Configuration + @Bean 声明,ChatMemoryAutoConfiguration 才能感知并组装 ChatMemory。
坑 4:conversation_id 字段长度限制
SPRING_AI_CHAT_MEMORY 表的 conversation_id 是 VARCHAR(36),刚好容纳一个标准 UUID。使用时注意:
java
// ❌ "test-" + UUID = 41 字符,超长报错
conversationId = "test-" + UUID.randomUUID();
// ✅ 纯 UUID = 36 字符
conversationId = UUID.randomUUID().toString();
七、快速验证
启动应用后用 curl 测试多轮记忆效果:
bash
# 第一轮:告诉 AI 你的名字
curl "http://localhost:8080/ai?userInput=我叫张三&conversationId=user-001"
# 第二轮:验证 AI 是否记住
curl "http://localhost:8080/ai?userInput=我叫什么名字?&conversationId=user-001"
# 预期输出包含「张三」
# 换一个会话,验证隔离
curl "http://localhost:8080/ai?userInput=我叫什么名字?&conversationId=user-002"
# 预期输出:AI 不知道你的名字
八、总结
| 步骤 | 操作 |
|---|---|
| 1 | pom.xml 添加 4 个依赖(autoconfigure + jdbc-repo + spring-jdbc + mysql-driver) |
| 2 | application.yaml 配置数据源,开启 spring.sql.init.mode: always |
| 3 | 添加 schema.sql 建表 DDL |
| 4 | 新增 ChatMemoryConfig 手动注册 JdbcChatMemoryRepository Bean |
| 5 | Controller 注入 ChatMemory,挂载 MessageChatMemoryAdvisor,新增 conversationId 参数 |
整体改动只涉及 4 个文件 ,对业务代码侵入极低,AI 记忆的读写完全由 MessageChatMemoryAdvisor 在 AOP 层面透明处理。