八、SpringAl 会话记忆,历史对话,隔离记忆

适用版本:Spring AI 1.0.x / 2.0


一、为什么要 ChatMemory

LLM 是无状态的,不会保留先前交互的信息:

java 复制代码
@Test
public void testChatOptions() {
    String content = chatClient.prompt().user("我叫徐庶").call().content();
    System.out.println(content);

    content = chatClient.prompt().user("我叫什么?").call().content();
    System.out.println(content);     // 模型答不上来
}

那平时跟大模型聊天为什么能"记住"?------因为每次对话都把之前的对话消息一起发给大模型 ,这就是多轮对话。

Spring AI 提供了 ChatMemory 组件专门存聊天记录:

  • 每次对话后,把「用户消息 + 模型响应」存入 ChatMemory;
  • 下次对话前,把历史记录取出来一起发给模型;
  • 可以为不同用户 / 不同会话维护独立上下文。

手动存/取太麻烦 → 用 Advisor 对话拦截自动完成(见第三节)。


二、自动配置:ChatMemoryAutoConfiguration

Spring AI 已经贴心提供了自动配置类:

java 复制代码
@AutoConfiguration
@ConditionalOnClass({ ChatMemory.class, ChatMemoryRepository.class })
public class ChatMemoryAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    ChatMemoryRepository chatMemoryRepository() {
        return new InMemoryChatMemoryRepository();      // 默认:存 JVM 内存
    }

    @Bean
    @ConditionalOnMissingBean
    ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)
                .build();
    }
}

引入依赖即生效:

xml 复制代码
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>

两个 Bean 都是 @ConditionalOnMissingBean:自定义自己的实现就会覆盖默认实现(这是后面换 JDBC / Redis 的关键)。


三、用 Advisor 解放双手

Spring AI 提供了专用于对话记忆的 Advisor:

版本 Advisor 说明
1.x PromptChatMemoryAdvisor 把历史对话作为 system 文本追加
2.0 MessageChatMemoryAdvisor(推荐) 以消息(Message)形式拼进 Prompt,角色更准确
java 复制代码
// 1.x
ChatMemory chatMemory = MessageWindowChatMemory.builder().build();
chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build())
        .build();

// 2.0
chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
        .build();

之后无需手动维护历史:

java 复制代码
chatClient.prompt().user("我叫徐庶").call().content();
chatClient.prompt().user("我叫什么?").call().content();   // 输出:你叫徐庶

3.1 配置聊天记录最大条数(必须关注 token)

聊天记录是要算 token的:token 有上限、越多越贵、解析越慢,所以窗口不能太长。

  • MessageWindowChatMemory.DEFAULT_MAX_MESSAGES = 20(即 10 次对话,一问一答算 2 条)
  • 超出后只保留最后 N 条 (FIFO,先进先出),参考 MessageWindowChatMemory 源码
java 复制代码
ChatMemory chatMemory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(repository)
        .maxMessages(10)        // 自定义窗口大小
        .build();

3.2 多用户隔离记忆

多个用户对话不能混在一起 → 用 CONVERSATION_ID 隔离:

java 复制代码
chatClient.prompt()
        .user("我叫什么?")
        .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, "user-1001"))
        .call()
        .content();

不同的 CONVERSATION_ID 拥有完全独立的记忆。


四、原理拆解

复制代码
MessageChatMemoryAdvisor(前置存储/拼装)
        ↓
MessageWindowChatMemory(窗口裁剪:只留最近 N 条)
        ↓
ChatMemoryRepository(真正落盘:内存 / JDBC / Redis / ...)

ChatMemoryRepository 接口只需要实现 4 个方法:

方法 作用
List<String> findConversationIds() 查询所有会话 ID
List<Message> findByConversationId(String conversationId) 查某个会话的历史消息
void saveAll(String conversationId, List<Message> messages) 保存(全量覆盖式)该会话消息
void deleteByConversationId(String conversationId) 删除某个会话

五、数据库存储(JDBC / MySQL)

默认存在 JVM 内存有两个致命问题:

  1. 一直存最终会撑爆 JVM → OOM;
  2. 服务重启就丢 → 无法持久化。

Spring AI 内置提供了几种持久化实现(Cassandra、JDBC、Neo4j),下面演示 JDBC + MySQL。

