Spring AI 对话记忆实战:ChatMemory 原理、会话隔离与上下文管理
2026/9/19 6:08:59 网站建设 项目流程

1. 从无状态说起:为什么大模型对话天生“记不住事”

很多人第一次用 Spring AI 写对话程序时,都会遇到一个很反直觉的现象:明明上一句刚告诉模型“我叫老王”,下一句问“我叫什么”,它却一脸茫然地回答“抱歉,我不知道您的名字”。这不是模型笨,而是它压根就没记住——大语言模型的 API 调用本质上是无状态的

1.1 HTTP 请求背后的真相

把每次调用大模型想象成去一家没有会员系统的快餐店点餐。你这次点了汉堡,下次再去,店员完全不认识你,也不知道你上次点了什么。每次请求都是独立的,模型看到的只有你这一次发过去的全部内容。它不会在服务端为你保留任何“上次聊到哪了”的信息。

从技术层面看,无论是 OpenAI 风格的/v1/chat/completions接口,还是国内各家大模型的兼容接口,请求体里都只有一个messages数组。这个数组就是模型能看到的全部世界。你发多少,它就看多少;你不发,它就什么都不知道。

{ "model": "your-model", "messages": [ {"role": "user", "content": "我叫老王"}, {"role": "assistant", "content": "你好老王"}, {"role": "user", "content": "我叫什么"} ] }

上面这个请求之所以能让模型答出“你叫老王”,唯一的原因就是我们手动把历史消息塞进了 messages 数组。模型本身没有记忆,记忆是我们“喂”给它的。

1.2 无状态带来的三个现实问题

理解了无状态本质,就能明白为什么对话记忆是一个必须自己解决的问题。实际开发中,无状态会直接带来三个麻烦:

  • 上下文丢失:多轮对话无法连贯,用户每句话都得重复背景,体验极差。
  • Token 成本失控:如果无脑把全部历史都塞进去,对话越长请求越大,费用和延迟都会飙升。
  • 多用户串台:如果服务端用一个全局变量存历史,A 用户的对话可能被 B 用户看到,这是灾难级的安全问题。

所以,对话记忆要解决的核心,其实就是三件事:存什么、存哪里、怎么按人隔离。Spring AI 给出的答案,就是ChatMemory这套抽象。

1.3 Spring AI 的定位与适用人群

Spring AI 是 Spring 生态里用来对接大模型的框架,它的价值在于把各家模型 API 的差异抹平,同时用 Spring 开发者熟悉的方式(依赖注入、自动配置、Advisor 链)来组织代码。如果你本身是 Java/Spring 背景,想快速把大模型能力接进现有系统,Spring AI 基本是最顺手的选择。

这篇内容适合三类人:一是刚接触 Spring AI、搞不清对话记忆怎么落地的开发者;二是已经能跑通单轮对话、但多轮会话总是出问题的同学;三是需要做多用户会话隔离、对上下文管理有工程化要求的后端工程师。下面我会从抽象设计讲到实操代码,再讲到踩坑经验,尽量让你看完就能直接抄作业。

2. ChatMemory 抽象拆解:Spring AI 到底帮我们做了什么

Spring AI 没有把“记忆”做成一个黑盒,而是拆成了几个职责清晰的组件。理解这套抽象,比死记 API 更重要,因为一旦你明白了每个组件管什么,遇到问题就知道该去哪个环节排查。

2.1 ChatMemory 接口的核心职责

ChatMemory是整个记忆体系的门面,它主要管两件事:往记忆里加消息从记忆里取消息。核心方法大致是add(conversationId, messages)get(conversationId),还有一个clear(conversationId)用来清空。

注意这里第一个参数永远是conversationId。这个设计非常关键——它意味着记忆从一开始就是按会话隔离的,而不是全局共享。你不需要自己去设计隔离方案,框架已经把隔离的“钩子”留好了,你要做的只是给每个会话一个唯一的 ID。

提示:conversationId的取值直接决定了隔离粒度。用用户 ID,就是“一个用户一份记忆”;用会话 ID,就是“一次会话一份记忆”。选错了会导致记忆串台或者记忆丢失,后面会专门讲。

2.2 MessageWindowChatMemory:滑动窗口式记忆

