Compaction:完整拆解
一句话解释
Compaction = 当上下文超出 LLM 的 context window 时,用一个独立的 LLM 调用生成「对话摘要」,替换掉大部分历史消息,腾出空间继续对话。
为什么需要 Compaction?
正常的 Agent 执行:
问 → LLM 理解 → 调用工具 → LLM 获得结果 → 回复
每一轮对话 = 多则几十个 messages (tool_use + tool_result + assistant)
20 轮下来 = 500-1000 条 messages
如果每条 tool_result 有几千字符的 stdout → 上下文轻松超过 100K tokens
LLM 的 context window 是有限的:
GPT-4o = 128K tokens
Claude 4 = 200K tokens
本地小模型 = 8K-32K tokens
当 messages 总 token 逼近甚至超过 context window 时:
→ LLM API 返回 "context_length_exceeded" 错误
→ 或者最前面的消息被截断,LLM 丢失关键上下文
Compaction 就是在超出之前主动压缩历史,防止爆窗口。
Compaction 的触发时机
┌─────────────────────────────────────────────────────────────────┐
│ 触发条件 │
│ │
│ 1. Overflow Compaction(溢出触发) │
│ 触发位置:run.ts 主循环,每次 LLM 返回之后 │
│ 条件:LLM 返回 "context_length_exceeded" 错误 │
│ 代码:run.ts:1029-1140 │
│ │
│ 2. Timeout-triggered Compaction(超时触发) │
│ 触发位置:run.ts 主循环,LLM 超时之后 │
│ 条件:LLM 超时 + prompt token 用量 > 70% context window │
│ 代码:run.ts:890-977 │
│ │
│ 3. Auto Compaction(SDK 自动触发) │
│ 触发位置:pi-coding-agent 库内部 │
│ 条件:SessionManager 检测到 token 接近上限 │
│ 在 session.prompt() 内部自动完成,对 OpenClaw 透明 │
│ │
│ 4. Manual Compaction(手动触发) │
│ 触发位置:用户通过 CLI/Gateway 命令手动发起 │
│ 条件:用户主动调用 /compact │
│ trigger = "manual" │
└─────────────────────────────────────────────────────────────────┘
两种 Compact 入口
compactEmbeddedPiSession() compactEmbeddedPiSessionDirect()
(compact.queued.ts) (compact.ts)
有 Lane 排队 没有 Lane 排队
从外部调用 从 Agent 内部调用
↓ ↓
检查 harness 是否接管 直接执行
↓ ↓
排 sessionLane + globalLane ... 一样的逻辑 ...
↓
→ compactEmbeddedPiSessionDirect()
compactEmbeddedPiSessionDirect 完整流程
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: 选择 Compaction 模型 │
│ │
│ compaction 不一定用主 Agent 的模型! │
│ resolveEmbeddedCompactionTarget() → │
│ 可以配置一个专门的小模型做摘要(省 token) │
│ 默认:和主 Agent 用同一个模型 │
│ │
│ Step 2: 解析模型 + 获取 API key │
│ 和主 Agent 一样的流程:resolveModelAsync() → getApiKeyForModel() │
│ │
│ Step 3: 准备沙箱 + workspace + skills │
│ 和 runEmbeddedAttempt 的前几步完全一样 │
│ │
│ Step 4: 构建「compaction 专用的系统提示词」 │
│ 和主 Agent 用同一套 buildEmbeddedSystemPrompt() │
│ 只是 promptMode = "minimal"(更精简) │
│ │
│ Step 5: 打开 SessionManager + 修复 session 文件 │
│ SessionManager.open(sessionFile) │
│ → 读入 JSONL transcript │
│ │
│ Step 6: 对历史做清理(不清理就让 LLM 做摘要) │
│ sanitizeSessionHistory() → 清理损坏的 tool_result │
│ validateReplayTurns() → 验证每轮对话的完整性 │
│ limitHistoryTurns() → 限制 DM channel 的历史轮次 │
│ │
│ Step 7: 运行 before_compaction hooks │
│ → 让 memory 插件在 compaction 前做长期记忆持久化 │
│ 这是关键的一步!不持久化的话 compaction 可能丢失重要记忆 │
│ │
│ Step 8: 创建新的 Agent 会话 + 调用 compact() │
│ │
│ const createdSession = await createAgentSession({ ... }); │
│ 注入 compact 专用的系统提示词 │
│ │
│ activeSession.messages 现在有: │
│ [系统提示词, 完整的对话历史 (可能很多条)] │
│ │
│ await compactWithSafetyTimeout( │
│ () => activeSession.compact(customInstructions), │
│ 900_000 // 默认 15 分钟超时 │
│ ); │
│ │
│ ↑ 这就是核心!一个独立的 LLM 调用,目的是: │
│ 「阅读全部对话历史,生成一个摘要,告诉我保留哪些关键消息」 │
│ │
│ Step 9: activeSession.compact() 内部做了什么? │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ compact() 调用 LLM,输入是: │ │
│ │ │ │
│ │ System: "You are a summarizer. Read the │ │
│ │ conversation and produce: │ │
│ │ 1. A structured summary of key decisions │ │
│ │ and outcomes │ │
│ │ 2. A list of message IDs to KEEP │ │
│ │ (messages critical for future turns) │ │
│ │ ..." │ │
│ │ │ │
│ │ Messages: [全部对话历史] │ │
│ │ │ │
│ │ LLM 输出: │ │
│ │ ┌─ summary_text ──────────────────────────┐ │ │
│ │ │ 用户要求开发一个 React 组件,Agent 创建 │ │ │
│ │ │ 了... 遇到了 ESLint 报错,通过修改配置 │ │ │
│ │ │ 解决... 最终完成了功能 A 和 B,C 尚未 │ │ │
│ │ │ 完成... │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ ┌─ kept ids ─────────────────────────────┐ │ │
│ │ │ [msg_045, msg_078, msg_120] │ │ │
│ │ │ (最近几轮完整的 tool_call + │ │ │
│ │ │ tool_result + assistant 回复) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ compact() 然后替换 messages: │ │
│ │ │ │
│ │ Before: [sys, msg1, msg2, ..., msg500] │ │
│ │ 100K tokens │ │
│ │ │ │
│ │ After: [sys, summary_msg, msg_045, ..., msg_120] │ │
│ │ 15K tokens │ │
│ │ │ │
│ │ 注意:summary_msg 是一条特殊的 assistant message, │ │
│ │ 内容就是 LLM 生成的摘要文本 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Step 10: 持久化 + 验证 │
│ hardenManualCompactionBoundary() → 写入 JSONL 文件 │
│ estimateTokensAfterCompaction() → 检查是否仍然超标 │
│ persistSessionCompactionCheckpoint() → 保存检查点(可回溯) │
│ │
│ Step 11: 运行 after_compaction hooks │
│ → memory 插件读取压缩后的状态 │
│ │
│ Step 12: 返回结果给 run.ts 主循环 │
│ { ok: true, compacted: true, tokensBefore: 100000, │
│ tokensAfter: 15000 } │
│ │
│ 主循环收到后: │
│ → 更新 activeSession.messages = 压缩后的 messages │
│ → 更新系统提示词(因为 tokensBefore/tokensAfter 变了) │
│ → continue; → 重新发 prompt 给 LLM │
└─────────────────────────────────────────────────────────────────────┘
安全防护
1. 防止无限循环:最大重试次数
// run.ts:470
const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3;
const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 2;
// 如果 compact 了 3 次还 overflow → 放弃 compact,走 failover(换模型)
2. 防止 compaction 干跑:跳过无内容会话
// compact.ts:1003-1011
if (!containsRealConversationMessages(session.messages)) {
// 如果对话历史里没有真正的人机对话(比如全是系统消息)
→ return { compacted: false, reason: "no real conversation messages" };
}
3. 超时保护
// compaction-safety-timeout.ts
const EMBEDDED_COMPACTION_TIMEOUT_MS = 900_000; // 默认 15 分钟超时
// 可配置: agents.defaults.compaction.timeoutSeconds
compactWithSafetyTimeout(compactFn, timeoutMs, {
abortSignal, // 用户取消
onCancel: () => activeSession.abortCompaction() // 超时/取消时停掉 LLM 调用
})
4. 文件锁
// compact.ts:782-787
const sessionLock = await acquireSessionWriteLock({
sessionFile,
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({ timeoutMs })
});
// 防止多个 Agent 同时 compact 同一个 session 文件,造成数据损坏
5. 检查点机制
// 在 compact 前拍快照,compact 后保存检查点
checkpointSnapshot = captureCompactionCheckpointSnapshot({
sessionManager, sessionFile
});
// 如果 compact 失败 → 可以通过检查点恢复到 compact 前的状态
// 如果 compact 成功 → 检查点记录完整的前后对比(供审计/调试)
Compaction 失败分类
// compact-reasons.ts
function classifyCompactionReason(reason) {
"no real conversation messages" → "no_compactable_entries"
"token count is below compact threshold" → "below_threshold"
"already compacted recently" → "already_compacted_recently"
"context still exceeds target" → "live_context_still_exceeds_target"
"guard blocked compaction" → "guard_blocked"
"summary generation failed" → "summary_failed"
"compaction timed out" → "timeout"
"HTTP 400/401/403/429" → "provider_error_4xx"
"HTTP 500/502/503/504" → "provider_error_5xx"
}
context engine compaction vs direct compaction
项目里有两套 compaction 机制:
SDK Auto Compaction OpenClaw Direct Compaction
(pi-coding-agent 库内置) (compact.ts)
│ │
│ 在 session.prompt() 内部 │ 在 prompt() 返回后才触发
│ 自动检测 token 接近上限 │ 处理 "context overflow" 错误
│ 透明地做压缩,OpenClaw 无感 │ 或者用户手动触发
│ │
│ context engine 可以接管传统 │ run.ts 根据 attempt.result
│ compaction 流程 → │ 判断是否需要 compact
│ runOwnsContextEngine() → │
│ contextEngine.compact() │
完整时序总结
runEmbeddedPiAgent() while(true) 循环
│
├── runEmbeddedAttemptWithBackend()
│ └── session.prompt()
│ │
│ ├── (SDK 内部: 检测到 token 接近上限)
│ │ → 自动 compact → 继续 prompt
│ │
│ └── 返回结果
│
├── 检查结果
│ ├── status = "context_overflow"
│ │ ├── 已经自动 compact 过了? → 再 compact 一次
│ │ ├── overflowCompactionAttempts < 3? → compact + retry prompt
│ │ └── overflowCompactionAttempts >= 3? → failover (换模型)
│ │
│ └── status = "timeout" + high token usage
│ ├── timeoutCompactionAttempts < 2? → compact + retry prompt
│ └── timeoutCompactionAttempts >= 2? → failover
│
└── compact 成功 → update session → continue 回到循环顶部
核心智慧:compaction 就是把「一段很长的对话」变成「一段摘要 + 最近几轮关键消息」,用一个小型 LLM 调用省下未来所有调用的 token 开销。但它本身也有风险——调用 LLM 本身要花钱、花时间,所以有重试上限防护。