首页 / Spring AI 入门教程 / 内存记忆与滑动窗口

Spring AI 入门教程

内存记忆与滑动窗口

本教程共 45 篇 · 第 22 篇 · 更新于 2026-08-16 · 约 7 分钟阅读

Spring AIInMemoryChatMemoryRepositoryMessageWindowChatMemory滑动窗口maxMessages多会话内存记忆

本节目标:掌握 Spring AI 默认的内存记忆实现。学完你会配置 MessageWindowChatMemory 的窗口大小,理解整轮裁剪策略,能用 conversationId 隔离多个会话。

22.1 默认实现:InMemoryChatMemoryRepository

上一章说过,ChatMemoryRepository 负责消息的存取。最基础的实现是 InMemoryChatMemoryRepository,内部用一个 ConcurrentHashMap 存消息,以 conversationId 为键。

不配置任何东西时,Spring AI 自动配置的 ChatMemoryRepository 就是它。官方快速入门直接注入:

@Autowired
ChatMemoryRepository chatMemoryRepository;

手动创建也很简单:

ChatMemoryRepository repository = new InMemoryChatMemoryRepository();

ConcurrentHashMap 意味着线程安全,多请求并发读写同一个会话不会乱。这是它适合做默认实现的原因。要是换成普通 HashMap,并发写可能丢数据,甚至把链表打成环,这是面试爱问的细节。

内存实现的代价也明显:数据存在 JVM 堆里,进程一重启就没了。多实例部署时,每个实例各存一份,用户请求打到不同实例,记忆就对不上。它适合开发和单机场景,生产环境要换持久化方案,第 23 章讲。

22.2 滑动窗口:MessageWindowChatMemory

光有仓库还不够,还要决定留哪些消息。Spring AI 内置的记忆实现是 MessageWindowChatMemory,维护一个滑动窗口。窗口里最多放 maxMessages 条消息,默认 20 条。新消息进来,窗口塞满,最旧的消息被挤出去。

系统消息特殊:无论窗口怎么挤,SystemMessage 永远保留。系统消息承载角色设定和指令,丢了整个对话就变味。

举个例子。窗口大小设为 4,消息按顺序进来:系统消息 S、用户 U1、助手 A1、用户 U2、助手 A2、用户 U3。塞满后新消息进来,最旧的内容被挤出,最终窗口里是 S、U2、A2、U3。S 始终在,最早的一轮 U1、A1 被整轮淘汰。

ChatMemory memory = MessageWindowChatMemory.builder()
        .maxMessages(10)
        .build();

这也是自动配置的默认记忆实现。Spring AI 启动时,如果项目里没有别的记忆 Bean,就装配一个 MessageWindowChatMemory 加 InMemoryChatMemoryRepository 的组合。大多数入门项目零配置就能用。

22.3 窗口大小怎么定

maxMessages 是窗口的核心参数。定多少合适?看你的场景。

值太小,模型能记住的上下文就短。用户几分钟前提的事,可能已经被挤出窗口。值太大,token 消耗和成本跟着涨,还可能顶到模型上下文窗口上限。

实操建议:从 20 起步,观察对话效果和 token 用量再调。工具调用多的场景,一轮对话会产生用户消息、助手回复、多次工具调用和工具响应,占的条数多,窗口要给足。

给个参考:纯问答的客服机器人,10 到 20 够用;带工具调用的复杂任务,往 30 到 50 靠。数字不是官方规定,是经验值,最终以你的场景实测为准。

22.4 2.0 的裁剪策略:整轮淘汰

Spring AI 2.0 对裁剪逻辑做了调整。窗口需要挤掉旧消息时,按”整轮”来切,而不是从中间砍。

这里的一轮(turn)从一条 UserMessage 开始,包括后续的助手回复、工具调用、工具响应,直到下一条 UserMessage 为止。如果按条数算出的裁剪点落在半轮中间,比如一条助手回复上,就把裁剪点往后挪到下一个 UserMessage,保证留下的窗口总是从完整的一轮开始。

这个策略有个推论:maxMessages 是上限,不是精确值。实际存的消息可能比它少,因为要凑整轮。窗口比一轮完整的对话还小时,可能出现极端情况:除了系统消息全被清空,直到新的用户消息进来。官方建议窗口大小要能容纳至少一轮有代表性的对话。

Tip

配窗口时,把一轮对话可能产生的消息条数估算进去。带工具调用的轮次,轻松就是五六条消息起步。

22.5 多会话隔离

conversationId 是会话的钥匙,记忆按它分桶。不同的 ID 各自有独立的历史,互不串扰。同一个 ID 重复使用,历史会不断累积。

这就解决了多用户问题:每个用户一个 ID,记忆天然隔离。常见做法是用用户 ID 或会话 ID 直接当 conversationId。比如 Web 应用从请求头取 userId:

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

并发场景也不用慌。同一个 conversationId 的读写是并发的,InMemoryChatMemoryRepository 用 ConcurrentHashMap 保证线程安全,MessageWindowChatMemory 内部也做了同步处理。

两个用户各聊各的,互不干扰:用户甲用 “user-a”,用户乙用 “user-b”,两边各自累积各自的历史。对话服务里这是基本盘,用户切错会话,等于偷看了别人的聊天记录。

会话结束想清场,调用 clear 方法:

chatMemory.clear(conversationId);

用户登出、会话过期、重开新话题,都可以用它把旧历史抹掉,避免下一轮对话带着上一话题的尾巴。

22.6 完整示例:给 ChatClient 装上窗口记忆

把前面几节串起来,写一个配置类:

@Configuration
public class AiConfig {

    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(new InMemoryChatMemoryRepository())
                .maxMessages(10)
                .build();
    }

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
}

两个 Bean 各司其职。chatMemory 定义记忆类型和窗口,chatClient 把记忆 Advisor 挂进默认调用链。业务代码只要传 conversationId,记忆就自动工作。想换存储,把 chatMemory 里的 Repository 换掉就行,其余不动。

这段代码没指定 Repository,默认就用 InMemoryChatMemoryRepository。想换滑动窗口以外的策略,实现 ChatMemory 接口自己写一个也行,官方预留了扩展位。

22.7 内存记忆的边界

内存方案适合什么?开发调试、原型验证、单实例部署、会话不要求跨重启保留。这些场景它零成本、零配置,体验最好。

什么时候该换?进程重启丢记忆、多实例共享记忆、需要审计留痕、需要按用户维度管理数据。出现这些需求,就看第 23 章的持久化实现。

还有个隐形成本别忘了:内存里存的是对象,不是文本。会话多了,堆内存占用跟着涨。一台机器撑几万条会话没问题,再往上就得考虑存储边界了。

22.8 小结

内存方案用 ConcurrentHashMap 存消息,配合滑动窗口按整轮裁剪,开发与单机场景完全够用。生产环境要持久化,下一章看六种持久化实现。