Spring AI 默认提供的实现是MessageWindowChatMemory,顾名思义,它用一个“滑动窗口”来保留最近的 N 条消息。当消息数量超过窗口大小时,最老的消息会被丢弃。

为什么默认用窗口而不是全量保留?因为 Token 是有成本的,而且模型的上下文长度也有上限。窗口机制是一种简单粗暴但有效的“遗忘策略”——只记住最近的,老的自动忘掉。

ChatMemory memory = MessageWindowChatMemory.builder() .maxMessages(20) // 最多保留 20 条消息 .build();

这里的maxMessages是消息条数,不是轮数。一轮对话通常包含一条 user 和一条 assistant,所以 20 条大约等于 10 轮。这个数字需要根据你的业务场景权衡:太小会“失忆”,太大会增加成本和延迟。

2.3 ChatMemoryRepository:记忆的存储后端

光有窗口策略还不够,消息总得存在某个地方。Spring AI 把“存哪里”抽象成了ChatMemoryRepository。默认情况下,它用的是内存实现InMemoryChatMemoryRepository,也就是存在一个 Map 里,应用一重启就全没了。

这个抽象的意义在于可替换。开发阶段用内存足够,但生产环境往往需要持久化——比如存到 Redis、数据库,甚至做成分布式共享。只要实现ChatMemoryRepository接口,就能无缝替换,上层代码完全不用改。

存储实现适用场景特点
InMemoryChatMemoryRepository本地开发、单机 demo零配置,重启即丢
自定义 Redis 实现分布式、多实例部署需自己实现接口,支持共享
自定义 JDBC 实现需要审计、长期留存可查询历史,写入有开销

2.4 MessageChatMemoryAdvisor:把记忆接进对话流程

前面几个组件是“存”的能力,但怎么让它们在每次对话时自动生效?答案是MessageChatMemoryAdvisor。它是一个 Advisor,挂在ChatClient的调用链上,负责在请求前把历史消息读出来拼进 prompt,在响应后把新消息写回记忆。

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

这样配置之后,你调用chatClient.prompt().user("...").call()时,Advisor 会自动处理记忆的读写,业务代码里完全不用手动拼历史。这就是 Spring AI 的优雅之处——把横切逻辑交给 Advisor,业务代码保持干净

2.5 一次对话的完整数据流

把上面几个组件串起来,一次带记忆的对话大致是这样流转的:

  1. 业务代码调用chatClient.prompt().user("我叫什么").advisors(a -> a.param("conversationId", "user-123")).call()
  2. MessageChatMemoryAdvisor拦截请求,用conversationIdChatMemory取出历史消息。
  3. 历史消息 + 当前用户消息一起组成messages数组,发给大模型。
  4. 模型返回响应,Advisor 把“用户消息”和“助手响应”一起写回ChatMemory
  5. 下次同一conversationId的请求,就能读到这次的历史。

理解了这条链路,后面所有的排查和优化都有了着力点。

3. 上下文管理实操:从零搭一个带记忆的对话服务

理论讲完,直接上代码。这一节我会带你从依赖引入到跑通多轮对话,每一步都说明为什么这么做。

3.1 依赖引入与基础配置

首先在pom.xml里引入 Spring AI 的 starter。注意版本要对齐,Spring AI 迭代较快,建议用当前稳定版。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency>

然后在application.yml里配置模型连接信息。这里以对接本地部署的模型为例,把base-url指向本地服务即可,这样调试时不用消耗线上额度。

spring: ai: openai: base-url: http://localhost:11434/v1 api-key: dummy-key chat: options: model: your-local-model temperature: 0.7

注意:本地部署的模型很多对api-key不校验,但 Spring AI 的自动配置要求这个字段非空,随便填一个占位符即可,不要留空导致启动报错。

3.2 定义 ChatMemory 与 ChatClient Bean

接下来把记忆和客户端注册成 Bean。这里我习惯把maxMessages设成 20,实测下来对大多数客服、助手类场景够用。

@Configuration public class ChatConfig { @Bean public ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); } @Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); } }

为什么把ChatMemory单独抽成 Bean?因为它是有状态的,如果每次请求都 new 一个,记忆就丢了。必须是单例,全局共享同一个记忆容器,靠conversationId来区分不同会话。

