Appearance
多轮对话与 Memory 持久化:让模型记住前面说过什么
1. 本节产出
一个支持多轮对话的接口:同一个 conversationId 下,模型能引用前文;应用重启后对话仍然在(持久化到 JDBC),并且能按「保留最近 N 条」或「按 Token 预算」裁剪历史,防止上下文超限。
2. 前置依赖
- 01-02 第一个 ChatClient:已有 ChatClient Bean
- 00-03 版本矩阵:已确认
ChatMemoryRepository的包路径 - 本地有一个可用数据库(H2 即可跑通,生产换 PostgreSQL/MySQL)
3. 为什么「把历史拼进 prompt」不是解法
最直觉的做法:自己维护一个 List<String>,每次把历史拼成一大段文本发给模型。三个立刻出现的问题:
| 问题 | 后果 |
|---|---|
| 不知道该截多少 | 拼太短丢上下文,拼太长超窗口或被静默截断 |
| 无法区分角色 | 用户说的和模型说的混在一起,模型分不清谁说了什么 |
| 重启即失忆 | 服务重启、多副本切换,用户发现「刚才说的它全忘了」 |
第三点在生产上是灾难性的:K8s 滚动更新一次,所有进行中的对话全部失忆。用户不会认为这是部署问题,只会认为你的产品很蠢。
4. 核心原理
4.1 ChatMemory 的三层结构
ChatMemory(策略层:决定给模型看多少)
│ MessageWindowChatMemory / MessageChatMemoryAdvisor
▼
ChatMemoryRepository(存储层:决定存在哪)
│ InMemory / JDBC / Redis / 自定义
▼
存储 内存 数据库 Redis策略层和存储层是分开的,这是设计上最值得讲的一点:
- 存多少历史 由
ChatMemory决定(窗口大小、Token 预算); - 存在哪里 由
ChatMemoryRepository决定(内存 / JDBC / Redis)。
所以从单机切到分布式,只换 Repository 实现,策略不变。类比:这跟 Spring 里「事务策略」和「数据源」分离是一个思路。
4.2 会话隔离靠 conversationId
java
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, convId))同一个 ChatClient Bean 服务所有会话,靠 conversationId 区分。这意味着:
conversationId必须全局唯一且不可预测(用 UUID,不要用自增 ID——自增 ID 意味着能猜到别人的会话);- 必须校验「当前用户是否有权访问这个 conversationId」,否则是越权漏洞;
- 不传
conversationId会落到默认值,导致所有用户的对话混在一起。
最后一条是最常见的翻车现场:省略了参数,本地测试一切正常(只有一个用户),上线后 A 用户看到了 B 用户的对话内容。
4.3 三种裁剪策略对比
| 策略 | 机制 | 优点 | 缺点 |
|---|---|---|---|
MessageWindowChatMemory | 保留最近 N 条消息 | 简单可预测 | 长对话下早期关键信息全丢 |
| Token 预算裁剪 | 累计 Token 超阈值就从最早开始丢 | 不会超窗口 | 实现复杂些,需要估算 |
| 摘要压缩 | 把早期对话让模型总结成一段 | 保留语义 | 额外花钱、额外延迟、总结本身可能丢信息 |
生产推荐:窗口 + Token 预算双保险。窗口控制条数(防止单条超长),Token 预算控制总量(防止超窗口)。摘要压缩留给 03C 实战,那里才需要长程记忆。
4.4 一个必须知道的细节:系统消息不进记忆
MessageWindowChatMemory 默认会把系统消息排除在窗口之外,因为它每次请求都会重新带上 defaultSystem。如果你手动把系统提示也存进了 Repository,会出现历史里堆积几十条重复的系统提示,白白占用 Token。
5. 代码走查
5.1 依赖
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- JDBC 版记忆仓库(starter 会自动装配 Repository) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>提示:这里刻意不用 H2。Spring AI 2.x 的 JDBC 记忆仓库内置 dialect 只有 Postgres / MySQL / Oracle / SQL Server / SQLite / HSQLDB,没有 H2——用 H2 会在建表时炸掉, 而报错指向 SQL 语法,非常误导。课程统一走 docker-compose 的 Postgres,顺便和 02 篇的向量库共用一个实例。
5.2 配置
yaml
# application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/aitech
username: aitech
password: aitech
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # 首次启动自动建表5.3 定义记忆 Bean
java
// ch01-basics/src/main/java/com/aitech/basics/memory/ChatMemoryConfig.java
@Configuration
public class ChatMemoryConfig {
@Bean
public ChatMemoryRepository chatMemoryRepository(JdbcTemplate jdbcTemplate) {
return JdbcChatMemoryRepository.builder()
.jdbcTemplate(jdbcTemplate)
.build();
}
@Bean
public ChatMemory chatMemory(ChatMemoryRepository repository) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(20) // 保留最近 20 条(10 轮)
.build();
}
}5.4 挂到 ChatClient 上
java
// ch01-basics/src/main/java/com/aitech/basics/config/ChatConfig.java
@Bean
public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
return builder
.defaultSystem("你是 Java 技术助理,回答简洁。")
// 2.x:builder 直接收 ChatMemory,不是 builder().chatMemory(x)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}提示:Advisor 的顺序很重要。记忆 Advisor 应该在检索类 Advisor 之前(先有对话上下文,才能改写检索查询),在安全审计 Advisor 之后(审计要看原始输入)。顺序错了不会报错,但效果会不对——这类 bug 最难查。
5.5 Controller:会话 ID 从哪来
java
// ch01-basics/src/main/java/com/aitech/basics/controller/MemoryChatController.java
@RestController
@RequestMapping("/api")
public class MemoryChatController {
private final ChatClient chatClient;
@PostMapping("/chat/{convId}")
public String chat(@PathVariable String convId,
@RequestBody String message,
Authentication auth) {
// 会话归属校验:不做就是越权漏洞
if (!conversationService.belongsTo(convId, auth.getName())) {
throw new AccessDeniedException("无权访问该会话");
}
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, convId))
.call()
.content();
}
@PostMapping("/chat/new")
public NewConv newConversation(Authentication auth) {
// 用 UUID,不要自增 ID
String id = UUID.randomUUID().toString();
conversationService.create(id, auth.getName());
return new NewConv(id);
}
public record NewConv(String conversationId) {}
}5.6 换 Redis(生产推荐)
java
@Bean
public ChatMemoryRepository chatMemoryRepository(RedisTemplate<String, String> redis) {
return new RedisChatMemoryRepository(redis); // 需引对应 starter
}为什么生产用 Redis 而不是数据库:对话记忆是高频读写、可容忍丢失的临时数据,数据库的写入压力和表膨胀会成为瓶颈。Redis 还能天然设置 TTL,让过期会话自动清理。要长期留档的话,再异步落库——读写分离,不要把两件事压在一个存储上。
6. 跑起来
bash
git checkout ch01-06-chat-memory
mvn spring-boot:run第一轮:
bash
curl -X POST http://localhost:8080/api/chat/new # → {"conversationId":"3f2a..."}
curl -X POST http://localhost:8080/api/chat/3f2a... \
-H "Content-Type: text/plain" -d "我叫木鱼,我做 Java 架构"第二轮(关键验证):
bash
curl -X POST http://localhost:8080/api/chat/3f2a... \
-H "Content-Type: text/plain" -d "我叫什么?"
# 期望:回答包含「木鱼」重启验证(本节最重要的一步):
bash
# Ctrl+C 停掉应用,再 mvn spring-boot:run
curl -X POST http://localhost:8080/api/chat/3f2a... \
-H "Content-Type: text/plain" -d "我叫什么?"
# 期望:仍然回答「木鱼」 —— 证明已持久化| 检查项 | 通过标准 |
|---|---|
| 多轮引用 | 第二轮能正确回答第一轮的信息 |
| 重启不失忆 | 重启后仍能引用 |
| 会话隔离 | 换个 convId 提问「我叫什么」,模型答不知道 |
| 窗口生效 | 聊超过 20 条后,最早的内容不再被引用 |
最后一项要真的聊满 20 条测试——很多同学写完从不验证窗口,上线后才发现「聊久了模型就忘了开头说的要求」。
7. 生产避坑
- 不传
conversationId会让所有用户共享一份记忆。这是最严重也最常犯的错误,本地只有一个用户时完全测不出来。防御做法:给conversationId加@NotBlank校验,并在 Advisor 里对缺失情况打警告日志。不要给默认值。 - 记忆里会存下敏感信息,落库前要考虑脱敏。用户在对话里可能提到手机号、身份证、内部系统地址。这些内容一旦进了记忆表,就等于进了你的数据库,后续要按个人信息保护要求处理。审计与脱敏方案见 03B-12。
- 没有 TTL 的记忆表会无限膨胀。上线三个月后表可能到千万行,查询变慢。做法是:创建会话时同时写 TTL(Redis 天然支持),或定时任务清理超过 N 天未活跃的会话。这件事必须在上线前做,等表长大了再清理代价高得多。
8. 延伸与锚点
- 思考题:窗口裁剪会丢掉早期的关键约束(比如用户第一句说「全程用英文回答」)。有什么办法既不超窗口又不丢约束?(提示:把关键约束固化到 system 或做成摘要——答案在 03A-05 状态管理)
- 代码锚点:
git checkout ch01-06-chat-memory - 下一课时:01-07 @Tool 工具调用
- 对应课件:L01-06 多轮对话与 Memory 持久化