文章目录
- 前言
- 大模型Agent上下文压缩实战:从原理到CompactSummarizationMiddleware实现
- 一、背景:当你和AI聊太久
-
- [1.1 一个真实的尴尬场景](#1.1 一个真实的尴尬场景)
- [1.2 解决思路对比](#1.2 解决思路对比)
- 二、核心设计:压缩机制的"骨架"
-
- [2.1 整体架构](#2.1 整体架构)
- [2.2 四个核心配置参数](#2.2 四个核心配置参数)
- [2.3 压缩触发时机](#2.3 压缩触发时机)
- 三、核心压缩算法(performCompression)深度剖析
-
- [3.1 算法全貌:一张图看懂压缩流程](#3.1 算法全貌:一张图看懂压缩流程)
- [3.2 Token估算器(TokenStatsUtil)](#3.2 Token估算器(TokenStatsUtil))
- [3.3 "从后往前"保留策略的巧妙之处](#3.3 “从后往前”保留策略的巧妙之处)
- [3.4 当前问题和历史消息的"抢位"逻辑](#3.4 当前问题和历史消息的“抢位”逻辑)
- [3.5 摘要生成中的按类别截断](#3.5 摘要生成中的按类别截断)
- [3.6 完整压缩示例](#3.6 完整压缩示例)
- 四、智能截断:工具结果太大怎么办
- 五、模型适配:自动获取上下文上限
- 六、踩坑经验与配置建议
-
- [6.1 常见问题及解决方案](#6.1 常见问题及解决方案)
- [6.2 不同场景的配置建议](#6.2 不同场景的配置建议)
- [6.3 效果对比](#6.3 效果对比)
- [6.4 日志示例](#6.4 日志示例)
- 资料获取

前言
博主介绍:✌目前全网粉丝4W+,csdn博客专家、Java领域优质创作者,博客之星、阿里云平台优质作者、专注于Java后端技术领域。
涵盖技术内容:Java后端、大数据、算法、分布式微服务、中间件、前端、运维等。
博主所有博客文件目录索引:博客目录索引(持续更新)
CSDN搜索:长路
视频平台:b站-Coder长路
大模型Agent上下文压缩实战:从原理到CompactSummarizationMiddleware实现
在开发基于大模型的Agent应用时,我们经常会遇到一个"甜蜜的烦恼":模型能力越强,对话越复杂,上下文token爆表的概率就越高。今天咱们就来聊聊如何在ReAct Agent中优雅地解决这个问题。
一、背景:当你和AI聊太久
1.1 一个真实的尴尬场景
想象一下这个画面:你的AI助手正在执行一个复杂的数据分析任务,已经调用了10多个工具,来回对话了20轮。突然,它开始"失忆"了------忘记了最开始用户问的是什么,或者直接报错说上下文太长了。
这就是典型的上下文超限问题。以当前主流的128K上下文模型为例,看起来很大是吧?但实际上一段带工具调用、代码块、日志输出的对话,可能20轮就会撑爆。
1.2 解决思路对比
市面上常见的方案有三种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 滑动窗口 | 只保留最近N轮对话 | 实现简单 | 丢失中间关键信息 |
| 全量摘要 | 定期将历史压缩成摘要 | 保留核心信息 | 单次摘要成本高 |
| 混合策略 | 保留最近对话+摘要历史 | 兼顾实时性和完整性 | 实现复杂,需要精细调优 |
我们的CompactSummarizationMiddleware采用的正是第三种方案------混合策略。
二、核心设计:压缩机制的"骨架"
2.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ CompactSummarizationMiddleware │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Token计算器 │ │ 压缩触发器 │ │ 摘要生成器 │ │
│ │ TokenStats │→│ needCompress│→│ generateSummary │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ ↓ ↓ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 消息编排器 (performCompression) │ │
│ │ SystemMessage + AI摘要(压缩历史) + 保留的最近对话 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
2.2 四个核心配置参数
java
public static class CompactConfig {
private final int maxContextTokens; // 模型最大上下文token
private final int compressThreshold; // 触发压缩阈值(%),默认80%
private final int keepRatio; // 保留最近对话占比(%),默认40%
private final int maxSystemRatio; // 摘要中系统消息上限(%),默认30%
private final int maxOtherRatio; // 摘要中其他消息上限(%),默认50%
}
通俗理解这套配置:
- maxContextTokens:模型的"内存上限"。不同模型差异很大,比如LongCat-Flash-Chat是256K,DeepSeek-V4-Pro是1024K
- compressThreshold=80%:当用了80%内存时,开始整理
- keepRatio=40%:整理后,给最近的对话保留40%的空间(保证当前任务流畅)
- maxSystemRatio/maxOtherRatio:生成摘要时,各种类型消息的"配额"
2.3 压缩触发时机
中间件在三个环节会检查是否需要压缩:
时机一:模型调用前(beforeModelCall)
java
if (needCompress(messages, maxContextTokens, compressThreshold)) {
performCompression(messages, chatContext); // 触发核心压缩
}
时机二:单工具执行后(afterToolExecution)
java
// 工具结果太大时,实时截断
if (needCompress(currentMessages, ...)) {
return truncateToolResult(toolRequest, toolResult, ...);
}
时机三:并发工具执行后(afterConcurrentToolExecution)
这种"多触点"设计确保无论何时token增加,都能及时响应。
三、核心压缩算法(performCompression)深度剖析
这是整个中间件的"心脏",咱们一步步拆解。
3.1 算法全貌:一张图看懂压缩流程
┌─────────────────────────────────────────────────────────────────────┐
│ performCompression 执行流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 输入: messages (原始消息列表) │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 1: 分离SystemMessage │ │
│ │ systemMessages = [系统提示词...] │ │
│ │ otherMessages = [User, AI, Tool, User, AI...] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 2: 计算保留配额 │ │
│ │ keepTokens = maxContextTokens × keepRatio │ │
│ │ 例如: 16000 × 40% = 6400 tokens │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 3: 处理当前用户问题 │ │
│ │ currentUserMsgTokens = estimateMessageTokens(当前问题) │ │
│ │ │ │
│ │ if (当前问题token > 保留配额) { │ │
│ │ // 极端情况:问题太长,只保留问题本身 │ │
│ │ keepMessages = [当前问题] │ │
│ │ compressMessages = 所有历史消息 │ │
│ │ } else { │ │
│ │ // 正常情况:从后往前捞历史消息 │ │
│ │ remainingTokens = 保留配额 - 当前问题token │ │
│ │ 从otherMessages末尾向前累积,直到凑满remainingTokens │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 4: 生成摘要 │ │
│ │ if (compressMessages不为空) { │ │
│ │ summary = generateSummary(compressMessages) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Step 5: 重组消息 │ │
│ │ 最终顺序 = SystemMessages + 摘要 + keepMessages │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 输出: 压缩后的消息列表 │
└─────────────────────────────────────────────────────────────────────┘
3.2 Token估算器(TokenStatsUtil)
准确的token计算是压缩的前提。我们实现了字符数/4的估算算法,并加上消息类型的开销:
java
public static int estimateMessageTokens(ChatMessage message) {
String content = getMessageContent(message);
int contentTokens = estimateTextTokens(content); // 字符数/4
if (message instanceof SystemMessage) {
contentTokens += SYSTEM_OVERHEAD; // +10 token
} else if (message instanceof AiMessage) {
contentTokens += AI_OVERHEAD; // +15 token
// 如果有工具调用,额外计算
if (aiMsg.toolExecutionRequests() != null) {
contentTokens += aiMsg.toolExecutionRequests().size() * 20;
}
} else if (message instanceof ToolExecutionResultMessage) {
contentTokens += TOOL_OVERHEAD; // +20 token
}
return Math.max(contentTokens, 1);
}
为什么需要额外开销? 因为消息的元数据(角色标识、时间戳等)也会占用token,忽略会导致估算偏小。
3.3 "从后往前"保留策略的巧妙之处
java
private KeepResult findKeepMessagesFromEnd(List<ChatMessage> messages, int maxTokens) {
int totalTokens = 0;
int keepStartIndex = messages.size();
// 从后往前累积
for (int i = messages.size() - 1; i >= 0; i--) {
int msgTokens = estimateMessageTokens(messages.get(i));
if (msgTokens > maxTokens) {
// 单条消息就超过限制 → 这条消息也不要了,全部压缩
return KeepResult.partial(new ArrayList<>(), new ArrayList<>(messages));
}
if (totalTokens + msgTokens <= maxTokens) {
totalTokens += msgTokens;
keepStartIndex = i; // 记录可以保留的起点
} else {
break;
}
}
// 截取 [keepStartIndex, messages.size()) 作为保留消息
}
为什么要"从后往前"而不是"从前往后"?
来看个具体例子:
消息列表:[M1, M2, M3, M4, M5, M6] ← 按时间顺序,M6最新
保留配额:能装下M5和M6
方案A(从前往后):
保留[M1, M2] → 丢掉了最新的M5、M6,用户当前问题可能没上下文
方案B(从后往前):
保留[M5, M6] → 保留了最近的交互,用户当前问题有充分上下文 ✓
结论:对话场景中"最近的消息"最重要,"从后往前"是保证实时性的关键。
3.4 当前问题和历史消息的"抢位"逻辑
java
UserMessage currentUserMessage = UserMessage.from(chatContext.getQuestion());
int currentUserMsgTokens = estimateMessageTokens(currentUserMessage);
if (currentUserMsgTokens >= keepTokens) {
// 情况A:问题本身就把保留配额占满了
keepMessages.add(currentUserMessage);
compressMessages = new ArrayList<>(otherMessages);
} else {
// 情况B:问题没占满,还能带一些历史
int remainingTokens = keepTokens - currentUserMsgTokens;
KeepResult result = findKeepMessagesFromEnd(otherMessages, remainingTokens);
keepMessages.addAll(result.getKeepMessages());
keepMessages.add(currentUserMessage); // 当前问题放最后
}
重点 :当前用户问题永远在keepMessages的最后,因为它是接下来对话的直接上下文。
来看个具体数值例子:
配置: maxContextTokens=16000, keepRatio=40% → keepTokens=6400
场景1: 用户问题很短(200 tokens)
→ remainingTokens = 6200
→ 可以从历史消息中保留约6200 tokens的内容(约8-10条消息)
→ 最终保留:6200 tokens历史 + 200 tokens当前问题
场景2: 用户问题很长(6000 tokens)
→ remainingTokens = 400
→ 只能保留约400 tokens的历史(可能只有1-2条简短消息)
→ 最终保留:400 tokens历史 + 6000 tokens当前问题
场景3: 用户问题超长(8000 tokens > 6400)
→ 不保留任何历史,只保留当前问题
→ 最终保留:8000 tokens当前问题(可能会触发工具结果截断)
3.5 摘要生成中的按类别截断
java
private List<ChatMessage> truncateByCategory(List<ChatMessage> messages) {
int maxSystemTokens = (int) (maxContextTokens * maxSystemRatio / 100.0);
int maxOtherTokens = (int) (maxContextTokens * maxOtherRatio / 100.0);
for (ChatMessage msg : messages) {
if (msg instanceof SystemMessage) {
// 系统消息:受maxSystemTokens限制
if (systemTokens + msgTokens <= maxSystemTokens) {
result.add(msg);
} else {
truncated = truncateText(content, remaining);
result.add(new SystemMessage(truncated));
}
} else if (!(msg instanceof UserMessage)) {
// AI/工具消息:受maxOtherTokens限制
// 用户消息:全文保留(不截断)
}
}
}
为什么UserMessage不截断?
用户的问题是后续对话的"意图锚点",任何信息丢失都可能导致AI理解偏差。举个例子:
用户问: "帮我分析2024年Q3销售数据中,北京地区前10名产品的销售额、增长率、以及环比变化"
如果截断后变成: "帮我分析2024年Q3销售数据..."
AI可能只返回销售额,丢失了增长率和环比变化的需求。
3.6 完整压缩示例
场景设定:
模型上限: 16000 tokens
压缩阈值: 80% (12800 tokens触发)
保留比例: 40% (6400 tokens)
经过多轮对话后,总计13500 tokens,超过12800阈值,触发压缩!
执行performCompression:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1 - 分离SystemMessage:
systemMessages = [系统提示词] (500 tokens)
otherMessages = [User查询... AI分析... Tool...] (13000 tokens)
Step 2 - 保留配额: 6400 tokens
当前问题tokens = 120 tokens
remainingTokens = 6280 tokens
Step 3 - 从后往前保留历史:
保留顺序:
- [User分析趋势] 120 tokens (当前问题)
- [AI分组完成] 600 tokens
- [User按地区分组] 80 tokens
- [AI查询结果] 800 tokens
- [Tool销售明细] 3000 tokens
- [User查询销售额] 100 tokens
总保留: 4700 tokens (加当前问题120 = 4820)
压缩的历史: 约8200 tokens
Step 4 - 生成摘要:
输入: 8200 tokens的历史消息
输出: 摘要消息 (约800 tokens)
Step 5 - 最终消息结构:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[System] 你是一个数据分析助手... (500 tokens)
[System] 【历史对话摘要】用户询问了本月销售额... (800 tokens)
[User] 查询本月销售额 (100 tokens)
[AI] 已查询,本月销售额为... (800 tokens)
[Tool] 销售明细表 (3000 tokens)
[User] 按地区分组 (80 tokens)
[AI] 分组完成... (600 tokens)
[User] 帮我分析趋势 (120 tokens) ← 当前问题
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
压缩后总计: 500 + 800 + 4700 = 6000 tokens ✓
四、智能截断:工具结果太大怎么办
工具执行结果经常有几十KB甚至上百KB的数据,直接塞进上下文肯定爆。我们的策略是带预警的智能截断:
java
private String truncateToolResult(ToolExecutionRequest toolRequest, String toolResult, ...) {
// 计算需要削减的token数
int needReduce = currentTotalTokens - targetTotalTokens;
// 按比例截断工具结果
String truncated = truncateText(toolResult, targetResultTokens);
// 返回带警告信息的截断结果
return String.format("【工具执行结果已截断】\n" +
"上下文使用率: %.1f%% (压缩阈值: %d%%),为避免超出上下文限制...\n" +
"截断后的结果:\n%s\n\n" +
"⚠️ 注意:由于上下文长度限制,结果已被截断...", ...);
}
这个设计的精巧之处在于:截断后会明确告知AI结果不完整,并给出建议(缩小查询范围、使用分页等),让AI能够自主调整策略。
五、模型适配:自动获取上下文上限
不同的模型有不同的上下文长度,硬编码肯定不行。通过ModelConfigRegistry实现动态适配:
java
// 创建中间件时,自动获取当前模型的实际输入token上限
new CompactSummarizationMiddleware(
CompactConfig.builder()
.maxContextTokens(ModelConfigRegistry.getActualInputTokens(llmBasicConfig))
.build()
)
配置示例(以美团LongCat为例),由于需要对接不同渠道的大模型,我们默认以模型baseurl+modelname来确认这个模型的最大上下文以及输出token大小:
java
MEITUAN_LONGCAT("美团longcat龙猫", "https://api.longcat.chat/openai/v1") {
@Override
protected void initModels() {
models.put("LongCat-Flash-Chat", new ModelAdvancedConfig(256, 128)); // 256K输入
models.put("LongCat-Flash-Thinking", new ModelAdvancedConfig(128, 256)); // 128K输入
models.put("LongCat-Flash-Lite", new ModelAdvancedConfig(256, 320));
}
}
六、踩坑经验与配置建议
6.1 常见问题及解决方案
问题一:摘要生成失败怎么办?
java
try {
ChatModel chatModel = chatContext.getChatModel();
String summary = chatModel.chat(summaryPrompt);
// 限制摘要长度
if (estimateTextTokens(summary) > maxSummaryTokens) {
summary = truncateText(summary, maxSummaryTokens);
}
return SystemMessage.from(summary);
} catch (Exception e) {
log.error("摘要生成失败", e);
// 降级方案:直接用格式化后的对话作为摘要
return SystemMessage.from("【历史对话摘要】\n" +
formatConversation(messages, maxSummaryTokens));
}
降级策略:即使LLM调用失败,也要保证压缩能继续执行,用格式化文本兜底。
问题二:token估算不准怎么办?
我们的估算公式是 字符数/4,这是基于英文的估算方式。中文字符一个字符大约2-3 token,会有一定偏差。
建议 :保留20%的安全余量。比如模型上限16000,配置时用14000-15000作为maxContextTokens。
问题三:压缩太频繁影响体验
如果发现每次请求都触发压缩,说明compressThreshold设置过低。排查步骤:
- 检查日志中的token占比
- 如果占比持续在70%-80%,考虑调高
compressThreshold到85% - 如果占比经常超过95%,可能有其他问题(工具结果过大等)
6.2 不同场景的配置建议
| 场景 | compressThreshold | keepRatio | maxSystemRatio | 说明 |
|---|---|---|---|---|
| 客服机器人(高频简短对话) | 70% | 50% | 30% | 触发更频繁,保留更多最近对话 |
| 数据分析Agent(长文本+工具调用) | 85% | 30% | 20% | 阈值调高减少压缩频率,压缩更激进 |
| 代码助手(上下文强依赖) | 90% | 60% | 35% | 尽量不压缩,保留尽可能多的上下文 |
6.3 效果对比
| 指标 | 无压缩 | 滑动窗口 | CompactSummarization |
|---|---|---|---|
| 对话轮次上限 | ~20轮 | ~15轮 | ~50轮+ |
| 信息完整性 | 100% | 丢失轮次边界信息 | 保留关键决策 |
| 单次响应延迟 | 低 | 低 | +500ms(摘要生成) |
| 内存占用 | 爆炸式增长 | 可控 | 可控 |
6.4 日志示例
2026-02-06 10:30:15 INFO - 当前对话token统计,最大token:16000, 上下文已占据百分比:84.4%
2026-02-06 10:30:15 INFO - 触发压缩: 当前token=13500/16000, 占比=84.4%
2026-02-06 10:30:16 INFO - 压缩完成: 45条 → 12条, 压缩后token=5800/16000, 占比=36.3%
2026-02-06 10:30:16 WARN - 工具结果导致上下文超过压缩阈值: 14200/16000 tokens (>= 80%),触发截断
2026-02-06 10:30:16 INFO - 工具 getSalesData 结果已截断,原始: 8500 tokens,截断后: 3200 tokens
资料获取
大家点赞、收藏、关注、评论啦~
精彩专栏推荐订阅:在下方专栏👇🏻
- 长路-文章目录汇总(算法、后端Java、前端、运维技术导航):博主所有博客导航索引汇总
- 开源项目Studio-Vue---校园工作室管理系统(含前后台,SpringBoot+Vue):博主个人独立项目,包含详细部署上线视频,已开源
- 学习与生活-专栏:可以了解博主的学习历程
- 算法专栏:算法收录
更多博客与资料可查看👇🏻获取联系方式👇🏻,🍅文末获取开发资源及更多资源博客获取🍅