Skip to content

多轮对话与 Memory 持久化:让模型记住前面说过什么 ​

1. 本节产出 ​

一个支持多轮对话的接口:同一个 conversationId 下,模型能引用前文;应用重启后对话仍然在(持久化到 JDBC),并且能按「保留最近 N 条」或「按 Token 预算」裁剪历史,防止上下文超限。

2. 前置依赖 ​

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. 生产避坑 ​

  1. 不传 conversationId 会让所有用户共享一份记忆。这是最严重也最常犯的错误,本地只有一个用户时完全测不出来。防御做法:给 conversationId 加 @NotBlank 校验,并在 Advisor 里对缺失情况打警告日志。不要给默认值。
  2. 记忆里会存下敏感信息,落库前要考虑脱敏。用户在对话里可能提到手机号、身份证、内部系统地址。这些内容一旦进了记忆表,就等于进了你的数据库,后续要按个人信息保护要求处理。审计与脱敏方案见 03B-12。
  3. 没有 TTL 的记忆表会无限膨胀。上线三个月后表可能到千万行,查询变慢。做法是:创建会话时同时写 TTL(Redis 天然支持),或定时任务清理超过 N 天未活跃的会话。这件事必须在上线前做,等表长大了再清理代价高得多。

8. 延伸与锚点 ​

  • 思考题:窗口裁剪会丢掉早期的关键约束(比如用户第一句说「全程用英文回答」)。有什么办法既不超窗口又不丢约束?(提示:把关键约束固化到 system 或做成摘要——答案在 03A-05 状态管理)
  • 代码锚点:git checkout ch01-06-chat-memory
  • 下一课时:01-07 @Tool 工具调用
  • 对应课件:L01-06 多轮对话与 Memory 持久化