5.1 依赖

xml 复制代码
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
  <groupId>com.mysql</groupId>
  <artifactId>mysql-connector-j</artifactId>
  <scope>runtime</scope>
</dependency>

5.2 配置

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ai_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: true
            schema: classpath:schema-mysql.sql      # 1.0.0 版本需自己提供脚本

5.3 resources/schema-mysql.sql

1.0.0 版本官方没有提供脚本,需要自己定义:

sql 复制代码
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
    conversation_id VARCHAR(36) NOT NULL,
    content         TEXT         NOT NULL,
    type            VARCHAR(10)  NOT NULL,
    `timestamp`     TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT SPRING_AI_CHAT_MEMORY_TYPE_CHECK
        CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
);

CREATE INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX
    ON SPRING_AI_CHAT_MEMORY (conversation_id, `timestamp`);
  • conversation_id:会话 ID(多用户隔离的关键列)
  • content:消息内容
  • type:消息角色(USER / ASSISTANT / SYSTEM / TOOL)
  • timestamp:排序用,配合窗口裁剪取最近 N 条

5.4 配置类(自定义窗口大小)

java 复制代码
@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)   // 注入的是 Jdbc 实现
                .maxMessages(20)
                .build();
    }
}

5.5 测试验证

java 复制代码
@Test
public void testJdbcMemory() {
    chatClient.prompt()
            .user("我叫徐庶")
            .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, "1001"))
            .call().content();

    String answer = chatClient.prompt()
            .user("我叫什么?")
            .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, "1001"))
            .call().content();
    System.out.println(answer);   // 输出:你叫徐庶
}

若配置了 .maxMessages(1),数据库中该会话只会保留 1 条记录(窗口裁剪的结果)。


六、Redis 存储

Spring AI 官方暂未提供 Redis 实现,需要自己实现 ChatMemoryRepository 接口 (增、删、查)。

(也可以用阿里现成的实现:spring-ai-alibaba 的 community/memories 模块,还包含 ES 实现 → https://github.com/alibaba/spring-ai-alibaba/tree/main/community/memories)

6.1 自定义 RedisChatMemoryRepository

java 复制代码
/**
 * 基于 Redis 的对话记忆存储
 * key 结构:
 *   chat:memory:ids             → Set,记录所有 conversationId
 *   chat:memory:{conversationId} → List,按序存放消息 JSON
 */
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    private static final String IDS_KEY = "chat:memory:ids";
    private static final String KEY_PREFIX = "chat:memory:";

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    /** 记忆过期时间(可选,防止无限膨胀) */
    private final Duration ttl;

    public RedisChatMemoryRepository(StringRedisTemplate redisTemplate,
                                     ObjectMapper objectMapper,
                                     Duration ttl) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
        this.ttl = ttl;
    }

    @Override
    public List<String> findConversationIds() {
        Set<String> ids = redisTemplate.opsForSet().members(IDS_KEY);
        return ids == null ? List.of() : new ArrayList<>(ids);
    }

    @Override
    public List<Message> findByConversationId(String conversationId) {
        List<String> jsonList = redisTemplate.opsForList().range(key(conversationId), 0, -1);
        if (jsonList == null || jsonList.isEmpty()) {
            return List.of();
        }
        return jsonList.stream()
                .map(this::deserialize)
                .filter(Objects::nonNull)
                .toList();
    }

    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        String key = key(conversationId);
        redisTemplate.delete(key);                                   // 全量覆盖
        List<String> jsonList = messages.stream().map(this::serialize).toList();
        redisTemplate.opsForList().rightPushAll(key, jsonList);
        redisTemplate.opsForSet().add(IDS_KEY, conversationId);
        if (ttl != null && !ttl.isZero()) {
            redisTemplate.expire(key, ttl);
        }
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        redisTemplate.delete(key(conversationId));
        redisTemplate.opsForSet().remove(IDS_KEY, conversationId);
    }

    private String key(String conversationId) {
        return KEY_PREFIX + conversationId;
    }

    // Message 是接口(多态),用 {role, content} 手动序列化,避免 Jackson 多态反序列化踩坑
    private String serialize(Message message) {
        String role = switch (message.getMessageType()) {
            case USER -> "USER";
            case ASSISTANT -> "ASSISTANT";
            case SYSTEM -> "SYSTEM";
            case TOOL -> "TOOL";
        };
        return objectMapper.writeValueAsString(Map.of("role", role, "content", message.getText()));
    }

    private Message deserialize(String json) {
        Map<?, ?> map = objectMapper.readValue(json, Map.class);
        String role = String.valueOf(map.get("role"));
        String content = String.valueOf(map.get("content"));
        return switch (role) {
            case "ASSISTANT" -> new AssistantMessage(content);
            case "SYSTEM" -> new SystemMessage(content);
            case "TOOL" -> new ToolResponseMessage(List.of());
            default -> new UserMessage(content);
        };
    }
}

