跳到主要内容

记忆压缩与容量治理

实战项目推荐

上下文容量治理需要摘要、筛选、检索和丢弃策略配合。超级 AI 智能体采用增量摘要和会话轮次管理,让记忆压缩从概念变成可运行的链路能力。

项目详细介绍:什么是超级 AI 智能体?

Context快满了,你的Agent会"失忆"

你用Agent帮你做一个需求调研的项目。前面花了一个小时确认需求边界、技术约束、预算范围,聊得很透彻。就在你准备让它输出最终方案的时候,它忽然开始提一些你早就否掉的思路,好像前面的讨论从来没发生过一样。

这种情况不是幻觉的问题,是context window溢出了

LLM每次生成回答依赖的是传入的messages列表。这个列表有硬性上限:GPT-4o是128K token,Claude系列是200K token。对话越长,列表越长,一旦逼近上限,系统会默认从最早的消息开始丢弃。那些你在第5轮就敲定的关键决策,到了第50轮可能已经不在context里了。

就算没超限,token数量也直接关联费用。一轮对话带着10万token的历史进去,比只带1万token贵10倍。对于高频调用的Agent来说,这个成本是很高的。

记忆压缩要解决的就两件事:在有限空间里保留最大信息量,以及控制每次调用的token开销

四种压缩策略,各解决不同层面的问题

压缩记忆不是只有一种做法。根据你想"保什么、丢什么"的侧重点不同,有四种方向。它们可以组合使用,不是互斥的。

四种压缩策略定位
四种压缩策略定位

策略一:滑动窗口——最快但最粗暴

做法很简单粗暴:设定一个保留窗口(比如最近20轮对话),超出窗口的历史直接移出context。

这就像你的手机只显示最近200条聊天记录——不是说前面的消息被删了,只是不在当前视野里了。

/**
* 滑动窗口实现 —— 旅行规划Agent场景
*/
public class SlidingWindowManager {

private static final int MAX_ROUNDS = 20; // 保留最近20轮

public List<Message> applyWindow(List<Message> fullHistory) {
if (fullHistory.size() <= MAX_ROUNDS * 2) {
return fullHistory; // 没超限,不处理
}

// 保留system prompt + 最近N轮的user/assistant对
List<Message> windowed = new ArrayList<>();
windowed.add(fullHistory.get(0)); // system prompt永远保留

int startIdx = fullHistory.size() - MAX_ROUNDS * 2;
windowed.addAll(fullHistory.subList(startIdx, fullHistory.size()));

return windowed;
}
}

优点:实现零成本,没有额外的LLM调用开销,延迟为零。

致命缺陷:它按时间一刀切,完全不管内容价值。可能第3轮对话里用户说了"预算绝对不能超过50万"这个硬约束,到第30轮这条信息已经被滑出窗口了,Agent就开始推荐80万的方案。

滑动窗口适合什么场景

短对话、或者历史信息确实不重要的场景。比如闲聊机器人、每次问答独立的FAQ助手。如果你的Agent需要维护跨轮次的状态和决策连贯性,单独用滑动窗口一定会出问题。

策略二:摘要蒸馏——丢之前先浓缩

摘要蒸馏的思路是:在滑动窗口把旧历史丢弃之前,先让LLM把这段历史总结成一小段精华摘要,用摘要替代原文。

打个比方:你的笔记本快写满了,你不是把前面的页撕掉,而是先把前半本的要点誊到一张便利贴上,贴在本子扉页,然后才把前半本归档。这样后面翻笔记的时候,至少知道前面讨论过什么、得出了什么结论。

