19-多轮会话记忆与持久化

O泡李华 3

多轮对话怎样管理?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 很随机,还要授权吗?

答:需要。它只是标识,不能证明归属。

问:提示词写“不泄露其他租户”够吗?

答:不够。资料进入模型前就要按可信身份限制。

问:最终删掉引用,就没有泄漏吗?

答:正文、日志和外部重排请求仍可能包含内容,应检查完整链路。

参考资料