6.2 注册(覆盖默认的内存实现)

java 复制代码
@Configuration
public class RedisChatMemoryConfig {

    @Bean
    @ConditionalOnMissingBean(ChatMemoryRepository.class)   // 自定义优先,覆盖 InMemory
    public ChatMemoryRepository chatMemoryRepository(StringRedisTemplate redisTemplate,
                                                     ObjectMapper objectMapper) {
        return new RedisChatMemoryRepository(redisTemplate, objectMapper, Duration.ofDays(7));
    }

    @Bean
    public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)
                .maxMessages(20)
                .build();
    }
}

6.3 Redis vs JDBC 对比

维度 JDBC / MySQL Redis
持久化 ✅ 永久落盘,可审计、可检索 ⚠️ 依赖持久化配置(AOF/RDB),一般设 TTL
性能 中等,有磁盘 IO ✅ 内存读写,延迟低
容量 大,成本低 受内存限制,需设过期时间
实现成本 官方 starter,开箱即用 需自己实现 ChatMemoryRepository
适用场景 需要长期留存、合规审计的业务对话 高频短会话、临时上下文、可容忍丢失

七、多层次记忆架构(模仿人类)

痛点 :记忆多 = 更"聪明",但记忆过多会触发 token 上限。

无论用什么存储,只能解决服务端存储性能问题;聊天记录一多,依然会超 token 上限,可我们仍希望存更多记录让对话更像"人"。

解决方案:分层记忆

层级 内容 策略
近期记忆 上下文窗口中的最近几轮对话 每轮对话完成后立即存储,走 ChatMemory,约 10 条
中期记忆 RAG 检索到的相关历史对话 每轮对话完成后异步转向量存入向量库,检索回约 5 条
长期记忆 关键信息的固化总结 见下方两种方式

长期记忆的两种实现方式

方式一:定时批处理

  • 通过定时任务(每天 / 每周)对积累的对话做总结提炼
  • 提取关键信息、用户偏好、重要事实
  • 批处理降低计算成本,适合大规模处理

方式二:关键点实时处理

  • 对话中识别出关键信息点时立即提取并存储

  • 例:用户明确表达偏好、提供个人信息、设置持久性指令

  • 采用"写入触发器"机制,在特定条件下自动更新长期记忆

    近期(ChatMemory,窗口 N 条)
    ↓ 异步向量化
    中期(向量库,语义检索 Top-K)
    ↓ 定时任务 / 写入触发器
    长期(结构化摘要:用户画像、偏好、事实)


八、实践要点 / 踩坑

  1. 一定要设 maxMessages:默认 20 条,token 与费用随窗口线性增长。
  2. 一定要传 CONVERSATION_ID:不传会使用默认值,导致所有用户共享同一份记忆。
  3. saveAll 是全量覆盖语义:自定义 Repository 时先删再写,不要 append,否则窗口裁剪失效。
  4. 消息序列化 :Message 是接口,直接 Jackson 序列化会失败或丢类型,用 {role, content} 手动映射最稳。
  5. Redis 要设 TTL :否则 key 无限膨胀;同时用 Set 单独维护 conversationId 列表,避免 KEYS 扫描。
  6. Advisor 顺序 :记忆 Advisor 一般在 RAG Advisor 之后、日志 Advisor 之前(参考 HIGHEST_PRECEDENCE + 200)。
  7. 生产别用 InMemoryChatMemoryRepository:会 OOM 且重启丢失。
  8. 1.0.0 没有官方建表脚本 :JDBC 方式需自备 schema-mysql.sql。

