上下文压缩管道 (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):
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 行)
当满足以下条件时触发:
- 特性启用(
enabled: true) - 主线程查询源(
querySource以repl_main_thread开头或为空) - 最后一条 assistant 消息的时间戳与当前时间之差超过配置阈值(默认 60 分钟)
- 存在可压缩的工具结果
此时,服务器端 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']) - 主线程查询源
执行流程:
- 扫描消息,收集所有可压缩工具 ID(
collectCompactableToolIds(),第 226 行) - 将新的工具结果注册到状态中(
registerToolResult()/registerToolMessage()) - 检查是否需要触发删除(
getToolResultsToDelete(),第 62 行) - 创建
cache_edits块(createCacheEditsBlock(),第 73 行) - 将待处理的编辑排入队列,在 API 调用时发送
可压缩工具列表(microCompact.ts:41-50):
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)。
// 外部构建:
export function isContextCollapseEnabled(): boolean { return false }
export async function applyCollapsesIfNeeded(messages, _, __) { return { messages } }
概念
上下文折叠是一种增量提交机制。它将多轮交互压缩为紧凑的摘要提交(commit), 同时保持主 REPL 数组的完整性。核心思想是将压缩操作从一次性大规模 API 调用 分解为持续的增量操作。
折叠状态通过提交日志持久化,每次投影视图时重新应用。这使得折叠在对话轮次之间保持稳定。
与自动压缩的关系
当上下文折叠启用时,自动压缩(Auto-compact)被抑制(autoCompact.ts:215-223):
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 行):
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 个部分:
- 主要请求和意图
- 关键技术概念
- 文件和代码部分
- 错误和修复
- 问题解决
- 所有用户消息
- 待处理任务
- 当前工作
- 可选下一步
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 中点击压缩按钮
手动压缩的流程:
- 首先尝试会话记忆压缩(如果没有自定义指令)
- 如果启用了反应式模式,通过反应式路径路由
- 退回到传统完整压缩
- 可选:传递自定义压缩指令(
/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 行):
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 行) 的策略:
- 从预压缩读文件状态获取最近访问的文件
- 排除计划文件、所有类型的
CLAUDE.md文件 - 排除已在保留消息中出现过的读文件结果(
collectReadToolFilePaths(),第 1610 行)- 识别
FILE_UNCHANGED_STUB标记以正确处理去重存根
- 识别
- 按时间戳降序排列,取前
POST_COMPACT_MAX_FILES_TO_RESTORE = 5个 - 使用
FileReadTool重新读取文件获取最新内容(限制:POST_COMPACT_MAX_TOKENS_PER_FILE = 5,000) - 按
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 中,会话记忆压缩优先于完整压缩:
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)。
这是下游恢复的锚点:
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 预压缩状态缓存清理
压缩后需要清理多个缓存以反映上下文变化:
// 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 有以下主要差异和保留:
保留的核心能力
五层管道完整保留: 预算削减 → Snip → Micro-compact → Context Collapse → Auto-compact 的全部结构保持与官方版一致。
缓存共享路径:
tengu_compact_cache_prefix特性通过 forkedAgent 复用主线程 prompt cache,在外部构建中同样生效。会话记忆压缩: 完整的
trySessionMemoryCompaction实现, 包括calculateMessagesToKeepIndex和 API 不变性保护。Cached Microcompact: 使用 API
cache_edits删除旧工具结果的机制完整保留, 包括状态管理和 pinnedEdits 机制。
差异点
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')→ 自主模式压缩提示词适配,外部构建中禁用
外部构建中的 Cached MC: 外部构建使用
getCachedMCConfig()的默认配置(enabled: false), 因此完整 Cached MC 路径在外部构建中默认不激活。 微压缩退回到时间触发 MC 路径或直接返回未修改的消息。反应式压缩桩:
reactiveCompact.ts(第 1-35 行)是完全的生成桩, 所有命名导出通过 Proxy 代理返回空操作。这是为满足bun build的 引用解析要求而存在的占位符。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