Spring AI 2.0 会话记忆开发实践

大语言模型是无状态的,这意味着它们不会保留之前的会话信息。当你需要在多次会话中维持上下文或状态时,就需要用到会话记忆。Spring AI 提供了会话记忆功能,使你能够在与大语言模型的多次交互中存储和检索会话信息。Spring AI 通过ChatMemory接口定义会话记忆的行为,底层存储由ChatMemoryRepository接口定义。这里所说的会话记忆并不是会话历史,会话记忆只包含最近几轮的会话历史。

一、默认会话记忆

Spring AI 会默认配置了一个MessageWindowChatMemory的bean,它是一个内置ChatMemory的实现。可以通过依赖注入直接使用它。默认情况下它使用内存仓库InMemoryChatMemoryRepository来存储会话。

java 复制代码
@Autowired
ChatMemory chatMemory;

也可以通过手动创建

java 复制代码
MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
    .maxMessages(10)
    .build();

二、记忆大小和逐出

MessageWindowChatMemory 通过maxMessages()可以设置一个最大消息数量,来限制记忆存储的大小。当消息数量超过上限时,旧消息会被逐出,但始终保留系统消息。默认大小为 20 条消息。来看一个实践数据,将maxMessages设置为5,以下是几轮对话的日志输出:

第一轮对话

textile 复制代码
------------request print---------------
SYSTEM:你是一个旅行指南
USER:列出北京3个旅游景点的名字,以数字排序.
----------------------------------------
------------response print---------------
chatResponse:1. 故宫  
2. 长城  
3. 天坛
----------------------------------------

第二轮对话

注意,这轮对话的请求带上了上一轮的对话内容,包括了助手 ASSISTANT 消息。由此可以知道会话记忆的本质就是把之前的会话消息都作为本次的会话内容发送给大模型。

textile 复制代码
------------request print---------------------------------------
SYSTEM:你是一个旅行指南
USER:列出北京3个旅游景点的名字,以数字排序.
ASSISTANT:1. 故宫  
2. 长城  
3. 天坛
USER:列出北京2个旅游景点的名字,以数字从4开始排序.不包括之前列出的景点.
------------------------------------------------------------------
------------response print-----------------------------------------
chatResponse:4. 颐和园  
5. 圆明园
------------------------------------------------------------------

第三轮对话

这次请求带上了前两轮 USER 和 ASSISTANT 消息。

textile 复制代码
------------request print-----------------------------------------
SYSTEM:你是一个旅行指南
USER:列出北京3个旅游景点的名字,以数字排序.
ASSISTANT:1. 故宫  
2. 长城  
3. 天坛
USER:列出北京2个旅游景点的名字,以数字从4开始排序.不包括之前列出的景点.
ASSISTANT:4. 颐和园  
5. 圆明园
USER:列出北京3个旅游景点的名字,以数字从6开始排序.不包括之前列出的景点.
-----------------------------------------------------------------

------------response print----------------------------------------
chatResponse:6. 天安门广场  
7. 北海公园  
8. 恭王府
-----------------------------------------------------------------

第四轮对话

这次请求带上了第二轮和第三轮的 USER 和 ASSISTANT 对话,第一轮对话已被逐出。由此可见当消息数量超过最大值,需要逐出时,MessageWindowChatMemory 总是移除完整的对话,而不会在一个对话的中间截断。一个对话轮次是从一个 USER 消息开始,到下一个 USER 消息结束。注意,系统消息始终被保存,不会被逐出。

textile 复制代码
------------request print---------
SYSTEM:你是一个旅行指南
USER:列出北京2个旅游景点的名字,以数字从4开始排序.不包括之前列出的景点.
ASSISTANT:4. 颐和园  
5. 圆明园
USER:列出北京3个旅游景点的名字,以数字从6开始排序.不包括之前列出的景点.
ASSISTANT:6. 天安门广场  
7. 北海公园  
8. 恭王府
USER:列出北京1个旅游景点的名字,以数字从9开始排序.不包括之前列出的景点.
---------------------------------
------------response print---------
chatResponse:9. 故宫
---------------------------------

三、用MySql数据库存储会话记忆

JdbcChatMemoryRepository 是一个内置 ChatMemoryRepository 实现,它使用JDBC 将消息存储在关系型数据库中。它开箱即用地支持多种数据库,适用于需要持久化存储聊天记忆的应用程序。

配置步骤

