滑动窗口,一行代码搞定,开箱即用——但你的AI在第4轮对话就失忆了。实测:窗口设4,到第4轮AI已经不知道你叫什么。滑走的老消息里,藏着你的姓名、偏好、所有上下文。摘要压缩能救这个命——老消息不扔,先让LLM提炼核心实体,像滚雪球一样保留关键信息。再往上走,Claude Code的四级压缩才是工业级:前三级零成本不调LLM,只有AutoCompact才触发摘要。理解这三层,你就理解了LLM记忆管理从原型到工业级的完整路径。

1 你的AI在悄悄遗忘——滑动窗口的致命缺陷

你用LangChain4j搭了个聊天应用,配了个MessageWindowChatMemory,窗口大小设4。觉得够用了——每次对话保留最近4条消息,老消息滑走,Token可控,响应速度也不错。

但你有没有想过,滑走的那些消息里,藏着什么?

我今天做了一个实测,5轮对话,窗口=4,结果让人头皮发麻:

第1轮,用户说"我叫张空少,Java后端开发者",AI正常回复。

第2轮,用户说"学过RAG,用LangChain4j",AI正常回复,还能关联到前面的信息。

第3轮,Memory里已经有5条消息了(User1 + AI1 + User2 + AI2 + User3),窗口快要满了。

第4轮,最关键的一轮——User1被丢弃了!那条"我叫张空少,Java后端开发者"的消息直接滑出了窗口。AI靠什么作答?靠AI2的回复里碰巧提到了"张空少"和"Java后端开发者"这些信息。这不是记住你,是捡到了别人提过的残片。

第5轮,问AI"我叫什么"——完全不知道。第1轮的消息早就滑走了,连残片都捡不到。但问"做什么工作+学了什么",AI居然答对了——Java + RAG。为什么?因为AI2的回复里提到了这些,而AI2的回复还在窗口里。

这说明了什么?滑动窗口不是在帮你记,是在帮你忘。 它丢弃消息的方式是粗暴的——不管内容重要不重要,老的就扔。你的姓名、你的职业、你的偏好,跟一句"好的谢谢"享有同等的淘汰权。

MessageWindowChatMemory的原理很简单:维护一个固定大小的消息列表,新消息进来,老消息超出窗口就删除。SystemMessage除外——它会一直保留。但SystemMessage一般只有一个,是你预设的系统角色提示,用户的个人信息不会出现在里面。

LangChain4j官方文档对MessageWindowChatMemory的态度很明确:

MessageWindowChatMemory主要用于快速原型开发。

官方自己都说了,这是原型工具,不是生产方案。但多少人在生产环境直接用了它?因为太方便了,一行代码:

ChatMemory chatMemory = MessageWindowChatMemory.builder()
    .maxMessages(4)
    .build();

窗口大小设4,看起来合理——毕竟一般对话也就三五轮。但你低估了用户的行为模式。真实场景里,用户可能跟你聊20轮、50轮。每轮都在滑走老消息,而那些老消息里,可能有用户一开始说的"我是前端开发者"、“帮我用React”、“我偏好TypeScript”。这些关键实体,在第5轮就没了。

更扎心的是:窗口大小设大了也不解决问题。设20?Token爆炸,成本飙升,响应变慢。而且20轮之后照样滑走,问题只是被推迟了,不是被解决了。

关键洞察:信息丢失是确定性事件,不是概率问题。 窗口满了就丢,这是硬逻辑,没有"运气好就保留"的可能。你要么接受丢信息,要么换方案。

2 摘要压缩——让AI像滚雪球一样记住你

滑动窗口的问题本质上是:它把消息当作等价的容器来管理,进去一批,出来一批,不管里面装的是金子还是沙子。

摘要压缩换了个思路:老消息不直接扔,而是先让LLM提炼一遍,把金子掏出来存好,沙子再扔。

原理是这样的——

当Memory里的消息数量达到阈值,触发压缩:把超出keepRecent的几条老消息,交给LLM做摘要,压缩成一条SystemMessage。这条SystemMessage包含了所有老消息中的关键实体——用户姓名、职业、偏好、已讨论的上下文。

然后在后续对话中,当新的老消息又超出阈值,再次触发压缩。但这次压缩不是从头开始——而是把旧摘要 + 新的老消息一起交给LLM,生成更新后的摘要。这就像滚雪球:每滚一圈,雪球更大,但核心始终在里面。

我写了一个自定义的CompressingChatMemoryStore来实现这套逻辑,核心代码也就二三十行:

public class CompressingChatMemoryStore implements ChatMemoryStore {
    private final ChatLanguageModel compressModel;
    private final int maxMessages;
    private final int keepRecent;
    private final ChatMemoryStore delegate;

    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        if (messages.size() <= maxMessages) {
            delegate.updateMessages(memoryId, messages);
            return;
        }
        // 分割:要压缩的老消息 + 保留的近期消息
        int splitIndex = messages.size() - keepRecent;
        List<ChatMessage> toCompress = messages.subList(0, splitIndex);
        List<ChatMessage> recent = messages.subList(splitIndex, messages.size());

        // 旧摘要 + 老消息 → 新摘要
        String oldSummary = extractExistingSummary(toCompress);
        String newSummary = compressModel.chat(
            buildSummaryPrompt(oldSummary, toCompress)
        );

        // 组装:摘要SystemMessage + 近期消息
        List<ChatMessage> compressed = List.of(
            SystemMessage.from(newSummary)
        );
        List<ChatMessage> result = new ArrayList<>(compressed);
        result.addAll(recent);
        delegate.updateMessages(memoryId, result);
    }
}

两个关键参数:maxMessages控制何时触发压缩(消息数超过这个值就压缩),keepRecent控制保留最近几条不压缩。我设的是maxMessages=6, keepRecent=4——当消息数超过6条,保留最近4条,其余压缩成摘要。

实测结果,跟滑动窗口天差地别——

第4轮触发第一次压缩:3条老消息(User1 + AI1 + User2)压缩成1条摘要SystemMessage:

“张空少,Java后端开发者,转行做大模型应用。学过RAG系统搭建,使用LangChain4j…”

你看,姓名、职业、学习内容全在里面。

第5轮触发第二次压缩:旧摘要 + 新的老消息 → 合并更新成新摘要:

“张空少,Java后端转做大模型应用,使用LangChain4j搭建RAG系统。今日学习上下文管理…”

滚雪球生效了——旧摘要的核心信息没丢,新信息又叠加上去了。

第5轮问AI"我叫什么+做什么工作+学了什么"——AI全部正确答出!张空少、Java后端、RAG + LangChain4j。跟滑动窗口第5轮完全失忆形成了鲜明对比。

当然,摘要压缩不是没有代价。每次压缩多调用1次LLM,增加了一点延迟和Token消耗。但这笔账算下来是划算的:压缩1次花的Token,远少于后续每轮把全部历史消息都发过去花的Token。信息密度更高,关键实体稳定保留,成本反而可控。

3 实测对比——数据说话

光说原理没说服力,看数据:

维度 滑动窗口(window=4) 摘要压缩(maxMessages=6, keepRecent=4)
第4轮问名字 ❌ 不知道(User1已滑走) ✅ 正确答出(摘要里有)
第5轮问职业 ⚠️ 靠AI2回复里的残片勉强答对 ✅ 精准回答
第5轮问学习内容 ⚠️ 同上,靠残片 ✅ 精准回答
第5轮问"我叫什么" ❌ 完全失忆 ✅ 正确答出
Token消耗 低(丢弃后只发窗口内消息) 中等(摘要 + 近期消息)
LLM调用次数 1次/轮 1次/轮 + 压缩时额外1次
信息保留率 ❌ 逐轮递减至零 核心实体稳定保留
实现复杂度 极低(开箱即用) 中(需自定义ChatMemoryStore)
适用场景 快速原型、3轮以内短对话 生产环境、5轮+长对话

这张表很直观:滑动窗口在短对话(3轮以内)完全够用,简单快速零成本。但一旦对话超过4轮,关键信息开始丢失,而且丢失速度线性递增——第4轮丢名字,第6轮丢偏好,第10轮丢上下文,越来越惨。

摘要压缩在4轮之后开始发力:核心实体始终保留,即使对话拉到20轮、50轮,AI依然知道你是谁、你在做什么、你之前聊了什么。代价就是每次压缩多调1次LLM,大概增加2-3秒延迟和几百Token消耗。

关键结论:滑动窗口适合3轮以内的短对话;摘要压缩适合5轮+长对话场景。 这不是信仰之争,是场景选择。但如果你要做生产环境的聊天应用,用户大概率会跟你聊超过4轮——那就别用滑动窗口。

4 Redis持久化——让记忆跨越会话

摘要压缩解决了"单次对话内记忆不丢失"的问题。但还有另一个场景:用户关掉对话窗口,明天再来,AI还记得你吗?

答案是:不记得。

默认的ChatMemoryStore是内存实现的——InMemoryChatMemoryStore。服务重启、会话过期、用户离开,内存清空,AI的记忆全部消失。第二天你回来,AI又得从头认识你,问你叫什么、做什么、偏好是什么。

