一个基于 Token 预算的上下文压缩控制器(Context Compaction Controller)。它不是简单配置 MessageWindowChatMemory,而是需要在每次模型调用之前检查上下文,并在必要时主动整理历史。
Spring AI 2.0 的 ToolCallingAdvisor 提供了 doBeforeCall、doAfterCall 等扩展钩子,能够介入工具调用循环的每次模型请求;ChatMemory 则负责记忆的存取。这两个能力可以配合实现。
Home
+2
我建议将它设计成下面的架构。
Chat Orchestrator / Client 层
接收请求,维护会话 ID,协调模型调用
Context Budget Manager
统计 Token、检查阈值、决定压缩策略
历史压缩
旧对话 → 摘要
工具结果整理
保留必要结果
会话切换
保存快照并续接
ChatModel / ToolCallingAdvisor
发送请求、执行工具、接收工具结果,进入下一次调用
整个方案有一个重要原则:不要把压缩逻辑全部塞进 ChatMemory,也不要只在用户发消息时检查一次 Token。
因为一次用户请求可能触发多次模型调用,例如:
用户提问
→ 模型调用工具 A
→ 工具 A 返回大量数据
→ 模型继续调用工具 B
→ 工具 B 又返回大量数据
→ 模型生成最终答案
如果只在第一步检查上下文,工具 A、B 返回的数据仍然可能把后续请求撑爆。因此需要在模型调用循环中重复检查。
九、先定义清晰的 Token 预算规则
假设模型上下文窗口是 128K Token,可以先将预算配置化,而不是把阈值写死在代码里。
配置项
示例值
含义
context-window
128,000
模型上下文窗口
compact-trigger-ratio
0.75
达到 75% 时开始压缩
target-ratio
0.55
希望压缩到 55% 左右
output-reserve
16,000
为输出预留的 Token
minimum-recent-turns
3
尽量保留最近 3 轮完整对话
max-compaction-rounds
3
单次请求最多尝试压缩次数
这些数值只是配置示例,并不是 Spring AI 的默认值。
对应配置:
app:
ai:
context:
context-window: 128000
compact-trigger-ratio: 0.75
target-ratio: 0.55
output-reserve: 16000
minimum-recent-turns: 3
max-compaction-rounds: 3
预算应该针对即将发送的完整请求计算,而不是只统计聊天历史。至少要考虑:
System Prompt
历史消息与摘要
当前用户输入
RAG 检索内容
工具调用消息和工具返回结果
模型输出预留
同时需要注意,模型上下文上限不等于模型的最大输出 Token 数;不同模型的限制可能不同。
十、第一步:实现 Token 统计器
建议先定义自己的接口,把 Token 计算与具体模型解耦。这样后续切换 OpenAI、Anthropic 或其他模型时,不需要重写压缩流程。
public interface TokenCounter {
/**
* 估算完整消息列表的 Token 数。
*/
int countMessages(List<Message> messages);
/**
* 估算文本的 Token 数。
*/
int countText(String text);
}
这里的 Message 使用 Spring AI 的消息类型。
实现类可以使用所选模型对应的 Tokenizer,也可以使用模型供应商提供的 Token 统计接口。对于不支持本地精确统计的模型,可以先采用估算值,并预留安全余量。
@Service
public class ContextBudgetService {
private final TokenCounter tokenCounter;
@Value("${app.ai.context.context-window}")
private int contextWindow;
@Value("${app.ai.context.compact-trigger-ratio}")
private double triggerRatio;
@Value("${app.ai.context.output-reserve}")
private int outputReserve;
public ContextBudgetService(TokenCounter tokenCounter) {
this.tokenCounter = tokenCounter;
}
public int count(List<Message> messages) {
return tokenCounter.countMessages(messages);
}
public boolean shouldCompact(List<Message> messages) {
int inputTokens = count(messages);
int usableBudget = contextWindow - outputReserve;
return inputTokens >= usableBudget * triggerRatio;
}
}
这只是一个简化的预算判断器。生产实现还应当计入工具定义、模型请求格式的额外开销,并针对不同模型设置独立的 Token 计算器。
另外,不能仅靠 ChatResponse 返回的 Token 使用量来做预防:它通常是在模型调用完成后才得到的,而我们的目标是在调用前就判断是否需要压缩。
十一、第二步:实现历史对话压缩
这是整个方案的核心。
不要直接删除最早的消息,而是把完整对话按轮次切分,压缩最早的一段,保留最近的原始对话。
例如:
原始上下文:
System Prompt
第 1 轮:用户介绍项目背景
第 2 轮:AI 分析技术架构
第 3 轮:用户补充数据库设计
第 4 轮:AI 给出代码
第 5 轮:用户提出新的问题
第 6 轮:AI 调用工具
第 7 轮:当前用户问题
压缩后:
System Prompt
历史摘要:
- 项目采用 Spring Boot + MySQL + Redis
- 当前正在实现 AI 对话记忆
- 已决定采用 Token 预算和历史压缩
- 之前讨论了工具调用和 Redis 记忆存储
最近原始对话:
第 5 轮
第 6 轮(完整工具调用过程)
第 7 轮
- 定义摘要数据结构
public record ConversationSummary(
String summary,
List keyFacts,
List decisions,
List pendingTasks
) {}
相比于让模型自由输出一段摘要,我更推荐结构化摘要,因为后续可以明确区分已经确定的决策、用户事实和待办事项。
-
编写摘要服务
@Service
public class ConversationCompactor {
private final ChatClient summaryClient;
private final ObjectMapper objectMapper;
public ConversationCompactor(
ChatClient summaryClient,
ObjectMapper objectMapper) {
this.summaryClient = summaryClient;
this.objectMapper = objectMapper;
}
public ConversationSummary summarize(
String previousSummary,
List oldMessages) {
String prompt = """ 你负责压缩长期 AI 对话上下文。 请保留: 1. 用户明确表达的目标和约束 2. 已确定的技术方案和关键决策 3. 尚未完成的任务 4. 后续继续工作必需的事实 5. 工具调用已经完成的操作及关键结果 删除: 1. 重复内容 2. 无关闲聊 3. 已经失去价值的中间推理 4. 可以重新查询的冗长工具输出 不得编造事实。无法确认的信息应明确标记。 返回符合 ConversationSummary 结构的 JSON。 """; String transcript = serializeMessages(oldMessages); String result = summaryClient.prompt() .system(prompt) .user(""" 之前的摘要: %s 本次需要合并的历史: %s """.formatted( previousSummary, transcript)) .call() .content(); try { return objectMapper.readValue( result, ConversationSummary.class); } catch (Exception e) { throw new IllegalStateException( "对话摘要生成或解析失败", e); }}
private String serializeMessages(List messages) {
// 实际实现应保留角色、时间、工具名称、
// 工具调用 ID、参数和结果等信息。
return messages.stream()
.map(m -> m.getMessageType()
- ": " + m.getText())
.collect(Collectors.joining("\n"));
}
}
这里的 summaryClient 应当是单独配置的摘要模型客户端,可以使用成本较低、适合摘要任务的模型。ConversationSummary 的结构化输出也可以通过 Spring AI 的结构化输出功能实现,减少手动解析 JSON 的工作。
非常重要:上面的 serializeMessages() 只是简化示例。 如果项目启用了工具调用,不能只序列化 getText()。必须保留工具名称、参数、调用 ID 和结果之间的关联,否则摘要可能破坏工具调用链。
- 摘要不是每轮都重做
建议给摘要维护一个版本或覆盖范围:
conversation_id
summary
summarized_until_sequence
summary_version
updated_at
例如,摘要已经覆盖第 1~40 轮,那么下一次只需要压缩第 41~50 轮,再与已有摘要合并,而不是每次都把第 1~50 轮重新发送给摘要模型。
这样能显著减少重复计算,也更适合长会话。
十二、第三步:压缩工具返回结果
你提到"如果摘要还不够,就再压缩工具返回结果",这部分非常有必要。
比如 MCP 工具返回了 30,000 Token 的数据库查询结果,而模型实际上只需要其中的前 10 条记录。如果将完整结果一直保留在后续对话里,上下文会迅速膨胀。
我建议把工具结果分为三类:
类型
处理方式
大量可重新获取的数据
保留查询条件和引用 ID,需要时重新查询
大型日志、搜索结果、文档
提取关键结论,完整结果保存到外部存储
重要业务结果
保留必要字段、执行状态、错误信息和业务 ID
例如,原始工具返回:
{
"total": 5000,
"records": [
"...大量记录..."
]
}
可以压缩为:
{
"total": 5000,
"returned": 10,
"summary": "查询到5000条记录,前10条符合条件",
"resultRef": "tool-result-20261009-001"
}
这里的 resultRef 指向数据库、对象存储或专门的工具结果存储,而不是让模型凭空恢复被删除的数据。
在 Spring AI 工具调用循环中实现
Spring AI 2.0 的 ToolCallingAdvisor 提供了介入每轮模型调用的钩子,因此你可以把上下文检查逻辑放在 doBeforeCall,把工具执行后的观测与整理逻辑放在 doAfterCall。
Home
+1
实现思路如下:
// 设计示意:省略 Spring AI 2.0 对应的
// ChatClientRequest / ChatClientResponse
// 和 ToolExecutionResult 的具体构造代码。
public class ContextManagedToolLoop {
private final ContextBudgetService budgetService;
private final ConversationCompactor compactor;
private final ToolResultStore toolResultStore;
public PreparedRequest beforeModelCall(
ConversationState state) {
// 1. 获取即将发送的完整上下文
List<Message> messages = state.getMessages();
// 2. 检查 Token 预算
if (budgetService.shouldCompact(messages)) {
// 3. 先压缩较早的完整对话轮次
state.compactOldTurns(compactor);
// 4. 再检查是否超限
if (budgetService.shouldCompact(
state.getMessages())) {
// 5. 对大型工具结果进行外部存储、
// 摘要化或替换为可重新获取的引用
state.compactToolResults(toolResultStore);
}
}
// 6. 返回符合预算的模型请求
return state.toPreparedRequest();
}
}
这是职责划分示意,并非可直接复制编译的 Spring AI API 实现。真正接入时,需要在所用版本的 ToolCallingAdvisor 钩子中操作 ChatClientRequest,并通过相应的请求转换机制更新消息。
另外,工具返回结果在执行后往往已经成为模型调用历史的一部分,因此要注意不能只修改数据库里的历史记录,却仍然把原始大结果留在当前工具调用循环的内存历史中。你必须确保后续实际发送给模型的消息也被替换或裁剪。
如果使用的是 Spring AI 1.x,没有与 2.0 完全相同的工具调用循环钩子时,可以采用应用层显式编排:自己控制每轮模型调用、执行工具、更新历史,再发起下一轮调用。
十三、第四步:达到极限时,创建新会话并恢复上下文
你说的"压缩完还是超了,就结束当前会话、把关键上下文持久化到文件,开一个新会话继续",思路是合理的。
但在实际系统中,我建议将其设计成逻辑会话不变、底层上下文代次切换,而不是直接结束用户的业务会话。
例如:
logical_session_id = session-1001
当前上下文代次:
session-1001:gen-1
↓ 无法继续压缩
保存 checkpoint
↓
session-1001:gen-2
↓
加载摘要 + 关键事实 + 未完成任务
↓
继续处理原问题
这样前端仍然可以显示同一条聊天记录,后端则可以切换到新的底层上下文。
建议持久化的 Checkpoint 至少包含:
public record ConversationCheckpoint(
String logicalSessionId,
String previousGenerationId,
String summary,
List keyFacts,
List decisions,
List pendingTasks,
String lastCompletedToolCallId,
long lastMessageSequence
) {}
如果你的业务中存在多步骤工具调用,还需要持久化执行状态,避免恢复时重复执行已经成功的操作。
文件存储与数据库如何选择?
本地开发:可以将 Checkpoint 写入 JSON 文件,便于调试与复现。
单机服务:可以使用持久化目录,但要保证权限、原子写入和备份。
多实例部署:优先使用数据库或对象存储,并将会话代次、摘要版本、消息序号等元数据保存在数据库中。
不建议在生产环境直接把文件保存在某个容器的临时目录中,因为容器重启、扩容或请求转发到其他实例时,文件可能不可用。
十四、把完整的自动压缩流程串起来
最终建议实现一个独立的 ContextCompactionService,由 Client 层调用,并让工具调用循环也能复用它。
完整流程如下:
这里有三个容易忽略的工程细节:
防止压缩死循环。 单次请求设置最大压缩次数;如果摘要始终无法达到预算,就进入降级流程,而不是无限调用摘要模型。
保留完整工具调用轮次。 不要随意删除尚未匹配的工具调用请求或工具响应。如果工具执行尚未结束,应先保存其状态,再决定如何裁剪上下文。
新会话不代表原请求可以无限重试。 如果模型仍然无法在新上下文中完成任务,应返回可恢复状态,让应用决定是否继续,而不是无限自动重试。
十五、实际项目中的类应该怎么组织?
我建议采用下面的包结构,避免把所有逻辑都堆进一个配置类:
ai/
├── client/
│ ├── AiChatService.java
│ └── ChatSessionManager.java
├── context/
│ ├── TokenCounter.java
│ ├── ContextBudgetService.java
│ ├── ContextCompactionService.java
│ ├── ConversationCompactor.java
│ ├── ToolResultCompactor.java
│ └── ContextCheckpointService.java
├── memory/
│ ├── ChatMemoryConfig.java
│ ├── RedisChatMemoryRepository.java
│ └── ConversationSummaryRepository.java
├── tool/
│ └── ContextManagedToolLoop.java
└── model/
├── ConversationSummary.java
├── ConversationCheckpoint.java
└── ConversationState.java
职责可以这样划分:
类
主要职责
AiChatService
接收用户请求,组织整个调用流程
ContextBudgetService
统计 Token、判断预算是否超限
ConversationCompactor
合并旧摘要,压缩历史消息
ToolResultCompactor
压缩大型工具输出、保存结果引用
ContextCheckpointService
持久化上下文快照,创建恢复记录
ChatSessionManager
管理逻辑会话 ID 和上下文代次
ConversationState
维护本次模型调用实际使用的消息及工具状态
注意,RedisChatMemoryRepository 这里表示 Redis 记忆仓储的适配层或配置,不是要求你再手动实现一个与 Spring AI 内置仓储重复的类。Spring AI 已提供相应的 Redis Chat Memory Repository;如果你的工具调用链需要持久化完整工具消息,必须确认所用版本和仓储实现支持这些消息类型。
Home
+1
十六、我建议你最终采用的方案
如果是一个准备长期维护的 Spring AI 项目,我会采用以下组合:
短期记忆: Redis 保存当前会话的原始消息和工具调用记录。
Token 预算: 自定义 ContextBudgetService,每次模型调用前重新计算。
自动压缩: 先压缩最早的完整对话轮次,保留最近几轮原文。
工具结果压缩: 大结果存入外部存储,消息中只保留必要信息和引用。
长期记忆: 数据库保存结构化事实、摘要和未完成任务;需要时结合向量检索。
极限恢复: 生成 Checkpoint,切换上下文代次,并恢复摘要和任务状态。
其中最值得投入精力的是 ContextCompactionService 与 ContextBudgetService。它们不应该依赖某个固定模型,也不应该和 Redis 绑定在一起。这样你之后无论接入 MCP、RAG、Agent,还是更复杂的多工具调用流程,都可以复用这套上下文管理机制。