1.将以下依赖项添加到你的项目中:

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

2.添加mysql连接相关配置,用传统的MySql数据来存储会话记忆,是比较简单的,数据库连接配置跟Spring boot项目里的配置一样。

3.配置文件修改

yaml 复制代码
spring:
  ai:
    chat:
      memory:
        repository:
          jdbc:
            initialize-schema: always

启动项目后数据库会自动创建一个 spring_ai_chat_memory 表。

代码实现

引入依赖后,Spring AI 为 JdbcChatMemoryRepository 提供了自动配置,你可以直接在应用程序中使用。

java 复制代码
@Autowired
JdbcChatMemoryRepository chatMemoryRepository;

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(chatMemoryRepository)
    .maxMessages(5)
    .build();

四、用Redis存储会话记忆

RedisChatMemoryRepository 是一个内置 ChatMemoryRepository 实现,它使用 Redis Stack 来存储会话消息。

配置步骤

1. 安装docker desktop。

2. 拉取redis-stack,如果不成功,可以在docker destop设置里配置 registry-mirrors,其中 docker.1ms.run 是比较好用的。

3. 启动redis-stack,不用设置密码,可以先创建数据卷redis_data,命令可参考:

textile 复制代码
docker run -v redis_data:/data -p 6379:6379 -p 8001:8001  -d redis/redis-stack:latest

4. 在项目里引入依赖

textile 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
</dependency>

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

5. 修改配置文件

yaml 复制代码
spring:
  ai:
    chat:
      memory:
        redis:
          port: 6379
          host: localhost
          initialize-schema: true
          max-messages-per-conversation: 1000
          max-conversation-ids: 1000
          time-to-live: 24h

Spring AI 为 RedisChatMemoryRepository 提供了自动配置,你可以直接在应用程序中使用。注意,同时使用redis和mysql会报错。

代码实现

java 复制代码
@Autowired
RedisChatMemoryRepository chatMemoryRepository;

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .chatMemoryRepository(chatMemoryRepository)
    .maxMessages(10)
    .build();

发现问题

项目启动后,进行了两轮对话,发现会话并没有产生记忆,在第二轮对话中大模型给出了错误的回答,并没有正确回答出编号为8的景点名称,并且莫名其妙的列出了10个景点,幻觉吧!

第一轮对话:

textile 复制代码
------------request print--------------------
SYSTEM:你是一个旅行指南
USER:列出10个北京旅游景点的名字,以数字为序号排序。
---------------------------------------------
------------response print--------------------
chatResponse:1. 故宫博物院  
2. 八达岭长城  
3. 天坛公园  
4. 颐和园  
5. 圆明园遗址公园  
6. 明十三陵  
7. 恭王府  
8. 雍和宫  
9. 北海公园  
10. 景山公园
---------------------------------------------

第二轮对话:

textile 复制代码
------------request print-----------------------
SYSTEM:你是一个旅行指南
USER:列出10个北京旅游景点的名字,以数字为序号排序。
USER:之前列出的景点名称中,编号为8的景点名称是什么?
------------------------------------------------
------------response print---------------------------------
chatResponse:您之前看到的列表中,编号为8的景点是 **南锣鼓巷**。

完整的10个北京旅游景点列表如下:
1. 故宫博物院  
2. 天安门广场  
3. 八达岭长城  
4. 颐和园  
5. 天坛公园  
6. 圆明园遗址公园  
7. 北海公园  
8. **南锣鼓巷**  
9. 雍和宫  
10. 鸟巢(国家体育场)
---------------------------------------------------------

通过输出日志很容易发现在第二轮对话请求中并没有带上第一轮对话的助手消息,即 ASSISTANT 消息。在redis stack的可视化工具,http://localhost:8001中查看,第一轮对话的 ASSISTANT 消息已经消失,只有第二轮的 ASSISTANT 消息。

其中一个USER消息

json 复制代码
{
  "metadata": {
    "messageType": "USER"
  },
  "conversation_id": "redisConversation",
  "type": "USER",
  "content": "之前列出的景点名称中,编号为8的景点名称是什么?",
  "timestamp": 1784173916743
}

ASSISTANT消息

