你的AI在第4轮对话就失忆了——滑动窗口的致命缺陷与摘要压缩的救赎
滑动窗口,一行代码搞定,开箱即用——但你的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的组合方案才是完整的生产级记忆管理:
- 对话过程中,
CompressingChatMemoryStore负责压缩,保持Token可控 - 压缩后的消息(摘要SystemMessage + 近期消息)直接存入Redis
- 新会话启动时,从Redis加载历史消息 → AI立即"恢复记忆"
- 继续对话 → 再次压缩 → 再次存入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。这导致两个问题:
- 不必要的LLM调用成本——“好的”"done"这种消息根本不需要LLM来压缩
- 压缩延迟——每条消息都经过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实战踩坑经验持续输出。
更多推荐

所有评论(0)