codev / docs /services /compact-deep-dive.md
chenbhao's picture
chore: rename VersperClaw to Codev, update org to chenbhao
96f34e3
|
Raw
History Blame Contribute Delete
38.6 kB
# 上下文压缩管道 (Compaction Pipeline) 深度分析
> 本文档基于 Codev 源代码分析,涵盖 `/src/services/compact/` 目录下的全部压缩机制,
> 以及 `/src/query.ts` 中压缩管道编排逻辑。
---
## 1. 压缩管道的动机
### 1.1 上下文窗口是绑定资源约束
大语言模型(LLM)的推理上下文存在**硬性 token 上限**。对 Claude 系列模型而言,
这个上限通常是 200K token。一旦消息历史超过该上限,API 将返回 `prompt_too_long` 错误,
对话无法继续。
在实际使用中,上下文窗口面临三个核心矛盾:
```
Token 使用量
^
| / 硬上限 (200K)
| /
| / 自动压缩阈值 (~window - 13K)
| /
| / 警告阈值 (~window - 20K)
| /
| / 实际用量 (随时间增长)
| /
+------------------------------------------> 时间/轮次
```
### 1.2 三个关键约束
| 约束 | 说明 | 代码证据 |
|------|------|----------|
| **Token 硬限制** | 超过模型上下文窗口后 API 拒绝请求 | `compact.ts:106-107``PROMPT_TOO_LONG_ERROR_MESSAGE` |
| **90% 阈值后性能下降** | 接近窗口上限时,模型的检索精度和推理质量显著下降 | `autoCompact.ts:62``AUTOCOMPACT_BUFFER_TOKENS = 13_000` 保留缓冲 |
| **Cache 效率衰减** | 大上下文降低 prompt caching 命中率,增加 API 成本和延迟 | `compact.ts:435-438``tengu_compact_cache_prefix` 实验开关 |
### 1.3 压缩管道的设计目标
压缩管道的核心目标是:**在保证对话质量的前提下,以最小代价持续将上下文维持在可用窗口内**
设计原则:
- **分层递进**:从零成本到高成本逐层尝试,避免过早触发昂贵的 LLM 调用
- **优先级有向**:保留高价值消息(用户意图、关键决策、代码变更),裁剪低价值内容(工具结果、确认消息)
- **无损恢复**:压缩后的关键状态(文件附件、计划、技能)自动恢复
- **语义连续**:模型感知不到压缩的发生,对话体验自然衔接
---
## 2. 五层压缩管道
压缩管道在 `src/query.ts` 的主循环中按固定顺序编排执行。
每一层如果成功缓解了上下文压力,后续更昂贵的层就不会触发。
```
查询开始
┌─────────────────────────────────────┐
│ Layer 1: 预算削减 (Budget Re- │ 零成本
│ duction) │
│ 工具结果 → 磁盘换预览 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Layer 2: 剪切 (Snip) │ 零成本
│ 移除低价值消息 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Layer 3: 微压缩 (Micro-compact) │ 近零成本
│ 删除旧工具结果 / 缓存编辑 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Layer 4: 上下文折叠 (Context │ O(1)
│ Collapse) │
│ 多轮交互 → 紧凑提交 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Layer 5: 自动压缩 (Auto-compact) │ 高成本
│ LLM 语义摘要 │ (一次 API 调用)
└─────────────────────────────────────┘
发送给 API
```
---
### Layer 1: 预算削减 (Budget Reduction)
**文件**: `src/utils/toolResultStorage.ts` (第 740-839 行)
**函数**: `enforceToolResultBudget()`
**成本**: 零 LLM 调用。纯内存操作,涉及可选的磁盘 I/O。
#### 触发条件
每次模型调用前无条件执行。它有一个内部"预算"概念:每条用户消息中的工具结果
`tool_result`)聚合大小超过**单条消息预算限制**(通过 GrowthBook 的
`tengu_plum_marten` 特性标识配置,见 `getPerMessageBudgetLimit()`)。
#### 算法/策略
```
对于每条用户消息的工具结果组:
1. 划分已知结果(seenIds 中已存在)和新鲜结果
2. 已知结果 → 从缓存中重新应用相同的预览替换
3. 新鲜结果 → 检查聚合大小是否超限
4. 超限 → 选择最大的新鲜结果持久化到磁盘,替换为预览摘要
5. 记录替换决策到 seenIds 和 replacements Map
```
核心逻辑(`enforceToolResultBudget` 第 769 行):
- `seenIds`:追踪已处理过的 `tool_use_id`,避免同一结果被反复替换
- `replacements`:缓存替换后的预览内容,后续调用直接复用(零 I/O)
- 每条消息独立计算,不会跨消息"借用"预算
#### 保留什么
- 所有不超过预算的消息保持原样
- 用户自然语言消息始终完整保留
- 不超过预算的工具结果完整保留
#### 丢弃什么
- 超预算消息中**最大的新鲜工具结果**被替换为磁盘文件引用 + 摘要预览
- 替换是幂等的:同一 `tool_use_id` 每次得到相同的预览
#### 为什么这是第一层
此层在物理上压缩消息内容但不改变消息结构。它运行在微压缩**之前**
因为缓存微压缩(Cached MC)只通过 `tool_use_id` 操作——它对内容替换不可见,
两者可以干净地组合(`query.ts:369-394` 注释)。
---
### Layer 2: 剪切 (Snip)
**文件**: `src/services/compact/snipCompact.ts`
**成本**: 零 LLM 调用。纯消息过滤。
#### 触发条件
`feature('HISTORY_SNIP')` 特性标识控制,仅在内部版本中启用
`query.ts:115-116`)。外部构建中是空操作桩(stub)。
外部构建中的状态(`snipCompact.ts:9`):
```typescript
export function isSnipRuntimeEnabled(): boolean {
return false
}
```
#### 算法/策略
移除低价值消息。典型的可剪切消息包括:
- 用户简短确认("ok"、"好的"、"继续")
- 简单的工具结果(bash 命令退出码 0、文件写入成功)
- 重复的系统消息
剪切操作会插入一个 `snip_marker` 边界消息(`subtype === 'snip_marker'`
以标记消息已被移除,同时保持消息链的连续性。
#### Token 节省传递
`query.ts:400-410` 中,snip 操作产生的 `tokensFreed` 被传递给后续的
`shouldAutoCompact()` 检查。这是因为 `tokenCountWithEstimation` 从存活
assistant 消息的 usage 字段读取 token 计数,而 snip 移除的是用户消息,
导致计费 token 与实际压缩后的上下文不一致。
---
### Layer 3: 微压缩 (Micro-compact)
**文件**: `src/services/compact/microCompact.ts`
**函数**: `microcompactMessages()` (第 253 行)
**成本**: 近零。纯 JS 逻辑,无 LLM 调用。
微压缩有三个子路径,按优先级执行:
```
microcompactMessages()
├── 时间触发微压缩 (Time-based MC)
│ 如果自上次 assistant 消息超过阈值 → 清除旧工具结果内容
├── 缓存微压缩 (Cached MC)
│ 使用 API cache_edits 在不破坏缓存前缀的前提下删除工具结果
└── 传统路径 (已移除)
之前使用本地消息修改,已被 Cached MC 完全取代
```
#### 3a: 时间触发微压缩 (Time-based MC)
**文件**: `src/services/compact/timeBasedMCConfig.ts`
**配置键**: `tengu_slate_heron`
**触发**: `evaluateTimeBasedTrigger()` (第 422 行)
当满足以下条件时触发:
1. 特性启用(`enabled: true`
2. 主线程查询源(`querySource``repl_main_thread` 开头或为空)
3. 最后一条 assistant 消息的时间戳与当前时间之差超过配置阈值(默认 60 分钟)
4. 存在可压缩的工具结果
此时,服务器端 prompt cache 几乎肯定已过期(60 分钟 TTL),
全部前缀将被重写——因此在请求前清除旧工具结果内容以缩小重写范围。
操作:将除最近 N 个(默认 5 个,`keepRecent: 5`)之外的所有可压缩工具
结果内容替换为 `'[Old tool result content cleared]'` 标记
`microCompact.ts:36``TIME_BASED_MC_CLEARED_MESSAGE`)。
**重要副作用**:内容更改使服务器缓存失效,因此重置缓存微压缩状态
`resetMicrocompactState()`),防止后续缓存编辑引用已不存在的工具。
#### 3b: 缓存微压缩 (Cached MC)
**文件**: `src/services/compact/cachedMicrocompact.ts`
**函数**: `cachedMicrocompactPath()` (第 305 行)
**配置**: `cachedMCConfig.ts`
这是真正巧妙的微压缩——利用 Anthropic API 的 `cache_edits` 功能,
在不改变本地消息数组的情况下,通过 API 层面删除旧工具结果。
prompt cache 前缀保持完整,无需重新计算。
```
传统方式 (已废弃) 缓存编辑方式 (当前)
┌────────────────┐ ┌────────────────┐
│ 修改消息内容 │ │ 消息内容不变 │
│ 删除工具结果块 │ │ 添加 cache_edits│
│ Prompt cache 失效│ │ Prompt cache 命中│
│ 重新发送全部前缀 │ │ 只发送差异编辑 │
└────────────────┘ └────────────────┘
```
状态管理(`CachedMCState`):
- `registeredTools`: 已注册的工具结果 ID 集合
- `toolOrder`: 工具结果注册的顺序列表
- `deletedRefs`: 已被删除的引用集合
- `pinnedEdits`: 需要固定在特定用户消息位置的编辑块
触发条件:
- 已注册的工具结果数 >= `triggerThreshold`(默认 12 个)
- 模型支持缓存编辑(`supportedModels: ['claude-opus-4-6', 'claude-sonnet-4-6']`
- 主线程查询源
执行流程:
1. 扫描消息,收集所有可压缩工具 ID(`collectCompactableToolIds()`,第 226 行)
2. 将新的工具结果注册到状态中(`registerToolResult()`/`registerToolMessage()`
3. 检查是否需要触发删除(`getToolResultsToDelete()`,第 62 行)
4. 创建 `cache_edits` 块(`createCacheEditsBlock()`,第 73 行)
5. 将待处理的编辑排入队列,在 API 调用时发送
**可压缩工具列表**`microCompact.ts:41-50`):
```typescript
const COMPACTABLE_TOOLS = new Set([
FILE_READ_TOOL_NAME, // Read
...SHELL_TOOL_NAMES, // Bash
GREP_TOOL_NAME, // Grep
GLOB_TOOL_NAME, // Glob
WEB_SEARCH_TOOL_NAME, // WebSearch
WEB_FETCH_TOOL_NAME, // WebFetch
FILE_EDIT_TOOL_NAME, // Edit
FILE_WRITE_TOOL_NAME, // Write
])
```
这些工具的结果通常体积大但信息密度低,且模型可以直接重新调用这些工具
获取最新信息。
#### 3c: API 微压缩 (API Micro-compact)
**文件**: `src/services/compact/apiMicrocompact.ts`
**函数**: `getAPIContextManagement()` (第 64 行)
提供服务器端上下文管理策略配置:
- `clear_tool_uses_20250919`: 按输入 token 阈值清除旧工具结果/工具调用
- `clear_thinking_20251015`: 按轮次保留/清除思考块
仅在 `process.env.USER_TYPE === 'ant'` 且设置了环境变量时生效。
---
### Layer 4: 上下文折叠 (Context Collapse)
**文件**: `src/services/contextCollapse/index.ts`
**成本**: O(1) 投影操作。无 LLM 调用。
#### 触发条件
`feature('CONTEXT_COLLAPSE')` 特性标识控制。当前版本中为**空操作桩**
仅在内部构建中实际启用(`query.ts:18-19`)。
```typescript
// 外部构建:
export function isContextCollapseEnabled(): boolean { return false }
export async function applyCollapsesIfNeeded(messages, _, __) { return { messages } }
```
#### 概念
上下文折叠是一种**增量提交**机制。它将多轮交互压缩为紧凑的摘要提交(commit),
同时保持主 REPL 数组的完整性。核心思想是将压缩操作从一次性大规模 API 调用
分解为持续的增量操作。
折叠状态通过提交日志持久化,每次投影视图时重新应用。这使得折叠在对话轮次之间保持稳定。
#### 与自动压缩的关系
当上下文折叠启用时,自动压缩(Auto-compact)**被抑制**`autoCompact.ts:215-223`):
```typescript
if (feature('CONTEXT_COLLAPSE')) {
const { isContextCollapseEnabled } = require('../contextCollapse/index.js')
if (isContextCollapseEnabled()) {
return false // 阻止自动压缩
}
}
```
原因是折叠系统在 90%(提交起点)和 95%(阻塞阈值)之间自主管理上下文空间。
自动压缩在 ~93% 的阈值触发,会与折叠竞争并通常获胜——破坏折叠即将保存的
细粒度上下文。
---
### Layer 5: 自动压缩 (Auto-compact)
**文件**: `src/services/compact/autoCompact.ts` (编排), `compact.ts` (执行), `prompt.ts` (提示词)
**函数**: `autoCompactIfNeeded()` (第 241 行) -> `compactConversation()` (第 387 行)
**成本**: **高。** 一次完整的 LLM API 调用,内容包括整个待压缩消息集 + 系统提示词。
#### 触发条件
`shouldAutoCompact()` (第 160 行) 计算:
```
阈值 = getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS(13,000)
= (contextWindow - min(maxOutputTokens, 20,000)) - 13,000
```
```
getEffectiveContextWindowSize(model) = contextWindow - min(maxOutput, 20,000)
getAutoCompactThreshold(model) = effectiveWindow - 13,000 (AUTOCOMPACT_BUFFER_TOKENS)
```
环境变量覆盖:
- `CLAUDE_CODE_AUTO_COMPACT_WINDOW`: 直接设置上下文窗口上限
- `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`: 按百分比设置阈值(用于测试)
**递归保护**(第 171-183 行):
- `querySource === 'session_memory'``'compact'` → 跳过(分叉代理会死锁)
- `querySource === 'marble_origami'` → 跳过(上下文折叠分叉会破坏主线程状态)
**熔断机制**`MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3`):
当上下文不可恢复地超限时(如 `prompt_too_long`),连续失败 3 次后
自动压缩永久跳过当前会话——否则全局每天浪费约 25 万次无效 API 调用。
#### 算法/策略
自动压缩首先尝试**会话记忆压缩**(见第 5 节,无 LLM 调用),
只有在该路径失败时才退回到完整的 LLM 摘要压缩。
```
autoCompactIfNeeded()
├── 尝试会话记忆压缩 (trySessionMemoryCompaction)
│ ├── 成功 → 返回 CompactionResult (无 API 调用)
│ └── 失败 → 继续
└── 完整压缩 (compactConversation)
├── 执行 PreCompact 钩子
├── 生成 LLM 摘要
├── 恢复文件附件
├── 创建边界标记
└── 执行 PostCompact 钩子
```
`compactConversation()` 的执行流程:
```
1. 验证消息非空
2. 执行 PreCompact 钩子(自定义指令合并)
3. 构建摘要提示词 (getCompactPrompt)
4. 流式调用 LLM 获取摘要
├── 优先使用缓存共享路径 (forked agent)
│ 复用主线程的 prompt cache,无需重新计算
│ 节省 ~90% 的输入 token 成本
└── 失败时退回到常规流式路径
5. 保存预压缩文件状态,清空读文件缓存
6. 并行生成:
├── 文件附件恢复 (createPostCompactFileAttachments)
├── 异步代理状态附件 (createAsyncAgentAttachmentsIfNeeded)
├── 计划文件附件 (createPlanAttachmentIfNeeded)
├── 计划模式指令附件 (createPlanModeAttachmentIfNeeded)
├── 技能内容附件 (createSkillAttachmentIfNeeded)
├── 延迟工具增量声明 (getDeferredToolsDeltaAttachment)
├── 代理列表增量声明 (getAgentListingDeltaAttachment)
└── MCP 指令增量声明 (getMcpInstructionsDeltaAttachment)
7. 执行 SessionStart 钩子
8. 创建压缩边界标记 (createCompactBoundaryMessage)
9. 构建摘要用户消息
10. 执行 PostCompact 钩子
11. 返回 CompactionResult
```
#### 缓存共享路径
`streamCompactSummary()` (第 1136 行) 使用 `runForkedAgent()` 创建分叉代理。
关键的优化:**不设置 `maxOutputTokens`**。分叉代理复用主线程的 prompt cache,
`maxOutputTokens` 会影响思考配置的 `budget_tokens`,设置它会破坏缓存匹配,
导致缓存未命中。
当缓存共享失败时(约 2.79% 的 Sonnet 4.6 调用),退回到在消息末尾附加
摘要请求的常规流式路径。
#### 摘要提示词结构
**文件**: `src/services/compact/prompt.ts`
提示词包含一个强力的"无工具"前缀(`NO_TOOLS_PREAMBLE`,第 19 行):
```text
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- Tool calls will be REJECTED and will waste your only turn.
```
提示词要求模型生成 `<analysis>` 草稿块 + `<summary>` 结构化摘要。
摘要包含 9 个部分:
1. 主要请求和意图
2. 关键技术概念
3. 文件和代码部分
4. 错误和修复
5. 问题解决
6. 所有用户消息
7. 待处理任务
8. 当前工作
9. 可选下一步
`formatCompactSummary()` (第 311 行) 在摘要到达上下文之前剥离 `<analysis>` 草稿块。
#### 图像剥离
在发送给摘要 LLM 之前,`stripImagesFromMessages()` (第 145 行) 将用户消息中的
图像/文档块替换为 `[image]` / `[document]` 文本标记。原因:
- 图像对生成会话摘要不是必需的
- 图像可能导致压缩 API 调用本身命中 `prompt_too_long` 限制
- 在 CCD 会话中用户经常附加图像,这个问题尤为明显
#### Prompt-Too-Long 重试
当摘要请求本身命中 `prompt_too_long`(CC-1180)时,
`truncateHeadForPTLRetry()` (第 243 行) 按 API 轮次组从最早的轮次开始丢弃消息,
直到释放足够的 token 空间。最多重试 `MAX_PTL_RETRIES = 3` 次。
---
## 3. 压缩触发时机
压缩可以在四个不同的时间点触发:
### 3.1 预模型上下文塑造 (每次模型调用前)
**位置**: `src/query.ts` 第 369-467 行
**始终发生**: 预算削减和微压缩**在每次 API 调用前执行**
这些是低成本的防御性操作,确保发送给 LLM 的上下文尽可能精简。
执行顺序:
```
applyToolResultBudget() → Layer 1 (无条件)
snipCompactIfNeeded() → Layer 2 (仅内部构建)
microcompactMessages() → Layer 3 (无条件)
contextCollapse → Layer 4 (仅内部构建)
autoCompactIfNeeded() → Layer 5 (条件触发)
```
### 3.2 反应式压缩 (API 返回 prompt_too_long 时)
**文件**: `src/services/compact/reactiveCompact.ts` (外部构建桩)
**触发**: 当 API 返回 `prompt_too_long` 错误时
即使所有五层都正确执行,理论上仍可能命中 `prompt_too_long`(token 预估不准确)。
反应式压缩是"最后一道防线"——当 API 拒绝请求时触发。
`feature('REACTIVE_COMPACT')` 控制。
### 3.3 手动压缩 (/compact 命令)
**文件**: `src/commands/compact/compact.ts`
**触发**: 用户输入 `/compact` 或在 UI 中点击压缩按钮
手动压缩的流程:
1. 首先尝试会话记忆压缩(如果没有自定义指令)
2. 如果启用了反应式模式,通过反应式路径路由
3. 退回到传统完整压缩
4. 可选:传递自定义压缩指令(`/compact 请重点关注测试文件`
手动压缩始终 `isAutoCompact = false`,这意味着失败时会显示错误通知。
### 3.4 自动压缩 (定期触发)
**触发**: `shouldAutoCompact()` 在每次模型调用前检查
如上所述,通过 token 阈值自动判断是否触发。自动压缩会抑制后续提问
`suppressFollowUpQuestions: true`),模型摘要后直接继续工作。
---
## 4. 部分压缩 vs 完整压缩
### 4.1 完整压缩 (compactConversation)
**文件**: `src/services/compact/compact.ts:387`
**函数**: `compactConversation(messages, context, cacheSafeParams, suppressFollowUp, customInstructions, isAutoCompact)`
作用于**所有消息**。LLM 对整个对话生成结构化摘要,用摘要替换所有早期消息,
保留最近的附件和状态。
```
压缩前 压缩后
┌──────────────────┐ ┌──────────────────┐
│ System Prompt │ │ System Prompt │
│ User: "写一个API" │ │ User: "写一个API" │
│ Assistant: [工具调用]│ ──► │ Boundary Marker │
│ User: [工具结果] │ │ Summary: "用户要求 │
│ Assistant: "完成了"│ │ 写API,使用了Read │
│ User: "再加日志" │ │ 和Edit..." │
│ Assistant: [工具调用]│ │ File Attachments │
│ ... │ │ Skill Attachments │
└──────────────────┘ │ Hook Results │
└──────────────────┘
```
### 4.2 部分压缩 (partialCompactConversation)
**文件**: `src/services/compact/compact.ts:772`
**函数**: `partialCompactConversation(allMessages, pivotIndex, context, cacheSafeParams, userFeedback, direction)`
作用于**枢轴点附近的消息**。有两种方向:
#### 方向 'from' (前缀保留)
压缩枢轴点**之后**的消息,保留之前的消息。prompt cache 对保留的(早期)消息有效。
```
├── 保留 ──┤ ←── 压缩 ──→
[早期消息] [枢轴] [后续消息]
保留 摘要
(cache 命中)
```
#### 方向 'up_to' (后缀保留)
压缩枢轴点**之前**的消息,保留之后的消息。prompt cache 失效(摘要位于保留消息之前)。
```
←── 压缩 ──→ ├── 保留 ──┤
[早期消息] [枢轴] [后续消息]
摘要 保留
(cache 失效)
```
#### 哪些消息类型可以被压缩
部分压缩接受完整的消息类型,但 `up_to` 方向会**剥离**旧压缩边界和摘要消息
(第 790-799 行):
```typescript
const messagesToKeep = direction === 'up_to'
? allMessages.slice(pivotIndex).filter(
m => m.type !== 'progress' &&
!isCompactBoundaryMessage(m) &&
!(m.type === 'user' && m.isCompactSummary))
: allMessages.slice(0, pivotIndex).filter(m => m.type !== 'progress')
```
`progress` 类型消息在两种方向下都被排除(不可记录)。
#### 哪些需要保留
以下内容**不在压缩范围内**,需要在压缩后恢复:
| 内容 | 恢复机制 | 文件 |
|------|----------|------|
| **System Prompt** | 始终作为前缀发送,不受压缩影响 | - |
| **工具定义** | 每次 API 调用由 `normalizeMessagesForAPI()` 注入 | `messages.ts` |
| **CLAUDE.md** | `processSessionStartHooks()` 在 SessionStart 钩子中恢复 | `sessionStart.ts` |
| **已读文件** | `createPostCompactFileAttachments()` 恢复最近 5 个文件 | `compact.ts:1415` |
| **计划文件** | `createPlanAttachmentIfNeeded()` | `compact.ts:1470` |
| **已调用技能** | `createSkillAttachmentIfNeeded()` | `compact.ts:1494` |
| **异步代理状态** | `createAsyncAgentAttachmentsIfNeeded()` | `compact.ts:1568` |
| **延迟工具声明** | `getDeferredToolsDeltaAttachment()` | `attachments.ts` |
| **MCP 指令** | `getMcpInstructionsDeltaAttachment()` | `attachments.ts` |
| **计划模式指令** | `createPlanModeAttachmentIfNeeded()` | `compact.ts:1542` |
#### 文件附件恢复策略
`createPostCompactFileAttachments()` (第 1415 行) 的策略:
1. 从预压缩读文件状态获取最近访问的文件
2. 排除计划文件、所有类型的 `CLAUDE.md` 文件
3. 排除已在保留消息中出现过的读文件结果(`collectReadToolFilePaths()`,第 1610 行)
- 识别 `FILE_UNCHANGED_STUB` 标记以正确处理去重存根
4. 按时间戳降序排列,取前 `POST_COMPACT_MAX_FILES_TO_RESTORE = 5`
5. 使用 `FileReadTool` 重新读取文件获取最新内容(限制: `POST_COMPACT_MAX_TOKENS_PER_FILE = 5,000`
6.`POST_COMPACT_TOKEN_BUDGET = 50,000` 的预算过滤
技能附件策略(`createSkillAttachmentIfNeeded()`, 第 1494 行):
- 按调用时间降序排列技能(最近调用优先)
- 每个技能截断到 `POST_COMPACT_MAX_TOKENS_PER_SKILL = 5,000`
- 总预算 `POST_COMPACT_SKILLS_TOKEN_BUDGET = 25,000`(约 5 个技能)
- 截断标记告知模型可通过 Read 获取完整内容
---
## 5. 会话记忆压缩 (Session Memory Compact)
**文件**: `src/services/compact/sessionMemoryCompact.ts`
**函数**: `trySessionMemoryCompaction()` (第 514 行)
### 5.1 动机
会话记忆压缩是自动压缩的第一候选路径,目的**完全避免 LLM 摘要调用**
它利用已有的会话记忆文件(通过 SessionMemory 服务异步生成的结构化记忆)
直接构建压缩后的上下文。
### 5.2 工作流程
```
trySessionMemoryCompaction()
├── 检查特性标识 (tengu_session_memory + tengu_sm_compact)
├── 初始化远程配置 (GrowthBook's tengu_sm_compact_config)
│ 默认: minTokens=10,000, minTextBlockMessages=5, maxTokens=40,000
├── 等待进行中的会话记忆提取完成
├── 获取 lastSummarizedMessageId (上次摘要到的消息)
├── 读取会话记忆文件内容
├── 计算保留消息的起始索引 (calculateMessagesToKeepIndex)
│ ├── 从 lastSummarizedMessageId 之后开始
│ ├── 向后扩展直到满足最小 token 和文本块消息数
│ └── 调整以确保 tool_use/tool_result 配对不分裂
├── 创建 CompactionResult (无 API 调用)
│ ├── 边界标记
│ ├── 基于会话记忆的摘要消息
│ ├── 保留的消息 (messagesToKeep)
│ └── 计划文件附件
└── 检查后压缩 token 计数是否低于自动压缩阈值
```
### 5.3 与自动压缩的集成
`autoCompact.ts:287-310` 中,会话记忆压缩优先于完整压缩:
```typescript
const sessionMemoryResult = await trySessionMemoryCompaction(
messages, toolUseContext.agentId, recompactionInfo.autoCompactThreshold,
)
if (sessionMemoryResult) {
setLastSummarizedMessageId(undefined)
runPostCompactCleanup(querySource)
// ...
return { wasCompacted: true, compactionResult: sessionMemoryResult }
}
```
### 5.4 关键算法: calculateMessagesToKeepIndex
`adjustIndexToPreserveAPIInvariants()` (第 232 行) 确保压缩不会分裂
`tool_use`/`tool_result` 配对:
```
场景: 流式输出产生的不同 message.id 但相同 content.id 的消息
索引 N: assistant, message.id=X, content: [thinking]
索引 N+1: assistant, message.id=X, content: [tool_use: ORPHAN_ID]
索引 N+2: assistant, message.id=X, content: [tool_use: VALID_ID]
索引 N+3: user, content: [tool_result: ORPHAN_ID, tool_result: VALID_ID]
如果 startIndex = N+2:
- N+2 保留但 N 被丢弃 → thinking 块丢失
- normalizeMessagesForAPI 合并后 → 孤立 tool_result ORPHAN_ID
- API 错误!
修正: 检测 N+2 与 N 共享 message.id,将起始索引前移到 N
```
同样处理思考块合并问题:如果某条 assistant 消息的 `message.id` 与保留范围内
的消息相同(流式拆分的 thinking 块),则向前扩展以包含所有相关块。
### 5.5 会话记忆压缩 vs memdir 系统
会话记忆压缩与 memdir(记忆目录)系统是**互补关系**
| 系统 | 作用域 | 生成方式 | 保留内容 |
|------|--------|----------|----------|
| **SessionMemory** | 单个会话 | 异步提取 (每轮自动) | 用户意图、关键决策、代码模式 |
| **memdir** | 跨会话持久知识 | autoDream 定期整理 | 长期记忆、项目知识、用户偏好 |
会话记忆压缩读取的是 `SessionMemory` 服务的输出文件,
而非 memdir 中的长期记忆。
---
## 6. 压缩状态的恢复
### 6.1 压缩边界标记
每次压缩后插入一个 `SystemCompactBoundaryMessage` (创建于 `compact.ts:598-611`)。
这是下游恢复的锚点:
```typescript
const boundaryMarker = createCompactBoundaryMessage(
isAutoCompact ? 'auto' : 'manual',
preCompactTokenCount ?? 0,
messages.at(-1)?.uuid,
)
```
边界标记携带元数据:
- `preCompactDiscoveredTools`: 压缩前发现的延迟工具名称列表
- `preservedSegment` (部分压缩): 保留的 `messagesToKeep` 的头/锚/尾 UUID
- 用于 `annotateBoundaryWithPreservedSegment()` (第 349 行) 在后压缩加载时
将保留段重新链接回摘要链
### 6.2 历史导航 (/history) 与压缩的协同
`/history` 命令利用压缩边界标记来导航。
`getMessagesAfterCompactBoundary()` 从最近的边界标记之后加载消息,
跳过已被摘要覆盖的历史部分。这使得用户可以通过边界标记快速跳转到
压缩前的活动工作区。
### 6.3 计划/skill/代理状态的保持
| 状态类型 | 保持方式 | 关键代码 |
|----------|----------|----------|
| **Plan (计划)** | 创建 `plan_file_reference` 附件 | `compact.ts:1470-1486` |
| **Plan Mode** | 创建 `plan_mode` 附件(含 reminderType: 'full') | `compact.ts:1542-1560` |
| **已调用技能** | 创建 `invoked_skills` 附件(含截断内容) | `compact.ts:1494-1534` |
| **Async Agents** | 创建 `task_status` 附件(含进度/结果) | `compact.ts:1568-1599` |
| **Deferred Tools** | 增量声明当前工具集(diff 模式) | `attachments.ts` |
| **已发现的工具** | 在边界标记中保存名称列表 | `compact.ts:607-611` |
### 6.4 预压缩状态缓存清理
压缩后需要清理多个缓存以反映上下文变化:
```typescript
// postCompactCleanup.ts
context.readFileState.clear() // 清理读文件状态
context.loadedNestedMemoryPaths?.clear() // 清理内存文件路径缓存
getUserContext.cache.clear?.() // 清理用户上下文缓存
resetGetMemoryFilesCache('compact') // 清理记忆文件缓存
clearSystemPromptSections() // 清理系统提示词节
clearClassifierApprovals() // 清理分类器审批状态
clearSpeculativeChecks() // 清理推测性检查
clearBetaTracingState() // 清理追踪状态
clearSessionMessagesCache() // 清理消息缓存
```
**注意**: 技能内容(`sentSkillNames`)故意不清除——重新注入完整的 `skill_listing`
(约 4K token)纯属浪费。模型仍然有 `SkillTool` 在 schema 中,
`invoked_skills` 附件保留了已使用的技能内容。
---
## 7. Codev 中的具体实现
### 7.1 与官方 Claude Code 的差异
Codev 的压缩实现相比于 Anthropic 官方 Claude Code 有以下主要差异和保留:
#### 保留的核心能力
1. **五层管道完整保留**: 预算削减 → Snip → Micro-compact → Context Collapse → Auto-compact
的全部结构保持与官方版一致。
2. **缓存共享路径**: `tengu_compact_cache_prefix` 特性通过 forkedAgent 复用主线程
prompt cache,在外部构建中同样生效。
3. **会话记忆压缩**: 完整的 `trySessionMemoryCompaction` 实现,
包括 `calculateMessagesToKeepIndex` 和 API 不变性保护。
4. **Cached Microcompact**: 使用 API `cache_edits` 删除旧工具结果的机制完整保留,
包括状态管理和 pinnedEdits 机制。
#### 差异点
1. **Feature Gate 差异**:
- `feature('HISTORY_SNIP')` → 外部构建中 Snip 是空操作(`snipCompact.ts` 返回空结果)
- `feature('CONTEXT_COLLAPSE')` → 外部构建中 Context Collapse 是空操作桩
(`contextCollapse/index.ts` 全部返回空/默认值)
- `feature('REACTIVE_COMPACT')` → 外部构建中是占位符桩
(`reactiveCompact.ts` 导出一个 noop 代理)
- `feature('CACHED_MICROCOMPACT')` → 外部构建中启用,Cached MC 正常运行
- `feature('KAIROS')` → 会话记录分段写入,外部构建中禁用
- `feature('PROACTIVE')` → 自主模式压缩提示词适配,外部构建中禁用
2. **外部构建中的 Cached MC**:
外部构建使用 `getCachedMCConfig()` 的默认配置(`enabled: false`),
因此完整 Cached MC 路径在外部构建中默认不激活。
微压缩退回到时间触发 MC 路径或直接返回未修改的消息。
3. **反应式压缩桩**:
`reactiveCompact.ts`(第 1-35 行)是完全的生成桩,
所有命名导出通过 Proxy 代理返回空操作。这是为满足 `bun build`
引用解析要求而存在的占位符。
4. **API 微压缩策略**:
`apiMicrocompact.ts` 中的 `getAPIContextManagement()` 仅在
`process.env.USER_TYPE === 'ant'` 时包含工具清除策略。
外部构建仅包含思考块保留策略。
### 7.2 核心文件引用速查
| 功能 | 文件 | 关键函数/导出 |
|------|------|---------------|
| **预算削减** | `src/utils/toolResultStorage.ts` | `enforceToolResultBudget()` (L769) |
| **剪切** | `src/services/compact/snipCompact.ts` | `snipCompactIfNeeded()` (L25, 桩) |
| **微压缩** | `src/services/compact/microCompact.ts` | `microcompactMessages()` (L253) |
| **缓存微压缩** | `src/services/compact/cachedMicrocompact.ts` | `createCachedMCState()`, `registerToolResult()` |
| **时间触发 MC** | `src/services/compact/timeBasedMCConfig.ts` | `getTimeBasedMCConfig()` (L36) |
| **Cached MC 配置** | `src/services/compact/cachedMCConfig.ts` | `getCachedMCConfig()` (L17) |
| **API MC 策略** | `src/services/compact/apiMicrocompact.ts` | `getAPIContextManagement()` (L64) |
| **上下文折叠** | `src/services/contextCollapse/index.ts` | `applyCollapsesIfNeeded()` (L43, 桩) |
| **自动压缩编排** | `src/services/compact/autoCompact.ts` | `autoCompactIfNeeded()` (L241), `shouldAutoCompact()` (L160) |
| **完整压缩执行** | `src/services/compact/compact.ts` | `compactConversation()` (L387), `partialCompactConversation()` (L772) |
| **摘要提示词** | `src/services/compact/prompt.ts` | `getCompactPrompt()` (L293), `getPartialCompactPrompt()` (L274), `formatCompactSummary()` (L311) |
| **消息分组** | `src/services/compact/grouping.ts` | `groupMessagesByApiRound()` (L22) |
| **后压缩清理** | `src/services/compact/postCompactCleanup.ts` | `runPostCompactCleanup()` (L31) |
| **手动压缩命令** | `src/commands/compact/compact.ts` | `call` (L40) |
| **管道编排** | `src/query.ts` | L369-543: 五层管道的完整编排 |
| **会话记忆压缩** | `src/services/compact/sessionMemoryCompact.ts` | `trySessionMemoryCompaction()` (L514), `calculateMessagesToKeepIndex()` (L324) |
### 7.3 关键常量一览
| 常量 | 值 | 定义位置 |
|------|-----|----------|
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
| `POST_COMPACT_MAX_TOKENS_PER_SKILL` | 5,000 | `compact.ts:129` |
| `POST_COMPACT_SKILLS_TOKEN_BUDGET` | 25,000 | `compact.ts:130` |
| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
| `MAX_PTL_RETRIES` | 3 | `compact.ts:228` |
| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
| `WARNING_THRESHOLD_BUFFER_TOKENS` | 20,000 | `autoCompact.ts:63` |
| `ERROR_THRESHOLD_BUFFER_TOKENS` | 20,000 | `autoCompact.ts:64` |
| `MANUAL_COMPACT_BUFFER_TOKENS` | 3,000 | `autoCompact.ts:65` |
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
| SM 压缩 `minTokens` | 10,000 | `sessionMemoryCompact.ts:58` |
| SM 压缩 `minTextBlockMessages` | 5 | `sessionMemoryCompact.ts:59` |
| SM 压缩 `maxTokens` | 40,000 | `sessionMemoryCompact.ts:60` |
| Cached MC `triggerThreshold` | 12 | `cachedMCConfig.ts:11` |
| Cached MC `keepRecent` | 3 | `cachedMCConfig.ts:12` |
| 时间触发 MC `gapThresholdMinutes` | 60 | `timeBasedMCConfig.ts:32` |
---
## A. 附录:事件遥测
压缩管道在各关键节点发出遥测事件,用于性能监控和调试:
| 事件名称 | 触发时机 | 定义位置 |
|----------|----------|----------|
| `tengu_compact` | 完整压缩完成 | `compact.ts:650` |
| `tengu_compact_failed` | 完整压缩失败 | `compact.ts:470/498/508/1379` |
| `tengu_compact_ptl_retry` | 压缩请求命中 PTL,重试丢弃消息 | `compact.ts:479` |
| `tengu_compact_cache_sharing_success` | 缓存共享路径成功 | `compact.ts:1214` |
| `tengu_compact_cache_sharing_fallback` | 缓存共享失败,退回流式 | `compact.ts:1235/1242` |
| `tengu_compact_streaming_retry` | 流式路径重试 | `compact.ts:1364` |
| `tengu_partial_compact` | 部分压缩完成 | `compact.ts:990` |
| `tengu_partial_compact_failed` | 部分压缩失败 | `compact.ts:880/901` |
| `tengu_auto_compact_succeeded` | 自动压缩成功 | `query.ts:478` |
| `tengu_cached_microcompact` | 缓存微压缩删除工具 | `microCompact.ts:346` |
| `tengu_time_based_microcompact` | 时间触发 MC 清除工具结果 | `microCompact.ts:498` |
| `tengu_sm_compact` | 会话记忆压缩尝试结果 | `sessionMemoryCompact.ts` 内多个事件 |
| `tengu_sm_compact_no_session_memory` | 无会话记忆文件 | `sessionMemoryCompact.ts:534` |
| `tengu_sm_compact_empty_template` | 会话记忆为空(模板文件) | `sessionMemoryCompact.ts:541` |
| `tengu_sm_compact_threshold_exceeded` | 压缩后仍超阈值,回退 | `sessionMemoryCompact.ts:609` |
---
> 文档版本: 基于 Codev `cdb3bdd` 提交分析
> 最后更新: 2026-06-22