这在生产环境是不可接受的。用户期望AI有连续性——今天告诉你的事,明天应该还在。

解决方案是把ChatMemoryStore换成Redis实现。核心就是三个方法:

public class RedisChatMemoryStore implements ChatMemoryStore {
    private final JedisPool jedisPool;

    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        String json = jedisPool.getResource().get("chat:" + memoryId);
        return ChatMessageSerializer.messagesFromJson(json);
    }

    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        String json = ChatMessageSerializer.messagesToJson(messages);
        jedisPool.getResource().set("chat:" + memoryId, json);
    }

    @Override
    public void deleteMessages(Object memoryId) {
        jedisPool.getResource().del("chat:" + memoryId);
    }
}

memoryId作为Redis key区分不同用户和不同会话。可以是用户ID、会话ID,或者两者的组合。

摘要压缩 + Redis的组合方案才是完整的生产级记忆管理

  1. 对话过程中,CompressingChatMemoryStore负责压缩,保持Token可控
  2. 压缩后的消息(摘要SystemMessage + 近期消息)直接存入Redis
  3. 新会话启动时,从Redis加载历史消息 → AI立即"恢复记忆"
  4. 继续对话 → 再次压缩 → 再次存入Redis → 循环

用户明天再来,AI能直接说:“你好张空少,上次你学了RAG,今天想继续聊什么?”

这才是用户期望的体验。

几个生产环境考量:

  • 过期策略:给Redis key设TTL,比如7天。超过7天没活动的会话自动清除,避免Redis内存膨胀
  • 多实例共享:多个服务实例共享同一个Redis,用户请求打到任何实例都能读到记忆
  • 序列化:用ChatMessageSerializer工具,别自己手写JSON,LangChain4j的消息类型有特殊结构

5 天花板——Claude Code的四级压缩

前面说的摘要压缩方案已经很实用了。但如果你想知道工业级方案做到什么程度,看Claude Code。

Claude Code是Anthropic出的AI编程助手(类似Cursor但基于Claude)。它要处理的上下文比聊天应用复杂得多——代码文件、工具调用结果、对话历史、错误日志……随便一个项目的上下文就能塞满整个Token窗口。

它的压缩策略不是一层,而是四层,逐级升级:

Level 1:Snip(截断单条消息)

一条消息太长?比如你让AI读一个5000行的文件,它把整个内容放进上下文。Snip直接截断:只保留头部N行和尾部N行,中间用"… truncated"标记。

这跟滑动窗口的粗暴丢弃完全不同——Snip保留了"开头说了什么"和"结尾说了什么",中间的内容虽然没了,但至少你知道中间有内容被省略了。

零成本,不调LLM,纯字符串操作。

Level 2:MicroCompact(合并短消息)

对话里经常有这种碎片:

User: 好的
AI: 已修改
User: done
AI: 完成

这些消息各自占一条,但信息密度极低。MicroCompact把它们合并成一条:“用户确认了修改,AI完成了操作。”

零成本,不调LLM,纯合并逻辑。

Level 3:Collapse(折叠工具调用结果)

AI调工具跑命令,结果可能很长。比如npm test输出200行日志。Collapse把这200行折叠成一行摘要标记:“3 failed, 47 passed”。

这也不是用LLM做的——Claude Code有专门的模板规则,识别常见工具输出格式(测试结果、编译错误、git log等),用正则和规则提取关键信息。

低成本,不用LLM,但比前两级稍复杂。

Level 4:AutoCompact(整体摘要)

前三级都搞不定的时候,才触发AutoCompact——跟我们的摘要压缩方案类似,把整个上下文交给LLM做摘要。

高成本,调LLM,产生额外Token消耗和延迟。

关键洞察在这里:Claude Code的四层压缩里,前三层都是零成本不调LLM。而根据Anthropic的数据,90%的上下文压力在前三级就解决了,只有10%的情况才需要触发AutoCompact。

这意味着什么?意味着大部分"上下文爆炸"不是真的需要摘要——而是需要截断、合并、折叠。你不需要让LLM读一个5000行的文件来做摘要,你只需要截断它。你不需要LLM来总结"好的"“done”“已改”,你只需要合并它们。

对比我们的摘要压缩方案

LangChain4j的摘要压缩是"一刀切"——所有老消息都交给LLM压缩。不管老消息里是一条5字确认还是一段500字的讨论,统统扔给LLM。这导致两个问题:

  1. 不必要的LLM调用成本——“好的”"done"这种消息根本不需要LLM来压缩
  2. 压缩延迟——每条消息都经过LLM,哪怕它毫无信息密度

