跳转到内容

05 上下文压缩与自动恢复

05 上下文压缩与自动恢复

学习目标

读完本章后,你将能够:

  • 理解 Goose 的 GOOSE_AUTO_COMPACT_THRESHOLD 环境变量与自动压缩触发机制
  • 掌握 compact_messages() 的完整流程和工具对摘要策略
  • 理解 ContextLengthExceeded 错误的最多 2 次自动恢复重试策略
  • 掌握 HistoryReplaced 事件如何通知 UI 更新

前置知识


核心概念

上下文溢出的三种场景

Goose 在处理长对话时,可能遇到以下三种上下文超限场景:

场景触发时机处理方式
主动压缩消息总 token 数超过 GOOSE_AUTO_COMPACT_THRESHOLD调用 compact_messages() 摘要化历史
被动恢复LLM API 返回 ContextLengthExceeded 错误触发自动恢复流程,最多重试 2 次
压缩失败compact_messages() 自身失败回退到截断策略,确保对话不中断

整体架构


项目实践

GOOSE_AUTO_COMPACT_THRESHOLD 环境变量

Goose 通过环境变量 GOOSE_AUTO_COMPACT_THRESHOLD 控制自动压缩的触发阈值:

// 伪代码:环境变量读取与默认值
GOOSE_AUTO_COMPACT_THRESHOLD = env.get("GOOSE_AUTO_COMPACT_THRESHOLD")
.parse::<usize>()
.unwrap_or(default_threshold) // 默认值由模型上下文窗口决定
// 阈值与模型窗口大小的关系
default_threshold = model_max_input_tokens * 0.8 // 留出 20% 余量

设计考虑:不同模型的上下文窗口差异巨大(从 8K 到 200K+)。阈值设置过小会频繁压缩、浪费 token;设置过大会在接近窗口上限时来不及压缩。预留 20% 余量确保压缩后的上下文仍有足够的操作空间。

compact_messages() 流程

压缩的核心函数 compact_messages() 遵循以下步骤:

关键步骤详解

// 伪代码:compact_messages() 核心流程
fn compact_messages():
messages = load_all_messages(session_id)
current_tokens = count_tokens(messages)
if current_tokens <= threshold:
return // 无需压缩
// 1. 保留不可压缩的消息
system_messages = messages.filter(role == "system")
recent_messages = messages.tail(K) // 保留最近 K 条
// 2. 提取需要压缩的中间消息
compressible = messages[system_messages.count() : -K]
// 3. 工具对摘要:将 tool_call + tool_result 配对摘要
tool_pairs = group_into_tool_pairs(compressible)
summaries = []
for pair in tool_pairs:
summary = summarize_tool_pair(pair) // LLM 摘要
summaries.append(summary)
// 4. 合并为单条摘要消息
summary_message = Message::assistant(
prefix: "SUMMARY:",
content: join(summaries, "\n")
)
// 5. 构造新消息列表
new_messages = system_messages + [summary_message] + recent_messages
// 6. 数据库操作:DELETE + INSERT(不是 UPDATE)
db.transaction():
db.execute("DELETE FROM messages WHERE session_id = ?", session_id)
db.batch_insert("INSERT INTO messages ...", new_messages)
// 7. 通知 UI
emit_event(HistoryReplaced { session_id, old_count: messages.len(), new_count: new_messages.len() })

工具对摘要(Tool-Pair Summarization)

工具对摘要是 Goose 压缩策略的核心优化——将单个工具调用及其结果配对摘要,而非将整个历史作为一整块文本摘要。

// 伪代码:工具对摘要
fn summarize_tool_pair(tool_call, tool_result):
prompt = """
请将以下工具调用和结果压缩为一条摘要:
- 工具名称: {tool_call.name}
- 工具参数: {tool_call.arguments}
- 工具结果: {tool_result.output}
摘要应保留:
1. 工具调用的目的(做了什么)
2. 关键结果(得到了什么)
3. 文件路径、错误类型等结构化信息
省略详细的输出内容。
"""
summary = call_llm(prompt, model=compact_model)
return summary

为什么用工具对而非整块摘要?

维度整块摘要工具对摘要
Token 消耗一次大摘要调用多次小摘要调用
信息保留可能遗漏细节每个工具对独立保留关键信息
可恢复性摘要质量差时全部丢失单个工具对摘要失败不影响其他
实现复杂度中(需要配对逻辑)

Goose 选择工具对摘要,因为 Agent 对话中工具调用通常占 token 的大头,逐个工具对摘要可以更精确地控制信息保留粒度。

ContextLengthExceeded 自动恢复

当 LLM API 返回 ContextLengthExceeded 错误时,Goose 触发自动恢复流程:

// 伪代码:ContextLengthExceeded 恢复
struct ContextRecoveryState {
max_retries: usize = 2,
current_retry: usize = 0,
}
fn handle_context_length_exceeded(error):
if recovery_state.current_retry >= recovery_state.max_retries:
// 超过最大重试次数,放弃恢复
notify_user("上下文过大,无法自动恢复。建议开启新会话。")
return Error::RecoveryExhausted
recovery_state.current_retry += 1
// 每次重试使用更激进的压缩
compact_messages(
tail_count = max(0, tail_count - 2), // 减少保留的最近消息数
aggressive = true // 使用更激进的摘要策略
)
// 重试发送请求
retry_send_request()

为什么限制最多 2 次重试?

  1. 成本上限:每次恢复都涉及额外的 LLM 调用(压缩 + 重试请求),无限重试会导致 token 费用失控
  2. 信息衰减:每次压缩都会丢失信息,经过 2 次压缩后历史摘要已经非常精简,再次压缩收益极低
  3. 用户体验:3 次尝试(初始 + 2 次恢复)已经是合理的耐心阈值,超过后应该让用户介入决策

