消息压缩
概述
对话越长,发送给模型的消息越多,Token 成本、响应延迟和触达模型上下文上限的风险都会增加。Agent 的消息压缩解决的是“模型本次需要看到什么”,而不是“业务历史应该删除什么”。
压缩始终只生成模型调用视图,原始 ChatMemory、AgentTurn、ToolCall、ToolMessage 和审计记录不会被清空或改写。
消息压缩通常分为三层:
| 层次 | 机制 | 是否调用模型 | 适用场景 |
|---|---|---|---|
| 窗口限制 | maxAttachedTurns、maxAttachedMessages | 否 | 控制每次请求的最大历史范围 |
| 规则压缩 | compactCompletedToolTurns | 否 | 删除较早已完成工具 Turn 的中间协议消息 |
| 语义压缩 | AgentContextCompressor | 可选 | 把较长历史提炼成事实、约束和决定 |
当压缩条件不是固定消息数量,还可以使用 AgentContextCompressionTrigger 和业务侧 Store,按 Token、Turn、工具结果大小、时间或租户配额做增量压缩。
快速开始
只限制模型窗口
这是最简单、成本最低的方式。它不改变历史,也不会调用摘要模型:
Agent agent = Agent.builder("support-agent")
.chatModel(chatModel)
.maxAttachedTurns(6)
.maxAttachedMessages(40)
.build();框架按完整 Turn 选择消息,不会从 ToolCall 和 ToolMessage 中间截断。当前 Turn 和必要的协议消息会优先保持完整。
归一化已完成工具 Turn
对于已经完成的旧工具 Turn,可以只保留用户问题和最终 AI 回复:
Agent agent = Agent.builder("support-agent")
.chatModel(chatModel)
.compactCompletedToolTurns(true)
.compressionKeepRecentTurns(2)
.build();例如:
UserMessage
AiMessage(tool_calls)
ToolMessage
AiMessage(最终回复)在模型上下文中变为:
UserMessage
AiMessage(最终回复)最近保护 Turn、当前 Turn、挂起 Turn、失败 Turn 和取消 Turn 不会被错误地删除。原始消息仍保留在业务 ChatMemory 中。
使用摘要模型
需要保留业务事实和长期约束时,配置语义压缩器:
AgentContextCompressor compressor = AgentContextCompressors.model(
summaryModel,
"请用中文总结历史事实,保留业务 ID、时间、用户约束、审批结果和未完成事项,不要编造信息。");
Agent agent = Agent.builder("support-agent")
.chatModel(chatModel)
.contextCompressor(compressor)
.compressionKeepRecentTurns(3)
.build();摘要模型的结果只用于当前模型请求,不会回写 ChatMemory。摘要失败时应保留原始历史并记录错误,不要直接清空业务数据库。
为什么需要增量压缩
如果每次达到上下文上限后都把全部历史重新交给摘要模型,会出现两个问题:
- 相同的早期消息被重复摘要,增加模型调用和成本。
- 并发请求可能同时生成摘要,后写入的结果覆盖先写入的进度。
增量压缩把历史划分为连续区间:
消息 1~100 -> 第一次压缩,保存游标 message-100
消息 101~200 -> 第二次压缩,读取旧摘要,只处理新增区间
消息 201~300 -> 第三次压缩,继续推进游标AgentContextCompressionState 保存摘要和覆盖游标;AgentContextCompressionTrigger 决定本次是否达到条件;AgentContextCompressionCoordinator 负责组合旧摘要与新增消息、调用压缩器并通过 CAS 保存新状态。
按 Token 或业务条件触发
AgentContextCompressionCoordinator coordinator =
new AgentContextCompressionCoordinator(
compressionStateStore,
input -> input.getEstimatedPendingTokens() >= 100_000,
AgentContextCompressors.model(
summaryModel,
"保留事实、实体 ID、约束、审批结果和未完成事项"),
messages -> tokenCounter.estimate(messages));
AgentContextCompressionResult result = coordinator.compress(
conversationId,
compressibleHistory);
AgentTurn turn = runner.run(
agent,
result.getModelMessages(),
new UserMessage("继续处理"));触发器收到的 AgentContextCompressionInput 包含:
- 上次游标之后的
pendingMessages - 已持久化的
summaryMessages - 新增消息的估算 Token 数
- 新增消息中的 Turn 数
- 当前完整压缩状态
因此可以表达更复杂的业务规则:
input -> input.getEstimatedPendingTokens() >= 80_000
|| input.getPendingTurnCount() >= 20只有存在新增消息且触发器返回 true 时,才调用压缩器并保存状态。达到一次阈值后,后续没有足够新增内容的请求不会重复调用摘要模型。
状态持久化
状态 Store 是业务侧可替换的最小接口:
public interface AgentContextCompressionStateStore {
AgentContextCompressionState load(String conversationId);
boolean save(String conversationId,
AgentContextCompressionState state,
long expectedVersion);
}首次保存使用 expectedVersion = 0。保存成功后,状态中的 version 会推进为新版本。CAS 返回 false 表示另一个请求已经先保存了状态,业务侧应重新加载历史和状态后重试,而不是覆盖对方摘要。
JDBC
JdbcAgentStoreConfig config = JdbcAgentStoreConfig.builder(dataSource)
.tablePrefix("app_agent_")
.build();
config.schema().initialize();
AgentContextCompressionStateStore store = config.compressionStateStore();JDBC 实现创建 app_agent_compression_states 表,保存会话 ID、版本和序列化后的状态正文。生产环境也可以把等价建表语句放入 Flyway、Liquibase 等迁移工具。
Redis
RedisAgentStoreConfig config = RedisAgentStoreConfig
.builder("redis://127.0.0.1:6379")
.keyPrefix("app:agent:")
.build();
AgentContextCompressionStateStore store = config.compressionStateStore();Redis 实现使用 Hash 保存状态,并通过 Lua 脚本完成版本比较和写入,适合多实例服务共享。由应用创建的 RedisAgentStoreConfig 使用完后应负责关闭;外部传入的 Redis 客户端仍由应用管理。
自定义压缩策略
AgentContextCompressor 只负责“如何压缩”,可以完全不依赖大模型:
AgentContextCompressor compressor = AgentContextCompressors.chain(
AgentContextCompressors.compactCompletedTurns(),
AgentContextCompressors.textExcerpt(8_000));常见扩展方式:
identity():只复制消息,用于关闭压缩或调试。compactCompletedTurns():每个已完成 Turn 只保留 UserMessage 和最终 AiMessage。textExcerpt(maxCharacters):生成简单文本摘录,适合低成本兜底。model(...):将整段历史总结为合法的模型消息。perMessageModel(...):为每条 User/AI 消息生成摘要,保留消息角色和 ID。- 自定义实现:按领域提取订单、权限、审批、时间和未完成事项等结构化事实。
摘要消息应携带来源和压缩版本 metadata,避免同一摘要被二次处理。包含未完成 ToolCall 的 Turn 不应被普通文本摘要替换,必须保留完整 ToolCall/ToolMessage 协议。
边界与故障处理
压缩游标不存在
协调器要求传入的可压缩历史包含 coveredUntilMessageId。如果分页遗漏了该消息、历史被错误删除或状态与会话不一致,会抛出异常,而不是静默从头重复摘要。
CAS 冲突
CAS 冲突说明同一会话存在并发压缩。应重新读取最新状态和完整历史,再执行一次触发判断;不要直接使用旧的 modelMessages。
摘要失败
压缩器返回 null 或空列表会被视为失败。业务侧应保留旧状态,记录失败原因,并可以暂时使用窗口限制或规则压缩继续服务。
原始历史与模型视图
压缩不会调用 ChatMemory.clear(),也不会删除事件、Snapshot 或 ToolMessage。页面历史继续读取业务 ChatMemory;只有发给模型的 Prompt 使用压缩后的消息列表。
生产建议
- 将压缩状态和会话消息放在同一租户、同一会话的持久化边界内。
- 为摘要模型设置独立 Token 预算和超时,避免压缩阻塞正常对话。
- 监控压缩次数、覆盖消息数、Token 节省量、摘要失败率和 CAS 冲突率。
- 保留最近完整 Turn,尤其是仍可能恢复的审批、表单和工具调用 Turn。
- 对摘要内容做版本化和可追踪记录,必要时支持重新生成或回滚。