1. 项目概述:上下文压缩技术的必要性
在AI对话系统开发中,上下文管理一直是个棘手的难题。随着对话轮数增加,上下文信息会像滚雪球一样膨胀,最终超出模型的处理能力上限。这个问题在需要长期运行的智能体(Agent)场景中尤为突出——每次工具调用的结果都会永久留在上下文里,直到超出模型的上下文窗口限制。
我最近在开发一个Java实现的编程智能体时,就遇到了典型的上下文膨胀问题。当智能体连续读取多个代码文件后,上下文中的文件内容会迅速占满token限额。这就像开车时后视镜里堆满了杂物,不仅影响视野,还会降低行驶效率。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三层压缩策略架构设计
2.1 整体架构设计思路
经过多次实践验证,我最终采用了三层渐进式压缩策略,这个方案在保证信息完整性的同时,实现了上下文的高效管理:
code复制LLM调用前
|
v
+---+ Layer 1: 微观压缩(microCompact)
| | 每次调用前自动执行
| | 替换旧的tool_result为占位符
+---+
| token超过阈值?
v
+---+ Layer 2: 自动压缩(autoCompact)
| | 保存完整会话记录
| | 生成对话总结
| | 重建精简上下文
+---+
| 模型调用compact工具?
v
+---+ Layer 3: 手动压缩(manual)
| | 用户或模型主动触发
| | 执行与autoCompact相同流程
+---+
这种分层设计的关键优势在于:
- 细粒度控制:从消息级到会话级的多层次管理
- 自动化与手动控制结合:既保证基本功能,又提供灵活干预手段
- 信息可追溯:压缩前自动保存完整会话记录
2.2 Token估算算法实现
精确的token估算是压缩策略的基础。考虑到中英文混合场景,我设计了一套混合计算方案:
java复制public static int estimateTokens(String text) {
if (text == null || text.isBlank()) {
return 0;
}
double tokenCount = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
// 中文处理:每个字符计为1token
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
tokenCount++;
} else {
// 英文处理:每4个字符≈1token
tokenCount += 0.25;
}
}
return (int) Math.ceil(tokenCount);
}
这个算法基于以下考虑:
- 中文通常每个字符对应一个token
- 英文单词平均长度约4个字母,对应1个token
- 向上取整确保不会低估token消耗
实际测试发现,这种估算方式与主流模型的实际tokenizer结果误差在±5%以内,完全满足压缩决策的需求。
3. 微观压缩层实现细节
3.1 核心算法解析
微观压缩(microCompact)是最高频执行的操作,在每次LLM调用前自动触发。它的核心逻辑是:
java复制private static final int KEEP_RECENT = 3; // 保留最近3条工具结果
public static void microCompact(List<ChatCompletionMessageParam> messages) {
// 1. 收集所有tool消息
List<ChatCompletionMessageParam> toolMessages = messages.stream()
.filter(ChatCompletionMessageParam::isTool)
.collect(Collectors.toList());
if (toolMessages.size() <= KEEP_RECENT) return;
// 2. 建立tool_call_id到tool_name的映射
Map<String, String> toolNameMap = new HashMap<>();
for (ChatCompletionMessageParam msg : messages) {
if (!msg.isAssistant()) continue;
// 解析tool_calls建立映射关系...
}
// 3. 替换旧的tool_result为占位符
List<ChatCompletionMessageParam> toClearMessages =
Commons.getFirstN(toolMessages, toolMessages.size() - KEEP_RECENT);
for (ChatCompletionMessageParam msg : toClearMessages) {
String toolName = toolNameMap.get(msg.toolCallId());
String placeholder = String.format("[上一步: 已使用 %s]", toolName);
// 构建新的压缩后消息...
}
}
3.2 设计考量与参数选择
保留最近3条工具结果的设计基于以下观察:
- 大多数情况下,智能体只需要参考最近几次工具调用的结果
- 完全保留3条可以在信息完整性和空间节省间取得平衡
- 更早的结果可以通过占位符追溯,必要时可查询完整会话记录
实际测试数据表明:
- 在连续文件读取场景中,这种策略可以减少60%-70%的token占用
- 对智能体的任务连续性几乎没有影响
4. 自动压缩层实现方案
4.1 完整实现流程
当token总数超过阈值(默认3000)时,自动压缩流程启动:
java复制public static void autoCompact(List<ChatCompletionMessageParam> messages) {
try {
// 1. 持久化完整会话记录
Path transcriptDir = Paths.get(Commons.TRANSCRIPT_DIR);
Files.createDirectories(transcriptDir);
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
Path transcriptPath = transcriptDir.resolve("transcript_" + timestamp + ".jsonl");
List<String> lines = messages.stream()
.map(Messages::toStandardJson)
.collect(Collectors.toList());
Files.write(transcriptPath, lines);
// 2. 生成对话总结
String conversationText = messages.stream()
.map(msg -> msg.role() + ": " + msg.content())
.collect(Collectors.joining("\n"));
String prompt = "对本次对话进行连贯总结,需包含:\n"
+ "1)已完成事项;2)当前所处状态;3)达成的关键决策。\n"
+ "请保持总结简洁,保留关键细节。\n" + conversationText;
// 3. 调用LLM生成总结
ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
.model("qwen3.5-plus")
.messages(List.of(ChatCompletionMessageParam.ofUser(prompt)))
.maxCompletionTokens(2000)
.build();
ChatCompletion response = Commons.getClient().chat().completions().create(params);
String summary = Commons.getAssistantText(response.choices().get(0).message());
// 4. 重建上下文
messages.clear();
messages.add(ChatCompletionMessageParam.ofUser(
"[对话已压缩。会话记录:" + transcriptPath + "]\n\n" + summary));
messages.add(ChatCompletionMessageParam.ofAssistant(
"我已获取总结中的上下文,继续执行"));
} catch (Exception e) {
System.err.println("压缩失败: " + e.getMessage());
}
}
4.2 关键设计决策
-
会话持久化方案:
- 采用jsonl格式存储,每条消息单独一行
- 文件名包含时间戳,便于追溯
- 存储在工作目录下的transcripts子目录
-
总结生成策略:
- 明确要求总结包含三个关键要素
- 限制总结长度在2000token以内
- 在总结前注明会话记录位置
-
上下文重建:
- 精简为两条核心消息
- 保留总结和记录位置信息
- 确认消息确保连续性
5. 手动压缩与系统集成
5.1 手动触发机制
除了自动压缩外,系统还提供了手动压缩接口:
java复制// 工具注册
TOOL_HANDLERS.put("compact", args -> "已请求手动压缩");
// 在工具调用处理中
for (ChatCompletionMessageToolCall toolCall : toolCalls) {
if (toolCall.function().name().equals("compact")) {
manualCompact = true; // 设置标记
continue;
}
// 处理其他工具调用...
}
5.2 主循环集成
三层压缩策略最终集成到智能体主循环中:
java复制public static void agentLoop(List<ChatCompletionMessageParam> messages) {
boolean manualCompact = false;
while (true) {
// Layer 1: 微观压缩
Compacts.microCompact(messages);
// Layer 2: 自动压缩
if (Tokens.countDialogTokens(messages) > THRESHOLD) {
System.out.println("[触发自动压缩]");
Compacts.autoCompact(messages);
}
// 正常LLM调用流程...
ChatCompletion response = Commons.getClient().chat().completions().create(params);
messages.add(ChatCompletionMessageParam.ofAssistant(response.choices().get(0).message()));
// 处理工具调用...
// Layer 3: 手动压缩
if (manualCompact) {
System.out.println("[执行手动压缩]");
Compacts.autoCompact(messages);
manualCompact = false;
}
}
}
6. 实践效果与优化建议
6.1 实测性能数据
在连续文件读取测试场景中:
- 未压缩情况下,约15次文件读取后达到token上限
- 启用微观压缩后,可支持50+次文件读取
- 结合自动压缩,理论上可实现无限会话
6.2 常见问题排查
-
压缩后信息丢失:
- 检查transcript目录权限
- 验证jsonl文件是否完整写入
- 确保总结提示词包含必要要素
-
token估算偏差大:
- 检查文本编码格式
- 验证非中文字符处理逻辑
- 考虑添加特殊符号处理
-
压缩触发不及时:
- 确认threshold设置是否合理
- 检查token计数是否包含系统消息
- 验证estimateTokens方法准确性
6.3 优化方向
-
增量式总结:
- 不是每次全量压缩
- 只总结新增对话内容
- 合并到现有总结中
-
智能占位符:
- 根据内容生成描述性占位符
- 而不仅是工具名
- 例如:"读取了约200行的Java类文件"
-
分层存储:
- 将不常用上下文移至二级存储
- 按需加载历史片段
- 类似计算机内存管理
在实际项目中采用这套压缩策略后,智能体的持续运行时间从平均20分钟提升到了数小时不间断工作。特别是在代码分析、数据处理等需要频繁调用工具的场景中,效果提升尤为明显。
