通用Agent设计:03、大模型Agent上下文压缩实战:从原理到CompactSummarizationMiddleware实现

文章目录

前言

博主介绍:✌目前全网粉丝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设置过低。排查步骤:

  1. 检查日志中的token占比
  2. 如果占比持续在70%-80%,考虑调高compressThreshold到85%
  3. 如果占比经常超过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

资料获取

大家点赞、收藏、关注、评论啦~

精彩专栏推荐订阅:在下方专栏👇🏻

更多博客与资料可查看👇🏻获取联系方式👇🏻,🍅文末获取开发资源及更多资源博客获取🍅

相关推荐
92year10 天前
AI编程一个月烧了多少钱?用CodeBurn一条命令算清楚
ai编程·开发工具·cursor·claude code·token优化
花千树-01016 天前
多步骤 ReAct 实战:让 Agent 自主完成航司比价与订票
java·agent·function call·react agent·harness·j-langchain·多步骤推理
码农垦荒笔记1 个月前
2026 Agent Token 成本优化实战:Prompt Caching + 模型路由组合降本 80%
agent成本优化·模型路由·token优化
董厂长3 个月前
langchain上下文管理的方式
langchain·上下文压缩·上下文管理
gentle coder4 个月前
一文入门ReAct Agent,附从零构建 ReAct Agent
ai·agent·思维链·智能体·react agent
真·skysys4 个月前
【技术报告解读】DeepSeek-OCR: Contexts Optical Compression
ocr·多模态·deepseek·上下文工程·deepseek-ocr·上下文压缩·上下文光学压缩
Coding的叶子1 年前
React Flow 节点属性详解:类型、样式与自定义技巧
react.js·node·节点·fgai·react agent
Coding的叶子1 年前
React Flow 节点事件处理实战:鼠标 / 键盘事件全解析(含节点交互代码示例)
react.js·交互·鼠标事件·fgai·react agent
Coding的叶子1 年前
Node.js 安装 + React Flow 快速入门:环境安装与项目搭建
react.js·node.js·react flow·fgai·react agent