/**
* 分层摘要蒸馏 —— 旅行规划Agent场景
* 策略:最近5轮保持原文,5~20轮压缩为中期摘要,20轮以前压缩为长期摘要
*/
@Service
public class HierarchicalSummarizer {

private final ChatModel chatModel;

public ContextPackage buildCompressedContext(List<Message> fullHistory) {
int totalRounds = fullHistory.size() / 2;

if (totalRounds <= 5) {
// 对话还短,不需要压缩
return new ContextPackage(fullHistory, null, null);
}

// 第一层:最近5轮保持原文(和当前任务最相关)
List<Message> recentRaw = tail(fullHistory, 10); // 5轮 = 10条消息

// 第二层:5~20轮的内容压缩为"中期摘要"
List<Message> midRange = slice(fullHistory, -40, -10);
String midSummary = summarize(midRange, "中期讨论摘要");

// 第三层:20轮之前的内容压缩为"长期摘要"(更精炼)
if (totalRounds > 20) {
List<Message> earlyRange = slice(fullHistory, 0, -40);
String longSummary = summarize(earlyRange, "早期背景摘要");
return new ContextPackage(recentRaw, midSummary, longSummary);
}

return new ContextPackage(recentRaw, midSummary, null);
}

private String summarize(List<Message> messages, String label) {
String prompt = """
请将以下对话历史压缩为一段简洁的摘要。
要求:
1. 保留所有明确的决策和结论
2. 保留用户表达的约束条件和偏好
3. 省略寒暄、重复确认、中间推理过程
4. 用陈述句列出要点,不要用对话体

对话内容:
%s
""".formatted(formatMessages(messages));

return chatModel.call(prompt);
}
}

分层的意义:不同时间段的信息用不同的精度保留。最近几轮可能随时被引用,需要原文细节;中期历史偶尔需要回顾,摘要级别够用;早期历史通常只需要知道"大方向",高度精炼即可。

这种分层设计类似于公司的文档管理体系:今天的会议有逐字记录,上个月的会议只有会议纪要,去年的只有年度总结。

代价:摘要过程本身需要一次LLM调用(额外的成本和延迟),而且摘要不可避免会丢失细节——LLM自己判断什么"不重要",这个判断不一定准确。

策略三:重要性评分——按价值而非时间筛选

前面两种策略本质上都是沿着时间轴操作的——越早的越容易被丢掉。但时间早不等于不重要。你项目第一天定下的技术路线,可能比昨天的一句闲聊重要一百倍。

重要性评分的做法是:给每条历史消息打一个"价值分",空间不够的时候按分数从低到高淘汰,和时间无关。

打分方式有两种思路:

规则打分(成本低、速度快):

  • 包含"决定"、"确认"、"必须"、"预算"等关键词 → +2分
  • 被后续对话引用或提及 → +3分
  • 用户主动强调的内容("这个很重要") → +5分
  • 纯粹的客套、感谢、确认收到 → -2分
  • Agent输出的中间推理过程 → -1分

LLM打分(精度高、成本大): 把一批待评估的消息喂给LLM,让它按1~10打分。适合定期批量清理时用,不适合实时处理。

/**
* 重要性评分 + 淘汰机制
*/
public class ImportanceFilter {

public List<Message> filterByImportance(List<Message> history, int tokenBudget) {
// 1. 给每条消息打分
List<ScoredMessage> scored = history.stream()
.map(msg -> new ScoredMessage(msg, calculateScore(msg)))
.toList();

// 2. 按分数降序排列
List<ScoredMessage> sorted = scored.stream()
.sorted(Comparator.comparingDouble(ScoredMessage::score).reversed())
.toList();

// 3. 从高分开始往里塞,直到token预算用完
List<Message> result = new ArrayList<>();
int usedTokens = 0;
for (ScoredMessage sm : sorted) {
int msgTokens = countTokens(sm.message());
if (usedTokens + msgTokens > tokenBudget) break;
result.add(sm.message());
usedTokens += msgTokens;
}

// 4. 按原始时间顺序恢复(Agent需要按时间线理解对话)
result.sort(Comparator.comparing(Message::getTimestamp));
return result;
}

private double calculateScore(Message msg) {
double score = 5.0; // 基础分
String text = msg.getContent();

// 关键决策信号
if (text.matches(".*(?:决定|确认|最终|敲定|选定).*")) score += 3;
// 约束条件信号
if (text.matches(".*(?:必须|不能|不允许|上限|底线|deadline).*")) score += 4;
// 闲聊信号
if (text.matches(".*(?:好的|收到|谢谢|明白了).*") && text.length() < 20) score -= 3;
// 工具返回的原始数据通常很长但信息密度低
if (msg.getRole().equals("tool") && text.length() > 2000) score -= 2;

return score;
}
}
观察遮蔽:一种更灵活的变体