恢复时的激进压缩策略

  • 减少 Tail 保护的消息数(从 K 条减到 K-2、K-4 条)
  • 使用更激进的摘要提示词(「仅保留最关键的信息」)
  • 必要时剥离大型工具输出(如日志文件、长 JSON)

HistoryReplaced 事件

当压缩完成后,Goose 通过 HistoryReplaced 事件通知前端 UI 更新消息列表:

// 伪代码:HistoryReplaced 事件
struct HistoryReplaced {
session_id: String,
old_message_count: usize, // 压缩前消息数
new_message_count: usize, // 压缩后消息数
summary_token_count: usize, // 摘要占用的 token 数
}
// UI 处理
on_event(HistoryReplaced):
ui.replace_messages(event.session_id, new_messages)
ui.show_notification(
"对话历史已压缩,{old_message_count} 条消息缩减为 {new_message_count} 条"
)

设计考虑:UI 需要知道历史被替换的原因是压缩而非其他操作。HistoryReplaced 事件让前端可以:

  • 替换消息列表而无需逐条 diff
  • 显示压缩通知,让用户理解为什么消息”消失”了
  • 提供”查看压缩摘要”的入口

问题与规避

压缩本身失败

问题:当 compact_messages() 自身失败时(如 LLM 不可用、摘要请求超时),如何处理?

对策

// 伪代码:压缩失败的回退策略
fn compact_with_fallback():
try:
return compact_messages()
catch LLMError:
// LLM 不可用,回退到截断
return truncate_messages(tail_count = K)
catch TimeoutError:
// 摘要请求超时,回退到截断
return truncate_messages(tail_count = K)
catch TokenOverflow:
// 压缩后仍然超限,使用更激进的截断
return truncate_messages(tail_count = K / 2)

截断策略

  • 截断是压缩的最终兜底方案,不依赖 LLM
  • 保留系统提示和最近 N 条消息,删除中间所有消息
  • 截断虽然丢失信息,但保证对话不中断

压缩导致的信息丢失

问题:摘要无法保留所有原始细节,可能导致 Agent 忘记关键约束。

对策

  • 保留所有系统提示(包含核心指令和工具定义)
  • 保留最近 K 条消息的完整内容(Tail 保护)
  • 工具对摘要保留文件路径、错误类型、关键数值等结构化信息
  • 压缩前将重要中间结果写入文件系统,后续可通过工具重新读取

压缩后的工具调用 ID 不一致

问题:压缩后历史消息被替换,原有的 tool_call_id 引用可能失效,导致 LLM 后续调用时出现格式错误。

对策

  • 压缩时保留 Tail 中的完整工具调用对(tool_call + tool_result)
  • 摘要消息不包含工具调用格式,避免 ID 混淆
  • 压缩后验证消息序列的格式正确性(工具调用和结果配对)

设计取舍

基于摘要的压缩 vs 基于截断的压缩

维度基于摘要(Goose 采用)基于截断
信息保留高(语义摘要保留关键信息)低(直接丢弃旧消息)
Token 开销高(需要 LLM 调用生成摘要)无(仅本地操作)
延迟高(摘要生成需要数百毫秒到数秒)极低(毫秒级)
实现复杂度高(配对、摘要、合并逻辑)低(简单裁剪)
可解释性中(用户可阅读摘要)低(消息直接消失)
适用场景长程任务、需要保留历史上下文简单对话、成本敏感

Goose 的选择:默认使用基于摘要的压缩,截断作为兜底方案。这种设计在信息保留和可靠性之间取得了平衡——摘要压缩保证对话质量,截断回退保证系统可用性。

工具对摘要 vs 整块摘要

维度工具对摘要(Goose 采用)整块摘要
精度高(每个工具对独立摘要)中(可能遗漏细节)
并行性可并行摘要多个工具对必须串行
容错性单个摘要失败不影响其他整体失败
Token 效率中(多次小摘要的 overhead)高(一次大摘要)
适用场景工具密集型对话文本密集型对话

Goose 的选择:工具对摘要更适合 Agent 场景,因为工具调用(尤其是文件读取、命令执行)通常占 token 消耗的大头。逐个工具对摘要可以精确保留每个操作的关键信息。

2 次恢复上限 vs 无限恢复

维度2 次上限(Goose 采用)无限恢复
成本控制严格(最多额外 2 次压缩 + 2 次请求)不可控
信息保留2 次压缩后信息已大幅精简理论上更好,但实际衰减更快
用户体验明确预期(最多尝试 3 次)可能陷入长时间等待
实现复杂度简单(计数器)复杂(需要动态策略调整)

Goose 的选择:2 次恢复上限在成本、信息保留和用户体验之间取得了合理平衡。超过 2 次恢复后,建议用户开启新会话是更经济的选择。

SQLite DELETE + INSERT vs 逐条 UPDATE

维度DELETE + INSERT(Goose 采用)逐条 UPDATE
实现复杂度低(整体替换)高(需要逐条匹配和更新)
事务安全性高(单事务完成)中(多次 UPDATE 可能部分失败)
性能高(批量操作)低(逐条操作)
消息结构变化天然支持(消息数可能改变)不支持(需要额外 INSERT/DELETE)

Goose 的选择:压缩后的消息列表可能与原始消息数量不同(多条消息合并为一条摘要),DELETE + INSERT 是更自然的选择。在单事务中执行保证了原子性,即使失败也不会导致数据不一致。


参考来源