九、项目实战:自实现 ChatMemory(MySQL)+ 会话清单双存储

前面五、六章用的是官方 ChatMemoryRepository(JDBC / Redis)。这一章是完全自己实现 的方案:

直接实现 ChatMemory 接口 + MyBatis-Plus 落库,并额外用一套 ChatHistoryService 维护会话清单。

9.0 两套存储的分工

模块 存储内容 用途
MySqlChatMemory(实现 ChatMemory) 每一条消息:user / assistant / system 的文本 供 Spring AI 的 Advisor 读取对话上下文,拼入 Prompt 传给大模型
ChatHistoryService(业务层) 仅 chatId + 业务 type 前端会话列表:查询该用户有哪些会话,不存对话内容
复制代码
chat_history(会话清单:chatId + type)  ← 前端左侧会话列表
chat_memory(对话明细:chatId + role + content) ← 大模型上下文
        两者通过 chatId 关联

9.1 MySqlChatMemory:自定义 ChatMemory(存储单条对话消息)

直接实现 ChatMemory 接口,替换默认的 MessageWindowChatMemory,把每一轮问答存入 MySQL,服务重启会话不丢失。

ChatMemory 接口只需实现 5 个方法:

方法 说明
void add(String conversationId, Message message) 存单条消息
void add(String conversationId, List<Message> messages) 批量存消息
List<Message> get(String conversationId) 取全部消息
List<Message> get(String conversationId, int lastN) 取最后 N 条(控制上下文窗口)
void clear(String conversationId) 清空会话
① 实现类
java 复制代码
package com.ruoyi.xingyueai.component;

import com.ruoyi.xingyueai.entity.ChatMemoryEntity;
import com.ruoyi.xingyueai.service.ChatMemoryService;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 基于 MySQL + MyBatis-Plus 的 ChatMemory 持久化实现
 * 供 MessageChatMemoryAdvisor 读取/写入对话消息
 */
@Component
public class MySqlChatMemory implements ChatMemory {

    private final ChatMemoryService chatMemoryService;

    public MySqlChatMemory(ChatMemoryService chatMemoryService) {
        this.chatMemoryService = chatMemoryService;
    }

    /** 保存单条消息 */
    @Override
    public void add(String conversationId, Message message) {
        String role = switch (message.getMessageType()) {
            case USER -> "user";
            case ASSISTANT -> "assistant";
            case SYSTEM -> "system";
            default -> "unknown";
        };
        String content = extractTextContentFromMessage(message);
        if (!content.isBlank()) {
            chatMemoryService.saveChatRecord(conversationId, role, content);
        }
    }

    /** 批量保存消息 */
    @Override
    public void add(String conversationId, List<Message> messages) {
        messages.forEach(message -> this.add(conversationId, message));
    }

    /** 查询会话全部消息 */
    @Override
    public List<Message> get(String conversationId) {
        List<ChatMemoryEntity> chatRecords = chatMemoryService.getChatRecordsByChatId(conversationId);
        if (chatRecords.isEmpty()) {
            return new ArrayList<>();
        }
        return chatRecords.stream()
                .map(record -> switch (record.getRole()) {
                    case "user" -> new UserMessage(record.getContent());
                    case "assistant" -> new AssistantMessage(record.getContent());
                    case "system" -> new SystemMessage(record.getContent());
                    default -> new UserMessage(record.getContent());
                })
                .collect(Collectors.toList());
    }

    /** 查询会话最后 N 条消息(控制上下文窗口) */
    @Override
    public List<Message> get(String conversationId, int lastN) {
        List<Message> allMessages = this.get(conversationId);
        if (allMessages.isEmpty() || lastN <= 0) {
            return new ArrayList<>();
        }
        int startIndex = Math.max(0, allMessages.size() - lastN);
        return allMessages.subList(startIndex, allMessages.size());
    }

    /** 清空会话(逻辑删除) */
    @Override
    public void clear(String conversationId) {
        chatMemoryService.clearChatMemory(conversationId);
    }

