【从零理解 Spring AI · 07】对话记忆:让模型记住上一句说了什么

【从零理解 Spring AI · 07】对话记忆:让模型记住上一句说了什么

写在开头

第 02 篇我们验证过:模型是无状态函数,"记忆"的本质是每轮把历史重新发一遍。

当时用的还是两个独立接口,各自发各自的------所以模型"失忆"。那正确姿势是什么?每次调用前,把历史消息手动拼进消息列表。问题是这样写又臭又长:

java 复制代码
// 手工管理记忆的样子(能跑,但谁维护谁头疼)
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是助教"));
messages.add(new UserMessage("我叫张三,负责订单系统"));
messages.add(new AssistantMessage("好的张三"));
messages.add(new UserMessage("我叫什么名字?"));
// 每轮都要:读历史 → 拼接 → 发送 → 把新问答存回去

存、读、拼、截断、隔离、持久化------六个动作每处都要自己写。Spring AI 把这套东西做成了组件,这篇讲清楚它的用法和每个配置背后的取舍。


一、先分清两个词:Memory 和 History

官方文档专门强调过这对概念的区别,因为混淆它们是最常见的埋雷方式:

概念 是什么 给谁看 怎么管
ChatMemory 维持对话上下文所需的那部分消息 给模型看 Spring AI 的 Advisor 自动管理
ChatHistory 完整的对话记录(所有消息) 给人看(界面回显、审计) 老老实实用 Spring Data 自己存

为什么要分开?因为模型需要的和产品要展示的,往往不是一回事:模型只需要窗口内的最近若干轮(多了反而占上下文),而产品要展示完整聊天记录、支持用户翻历史。

把完整记录塞给模型? 浪费窗口、成本飙升、老话题干扰新回答。把窗口内的内容当历史存? 用户刷新页面,聊天记录没了。

各管各的,别互相客串。 有了这个共识,再看 Spring AI 的设计就顺理成章了。


二、最小可用:三行开启记忆

2.1 直接用自动配置的 Bean

Spring AI 启动时自动装配好了一个 ChatMemory:默认用内存存储 + 消息窗口策略(保留最近 20 条)。直接注入就能用。

真正要写的只有两处:把记忆 Advisor 挂到 ChatClient 上,每次调用带上会话 ID。

java 复制代码
@Service
public class ChatService {

    private final ChatClient chatClient;

    public ChatService(ChatClient.Builder builder, ChatMemory chatMemory) {
        this.chatClient = builder
                .defaultSystem("你是一个面向 Java 初学者的助教。")
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

    public String chat(String conversationId, String message) {
        return chatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .call()
                .content();
    }
}

2.2 效果验证

用同一个会话 ID 连发两轮:

java 复制代码
String cid = "user-1:conv-1";
chat(cid, "我叫张三,负责订单系统");
chat(cid, "我叫什么名字?负责什么系统?");   // 这次它能答对了

第二轮的完整流程是:

复制代码
收到"我叫什么名字?"
  → 记忆 Advisor 拦截:按 conversationId 从 ChatMemory 读出历史
  → 把历史拼进消息列表:[system, user(我叫张三...), assistant(好的...), user(我叫什么名字?)]
  → 发给模型 → 模型这次"看得见"张三 → 正确回答
  → Advisor 把这轮的问答追加回 ChatMemory

和第 02 篇手工版的对照:六个动作(存、读、拼、截断、隔离、持久化)里,前五个全被 Advisor 接管了,业务代码只剩"传一个会话 ID"。

2.3 一个必须记住的硬规则

CONVERSATION_ID 是必传参数。 不传的话,运行时直接抛 IllegalArgumentException------框架不会猜一个默认会话给你。

这其实是好事:会话归属是业务决策,框架替你猜才是事故源。


三、窗口策略:怎么留、怎么丢

MessageWindowChatMemory 是默认的记忆实现,核心机制就是第 02 篇说的"滑动窗口":

窗口怎么留、怎么丢、怎么隔离

三个关键行为:

① 只留最近 N 条。默认 20 条,可调:

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

② SystemMessage 永远保留。角色设定被逐出了,模型的"人格"就没了------框架默认保它。

③ 按"整轮"逐出,不把一轮切一半 。这个细节很贴心:逐出点如果落在一轮的中间(比如助手回答还没删、工具交互还没删),框架会把切点推进到下一个用户消息,保证留下来的窗口永远从完整的一轮开始。

为什么强调"整轮"?因为半轮历史比没有更糟------模型看到"用户问了一句"却没有回答,会误解语境。

窗口大小怎么定? 不是越大越好:窗口越大,每次请求携带的内容越多(费用、延迟上升),且老话题可能干扰新回答。经验做法:从默认 20 条起步,根据业务的平均对话长度调。 单轮任务(查个天气)窗口小点没关系;长咨询类对话给足空间。


四、多用户隔离:conversationId 怎么设计

conversationId 是记忆的唯一隔离边界------同一个 ID 的历史归拢在一起,不同 ID 完全互不可见。

绝对不能犯的错:所有用户共用一个写死的 ID。张三和李四的历史混在一起,张三能"看到"李四说了什么------这不是 bug,是安全事故。

推荐做法:在服务端从当前用户推导,不要从前端传:

java 复制代码
// 登录态里有用户,会话里有 sessionId
String conversationId = currentUser.getId() + ":" + httpSession.getId();

chatClient.prompt()
    .user(userInput)
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
    .call()
    .content();

三层建议(来自官方文档的原话精神):

