19-多轮会话记忆与持久化
多轮对话怎样管理?ChatMemory、Redis 与会话归属
页面保留了一百条历史,模型却只记得最近几轮;两个请求同时发送,又可能覆盖彼此记忆。完整历史、模型上下文与并发会话是三种不同需求,需要分别设计。
Spring AI 接入片段使用 Java 17、Spring Boot 3.5.3 与 Spring AI 1.1.8;协议与第三方平台示例另行标明适用范围。
1. 把历史和上下文分开
保存所有消息不意味着每次全部发送给模型。
记忆窗口不是完整历史
MessageWindowChatMemory 按消息数量保留窗口,不能把 maxMessages 理解为对话轮数,更不能把消息数当成 Token 上限。某一条长消息仍可能占用很多上下文。
ChatMemoryRepository 负责存取消息,ChatMemory 决定保留策略。窗口淘汰旧消息后,若仓库保存的是当前窗口快照,旧内容不会因为放进 Redis 就自然成为完整档案。Spring AI 1.1.8 记忆设计
可以分别保存会话表、消息历史表、生成任务表与短期记忆。这样才能表达一条消息正在生成、失败还是取消,以及哪些内容最终进入模型上下文。
conversationId 必须经过授权
客户端可以提交 conversationId,但服务端要检查它属于当前用户和租户。随机 ID 降低猜测概率,不能代替权限检查。
应用内部可以生成 tenant:3:user:7:conversation:abc 一类经过验证的存储标识。不要直接将任意客户端字符串拼到 Redis 命名空间。本文固定的 1.1.8 版本要求每次调用传入 conversationId,遗漏时会抛出异常;不能依赖其他版本教程中的默认会话行为。
管理员查询完整历史与模型读取当前记忆具有不同权限。仓库枚举方法不能作为公开聊天接口使用。
2. 一个 Redis 记忆仓库怎样接入
示例说明可持久化的消息类型与依赖,不冒充完整聊天系统。
一个文本消息 Redis 仓库
以下接入代码对应 Spring AI 1.1.8,沿用Java 服务变慢怎样定位?从 CPU、GC 到内存保留链依赖并增加 spring-boot-starter-data-redis。它使用普通 Redis String,不依赖 Redis Search;只保存纯文本用户、助手和系统消息,拒绝工具调用与多模态消息。
package demo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeSet;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
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.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
public final class TextRedisMemoryRepository implements ChatMemoryRepository {
public record Stored(String role, String text) {}
private static final String PREFIX = "blog:chat:memory:";
private final StringRedisTemplate redis;
private final ObjectMapper json;
public TextRedisMemoryRepository(StringRedisTemplate redis, ObjectMapper json) {
this.redis = redis;
this.json = json;
}
private String key(String id) {
if (id == null || !id.matches("[A-Za-z0-9:_-]{1,160}")) {
throw new IllegalArgumentException("invalid conversation id");
}
return PREFIX + id;
}
@Override
public List<String> findConversationIds() {
TreeSet<String> ids = new TreeSet<>();
try (var cursor = redis.scan(ScanOptions.scanOptions()
.match(PREFIX + "*").count(100).build())) {
while (cursor.hasNext()) {
ids.add(cursor.next().substring(PREFIX.length()));
if (ids.size() > 10_000) {
throw new IllegalStateException("use paged business conversation index");
}
}
}
return List.copyOf(ids);
}
@Override
public List<Message> findByConversationId(String id) {
String value = redis.opsForValue().get(key(id));
if (value == null) return List.of();
try {
List<Stored> stored = json.readValue(value, new TypeReference<List<Stored>>() {});
List<Message> messages = new ArrayList<>();
for (Stored item : stored) {
messages.add(switch (item.role()) {
case "USER" -> new UserMessage(item.text());
case "ASSISTANT" -> new AssistantMessage(item.text());
case "SYSTEM" -> new SystemMessage(item.text());
default -> throw new IllegalArgumentException("unsupported stored role");
});
}
return messages;
} catch (Exception error) {
throw new IllegalStateException("invalid memory data", error);
}
}
@Override
public void saveAll(String id, List<Message> messages) {
String target = key(id);
List<Stored> stored = new ArrayList<>();
for (Message message : messages) {
if (message instanceof UserMessage user && !user.getMedia().isEmpty()) {
throw new IllegalArgumentException("media is unsupported");
}
if (message instanceof AssistantMessage assistant
&& (!assistant.getToolCalls().isEmpty() || !assistant.getMedia().isEmpty())) {
throw new IllegalArgumentException("tool calls and media are unsupported");
}
String role = message.getMessageType().name();
if (!List.of("USER", "ASSISTANT", "SYSTEM").contains(role)) {
throw new IllegalArgumentException("unsupported message type");
}
stored.add(new Stored(role, message.getText()));
}
try {
redis.opsForValue().set(target, json.writeValueAsString(stored), Duration.ofDays(30));
} catch (Exception error) {
throw new IllegalStateException("memory save failed", error);
}
}
@Override
public void deleteByConversationId(String id) {
redis.delete(key(id));
}
}
saveAll 替换当前快照,不是追加历史;一次 SET 与过期设置使用对应原子命令,但整个读取、修改、写回过程并不原子。代码不保留消息元数据,不能用于要求完整保留工具协议或多模态内容的场景。
SCAN 适用于这里的有界示例,可能重复且不提供固定快照;集群扫描还要考虑实际节点覆盖。正式会话列表应使用具有授权和分页的业务索引。
连接记忆与 ChatClient
在已有应用中注册对应 Bean:
package demo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
@Configuration
public class MemoryConfig {
@Bean
ChatMemory chatMemory(StringRedisTemplate redis, ObjectMapper json) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new TextRedisMemoryRepository(redis, json))
.maxMessages(20).build();
}
@Bean
ChatClient rememberedClient(ChatClient.Builder builder, ChatMemory memory) {
return builder.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build();
}
}
业务应注入这个 ChatClient Bean,再使用 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, authorizedId)) 指定已授权会话。Java 服务变慢怎样定位?从 CPU、GC 到内存保留链原 Controller 自己构建客户端,添加 Bean 不会自动让它改用这个实例。
预期同一有效会话后续请求可以携带窗口内上下文;到期或删除后不能继续依赖该记忆。模型是否正确利用记忆需要另外评估。
3. 并发与权限为什么会让记忆失效
会话更新需要顺序,读取与缓存也必须遵守用户范围。
并发为何会丢消息?
同一会话的请求 A、B 都读到相同旧窗口,各自生成回答并保存新快照,后写者可能覆盖先写者。线程安全 Map 或单次 Redis SET 不会解决整个会话操作的竞争。
可以按会话串行排队,或者使用版本检查发现冲突并按明确策略处理。串行约束应覆盖整个一轮生成,而不仅覆盖保存瞬间;跨实例还需统一协调和超时恢复。
模型请求失败时,用户消息可能已经加入记忆,而助手答案未加入。应结合生成状态解释这种不完整轮次。流式取消是否留下部分内容,也要看 Advisor 与保存机制,不能默认都是完整历史。
缓存如何避免跨用户串线?
同一问题在不同租户、不同文档范围和版本下,答案可能不同。缓存键需要体现影响结果的范围、资料版本与配置,或采用可证明安全的共享策略。
只用“问题 hash”作为缓存键,可能把管理员得到的内容返回普通用户。会话记忆也要隔离,不能检索范围正确,却在历史中带入另一个用户的答案。
引用文件的下载地址应经过授权入口或合法短期凭证,不能把永久公开链接当作默认引用方式。
引用、历史和工具同样需要授权
引用链接可能泄漏文档名称或文件位置。回答前验证来源可见,打开文件时再验证;短期签名链接按对象和期限限制,不能当作永久公共链接。
用户权限撤销后,历史消息可能仍保存旧内容。项目需要定义历史访问与撤权策略,不能只清向量库就认为所有展示渠道都已消除。
工具查询使用服务器用户身份,订单编号来自用户内容。SQL 同时约束租户和所有者;是否有管理员查询入口,应独立授权和审计,不让普通助手自动切换到管理员模式。
MCP 或其他远程工具需要验证可信用户委托。仅传递一个字符串 userId,不足以防止客户端伪造身份。
4. 设计判断与适用边界
数据库保存对用户可见的完整记录,模型记忆根据预算选择窗口、摘要或相关片段。把工具结果、大文件和所有旧回答无差别放进窗口,会消耗上下文并引入无关信息。记忆方案通过需要跨轮事实的样本验证。
会话编号只用于定位,不是访问凭证。列表、历史、停止和删除都验证当前归属。并发请求采用明确的串行或版本策略,跨实例时不能只使用本地锁。权限撤销也影响历史和缓存,不只影响下一次检索。
5. 问答
问:Redis 保存了窗口,就有完整聊天历史吗?
答:没有。窗口可能淘汰旧消息,完整历史需要独立持久化。
问:conversationId 很随机,还要授权吗?
答:需要。它只是标识,不能证明归属。
问:提示词写“不泄露其他租户”够吗?
答:不够。资料进入模型前就要按可信身份限制。
问:最终删掉引用,就没有泄漏吗?
答:正文、日志和外部重排请求仍可能包含内容,应检查完整链路。