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 次重试?
- 成本上限:每次恢复都涉及额外的 LLM 调用(压缩 + 重试请求),无限重试会导致 token 费用失控
- 信息衰减:每次压缩都会丢失信息,经过 2 次压缩后历史摘要已经非常精简,再次压缩收益极低
- 用户体验: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 是更自然的选择。在单事务中执行保证了原子性,即使失败也不会导致数据不一致。
参考来源
- LangGraph 文档 — 状态管理和消息裁剪
- Context Engineering for Agents, 2025
- Goose 源码:
crates/goose/tests/compaction.rs、crates/goose/src/prompt_template.rs