docs/plan/2026-05-28-session-compaction-technical-solution.md
Rama main · réplica de lectura del repositorio original.
# Agent 会话压缩技术方案
## 1. 文档目标
本文档描述一个多轮 Agent 系统中的“会话压缩”实现方案。目标不是解释某一段源码,而是抽象出一套可复用的技术设计,便于向外部团队说明该系统如何在长上下文、复杂工具调用和多轮会话场景下控制 token 成本,同时保持任务连续性和可恢复性。
本文档覆盖两类压缩能力:
1. 会话历史压缩:针对跨轮次累积的历史消息做滚动摘要。
2. 单次执行工作记忆压缩:针对一次请求内部的多轮工具调用链路做中间过程压缩。
这两类压缩共同构成“分层上下文治理”机制:
1. 第一层解决“会话越来越长”的问题。
2. 第二层解决“单次任务内部工具 loop 越跑越长”的问题。
## 2. 设计背景
一个具备 Tool Calling、文件读写、Shell、检索、联网、技能注入等能力的 Agent,在真实使用中会遇到三个典型问题:
1. 历史会话持续增长,输入 token 成本快速上升。
2. 单次复杂任务会经历多轮工具调用,临时工作记忆膨胀非常快。
3. 如果只做简单截断,容易丢失用户目标、已完成事项、错误处理上下文和待办状态。
传统的“保留最近 N 轮消息”只能缓解问题,不能解决问题。因为对 Agent 来说,真正重要的不只是最近消息,还包括:
1. 用户长期有效的意图、偏好和约束。
2. 已经完成的步骤和结论,避免重复劳动。
3. 正在进行中的任务状态和未完成事项。
4. 关键文件、命令、错误、路径、参数和决策。
因此,这套方案不是“删历史”,而是“把旧上下文重写成可继续执行的摘要上下文”。
## 3. 设计目标
### 3.1 核心目标
1. 控制上下文长度,降低模型输入成本。
2. 在压缩后仍能延续任务,而不是让 Agent 失忆。
3. 保留对后续执行真正有价值的信息,而不是机械保留原文。
4. 支持复杂工具链路下的中间态压缩。
5. 在压缩失败时优雅降级,不能影响主链路回答。
### 3.2 非目标
1. 不追求无损还原全部原始对话。
2. 不要求压缩摘要具备审计级可追溯性。
3. 不依赖向量检索或外部知识库才能恢复压缩内容。
4. 不试图用单一摘要替代长期记忆系统。
## 4. 总体架构
系统采用“两层压缩 + 一层长期记忆”的结构:
1. 长期记忆层:面向用户级、跨会话的稳定信息,例如偏好、画像、长期项目背景。
2. 会话压缩层:面向单个 conversation,压缩较早历史轮次。
3. 工作记忆压缩层:面向当前请求内的工具 loop,压缩本轮执行过程中的中间轨迹。
三者职责边界如下:
1. 长期记忆解决“跨会话复用什么”。
2. 会话压缩解决“当前会话历史太长怎么办”。
3. 工作记忆压缩解决“这一轮任务内部跑太久怎么办”。
在真正发给模型的 prompt 中,三者通常同时存在:
1. system prompt
2. 长期记忆注入内容
3. 会话摘要
4. 未被压缩的最近历史消息
5. 当前用户输入
如果发生工具调用,则后续每一轮模型续跑时还会额外包含:
1. 当前任务的工作摘要
2. 最近若干轮原始工具交互消息
## 5. 两层压缩的核心思想
### 5.1 会话历史压缩
会话历史压缩的目标是把“较早历史轮次”变成一段稳定、可复用、可继续执行的摘要,并在后续轮次中将其作为系统消息重新注入。
它处理的是跨请求累积的历史消息,属于持久化摘要。
### 5.2 工作记忆压缩
工作记忆压缩的目标是把“一次请求内部,最后一条用户消息之后产生的较早工具调用链路”压缩成工作摘要,让后续模型续跑时不必反复携带全部中间过程。
它处理的是本轮执行态消息,属于运行时摘要,不单独持久化为跨轮次资产。
### 5.3 为什么必须分两层
如果只做会话历史压缩,会出现一个问题:
1. 一次复杂任务在单轮内就可能消耗极多 token。
2. 即便历史会话很短,工具 loop 本身也会迅速膨胀。
如果只做工作记忆压缩,也会有问题:
1. 旧会话仍会不断积累。
2. 下一轮请求重新进入时,无法复用上一轮压缩结果。
因此,两层压缩分别解决不同时间尺度上的上下文膨胀:
1. 会话层是“跨轮次”的慢变量。
2. loop 层是“单请求内”的快变量。
## 6. 会话历史压缩方案
## 6.1 触发时机
会话历史压缩发生在正式组装回答上下文之后、真正发起模型主回答之前。也就是说,它不是后台异步任务,而是主链路的一部分。
这样设计的好处是:
1. 每次回答前都能基于最新会话状态判断是否需要压缩。
2. 压缩结果可以立即参与本次回答。
3. 不需要额外的离线调度系统。
## 6.2 输入范围
会话压缩不会直接处理整段会话,而是只处理“尚未被摘要覆盖的旧消息”。
系统会维护一个“压缩覆盖边界”,表示当前摘要已经覆盖到哪一条消息。后续新的压缩只需要处理边界之后、最近保留窗口之前的那一段消息。
这样做有两个价值:
1. 避免每次从头总结整段会话,节省成本。
2. 让摘要具备滚动累积能力,而不是一次性重写全部历史。
## 6.3 摘要持久化模型
系统会为每个 conversation 维护一份唯一的会话摘要记录,典型字段包括:
1. conversationId
2. summaryText
3. lastCompactedMessageId
4. compactCount
5. inputTokens
6. outputTokens
7. compactModel
8. createTime
9. updateTime
其中最关键的是两个字段:
1. summaryText:当前生效的滚动摘要正文。
2. lastCompactedMessageId:该摘要已经覆盖到的最后一条历史消息。
这使得系统可以把压缩过程建模为“增量合并摘要”。
## 6.4 保留窗口策略
为了避免把最近上下文也压掉,系统总会保留一段最近原始消息。默认策略是保留最近若干轮对话,按“1轮用户消息 + 1轮助手消息”估算为 2 条消息。
默认值:
1. recentTurns = 6
也就是默认尽量保留最近约 12 条消息作为原始上下文,不参与压缩。
这样做的原因是:
1. 最近消息通常与当前问题最直接相关。
2. 模型对最新原始措辞更敏感。
3. 工具调用、用户补充说明、局部修正通常发生在最近区间。
## 6.5 触发判定逻辑
会话压缩采用“两级触发”:
### 第一优先级:新增未压缩内容足够大
如果可压缩的新增消息 token 估算值达到阈值,则优先触发压缩。
默认值:
1. minNewTokenCount = 20000
这条规则适合处理“会话虽然总长度还没接近上限,但新增内容已经很大”的情况。
### 第二优先级:整体上下文接近窗口阈值
如果新增消息条数达到最小要求,并且整体输入估算已接近上下文窗口阈值,则触发压缩。
相关默认值:
1. contextWindowTokens = 96000
2. triggerRatio = 0.72
3. minNewMessageCount = 8
4. minCompactMessageCount = 10
也就是说,只有当整体上下文估算达到大约 `96000 * 0.72 = 69120` token 时,才会因为“接近窗口”而触发压缩;同时还要求存在足够多的新消息,避免短会话被频繁压缩。
### 实际估算内容
整体输入估算通常包含:
1. system prompt
2. 当前已有会话摘要
3. 未压缩历史消息
4. 当前用户问题
这说明压缩决策不是只看历史消息本身,而是站在“本次真实送模上下文成本”的角度做判断。
## 6.6 批量压缩机制
为了避免一次压缩输入过大,会话压缩支持分批滚动执行。
默认值:
1. maxCompactionBatchMessages = 200
2. maxRoundsPerRequest = 30
含义是:
1. 单次压缩最多处理 200 条消息。
2. 一次用户请求里最多连续做 30 轮压缩。
这使系统可以处理极长会话,而不会因为一次压缩请求过大而失败。
### 分批逻辑
如果当前未压缩消息数远大于“最近保留窗口 + 单批上限”,系统会先拿出一批旧消息做压缩,更新摘要边界,然后继续判断下一批,直到:
1. 剩余消息只剩最近保留窗口;
2. 或剩余消息不足以继续触发压缩;
3. 或达到单次请求允许的最大压缩轮数。
## 6.7 压缩输入格式
会话压缩不会把原始消息对象直接原样传给压缩模型,而是先做摘要友好的结构化文本重组:
1. 用户消息转换为“用户: 内容”
2. 助手消息优先取“回答摘要”,其次取回答正文
3. 单条内容过长时做截断,中间用省略标记压缩
这一步很关键,因为压缩模型不需要原始协议格式,只需要可理解的语义线索。
设计上的意图是:
1. 尽量保留用户目标和结论。
2. 降低工具结果、冗长回答、超长正文对压缩模型的干扰。
3. 减少压缩自身的输入成本。
## 6.8 摘要生成方式
会话压缩不是简单让模型“总结一下”,而是采用“已有摘要 + 新增历史片段 + 当前用户问题”的融合模式。
输入包括三部分:
1. 既有摘要
2. 本轮待压缩的新增历史片段
3. 当前用户问题
压缩模型要做的不是生成一个泛泛摘要,而是生成一份“可继续执行任务”的统一摘要。摘要要求重点保留:
1. 用户明确请求和意图
2. 关键技术概念、架构判断和决策
3. 文件、路径、接口、命令、错误、测试结果、版本和数值
4. 已完成事项、未完成待办、当前停留位置
5. 与最近请求最相关的下一步
换句话说,这个摘要本质上是“面向下一轮执行”的,而不是“面向人阅读的简报”。
## 6.9 摘要输出结构
压缩输出约定为纯文本,并允许使用 `<summary>` 包裹真正摘要正文。系统会从模型输出中提取 `<summary>` 片段;如果没有该标签,则会把整个文本视为摘要。
这个设计有两个好处:
1. 对模型输出格式提供弱约束,兼顾稳健性。
2. 即使模型没有严格遵守格式,也能尽量降级提取结果。
## 6.10 摘要如何回注到主对话
会话摘要不会替代全部历史,而是以系统消息的形式插入到主对话前部,语义上相当于:
1. 这是上一段对话的延续。
2. 以下是系统生成的会话摘要。
3. 请将其作为历史事实参考。
4. 延续用户目标、约束、已完成事项和未完成待办。
5. 避免重复检索或重复执行已完成步骤。
在这条系统摘要消息之后,系统再附上最近保留的原始消息。
因此,最终送模上下文是:
1. 主 system prompt
2. 会话摘要 system message
3. 最近未压缩原始历史
4. 当前用户消息
这是一种典型的“摘要 + 近场原文”混合上下文结构。
## 6.11 失败降级策略
如果会话压缩失败,系统不会中断主对话流程,而是回退到原始历史消息。
可能的失败情况包括:
1. 压缩模型调用异常
2. 摘要为空
3. 边界消息丢失或异常
降级原则是:
1. 宁可多带一点原始上下文,也不能让主回答失败。
2. 压缩是优化能力,不是主链路硬依赖。
## 7. 单次执行工作记忆压缩方案
## 7.1 为什么需要 loop 压缩
很多 Agent 问题不是在“会话维度”上变长,而是在“这一轮任务”内部变长。
典型场景:
1. 模型先读取文件
2. 再搜索关键字
3. 再查看多个片段
4. 再执行命令
5. 再修改文件
6. 再验证结果
这些中间消息会随着工具轮次累积,导致同一次请求的 conversationHistory 越来越大。若不压缩,会出现:
1. 后续每轮工具续跑成本越来越高。
2. 模型容易反复读取已看过的信息。
3. Tool loop 可能在上下文膨胀中退化。
因此,系统在工具调用管理器内部加入了工作记忆压缩。
## 7.2 触发位置
工作记忆压缩发生在每次工具调用完成之后、下一轮模型续跑之前。
顺序大致是:
1. 模型请求工具
2. 工具执行
3. 工具结果写回 conversationHistory
4. 判断是否需要 loop 压缩
5. 如需要则重建 conversationHistory
6. 再进入下一轮模型调用
这意味着 loop 压缩直接作用于“下一轮送模历史”,而不是事后持久化装饰。
## 7.3 压缩范围
loop 压缩只处理“最后一条用户消息之后的消息”,不会动更早的跨轮次历史。
这是一个非常重要的边界:
1. 会话层只关心旧轮次。
2. loop 层只关心当前轮次内部。
系统会先找到最后一条用户消息,然后把后面的消息视为当前工作区间,再在这个区间内执行压缩。
## 7.4 已有工作摘要的复用
如果当前这轮请求之前已经做过一次 loop 压缩,那么系统会先取出已有工作摘要,与本次要压缩的更早工具消息一起再次融合。
这说明 loop 压缩不是覆盖式的,而是滚动累积式的。
## 7.5 触发条件
loop 压缩同样采用双条件触发,只要满足其一即可:
1. 工具往返轮数达到阈值。
2. 工作区消息 token 估算达到阈值。
默认值:
1. loopTriggerToolRounds = 5
2. loopTriggerTokens = 96000
也就是说,当本轮工具调用已经足够多,或者工作区消息已经足够长,就会触发压缩。
## 7.6 保留最近原始工具轮次
即使发生 loop 压缩,系统也不会把本轮工具轨迹全部摘要化,而是保留最近若干轮原始工具交互。
默认值:
1. loopRecentToolRounds = 5
原因在于:
1. 最近工具结果最可能与下一步动作直接相关。
2. 模型对最新原始工具输出的依赖远高于更早历史。
3. 保留最近几轮原文能降低摘要误差对执行的影响。
## 7.7 工作区消息如何转写
工作记忆压缩会把当前轮次中的不同消息类型转成统一文本:
1. 普通助手消息转成“助手: 内容”
2. 带工具调用的助手消息会记录工具名和参数
3. 工具响应会记录成“工具结果[工具名]: 结果”
4. 用户消息和系统消息也会按文本形式纳入
同时,对超长文本、参数和工具结果做截断,只保留前后关键片段。
这保证压缩模型能看到:
1. 做过什么动作
2. 调了什么工具
3. 用了什么参数
4. 得到了什么结果
5. 现在应该继续做什么
## 7.8 工作摘要的语义
工作记忆摘要的目标不是“回顾历史”,而是“帮助模型继续完成当前任务”。
摘要重点保留:
1. 当前目标
2. 已完成动作
3. 关键发现
4. 关键路径、对象、参数和文件
5. 错误及处理
6. 当前状态
7. 下一步待做
此外,系统明确告诉模型:
1. 这是一段工具调用链路的延续。
2. 工作摘要用于延续执行状态。
3. 如果工作摘要与后续原始工具结果冲突,以后续原始消息为准。
这条规则非常关键,因为它定义了“摘要”和“最近原文”的优先级关系。
## 7.9 压缩后的重建方式
压缩成功后,系统会重建下一轮送模历史:
1. 保留最后一条用户消息之前的前缀消息
2. 插入一条工作摘要系统消息
3. 追加最近若干轮未压缩的原始工具消息
因此,下一轮模型看到的是:
1. 原始任务起点
2. 经过压缩的较早执行过程
3. 最近的原始工具现场
这本质上是“任务状态机快照 + 近场现场信息”的组合。
## 7.10 是否持久化
工作记忆摘要通常只保存在当前运行态上下文中,用于本次请求的多轮续跑,不单独作为跨会话摘要落库。
但原始工具调用过程仍会通过消息上下文和工具调用记录被保存下来,用于后续查看、调试和审计。
因此它的设计哲学是:
1. 摘要负责降低运行成本。
2. 原始记录负责保留可观测性。
## 7.11 失败降级
如果 loop 压缩失败,系统直接保留原始 conversationHistory,不阻断后续工具 loop。
这与会话压缩保持一致:
1. 压缩是优化,不是功能正确性的前提。
2. 失败时优先保证任务继续执行。
## 8. 历史消息重放策略
系统在把历史消息重新送给模型时,并不是总是完整回放原始内容。
对于较老消息,会采取额外的轻量裁剪策略:
1. 优先保证消息顺序和用户开头完整。
2. 较老消息中的长文本只保留部分内容。
3. 较老助手消息中的工具调用内容可以不再完整回放。
这个策略和摘要机制配合后形成三层削峰:
1. 最旧历史被会话摘要替代。
2. 中间历史保留为部分裁剪的原始消息。
3. 最新历史保留原始形态。
这是一种典型的“按时间距离递减保真度”设计。
## 9. 与 Prompt 场景系统的关系
压缩逻辑本身并不把 Prompt 写死在代码里,而是通过“模型场景配置”来管理不同压缩任务。
至少存在以下压缩相关场景:
1. 会话压缩 system 场景
2. 会话压缩 user 场景
3. loop 压缩 system 场景
4. loop 压缩 user 场景
这意味着:
1. 压缩提示词可以独立演进。
2. 压缩模型可以与主对话模型不同。
3. 可以针对不同业务场景调节温度、topP 和模型版本。
4. 不需要改业务代码就能优化压缩质量。
这是该方案非常重要的工程化特征。
## 10. token 估算策略
系统使用统一的 tokenizer 估算上下文 token,大致基于主流兼容模型的分词方案。
估算失败时,会退回到基于字符数的粗略估算。
这样做的目的不是得到精确账单,而是提供足够稳定的压缩触发依据。
设计上需要注意:
1. 估算值不是实际计费值。
2. 但只要相对趋势稳定,就足以支撑阈值触发。
## 11. 可观测性设计
压缩过程不是静默发生的,系统会通过事件和日志暴露关键状态。
### 11.1 前端事件
压缩前后会发出类似以下状态事件:
1. `compacting`
2. `compacted`
这样前端或调用方可以感知:
1. 当前正在压缩上下文
2. 压缩已经完成
### 11.2 日志要点
日志会记录:
1. 触发原因
2. 会话 ID
3. 剩余消息数
4. 压缩消息数
5. 输入输出 token 估算
6. 工具轮次数
7. 压缩次数
这些信息足以支持线上调优和问题排查。
### 11.3 工具调用记录
虽然 loop 压缩会折叠部分中间过程,但工具调用本身仍单独记录:
1. 工具名
2. 参数
3. 结果
4. 耗时
所以压缩并不会让系统失去可诊断性。
## 12. 默认配置建议
以下是一组较平衡的默认值:
| 配置项 | 默认值 | 说明 |
| --- | --- | --- |
| compaction.enabled | true | 是否启用会话压缩 |
| compaction.contextWindowTokens | 96000 | 上下文窗口估算值 |
| compaction.triggerRatio | 0.72 | 会话压缩窗口触发比例 |
| compaction.recentTurns | 6 | 保留最近原始轮数 |
| compaction.minCompactMessageCount | 10 | 允许触发压缩的最小消息数 |
| compaction.minNewMessageCount | 8 | 基于窗口触发时要求的最小新增消息数 |
| compaction.minNewTokenCount | 20000 | 新增未压缩 token 达标即优先压缩 |
| compaction.maxCompactionBatchMessages | 200 | 单轮最多压缩消息数 |
| compaction.maxRoundsPerRequest | 30 | 单次请求内最多连续压缩轮数 |
| compaction.loopEnabled | true | 是否启用 loop 压缩 |
| compaction.loopTriggerToolRounds | 5 | loop 压缩工具轮次阈值 |
| compaction.loopTriggerTokens | 96000 | loop 压缩 token 阈值 |
| compaction.loopRecentToolRounds | 5 | loop 压缩后保留的最近原始工具轮数 |
## 13. 方案优点
### 13.1 成本控制有效
通过“滚动摘要 + 最近原文保留”,系统能显著降低长会话和复杂工具链带来的 token 膨胀。
### 13.2 连续执行能力强
摘要保留的是任务状态,而不是泛泛结论,因此更适合 Agent 继续执行。
### 13.3 工程化程度高
压缩 Prompt、压缩模型、触发阈值和场景配置相互解耦,便于迭代。
### 13.4 失败可降级
压缩失败不会让主链路中断,系统鲁棒性较好。
### 13.5 兼顾可观测性
即使上下文被压缩,原始消息和工具记录仍然可被保留和查看。
## 14. 方案局限
### 14.1 摘要不可避免会有信息损失
再优秀的摘要模型,也不可能完全无损替代原始消息。
### 14.2 摘要质量高度依赖 Prompt 设计
如果压缩 Prompt 不够强调约束、待办、错误和状态,摘要就容易退化成普通总结。
### 14.3 阈值是经验值
不同模型、不同业务、不同平均消息长度下,阈值需要重新标定。
### 14.4 最近窗口仍可能过大
当最近几轮本身非常长时,即使做了摘要,近场原文也可能继续占据大量 token。
### 14.5 loop 摘要只在当前请求内生效
如果本轮任务被打断后重开,请求级工作摘要通常不会直接复用为跨轮资产。
## 15. 适用场景
这套方案特别适合以下类型的 Agent:
1. 支持工具调用且单轮执行链路较长
2. 会持续多轮协作完成任务
3. 经常涉及代码、文档、Shell、搜索、检索和修改
4. 用户要求“延续上下文”而不是一次性问答
不太适合的场景包括:
1. 极短问答型机器人
2. 没有工具调用的轻量客服问答
3. 完全依赖外部检索而几乎不保留上下文状态的系统
## 16. 推荐优化方向
### 16.1 区分摘要层级
可进一步把会话摘要拆成:
1. 用户长期约束
2. 当前项目背景
3. 最近任务状态
这样不同层级可以分别更新,减少整段重写。
### 16.2 引入结构化摘要存储
当前摘要本质是文本。后续可升级为半结构化 JSON 或 Markdown 分段,便于:
1. 精确更新某一部分
2. 更稳定地注入 prompt
3. 面向管理后台展示
### 16.3 动态保留窗口
可根据消息长度、工具类型和当前任务阶段动态调整 recentTurns 或 recentToolRounds,而不是固定常量。
### 16.4 压缩质量评估
可以增加自动评估指标,例如:
1. 摘要覆盖率
2. 重复执行率
3. 压缩后回答成功率
4. 工具调用回退率
### 16.5 与中断重启机制结合
如果系统支持“执行中纠偏”或“安全点重启”,可考虑让工作摘要在重启时部分转化为会话级任务摘要,提升恢复能力。
## 17. 一句话总结
这套会话压缩方案的本质,不是把旧消息删掉,而是把旧消息改写成“可继续执行的任务状态”;再通过“跨轮会话摘要 + 单轮工作摘要 + 最近原始上下文保留”的三段式结构,在控制 token 成本的同时,尽量保住 Agent 的连续行动能力。