    /** 工具方法:提取消息文本 */
    private String extractTextContentFromMessage(Message message) {
        String text = message.getText();
        return text == null ? "" : text.trim();
    }
}
② 实体 ChatMemoryEntity
java 复制代码
@Data
@TableName("chat_memory")
public class ChatMemoryEntity implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.ASSIGN_ID)
    private Long id;
    /** 会话唯一标识 chatId */
    private String chatId;
    /** 角色 user / assistant / system */
    private String role;
    /** 对话文本内容 */
    private String content;
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
    /** 逻辑删除 0未删 1已删 */
    @TableLogic
    private Integer deleted;
}
③ Mapper
java 复制代码
@Mapper
public interface ChatMemoryMapper extends BaseMapper<ChatMemoryEntity> {
    /** 根据会话ID查询对话记录,按时间正序(@TableLogic 会自动追加 deleted = 0) */
    @Select("SELECT * FROM chat_memory WHERE chat_id = #{chatId} ORDER BY create_time ASC, id ASC")
    List<ChatMemoryEntity> selectByChatId(@Param("chatId") String chatId);
}

也可用 XML 写 <select id="selectByChatId">,二选一即可。

④ Service
java 复制代码
public interface ChatMemoryService extends IService<ChatMemoryEntity> {
    List<ChatMemoryEntity> getChatRecordsByChatId(String chatId);
    void saveChatRecord(String chatId, String role, String content);
    void clearChatMemory(String chatId);
}

@Service
public class ChatMemoryServiceImpl extends ServiceImpl<ChatMemoryMapper, ChatMemoryEntity>
        implements ChatMemoryService {

    @Override
    public List<ChatMemoryEntity> getChatRecordsByChatId(String chatId) {
        return baseMapper.selectByChatId(chatId);
    }

    @Override
    public void saveChatRecord(String chatId, String role, String content) {
        ChatMemoryEntity entity = new ChatMemoryEntity();
        entity.setChatId(chatId);
        entity.setRole(role);
        entity.setContent(content);
        this.save(entity);
    }

    @Override
    public void clearChatMemory(String chatId) {
        List<ChatMemoryEntity> records = this.getChatRecordsByChatId(chatId);
        if (!records.isEmpty()) {
            records.forEach(record -> record.setDeleted(1));
            this.updateBatchById(records);
        }
    }
}

提示:逻辑删除用 @TableLogic 时,更简洁的写法是

this.lambdaUpdate().eq(ChatMemoryEntity::getChatId, chatId).remove(),MyBatis-Plus 会转成 UPDATE ... SET deleted = 1。

⑤ ChatClient 配置(绑定自定义 ChatMemory)
java 复制代码
@Bean
public MySqlChatMemory chatMemory(ChatMemoryService chatMemoryService) {
    return new MySqlChatMemory(chatMemoryService);
}

/**
 * 构建带 MySQL 持久化记忆的 ChatClient
 */
@Bean
public ChatClient chatClient(OpenAiChatModel openAiChatModel, MySqlChatMemory mySqlChatMemory) {
    return ChatClient.builder(openAiChatModel)
            .defaultSystem("你是一个智能助手,名字叫煋玥")
            .defaultAdvisors(
                    new SimpleLoggerAdvisor(),
                    // 绑定自定义持久化 ChatMemory
                    MessageChatMemoryAdvisor.builder(mySqlChatMemory).build()
            )
            .build();
}

⚠️ 自定义 ChatMemory 后就没有 MessageWindowChatMemory 的窗口裁剪了,需要自己控制条数:

方案 A:MessageChatMemoryAdvisor.builder(chatMemory).chatMemoryRetrieveSize(20).build()(1.1.x)

方案 B:在 get(conversationId) 里直接返回最后 N 条(SQL LIMIT 或内存截取)

⑥ 历史消息查询接口
java 复制代码
/** 查询会话全部历史消息 */
@GetMapping("/getAllHistoryAll")
public Result<List<Message>> getAllHistory(@RequestParam @NotBlank(message = "会话ID不能为空") String chatId) {
    try {
        List<Message> allHistory = mySqlChatMemory.get(chatId, Integer.MAX_VALUE);
        return Result.success(allHistory);
    } catch (Exception e) {
        return Result.error("查询全部历史消息失败:" + e.getMessage());
    }
}