json 复制代码
{
  "metadata": {
    "finishReason": "STOP",
    "index": 0,
    "role": "ASSISTANT",
    "id": "6b5b0c7c-14aa-409f-a91f-23ea9fb4461e",
    "messageType": "ASSISTANT"
  },
  "conversation_id": "redisConversation",
  "type": "ASSISTANT",
  "content": "您之前看到的列表中,编号为8的景点是 **南锣鼓巷**。\n\n完整的10个北京旅游景点列表如下:\n1. 故宫博物院  \n2. 天安门广场  \n3. 八达岭长城  \n4. 颐和园  \n5. 天坛公园  \n6. 圆明园遗址公园  \n7. 北海公园  \n8. **南锣鼓巷**  \n9. 雍和宫  \n10. 鸟巢(国家体育场)",
  "timestamp": 1784173916744
}

通过查看源码和反复测试,发现是在进行消息检索的时候,如下代码并没有检出助手消息,而USER消息却能完全检出,显然助手消息并没有进入索引。

java 复制代码
this.jedisClient.ftSearch(this.config.getIndexName(), query);

解决问题

USER消息和ASSISTANT消息,在json结构上大致相同,唯一的区别是metadata,因此修改配置文件:

yaml 复制代码
spring:
  ai:
    chat:
      memory:
        redis:
          port: 6379
          host: localhost
          initialize-schema: true
          max-messages-per-conversation: 1000
          max-conversation-ids: 1000
          time-to-live: 24h
          metadata-fields:
            - name: finishReason
              type: tag
            - name: index
              type: numeric
            - name: role
              type: tag
            - name: id
              type: tag
            - name: messageType
              type: tag

清空redis,重启项目,问题完美解决。第二轮对话已经带上了上一轮对话的助手消息,没有意外,大模型正确的回答了问题,编号为8的景点名称是雍和宫 ✔

第一轮对话

textile 复制代码
------------request print---------------------
SYSTEM:你是一个旅行指南
USER:列出10个北京旅游景点的名字,以数字为序号排序。
----------------------------------------------
------------response print---------
chatResponse:1. 故宫博物院  
2. 长城(八达岭)  
3. 天安门广场  
4. 颐和园  
5. 天坛公园  
6. 圆明园  
7. 北海公园  
8. 雍和宫  
9. 景山公园  
10. 十三陵
---------------------------------

第二轮对话

textile 复制代码
------------request print--------------------------
SYSTEM:你是一个旅行指南
USER:列出10个北京旅游景点的名字,以数字为序号排序。
ASSISTANT:1. 故宫博物院  
2. 长城(八达岭)  
3. 天安门广场  
4. 颐和园  
5. 天坛公园  
6. 圆明园  
7. 北海公园  
8. 雍和宫  
9. 景山公园  
10. 十三陵
USER:之前列出的景点名称中,编号为8的景点名称是什么?
--------------------------------------------------
------------response print----------------
chatResponse:编号为8的景点名称是**雍和宫**。
-----------------------------------------

总结

Spirng AI chatMemory 底层能连接多种数据库,除了上面介绍的以外,还可以使用CassandraNeo4jMongoDB。依靠Spring依赖注入,使用起来很方便,其优秀的结构设计也易于扩展和进行自定义。注:测试中采用的大模型是deepseek-v4-pro, temperature设为0.8。

相关推荐
从零开始的代码生活_2 小时前
C++ stack、queue 与 priority_queue:容器适配器原理与实战
开发语言·c++·后端·学习·算法
爱勇宝2 小时前
《道德经》第6章:别把团队的创造力用到枯竭
前端·后端
无双_Joney2 小时前
[四期 - 4] NestJS专栏 - 征召一下大家的意见,非常想知道大家都想看看Nest的哪些方面
前端·后端·nestjs
彭亚川Allen2 小时前
停更的这一年,All In AI、陪媳妇生娃、还考了个研
前端·后端·产品经理
techdashen2 小时前
Go 1.25 新增 `reflect.TypeAssert`:更直接、更高效地从 `reflect.Value` 取出类型值
开发语言·后端·golang
千桐科技3 小时前
🚀 qModel 算法模型平台v1.2.0 开源版发布!Python 模型接入链路全面升级,告别“能跑但上不了线”的尴尬
后端·算法·llm
用户40966601317513 小时前
实时数据大屏的 4 种推送方案:短轮询 → 长轮询 → SSE → WebSocket
后端·面试
yinshaojun0013 小时前
Function Calling:从模型输出到安全的工具执行
后端
码事漫谈3 小时前
让 AI 直接操作数据库:电科金仓 KES MCP Server 实践
后端