3.3 编写带会话隔离的对话接口

下面是一个典型的 Controller,关键点在于每次调用都要传 conversationId

@RestController @RequestMapping("/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @GetMapping("/ask") public String ask(@RequestParam String conversationId, @RequestParam String message) { return chatClient.prompt() .user(message) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); } }

这里的ChatMemory.CONVERSATION_ID是框架定义的常量,值就是"conversationId"。用常量而不是硬编码字符串,能避免拼写错误。

3.4 验证多轮对话与隔离效果

启动服务后,用两组不同的conversationId测试,就能直观看到记忆和隔离的效果。

# 会话 A 第一轮 curl "http://localhost:8080/chat/ask?conversationId=session-A&message=我叫老王" # 返回:你好老王 # 会话 A 第二轮,应该记得 curl "http://localhost:8080/chat/ask?conversationId=session-A&message=我叫什么" # 返回:你叫老王 # 会话 B,不应该知道 A 的信息 curl "http://localhost:8080/chat/ask?conversationId=session-B&message=我叫什么" # 返回:抱歉,我不知道您的名字

如果第三组返回了“你叫老王”,说明隔离失效了,八成是conversationId没传对,或者ChatMemory被错误地做成了每次请求新建。这个测试用例建议你固化到集成测试里,防止后续改动破坏隔离逻辑。

3.5 参数选择背后的计算逻辑

maxMessages到底设多少合适?这里给一个粗略的估算方法。假设你的模型上下文窗口是 8K token,系统提示词占 500 token,每次回复预留 500 token,那么留给历史的空间大约是 7000 token。按每条消息平均 100 token 算,理论上能放 70 条。但考虑到成本和延迟,没必要放满,通常取 10 到 20 条即可。

真正需要长记忆的场景,靠窗口是不够的,得引入摘要或向量检索,这属于进阶话题,后面会提。

4. 会话隔离的深水区:那些文档不会告诉你的坑

会话隔离看起来只是“传个 ID”,但实际项目里翻车的地方非常多。这一节我把踩过的坑整理出来,帮你少走弯路。

4.1 conversationId 的三种常见取值策略

选什么当conversationId,直接决定了产品形态。常见有三种:

  • 用户 ID:一个用户永远一份记忆,跨设备、跨会话都连续。适合个人助手类产品,但用户想“开个新话题”时会发现旧记忆甩不掉。
  • 会话 ID:每次新建对话生成一个 UUID,会话之间完全独立。适合 ChatGPT 那种“左侧会话列表”的形态。
  • 用户 ID + 场景 ID:比如user-123-orderuser-123-support分开。适合一个用户在不同业务线之间需要记忆隔离的场景。

我的建议是:默认用会话 ID,需要跨会话连续时再叠加用户维度。因为“记忆串台”比“记忆丢失”严重得多,前者是事故,后者只是体验问题。

4.2 内存存储的致命缺陷

InMemoryChatMemoryRepository在单机 demo 里很爽,但一旦上生产就会暴露两个问题:

  • 重启即失忆:应用一重启,所有用户的对话历史全没了,用户会以为系统坏了。
  • 多实例不共享:如果部署了两个实例,负载均衡把请求打到不同实例上,用户会发现“记忆时有时无”,体验极其诡异。

解决办法是实现一个基于 Redis 的ChatMemoryRepository。核心思路是把conversationId作为 key,消息列表序列化后存进去,读取时反序列化。要注意设置合理的过期时间,否则 Redis 会被历史消息撑爆。

public class RedisChatMemoryRepository implements ChatMemoryRepository { // 以 conversationId 为 key,存储消息列表 // 建议 TTL 设为 7 天,避免无限增长 }

4.3 并发写入导致的消息错乱

同一个conversationId如果被并发请求,可能出现消息顺序错乱。比如用户快速连发两条消息,两个请求同时读到相同的历史,各自追加后写回,后写的会覆盖先写的,导致丢消息。

解决思路有两个:一是对同一conversationId加锁,串行处理;二是用支持原子追加的存储(比如 Redis 的 List 结构,用RPUSH天然保证顺序)。前者简单但影响吞吐,后者性能好但依赖存储特性。

提示:如果你的场景对实时性要求高,优先考虑存储层的原子操作,而不是在应用层加锁。

4.4 常见问题速查表

现象可能原因排查方向
模型完全不记得上一句conversationId 每次不同检查前端是否每次生成新 ID
不同用户互相看到对方信息conversationId 用了固定值检查是否硬编码了 ID
重启后记忆丢失用了内存存储换成 Redis 或数据库实现
多实例部署记忆时有时无存储不共享检查是否各实例独立内存
对话越长响应越慢窗口设置过大调小 maxMessages 或加摘要
消息顺序错乱并发写入覆盖加锁或用原子追加存储

4.5 一个容易被忽略的细节:系统提示词的位置

MessageChatMemoryAdvisor默认会把历史消息拼在系统提示词之后。如果你自己也在 prompt 里塞了系统消息,要注意顺序,否则可能出现“系统指令被历史消息淹没”的情况。实测下来,把系统提示词放在最前面、历史消息紧随其后,模型遵循指令的效果最稳。

5. 进阶玩法:让记忆更聪明、更省钱

基础版跑通之后,如果你想让记忆系统更上一层楼,可以试试下面几个方向。这些是我在实际项目里验证过、确实有效的做法。

5.1 用摘要压缩长对话

窗口机制的问题是“一刀切”——老消息直接丢,可能丢掉关键信息。更好的做法是:当消息超过阈值时,把最老的一批消息交给模型做摘要,用一条摘要消息替代它们。这样既压缩了 Token,又保留了核心信息。

实现上可以自定义一个 Advisor,在写入记忆前判断消息数量,超过阈值就触发摘要。摘要本身也是一次模型调用,所以要控制触发频率,别每轮都摘要。

5.2 结合向量检索做长期记忆

摘要解决的是“近期记忆压缩”,但有些信息需要长期保留,比如用户三个月前说过的偏好。这时候可以引入向量库:把重要消息做 embedding 存起来,每次对话时按语义检索相关历史,拼进 prompt。

这套方案的成本明显更高,适合对个性化要求高的场景。普通客服机器人用窗口加摘要就够了,别过度设计。

5.3 对接 Spring AI Alibaba 的注意事项

如果你用的是 Spring AI Alibaba 这套生态,记忆相关的抽象基本一致,但要注意版本差异带来的 API 变化。有些版本里ChatMemory的包路径和 Spring AI 官方不同,升级时容易编译不过。建议锁定版本,升级前先看 changelog。

另外,Spring AI Alibaba 提供了 Admin 控制台,用 Docker 部署后可以可视化查看会话和调用情况,调试多轮对话时非常方便。部署时注意把存储配置指向持久化后端,否则控制台重启后数据也没了。

5.4 记忆清理与隐私合规

用户有权要求“忘记我”。所以你的记忆系统必须支持按conversationId或用户维度清理数据。ChatMemory.clear(conversationId)能清掉单个会话,但如果是用户维度的清理,需要遍历该用户的所有会话 ID。

建议在设计之初就把conversationId和用户 ID 的映射关系存下来,否则后期想按用户清理会非常麻烦。这个映射表本身也要考虑过期和清理策略。

5.5 性能与成本的平衡点

最后聊聊取舍。记忆越全,体验越好,但成本和延迟越高。我的经验是分场景定策略:

  • 闲聊类:窗口 10 条足够,不需要摘要。
  • 任务类(如订票):窗口 20 条 + 关键信息抽取,把订单号等结构化信息单独存。
  • 长期陪伴类:窗口 + 摘要 + 向量检索,三者结合。

不要一上来就上最复杂的方案,先用窗口跑通,遇到瓶颈再逐步加码。很多项目其实窗口就够用了,过度设计反而增加维护负担。

我在实际项目里最深的一个体会是:对话记忆的难点从来不在“怎么存”,而在“怎么隔离”和“怎么清理”。存消息本身很简单,但一旦涉及多用户、多实例、隐私合规,复杂度就上来了。所以我的建议是,从第一天起就把conversationId的设计当回事,别等到用户投诉串台了才回头改,那时候改造成本会高得让你怀疑人生。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询