/** 查询会话最后 N 条消息 */
@GetMapping("/getAllHistorylast")
public Result<List<Message>> getLastNHistory(@Validated HistoryQueryDTO historyQueryDTO) {
    try {
        String conversationId = historyQueryDTO.getChatId();
        Integer lastN = historyQueryDTO.getLastN();
        List<Message> lastNHistory = mySqlChatMemory.get(conversationId, lastN == null ? Integer.MAX_VALUE : lastN);
        return Result.success(lastNHistory);
    } catch (Exception e) {
        return Result.error("查询最后N条历史消息失败:" + e.getMessage());
    }
}

建议:直接把 Message 对象返回给前端会带上 messageType、metadata 等字段,通常封装成 {role, content, createTime} 的 VO 更合适。

⑦ chat_memory 建表 SQL
sql 复制代码
CREATE TABLE chat_memory (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    chat_id     VARCHAR(255) NOT NULL COMMENT '会话ID',
    role        VARCHAR(50)  NOT NULL COMMENT '消息角色 user/assistant/system',
    content     TEXT         NOT NULL COMMENT '对话内容',
    create_time DATETIME     COMMENT '创建时间',
    update_time DATETIME     COMMENT '更新时间',
    deleted     TINYINT DEFAULT 0 COMMENT '逻辑删除 0未删 1已删',
    KEY idx_chat_id_create_time (chat_id, create_time)
);

9.2 ChatHistoryService:业务会话清单(管理 chatId 列表)

只保存会话 ID ,不存消息内容。按业务 type(chat / pdf / service)归类,用于前端会话列表展示。

提供两套实现:内存实现(开发调试)、MySQL 实现(生产)。

① 顶层接口
java 复制代码
public interface ChatHistoryService {
    /** 保存会话ID:type = chat / service / pdf */
    void save(String type, String chatId);
    /** 删除会话 */
    void delete(String type, String chatId);
    /** 查询该业务类型下全部会话ID */
    List<String> getChatIds(String type);
}
② 内存实现(开发调试,重启丢失)
java 复制代码
@Repository
public class InMemoryChatHistoryServiceImpl implements ChatHistoryService {

    private final Map<String, List<String>> chatHistory = new ConcurrentHashMap<>();

    @Override
    public void save(String type, String chatId) {
        List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>());
        if (chatIds.contains(chatId)) {
            return;
        }
        chatIds.add(chatId);
    }

    @Override
    public void delete(String type, String chatId) {
        List<String> chatIds = chatHistory.get(type);
        if (chatIds != null) {
            chatIds.remove(chatId);
        }
    }

    @Override
    public List<String> getChatIds(String type) {
        return chatHistory.getOrDefault(type, new ArrayList<>());
    }
}

注意:原实现 delete 是空方法(删不掉),且 HashMap + ArrayList 在多线程下不安全,这里换成 ConcurrentHashMap + CopyOnWriteArrayList 并补全删除逻辑。

③ MySQL 实现(生产持久化)
java 复制代码
@Repository
public class InSqlChatHistoryServiceImpl implements ChatHistoryService {

    @Autowired
    private ChatHistoryMapper chatHistoryMapper;

    @Override
    public void save(String type, String chatId) {
        if (exists(type, chatId)) {
            return;
        }
        ChatHistory chatHistory = new ChatHistory();
        chatHistory.setType(type);
        chatHistory.setChatId(chatId);
        chatHistoryMapper.insert(chatHistory);
    }

    private boolean exists(String type, String chatId) {
        List<String> chatIds = chatHistoryMapper.selectChatIdsByType(type);
        return chatIds.contains(chatId);
    }

    @Override
    public void delete(String type, String chatId) {
        chatHistoryMapper.delete(type, chatId);
    }

    @Override
    public List<String> getChatIds(String type) {
        return chatHistoryMapper.selectChatIdsByType(type);
    }
}

优化建议:exists 现在会把该 type 下所有 chatId 查回来再判断,数据量大时改成

SELECT COUNT(1) FROM chat_history WHERE type=#{type} AND chat_id=#{chatId};

并给 (type, chat_id) 加唯一索引 ,用 INSERT IGNORE 防重复。

④ Mapper
java 复制代码
@Mapper
public interface ChatHistoryMapper {

    @Insert("INSERT INTO chat_history (type, chat_id) VALUES (#{type}, #{chatId})")
    void insert(ChatHistory chatHistory);