Claude Code的分级思路给我们的启发是:压缩策略应该分级,先用零成本手段,实在不行了再调LLM。

想象一下,如果你在LangChain4j的CompressingChatMemoryStore里加了这些前置处理:

  • 先合并短消息(Level 2 MicroCompact)
  • 先折叠工具调用结果(Level 3 Collapse)
  • 然后再把剩余的消息交给LLM做摘要(Level 4 AutoCompact)

LLM调用的次数和Token消耗会大幅减少,压缩延迟也会降低。这不是理论推演——Claude Code已经在实际产品里验证了这条路。

6 实战建议——选哪种方案?

说了这么多,到底该怎么选?我给一个简单的决策树:

3轮以内短对话 → MessageWindowChatMemory

如果你的场景是单次问答式交互——用户问一个问题,AI回答,结束。比如客服FAQ、单次翻译、一次性代码审查。这种场景对话不会超过3轮,滑动窗口完全够用,别过度设计。

5轮+长对话 → 自定义摘要压缩ChatMemoryStore

如果你的场景是多轮深度对话——学习辅导、项目讨论、长期心理咨询。用户会跟你聊10轮、20轮甚至更多。这种场景必须用摘要压缩,否则AI在第5轮就开始失忆,用户体验直线下降。

跨会话需要 → 摘要压缩 + Redis持久化

如果用户会反复回来——比如个人助手、健康管理、长期项目跟踪。你需要Redis持久化,让记忆跨越会话。今天告诉AI的事,明天还在。

工业级优化 → 参考Claude Code分级压缩思路

如果你已经用摘要压缩了,还想进一步优化成本和延迟。可以参考Claude Code的分级思路,在压缩之前加零成本的前置处理:先合并短消息,先折叠工具结果,到这步才调LLM做摘要。

不过说实话,对于大多数项目,摘要压缩+Redis已经够用了。分级压缩是锦上添花,不是雪中送炭。先把摘要压缩搞定,再考虑分级优化。

两个踩坑记录

写这篇文章的过程中踩了两个坑,记录一下免得你也踩:

坑1:LangChain4j 1.15.0的chatModel.chat(String)返回String不是ChatResponse

我一开始写压缩代码的时候,用了这样的调用方式:

// 错误写法——1.15.0里chat(String)返回String
String summary = chatModel.chat("压缩这些消息...");
// 然后试图调 .aiMessage().text() → 编译报错

chatModel.chat(String)在1.15.0里直接返回String,不是ChatResponse对象。你不能对String调.aiMessage().text()。要么用chat(ChatRequest)方法返回ChatResponse,要么直接拿String用。

这坑看起来小,但浪费了我半小时排查编译错误。LangChain4j的API版本差异挺大的,不同版本之间方法签名会变,建议锁定版本号。

坑2:摘要压缩需额外LLM调用,默认超时太短 → HttpTimeoutException

摘要压缩要调LLM,而且输入是一堆历史消息,输出是一段摘要。这比普通一轮对话的输入量更大,LLM处理时间更长。如果你用的是默认超时配置,大概率会超时。

我第一次跑压缩的时候就遇到了HttpTimeoutException——LLM还没压缩完,客户端就超时断开了。

解决办法很简单,加超时配置:

OpenAiChatModel chatModel = OpenAiChatModel.builder()
    .apiKey("your-key")
    .modelName("gpt-4o-mini")
    .timeout(Duration.ofSeconds(120))  // 关键!默认太短
    .build();

120秒足够了。如果你的历史消息特别多(超过20条),可以考虑设到180秒。

这两个坑都不是什么高深问题,但都是实操中容易遇到的。写下来希望你少走弯路。


如果你在做AI聊天应用,上下文管理是绕不过去的坎。从滑动窗口到摘要压缩,再到Claude Code的四级压缩——理解了这三层,你就理解了LLM记忆管理从原型到工业级的完整路径。

别让你的AI在第4轮就开始遗忘。该压缩的时候压缩,该持久化的时候持久化。记住用户的名字,记住他们说过的话——这不是奢侈品,这是基本功能。

欢迎关注,更多LLM实战踩坑经验持续输出。

Logo

脑启社区是一个专注类脑智能领域的开发者社区。欢迎加入社区,共建类脑智能生态。社区为开发者提供了丰富的开源类脑工具软件、类脑算法模型及数据集、类脑知识库、类脑技术培训课程以及类脑应用案例等资源。

更多推荐