重要性过滤的一种进阶形态叫"观察遮蔽"(Observation Masking)。它不是真的删除低分内容,而是在构造prompt的时候选择性地跳过某些历史。比如Agent当前在写代码,那需求讨论阶段的对话就暂时"隐藏",只把和编码相关的上下文传进去。等进入测试阶段,再把测试相关的内容"显示"出来。信息没有丢失,只是按当前任务阶段动态选择"此刻最需要看什么"。

策略四:结构化提取——换一种更紧凑的载体

前三种策略都有一个前提:信息以对话文本的形式保留。结构化提取跳出了这个框架,问了一个更本质的问题:对话原文本身真的需要保留吗?

很多时候,Agent真正需要的不是"用户当时说了什么话",而是"从对话中得出了什么结论"。把结论提取成结构化字段,信息密度能提升一个数量级。

/**
* 结构化提取 —— 旅行规划Agent从对话中提炼关键约束
* 300 token的对话 → 50 token的结构化字段
*/
@Data
public class TripConstraints {
private String destination; // "日本京都"
private String dateRange; // "2026-10-01 到 2026-10-08"
private int budgetCeiling; // 35000 (元)
private String travelStyle; // "文化深度游,不喜欢购物"
private List<String> mustVisit; // ["金阁寺", "岚山竹林"]
private List<String> dietRestriction;// ["不吃生鱼片"]
private String pacePreference; // "每天最多两个景点,中午要休息"
private boolean needVisa; // true
}

上面这个结构体用了大约50个token,但包含的信息量等价于原始对话中可能三四百token的来回确认。而且查询极快——要知道用户预算多少,直接读字段,不需要去一大段历史里"找"。

适合结构化提取的内容:约束条件、配置参数、用户画像、确认的决策。这些信息稳定、明确、可以用字段枚举。

不适合的:讨论过程中的推理脉络、还没有结论的开放性讨论、需要保留上下文才能理解的信息。

记忆衰减:不是所有记忆都该永远保留

前面讲的是"主动压缩"——因为空间不够了所以做裁剪。还有一种情况是:记忆本身在"贬值",时间越久价值越低,应该有一套自然的遗忘机制。

为什么需要遗忘

人类的遗忘不是缺陷,是进化优势。如果你记住了人生中每一秒的每个细节,大脑根本没法正常运转——你会被海量无关信息淹没,找不到真正重要的东西。

Agent的记忆也一样。一个运行了半年的Agent,长期记忆里可能堆了几万条记录。如果检索时所有记录权重相同,那半年前的一条过时信息和昨天的新决策会被同等对待,检索质量会持续下降。

时间衰减函数

最简单的遗忘机制是给记忆的相关性分数加一个时间衰减因子:

/**
* 带时间衰减的记忆检索
* 越久远的记忆,即使语义匹配度高,最终得分也会被打折
*/
public double calculateFinalScore(MemoryRecord record, double semanticSimilarity) {
// 记忆的年龄(天数)
long ageDays = ChronoUnit.DAYS.between(record.getCreatedAt(), Instant.now());

// 指数衰减:半衰期30天(30天后权重降为50%,60天后降为25%)
double decayFactor = Math.exp(-0.693 * ageDays / 30.0);

// 但如果这条记忆近期被"使用"过(被检索命中且Agent确实引用了),重置衰减
if (record.getLastUsedAt() != null) {
long daysSinceUse = ChronoUnit.DAYS.between(record.getLastUsedAt(), Instant.now());
decayFactor = Math.max(decayFactor, Math.exp(-0.693 * daysSinceUse / 30.0));
}

return semanticSimilarity * decayFactor;
}

