12 - 上下文压缩
一、定位
context/ 包提供了长对话自动压缩机制,避免多轮 ReAct 循环导致上下文超出 LLM 限制。
二、双层压缩策略
1 2 3 4 5 6 7 8 9 10 11 12 13
| ┌───────────────────────────────────────────────┐ │ compact(messages) │ │ ↓ │ │ Layer 1: micro_compact (每轮自动执行) │ │ - 替换旧 ToolResponse 为占位符 │ │ - 截断长 ToolCall 参数 │ │ ↓ │ │ TokenEstimator.estimateTokens(messages) │ │ ↓ > policy.tokenThreshold(默认 60000)? │ │ Layer 2: auto_compact (超阈值时执行) │ │ - 用 LLM 摘要替换所有旧消息 │ │ - 保留 SystemMessage + 最近 N 轮 │ └───────────────────────────────────────────────┘
|
三、ContextPolicy 配置
context/ContextPolicy.java 是策略配置 record:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public record ContextPolicy( int tokenThreshold, // 触发 auto_compact 的 token 阈值 int keepRecentTools, // 保留最近几轮工具调用 int maxToolLength, // ToolResponse/ToolCall 参数截断长度 Set<String> protectedTools // 受保护的工具(不压缩) ) { public static final int DEFAULT_TOKEN_THRESHOLD = 60000; public static final int DEFAULT_KEEP_RECENT_TOOLS = 4; public static final int DEFAULT_MAX_TOOL_LENGTH = 200; private static final Set<String> BUILTIN_PROTECTED_TOOLS = Set.of("Skill");
public static ContextPolicy defaults() { return new ContextPolicy( DEFAULT_TOKEN_THRESHOLD, DEFAULT_KEEP_RECENT_TOOLS, DEFAULT_MAX_TOOL_LENGTH, Set.of() ); } }
|
默认配置:
| 配置项 | 值 | 说明 |
|---|
tokenThreshold | 60000 | 触发 LLM 摘要的 token 阈值 |
keepRecentTools | 4 | 保留最近 4 轮工具调用 |
maxToolLength | 200 | 工具内容超过 200 字符截断 |
protectedTools | {"Skill"} | 技能工具受保护 |
Builder 模式:
1 2 3 4 5 6
| ContextPolicy customPolicy = ContextPolicy.builder() .tokenThreshold(80000) .keepRecentTools(6) .maxToolLength(300) .protectedTools("Skill", "bash") .build();
|
四、TokenEstimator 估算
context/TokenEstimator.java 区分中英文估算 token 数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public static int estimateTokens(List<Message> messages) { if (messages == null || messages.isEmpty()) return 0;
int cjkCount = 0; int nonCjkCount = 0;
for (Message msg : messages) { int[] counts = countChars(msg); cjkCount += counts[0]; nonCjkCount += counts[1]; }
int tokens = (int) (cjkCount / CHARS_PER_TOKEN_CJK + nonCjkCount / CHARS_PER_TOKEN_EN); return tokens; }
|
估算规则:
| 字符类型 | 字符/token 比例 |
|---|
| 英文/ASCII | 4.0 |
| 中文/CJK | 1.5 |
特点:
- 不依赖外部库(轻量级)
- 区分中英文
- 涵盖所有消息类型(System/User/Assistant/ToolResponse)
- 对 AssistantMessage 还会累计 ToolCall 名称 + 参数
五、ContextCompactor 双层压缩
context/ContextCompactor.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class ContextCompactor { private final ContextPolicy policy; private final ChatModel chatModel;
public void compact(List<Message> messages, String currentQuestion) { if (messages == null || messages.size() <= 2) return;
microCompact(messages);
int estimatedTokens = TokenEstimator.estimateTokens(messages); if (estimatedTokens > policy.tokenThreshold()) { log.info("Context auto_compact triggered: estimated tokens={} > threshold={}", estimatedTokens, policy.tokenThreshold()); autoCompact(messages, currentQuestion); } } }
|
5.1 Layer 1: micro_compact
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| private void microCompact(List<Message> messages) { Map<String, String> toolNameMap = buildToolNameMap(messages);
List<Integer> trmIndices = ...;
List<Integer> assistantWithToolCallIndices = ...;
int trmKeepCount = Math.min(policy.keepRecentTools(), trmIndices.size()); int trmClearCount = trmIndices.size() - trmKeepCount; for (int idx = 0; idx < trmClearCount; idx++) { int msgIndex = trmIndices.get(idx); ToolResponseMessage original = (ToolResponseMessage) messages.get(msgIndex); }
}
|
Layer 1 关键操作:
- 保留最近 N 轮工具调用:旧轮次的 ToolResponse 替换为占位符
- 截断长 ToolCall 参数:超过
maxToolLength 截断 - 截断长文本内容:超过
maxToolLength 截断 - 保护特殊工具:
protectedTools 列表中的工具不受影响
5.2 Layer 2: auto_compact
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| private void autoCompact(List<Message> messages, String currentQuestion) { int splitIndex = findSplitIndex(messages);
List<Message> toSummarize = messages.subList(0, splitIndex); if (toSummarize.isEmpty()) return;
String summary = summarizeWithLLM(toSummarize, currentQuestion);
List<Message> newMessages = new ArrayList<>(); newMessages.add(messages.get(0)); newMessages.add(new SystemMessage("【历史摘要】\n" + summary)); newMessages.addAll(messages.subList(splitIndex, messages.size()));
messages.clear(); messages.addAll(newMessages); }
private String summarizeWithLLM(List<Message> messages, String currentQuestion) { String prompt = String.format(""" 请将以下对话历史压缩为简洁的摘要,保留关键信息(用户意图、决策、工具结果等):
当前用户问题:%s
对话历史: %s
要求: 1. 保留所有关键事实和数据 2. 压缩冗余描述 3. 突出与当前问题相关的内容 """, currentQuestion, formatMessages(messages));
ChatResponse response = chatModel.call(new Prompt(prompt)); return response.getResult().getOutput().getText(); }
|
六、典型应用:SkillsReactAgent
SkillsReactAgent 在构造函数中初始化 ContextCompactor:
1 2 3 4 5 6 7
| public SkillsReactAgent(..., ContextPolicy contextPolicy) { super(name, chatModel, "skills"); this.contextCompactor = contextPolicy != null ? new ContextCompactor(contextPolicy, chatModel) : null; }
|
在 ReAct 循环中调用:
1 2 3 4 5 6 7 8 9
| private void scheduleRound(...) { if (contextCompactor != null) { contextCompactor.compact(messages, currentQuestion); }
chatClient.prompt().messages(messages).stream()... }
|
调用时机:每轮 scheduleRound 之前
七、压缩效果示例
压缩前(假设 80000 tokens):
1 2 3 4 5 6 7 8 9 10
| [SystemMessage] 你是豆豆... [UserMessage] 用户问题1 [AssistantMessage] 思考1 [ToolResponseMessage] loadContent: 5000字内容 [AssistantMessage] 思考2 [ToolResponseMessage] search: 2000字内容 [UserMessage] 用户问题2 ... [ToolResponseMessage] bash: 3000字内容 [UserMessage] 当前问题
|
Layer 1 压缩后(约 30000 tokens):
1 2 3 4 5 6 7 8 9 10
| [SystemMessage] 你是豆豆... [UserMessage] 用户问题1 [AssistantMessage] 思考1 [ToolResponseMessage] loadContent: [truncated: 5000 chars] [AssistantMessage] 思考2 [ToolResponseMessage] search: [truncated: 2000 chars] [UserMessage] 用户问题2 ... [ToolResponseMessage] bash: 3000字内容(最近4轮,保留) [UserMessage] 当前问题
|
Layer 2 压缩后(约 15000 tokens):
1 2 3 4 5 6
| [SystemMessage] 你是豆豆... [SystemMessage] 【历史摘要】用户最初询问了... AI 调用了 loadContent 和 search... 最终... [UserMessage] 用户问题2 [AssistantMessage] 思考2 [ToolResponseMessage] search: 2000字内容(最近4轮,保留) [UserMessage] 当前问题
|
八、性能影响
| 指标 | 压缩前 | Layer 1 后 | Layer 2 后 |
|---|
| Token 数 | 80000 | 30000 | 15000 |
| 单次 LLM 调用耗时 | 30s | 12s | 6s |
| 调用费用 | 高 | 中 | 低 |
| 上下文完整性 | 100% | 80%(丢失旧细节) | 60%(仅保留摘要) |
九、扩展方向
- 增量摘要:对每轮消息单独摘要,保留历史摘要链
- 结构化压缩:将摘要输出为 JSON(主题/实体/行动项)
- 重要性评分:基于 attention 评估消息重要性,决定是否压缩
- 动态阈值:根据模型最大上下文动态调整