  1. 每个用户一个独立 ID;一个用户可以有多个会话时,每个会话一个 ID;
  2. 在服务端推导,而不是信任前端传的值;
  3. 列出、删除会话时,只操作属于当前用户的那些 ID。

五、历史存哪里:四种存储的选型

内存存储重启就丢。Spring AI 提供了一组 ChatMemoryRepository 实现,换个 starter 就换存储:

实现 一句话定位 适合
InMemory(默认) ConcurrentHashMap 存内存 开发、demo、单机玩具
JDBC 关系库,支持 PostgreSQL / MySQL / MariaDB / SQL Server / Oracle 等 大多数业务的默认选择
Redis Redis Stack 存储,低延迟,支持 TTL 过期和高级查询 高并发、历史需要过期
Cassandra / Neo4j / MongoDB 各有专长(时序 TTL / 图关系 / 文档) 已有对应技术栈的团队

以 JDBC 为例,换存储只需两步:

xml 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
java 复制代码
// 自动配置会检测到 JDBC 仓库并优先使用它,也可以显式组装:
ChatMemory chatMemory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(chatMemoryRepository)   // 注入的 JDBC 仓库
        .maxMessages(20)
        .build();

建表脚本默认只对嵌入式数据库自动执行;连 MySQL/PostgreSQL 时要显式配置:

properties 复制代码
spring.ai.chat.memory.repository.jdbc.initialize-schema=always

(生产配合 Flyway/Liquibase 时设为 never,把建表脚本纳管进迁移体系。)

⚠️ 一个提前预告的坑 :JDBC、Mongo、Cassandra 这几个存储不支持持久化工具调用类的消息(保存时会被静默过滤)。平时没影响,等第三辑上了工具调用,多步工具交互的记忆就会缺片段------官方推荐用社区项目 Spring AI Session 解决。这里先埋个点,工具调用篇回收。


六、进阶:长期记忆的另一种思路

滑动窗口解决"最近几轮",但跨天、跨会话的偏好记忆("用户喜欢简洁回答")窗口装不下。

Spring AI 还有一个 VectorStoreChatMemoryAdvisor:把历史存进向量库,每次请求时检索相关片段、拼进系统消息。它和滑动窗口是互补关系:

  • 窗口记忆:管最近几轮的完整上下文,逐字带上;
  • 向量记忆:管久远的相关信息,按相关性捞出摘要拼上去。

详细机制牵扯向量库和检索(第四辑 RAG 的核心内容),这里记住分工即可。


七、这篇的四个要点

  1. Memory 和 History 是两个东西------给模型看的窗口上下文用 Spring AI 管;给人看的完整记录用 Spring Data 自己存。别混。
  2. 三行开启记忆 ------注入自动配置的 ChatMemory → 挂 MessageChatMemoryAdvisor → 每次调用传 CONVERSATION_ID。这个参数必传,不传抛异常。
  3. 滑动窗口的三个行为------默认留 20 条、SystemMessage 永远保留、按整轮逐出不切半。
  4. 存储按场景选 ------开发用内存,业务默认 JDBC(注意 initialize-schema),高并发短历史用 Redis;工具调用消息的持久化有已知限制。

延伸思考

到这里,"模型记住上下文"解决了。但还有个更隐蔽的问题:同一个问题换个问法,答案质量忽高忽低;或者让模型写长文,开头华丽结尾拉胯。

模型输出质量的稳定性,除了参数(温度)之外,最大的杠杆在提示词本身怎么写。第二篇说过提示词是"第一层控制手段",下一篇把它从"控制"升级成"工程"。


下一篇

《提示词工程:几个好用的框架》------CO-STAR 六要素填空法、少样本示例为什么比十条规则都管用、思维链什么时候有效什么时候纯属浪费、以及这些技巧在 ChatClient 里的落点。

第二辑收官,也是进入第三辑(工具调用与 MCP)前的最后一块拼图。


本专栏按 Spring AI 当前版本讲解。

相关推荐
SL_staff1 小时前
制造业交付崩盘?用跨部门节点协同计划打通销售、生产与质量链路
java·开源·产品
文丑颜不良啊2 小时前
LeetCode2261~2280题目解析
java·leetcode
SL_staff2 小时前
告别规则黑盒:JVS-Rules 在金融风控中的可视化决策编排实践
java·产品·全栈
逃逸线LOF3 小时前
服务网关Gateway
java·运维·gateway
灿飞屋3 小时前
Kotlin与Java虚拟机四:函数
java·python·kotlin·安卓·android jetpack
阡陌数智4 小时前
Claude API新版本重大变更与安全升级指南
java·linux·安全
学长毕设6 小时前
基于SpringBoot的野生动物公益保护平台设计与实现-附源码
java·spring boot·后端
SL_staff6 小时前
离散制造排程破局:JVS-APS 四步闭环落地实践(开发者视角)
java·产品·全栈
这个昵称不容易7 小时前
SpringBoot 美容院管理系统:232个Java文件315个接口,5套角色从预约到发货一整条链路
java·springboot·毕设