这个设计的精妙之处在于"使用即续命":如果一条记忆经常被检索并被Agent实际引用,说明它仍然有价值,衰减就被重置。只有那些长期不被使用的记忆才会逐渐淡化——这和人类记忆的"用进废退"机制非常相似。

主动反思:让Agent自己整理记忆

比被动衰减更高级的做法是让Agent定期反思自己的记忆库。具体来说,每隔一段时间(比如每天或每周),触发一次"记忆整理"任务:

  1. 把最近一段时间新增的记忆和已有的旧记忆做对比
  2. 发现矛盾的标记冲突(保留新的,给旧的打"已取代"标签)
  3. 发现重复的做合并
  4. 发现规律的做抽象提炼(多条具体事件 → 一条通用规律)

这个过程可以用一次LLM调用来完成:把待整理的记忆打包喂给模型,让它输出整理后的结果。

Prompt Caching:计算层面的互补优化

前面讲的所有策略都是在"信息层"工作——决定哪些内容该留、哪些该丢、以什么形式保留。还有一个维度是"计算层"的优化:对于已经决定要带进context的内容,能不能减少重复计算的开销?

答案是Prompt Caching。

原理

LLM每次处理请求时,需要对输入的所有token做一次前向计算(prefill)。如果你连续多次请求的prompt前缀部分一模一样(比如system prompt + 注入的长期记忆 + 用户画像),每次都重新算一遍其实很浪费。

Prompt Caching的做法是:把稳定不变的prompt前缀的计算结果缓存起来,后续请求如果前缀匹配就直接复用,省掉重复计算的时间和费用。

以Claude为例,命中缓存的token费用大约是正常输入的1/10。对于Agent场景,system prompt + 长期记忆注入的部分在多轮对话中基本固定,天然适合被缓存。

和记忆压缩的关系

这两者是互补的,解决不同层面的问题:

维度记忆压缩Prompt Caching
工作层面信息层——决定"带什么进去"计算层——对"已经带进去的"减少重算
优化目标减少总token数减少重复token的计算成本
是否丢信息会丢(压缩必然有损)不丢(纯计算优化)
使用前提永远需要prompt有稳定不变的前缀时才有效

在实际系统里,两者配合使用效果最好:先通过压缩把context控制在合理范围内,再对其中稳定的部分启用缓存降低计算开销。

实战中的组合方案

讲了四种压缩策略 + 衰减机制 + 计算层优化,真到项目里用的时候该怎么组合?

分享一个经过验证的方案组合(适用于大多数需要多轮对话的Agent):

生产级记忆压缩组合方案
生产级记忆压缩组合方案

各层的分工:

  • 实时处理层:每轮对话结束后立即运行。发现用户新表达的偏好或约束就提取成结构化字段;如果这轮有工具调用返回了很长的原始数据(比如搜索结果、API响应),立刻压缩成摘要,不让原始数据占太多空间。
  • 容量管理层:当context里的token数接近预算的70%时触发。按时间远近分层做摘要蒸馏,最后再加一道滑动窗口作为硬性兜底,确保不管怎样都不超限。
  • 定期维护层:离线批量执行。对长期记忆库做重要性评分清理、时间衰减调整、跨记忆的整合反思。

小结

策略核心逻辑优势代价适合场景
滑动窗口按时间硬截断零成本、零延迟丢失早期关键信息短对话、历史不重要
摘要蒸馏截断前先总结保留关键脉络额外LLM调用、细节丢失长对话、需要连贯性
重要性评分按价值筛选保留高价值信息评分不一定准对话中混杂重要/不重要
结构化提取文本→字段信息密度最高需要预定义schema约束/偏好类信息
时间衰减自然遗忘防止旧记忆干扰偶尔误衰减有效记忆长期运行的Agent
Prompt Caching缓存计算结果降费用降延迟仅对稳定前缀有效prompt有固定部分时
🎁优惠