    @Delete("DELETE FROM chat_history WHERE type = #{type} AND chat_id = #{chatId}")
    void delete(@Param("type") String type, @Param("chatId") String chatId);

    @Select("SELECT chat_id FROM chat_history WHERE type = #{type} ORDER BY id DESC")
    List<String> selectChatIdsByType(String type);
}
⑤ 实体
java 复制代码
@Data
public class ChatHistory {
    private Long id;
    /** 业务类型 chat / pdf / service */
    private String type;
    /** 会话ID */
    private String chatId;
}
⑥ chat_history 建表 SQL
sql 复制代码
CREATE TABLE chat_history (
    id      BIGINT PRIMARY KEY AUTO_INCREMENT,
    type    VARCHAR(255) NOT NULL COMMENT '业务类型 chat/pdf/service',
    chat_id VARCHAR(255) NOT NULL COMMENT '会话ID',
    UNIQUE KEY uk_type_chat_id (type, chat_id),
    KEY idx_type (type)
);

9.3 流式对话接口(整合两套存储)

每次请求:

  1. 先把 chatId 存入 ChatHistoryService(记录会话清单);
  2. MessageChatMemoryAdvisor 自动用 MySqlChatMemory 读写历史消息;
  3. Spring AI 1.1.3 使用常量 ChatMemory.CONVERSATION_ID,旧常量 CHAT_MEMORY_CONVERSATION_ID_KEY 已移除。
java 复制代码
@GetMapping(value = "/chatStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestParam("question") String question,
                         @RequestParam("chatId") String chatId) {
    // 1. 保存会话ID到会话清单表 chat_history
    chatHistoryRepository.save(ChatType.CHAT.getValue(), chatId);

    // 2. Advisor 自动读写 chat_memory
    return client.prompt()
            .user(question)
            .advisors(spec -> spec.param(ChatMemory.CONVERSATION_ID, chatId))
            .stream()
            .content();
}

流式场景下,MessageChatMemoryAdvisor 会在流结束后 才把 assistant 的回复写入 ChatMemory,因此不用担心半截内容入库。


9.4 两种实现路线对比(官方 vs 自实现)

维度 官方 ChatMemoryRepository(JDBC / Redis) 自实现 ChatMemory(MyBatis-Plus)
改动点 实现 ChatMemoryRepository 4 个方法 实现 ChatMemory 5 个方法
窗口裁剪 MessageWindowChatMemory 自带 FIFO ❌ 需自己控制(SQL limit / retrieveSize)
存储粒度 saveAll 全量覆盖 add 单条追加(更符合直觉)
灵活性 只能用官方表结构 完全自定义表、字段、逻辑删除、多租户
适合场景 快速接入、通用 需要定制业务(会话清单、业务类型、审计)

两套方案可以叠加 :用官方 MessageWindowChatMemory 做窗口裁剪,内部套一个自己实现的 ChatMemoryRepository;

会话清单这类业务需求则单独用 ChatHistoryService 维护,与框架解耦------这也是本项目采用的思路。

相关推荐
七夜zippoe1 小时前
短期记忆 vs 长期记忆:Agent 记忆系统的分层架构设计
ai·agent·长期记忆·短期记忆·记忆系统
三声三视2 小时前
周日晚九点半,两份 JD 喂进 tri-jobhunt:全中的那份和缺一项的,都拿了 70 分
人工智能·ai·skillhub·tri-skill·tri-jobhunt
葡萄城技术团队2 小时前
Jev 爆火之后,给企业应用配一个 AI 决策模型
ai
解决小子2 小时前
2026年中国就业情况报告
ai·职场和发展·创业创新·业界资讯·就业
运维开发王义杰3 小时前
Gemini 3.8 语音大模型 GA:当 TTS 学会“演戏”,家庭英语学习与视频创作价值拆解
ai
XLYcmy3 小时前
AI 时代,MOM(制造运营管理系统)该如何演进? 上
ai·llm·agent·模型·mom·harness·工业系统
启雀AI4 小时前
培训管理系统的 AI 智能陪练完整功能逻辑,以家电门店销售为例的剧本框架
人工智能·ai·软件需求·培训系统·培训平台
wflynn4 小时前
Claude 自主发现类 CRISPR 结构的新型逆转录酶系统:Anthropic 生命科学实验室的早期成果
人工智能·ai