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()
);
}
}

默认配置

配置项说明
tokenThreshold60000触发 LLM 摘要的 token 阈值
keepRecentTools4保留最近 4 轮工具调用
maxToolLength200工具内容超过 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 比例
英文/ASCII4.0
中文/CJK1.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;

// Layer 1: micro_compact(每轮执行)
microCompact(messages);

// Layer 2: auto_compact(超阈值时执行)
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) {
// 1. 构建 toolCallId -> toolName 映射
Map<String, String> toolNameMap = buildToolNameMap(messages);

// 2. 收集 ToolResponseMessage 索引
List<Integer> trmIndices = ...;

// 3. 收集包含 ToolCall 的 AssistantMessage 索引
List<Integer> assistantWithToolCallIndices = ...;

// 4. 替换旧的 ToolResponse 内容(保留最近 keepRecentTools 个)
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);
// 替换 responseData 为 "[truncated: <length> chars]"
}

// 5. 截断长 ToolCall 参数
// 6. 截断超长文本(> maxToolLength)
}

Layer 1 关键操作

  1. 保留最近 N 轮工具调用:旧轮次的 ToolResponse 替换为占位符
  2. 截断长 ToolCall 参数:超过 maxToolLength 截断
  3. 截断长文本内容:超过 maxToolLength 截断
  4. 保护特殊工具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) {
// 1. 找到分割点(保留 SystemMessage + 最近 N 轮 User/Assistant)
int splitIndex = findSplitIndex(messages);

// 2. 准备要被摘要的消息
List<Message> toSummarize = messages.subList(0, splitIndex);
if (toSummarize.isEmpty()) return;

// 3. 调用 LLM 摘要
String summary = summarizeWithLLM(toSummarize, currentQuestion);

// 4. 替换原 messages
// - 保留 SystemMessage
// - 用一条摘要消息替换 toSummarize
// - 保留 splitIndex 之后的消息
List<Message> newMessages = new ArrayList<>();
newMessages.add(messages.get(0)); // SystemMessage
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 数800003000015000
单次 LLM 调用耗时30s12s6s
调用费用
上下文完整性100%80%(丢失旧细节)60%(仅保留摘要)

九、扩展方向

  • 增量摘要:对每轮消息单独摘要,保留历史摘要链
  • 结构化压缩:将摘要输出为 JSON(主题/实体/行动项)
  • 重要性评分:基于 attention 评估消息重要性,决定是否压缩
  • 动态阈值:根据模型最大上下文动态调整