Claude Code Harness 第10章:压缩后的文件状态保留
前言:压缩不是终点,而是新的起点
在第9章中,我们探讨了压缩触发的时机和摘要生成的机制。当一个长对话被压缩为一条简洁的摘要消息时,看似一切都结束了——模型"失去"了之前的上下文,不再知道它读过哪些文件,不记得正在执行的计划,甚至不知道有哪些工具可用。
但这里有一个关键问题:如果压缩后的第一个回合,模型需要继续编辑它"刚刚"读过的文件,而它却一脸茫然地重新读取一遍,这不仅浪费 token,更打断了用户的工作流。
压缩不是遗忘,而是有选择地记住。
本章的主题是压缩后的状态恢复——Claude Code 如何在压缩完成后,通过一系列精心设计的附件(attachments),将模型"需要但已丢失"的关键上下文注入回对话流。我们将逐一拆解五个恢复维度:
- 文件状态恢复:最近读取的文件如何被智能恢复
- 技能内容恢复:已调用技能的指令如何被保留
- 计划状态恢复:正在执行的计划如何跨越压缩边界
- Delta 工具声明:工具和指令如何被重新宣告
- 刻意不恢复的内容:哪些状态被故意遗忘以节省 token
让我们从源码出发,深入理解这个精妙的恢复系统。
10.1 压缩前快照:先存再清的艺术
压缩恢复的第一步,不是在压缩后做什么,而是在压缩前先保存好现场。这是一个经典的快照-清空模式。
10.1.1 FileStateCache:文件状态的虚拟文件系统
在深入快照机制之前,我们需要理解 FileStateCache 的设计。这个类位于 utils/fileStateCache.ts,是一个基于 LRU(最近最少使用)策略的缓存系统:
// utils/fileStateCache.ts
export type FileState = {
content: string // 文件内容
timestamp: number // 最后读取时间戳
offset: number | undefined // 读取偏移量
limit: number | undefined // 读取限制
isPartialView?: boolean // 是否为部分视图(如 CLAUDE.md)
}
export class FileStateCache {
private cache: LRUCache
constructor(maxEntries: number, maxSizeBytes: number) {
this.cache = new LRUCache({
max: maxEntries, // 最多 100 个条目
maxSize: maxSizeBytes, // 最大 25MB
sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content))
})
}
// ... get, set, has, delete, clear 等方法
} 这个设计的精妙之处在于:
- 双重限制:既限制条目数量(100个),又限制总大小(25MB),防止大文件占用过多内存
- 自动淘汰:LRU 策略确保最近最少使用的文件首先被淘汰
- 路径归一化:所有路径都通过
normalize()处理,确保相对路径和绝对路径能正确匹配 - 部分视图标记:
isPartialView标记那些被预处理过的文件(如 CLAUDE.md 去除 HTML 注释后),模型看到的是处理后的内容,但缓存中保存的是原始内容
10.1.2 cacheToObject + clear:快照-清空模式
在压缩开始时,系统执行这三个关键操作:
// services/compact/compact.ts:517-522
// Store the current file state before clearing
const preCompactReadFileState = cacheToObject(context.readFileState)
// Clear the cache
context.readFileState.clear()
context.loadedNestedMemoryPaths?.clear()flowchart LR
subgraph Before["压缩前状态"]
FSC["FileStateCache
100个文件条目
25MB总大小"]
LNMP["loadedNestedMemoryPaths
已加载的记忆路径"]
end
step1["cacheToObject
序列化为普通对象"]
step2["readFileState.clear
清空文件缓存"]
step3["loadedNestedMemoryPaths.clear
清空记忆路径"]
subgraph After["压缩后状态"]
PRFS["preCompactReadFileState
Record
包含所有文件状态快照"]
EmptyFS["空的 FileStateCache"]
EmptyLNMP["空的 loadedNestedMemoryPaths"]
end
Before --> step1 --> PRFS
Before --> step2 --> EmptyFS
Before --> step3 --> EmptyLNMP
style PRFS fill:#e1f5ff
style EmptyFS fill:#ffe1e1
style EmptyLNMP fill:#ffe1e1这个三步走模式背后的设计哲学:
为什么要先清空?
如果不清空缓存,系统会误以为模型仍然"知道"这些文件的内容,导致后续的文件去重逻辑出错。清空后,系统进入一个干净的状态,然后有选择地恢复最重要的文件——而不是全部恢复。
为什么不全部恢复?
一次长会话中,模型可能读过几十甚至上百个文件。如果压缩后将它们全部注入回对话,就会造成一个荒谬的循环:压缩刚省出的 token 空间,立刻被恢复的文件内容填满。
因此,恢复策略的本质是一个预算分配问题——在有限的 token 预算内,选择性地恢复最有价值的状态。
10.2 文件恢复:五个常量的预算体系
文件恢复是压缩后恢复的核心,它通过一套精细的预算体系,确保只恢复最有价值的文件内容。
10.2.1 五个常量的完整预算框架
// services/compact/compact.ts:122-130
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
export const POST_COMPACT_TOKEN_BUDGET = 50_000
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000这五个常量构成了压缩后恢复的完整预算框架。让我们用一张表来理解它们的分配逻辑:
表 10-1:压缩后 token 预算分配表
| 预算类别 | 常量名 | 限额 | 含义 | 设计考量 |
|---|---|---|---|---|
| 文件数量上限 | POST_COMPACT_MAX_FILES_TO_RESTORE |
5 个 | 最多恢复最近读取的 5 个文件 | 平衡"覆盖面"和"精确性"——太多文件稀释焦点,太少文件遗漏关键上下文 |
| 单文件 token 上限 | POST_COMPACT_MAX_TOKENS_PER_FILE |
5,000 | 每个文件最多占用 5K token | 约 2000-2500 行代码,足以覆盖大多数单个文件的上下文 |
| 文件恢复总预算 | POST_COMPACT_TOKEN_BUDGET |
50,000 | 所有恢复文件的 token 总量不超过 50K | 防止 5 个大文件占满预算,为其他附件(技能、计划)留出空间 |
| 单技能 token 上限 | POST_COMPACT_MAX_TOKENS_PER_SKILL |
5,000 | 每个技能文件截断到 5K token | 技能文件可能很大,但指令通常在头部 |
| 技能恢复总预算 | POST_COMPACT_SKILLS_TOKEN_BUDGET |
25,000 | 所有技能的 token 总量不超过 25K | 约可容纳 5 个完整技能,覆盖会话中调用的主要技能 |
预算算术:以 200K 上下文窗口为例
- 压缩前:对话历史接近 200K token(假设)
- 压缩摘要:约 10K-20K token
- 文件恢复:最多 50K token
- 技能恢复:最多 25K token
- 系统/工具/指令:约 30K-40K token
- 压缩后总计:约 115K-135K token
- 剩余空间:约 65K-85K token(为后续对话留出充足空间)
这是一个深思熟虑的平衡:恢复足够的上下文让模型无缝继续工作,但不至于让压缩变得无意义。
10.2.2 恢复逻辑的五步管线
createPostCompactFileAttachments 函数实现了一个复杂的多步过滤管线。让我们逐步拆解:
// services/compact/compact.ts:1415-1464
export async function createPostCompactFileAttachments(
readFileState: Record,
toolUseContext: ToolUseContext,
maxFiles: number,
preservedMessages: Message[] = [],
): Promise {
// 第一步:收集已保留消息中的文件路径
const preservedReadPaths = collectReadToolFilePaths(preservedMessages)
// 第二步:排序、过滤、截取
const recentFiles = Object.entries(readFileState)
.map(([filename, state]) => ({ filename, ...state }))
.filter(file =>
!shouldExcludeFromPostCompactRestore(file.filename, toolUseContext.agentId)
&& !preservedReadPaths.has(expandPath(file.filename))
)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, maxFiles)
// 第三步:并行生成附件
const results = await Promise.all(
recentFiles.map(async file => {
const attachment = await generateFileAttachment(
file.filename,
{ ...toolUseContext, fileReadingLimits: { maxTokens: POST_COMPACT_MAX_TOKENS_PER_FILE } },
'tengu_post_compact_file_restore_success',
'tengu_post_compact_file_restore_error',
'compact'
)
return attachment ? createAttachmentMessage(attachment) : null
})
)
// 第四步:预算控制
let usedTokens = 0
return results.filter((result): result is AttachmentMessage => {
if (result === null) return false
const attachmentTokens = roughTokenCountEstimation(jsonStringify(result))
if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) {
usedTokens += attachmentTokens
return true
}
return false
})
} flowchart TD
A["readFileState
所有已读取文件"] --> B["collectReadToolFilePaths
收集已保留消息中的文件路径"]
A --> C["排除过滤"]
B --> C
C --> D{shouldExcludeFromPostCompactRestore?}
D -->|Plan 文件| E1["排除"]
D -->|CLAUDE.md 记忆文件| E2["排除"]
D -->|其他文件| F["继续"]
F --> G{已在 preservedMessages?}
G -->|是| E3["排除"]
G -->|否| H["按时间戳降序排序"]
H --> I["slice 0, maxFiles
取前 5 个"]
I --> J["并行 generateFileAttachment
重新读取磁盘内容"]
J --> K{单文件超过 5K token?}
K -->|是| L["截断到 5K token"]
K -->|否| M["保持完整"]
L --> N["预算累加"]
M --> N
N --> O{总 token 超过 50K?}
O -->|是| P["丢弃后续文件"]
O -->|否| Q["保留"]
Q --> R["返回 AttachmentMessage 数组"]
P --> R
style E1 fill:#ffe1e1
style E2 fill:#ffe1e1
style E3 fill:#ffe1e1
style P fill:#ffe1e1
style Q fill:#e1f5ff
style R fill:#e1f5ff第一步:排除不需要恢复的文件
shouldExcludeFromPostCompactRestore 函数实现了两层排除逻辑:
// services/compact/compact.ts:1674-1705
function shouldExcludeFromPostCompactRestore(
filename: string,
agentId?: AgentId,
): boolean {
const normalizedFilename = expandPath(filename)
// 第一层:排除 Plan 文件
try {
const planFilePath = expandPath(getPlanFilePath(agentId))
if (normalizedFilename === planFilePath) {
return true // Plan 文件有独立的恢复通道
}
} catch { /* ignore */ }
// 第二层:排除所有 CLAUDE.md 记忆文件
try {
const normalizedMemoryPaths = new Set(
MEMORY_TYPE_VALUES.map(type => expandPath(getMemoryPath(type)))
)
if (normalizedMemoryPaths.has(normalizedFilename)) {
return true // 记忆文件通过系统提示词注入
}
} catch { /* ignore */ }
return false
}为什么要排除这两类文件?
- Plan 文件:它们有独立的恢复通道(见 10.4 节),避免同一个文件被恢复两次
- CLAUDE.md 记忆文件:这些文件通过系统提示词注入,不需要通过文件恢复通道重复注入
第二步:避免重复恢复
collectReadToolFilePaths 函数扫描保留的消息尾部,收集其中所有 FileRead 工具调用的文件路径。如果某个文件已经在保留的消息中,就不需要重复恢复——模型已经能在上下文中看到它。
// services/compact/compact.ts:1610-1636
function collectReadToolFilePaths(messages: Message[]): Set {
const stubIds = new Set()
// 第一遍扫描:收集 FILE_UNCHANGED_STUB 的 tool_use_id
for (const message of messages) {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
continue
}
for (const block of message.message.content) {
if (
block.type === 'tool_result' &&
typeof block.content === 'string' &&
block.content.startsWith(FILE_UNCHANGED_STUB)
) {
stubIds.add(block.tool_use_id)
}
}
}
// 第二遍扫描:收集非 stub 的 FileRead 文件路径
const paths = new Set()
for (const message of messages) {
if (message.type !== 'assistant' || !Array.isArray(message.message.content)) {
continue
}
for (const block of message.message.content) {
if (
block.type !== 'tool_use' ||
block.name !== FILE_READ_TOOL_NAME ||
stubIds.has(block.id)
) {
continue
}
const input = block.input
if (
input &&
typeof input === 'object' &&
'file_path' in input &&
typeof input.file_path === 'string'
) {
paths.add(expandPath(input.file_path))
}
}
}
return paths
} 这个函数的精妙之处在于两遍扫描:
- 第一遍:收集所有
FILE_UNCHANGED_STUB的tool_use_id(这些是"文件未变化"的占位符,不是真实内容) - 第二遍:收集所有非 stub 的 FileRead 调用的文件路径(这些是真实内容)
为什么要区分 stub 和真实内容?因为 stub 只是告诉模型"文件未变化",不包含实际内容。如果在保留的消息中只有 stub,那么文件内容仍然需要通过恢复通道注入。
第三步:时间戳排序与截取
.sort((a, b) => b.timestamp - a.timestamp) // 降序:最新的在前
.slice(0, maxFiles) // 取前 5 个这个排序策略基于一个简单但强大的假设:最近读取的文件最有可能是模型下一步需要操作的文件。
考虑这样一个场景:你在重构一个模块,模型先读取了 10 个参考文件(测试、类型定义、配置),然后开始读取和编辑 3 个核心文件。压缩触发时,最后 3 个核心文件会被恢复,而之前的 10 个参考文件会被丢弃——这是合理的,因为接下来的工作很可能继续围绕这 3 个核心文件展开。
第四步:并行重新读取
const results = await Promise.all(
recentFiles.map(async file => {
const attachment = await generateFileAttachment(
file.filename,
{ ...toolUseContext, fileReadingLimits: { maxTokens: POST_COMPACT_MAX_TOKENS_PER_FILE } },
'tengu_post_compact_file_restore_success',
'tengu_post_compact_file_restore_error',
'compact'
)
return attachment ? createAttachmentMessage(attachment) : null
})
)这里有一个关键细节:恢复时读取的是磁盘上的当前内容,而非快照中的缓存内容。
这意味着:
- 文件同步:如果文件在压缩期间被外部修改(比如用户在编辑器中手动修改),恢复的内容是修改后的版本
- 内容验证:通过 FileReadTool 的完整流程读取,包括权限检查、token 计数、错误处理等
- 并行优化:多个文件的读取是并行的,而非串行,这在大文件场景下能显著提升性能
第五步:预算控制
let usedTokens = 0
return results.filter((result): result is AttachmentMessage => {
if (result === null) return false
const attachmentTokens = roughTokenCountEstimation(jsonStringify(result))
if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) {
usedTokens += attachmentTokens
return true
}
return false
})这是最后一道防线。即使只有 5 个文件,如果它们都很大(每个接近 5K token),总量也可能超过 50K 预算。这个 filter 充当最后的守门人——按顺序累加每个文件的 token 数,一旦总量超过预算,就丢弃剩余的文件。
注意这里的"按顺序":文件是按时间戳降序排列的,所以最近的文件优先被恢复,较老的文件优先被丢弃。这与 LRU 缓存的思想一致——最近使用的内容最有价值。
10.2.3 "保留 vs 丢弃"决策树
下面这棵决策树描述了每个文件在压缩后是否会被恢复的完整判定逻辑:
flowchart TD
A["文件在压缩前被读取过?"] -->|否| B["不恢复:文件不在 readFileState 中"]
A -->|是| C{"是 plan 文件?"}
C -->|是| D["排除:通过 Plan 附件独立恢复(见 10.4)"]
C -->|否| E{"是 CLAUDE.md 记忆文件?"}
E -->|是| F["排除:通过系统提示词注入"]
E -->|否| G{"已在保留的消息尾部?"}
G -->|是| H["排除:模型已能看到,无需重复"]
G -->|否| I{"按时间戳排序后排名前 5?"]
I -->|否| J["丢弃:超出文件数量上限"]
I -->|是| K{"单文件超过 5K token?"}
K -->|是| L["截断到 5K token 后继续"]
K -->|否| M{"累加后总 token 超过 50K?"}
L --> M
M -->|是| N["丢弃:超出总预算"]
M -->|否| O["恢复 ✓ 作为附件注入"]
style D fill:#ffe1e1
style F fill:#ffe1e1
style H fill:#ffe1e1
style J fill:#ffe1e1
style N fill:#ffe1e1
style O fill:#e1f5ff这棵决策树揭示了一个重要设计:恢复不是一个简单的"最近 N 个"算法,而是一个多层过滤管线。
- 排除规则:第一层防护,避免重复恢复或错误恢复
- 数量限制:第二层防护,确保不会恢复太多文件
- 单文件截断:第三层防护,防止单个大文件占满预算
- 总预算上限:第四层防护,最终的守门人
每一层都有其存在的理由,它们协同工作,确保恢复的内容既有价值又不会过度膨胀。
10.3 技能重注入:截断策略优于丢弃策略
技能(Skills)是 Claude Code 的扩展能力系统。当用户在会话中调用了一个技能(比如 code-review 或 commit),技能的指令内容会被注入到对话中。压缩后,这些指令和上下文一起消失。但技能往往包含关键的行为约束——比如"提交前必须运行测试"或"代码审查时关注安全问题"。如果不恢复它们,模型在压缩后可能违反这些约束。
10.3.1 技能恢复机制
// services/compact/compact.ts:1494-1534
export function createSkillAttachmentIfNeeded(
agentId?: string,
): AttachmentMessage | null {
const invokedSkills = getInvokedSkillsForAgent(agentId)
if (invokedSkills.size === 0) {
return null
}
// Sorted most-recent-first so budget pressure drops the least-relevant skills.
let usedTokens = 0
const skills = Array.from(invokedSkills.values())
.sort((a, b) => b.invokedAt - a.invokedAt)
.map(skill => ({
name: skill.skillName,
path: skill.skillPath,
content: truncateToTokens(
skill.content,
POST_COMPACT_MAX_TOKENS_PER_SKILL,
),
}))
.filter(skill => {
const tokens = roughTokenCountEstimation(skill.content)
if (usedTokens + tokens > POST_COMPACT_SKILLS_TOKEN_BUDGET) {
return false
}
usedTokens += tokens
return true
})
if (skills.length === 0) {
return null
}
return createAttachmentMessage({
type: 'invoked_skills',
skills,
})
}10.3.2 截断 vs 丢弃:一个关键的设计决策
技能恢复的策略与文件恢复高度相似,但有一个关键差异:截断而非丢弃。
源码注释(行 125-128)解释了设计意图:
Skills can be large (verify=18.7KB, claude-api=20.1KB). Previously re-injected unbounded on every compact → 5-10K tok/compact. Per-skill truncation beats dropping — instructions at the top of a skill file are usually the critical part. Budget sized to hold ~5 skills at the per-skill cap.
截断函数的实现:
// services/compact/compact.ts:1655-1672
const SKILL_TRUNCATION_MARKER =
'\n\n[... skill content truncated for compaction; use Read on the skill path if you need the full text]'
function truncateToTokens(content: string, maxTokens: number): string {
if (roughTokenCountEstimation(content) <= maxTokens) {
return content
}
const charBudget = maxTokens * 4 - SKILL_TRUNCATION_MARKER.length
return content.slice(0, charBudget) + SKILL_TRUNCATION_MARKER
}这个函数的设计细节:
- 保留头部:
content.slice(0, charBudget)保留文件开头部分 - 添加标记:在截断处添加明确的标记,告诉模型"内容被截断了"
- 提示恢复:标记中包含"如果需要完整内容,使用 Read 工具读取技能路径"的提示
- token 估算:使用
roughTokenCountEstimation(约 4 字符/token)计算字符预算
为什么截断优于丢弃?
考虑一个 verify 技能的典型结构:
# Verify Skill
## 核心规则
- 提交前必须运行测试
- 确保所有测试通过
- 检查代码覆盖率
## 测试命令
- 单元测试:npm test
- 集成测试:npm run test:integration
- E2E 测试:npm run test:e2e
## 代码覆盖率要求
- 行覆盖率:> 80%
- 分支覆盖率:> 75%
- 函数覆盖率:> 85%
## 常见问题排查
1. 测试超时:增加 timeout 配置
2. 内存泄漏:检查闭包和事件监听器
3. 并发问题:确保正确使用 async/await
## 参考资源
- Jest 文档:https://jestjs.io/docs/getting-started
- Testing Library:https://testing-library.com/docs/
...(更多内容)如果使用"丢弃"策略,要么:
- 全部保留:20KB 的技能文件完整注入,占用大量 token
- 全部丢弃:模型完全不知道"提交前必须运行测试"的约束
如果使用"截断"策略:
- 保留头部:核心规则和测试命令被保留(约 2-3KB)
- 丢弃尾部:常见问题排查和参考资源被丢弃(约 17-18KB)
- 添加标记:模型知道内容被截断,可以在需要时读取完整文件
显然,截断策略在"信息密度"和"token 成本"之间取得了更好的平衡。
10.3.3 预算算术与按 Agent 隔离
25K 的总预算能恢复多少个技能?按每个技能 5K token 计算,理论上最多 5 个技能。源码注释也验证了这一点:"Budget sized to hold ~5 skills at the per-skill cap."
但实际中,许多技能截断后不到 5K token,所以 25K 预算通常能覆盖会话中所有被调用的技能。只有当用户在一次长会话中调用了大量大型技能时,预算才会成为瓶颈——此时最久远的技能会被优先丢弃。
另一个关键设计是按 Agent 隔离:
const invokedSkills = getInvokedSkillsForAgent(agentId)这个函数只返回属于当前 agent 的技能。这防止了:
- 主会话的技能泄露到子 agent:子 agent 不需要知道主会话调用了哪些技能
- 子 agent 的技能污染主会话:主会话不需要知道子 agent 内部调用了哪些技能
这种隔离确保了每个 agent 的上下文都是干净且独立的。
10.4 刻意不恢复的内容:sentSkillNames 的智慧
并非所有被清空的状态都需要恢复。源码中最有意思的一个设计决策是关于 sentSkillNames 的处理:
// services/compact/compact.ts:524-529
// Intentionally NOT resetting sentSkillNames: re-injecting the full
// skill_listing (~4K tokens) post-compact is pure cache_creation with
// marginal benefit. The model still has SkillTool in its schema and
// invoked_skills attachment (below) preserves used-skill content. Ants
// with EXPERIMENTAL_SKILL_SEARCH already skip re-injection via the
// early-return in getSkillListingAttachments.10.4.1 sentSkillNames 是什么?
sentSkillNames 是一个模块级的 Map<string, Set<string>>,记录了哪些技能的名称列表已经发送给模型。它的作用是避免重复注入技能列表。
在正常的对话轮次中,系统会在适当的时候向模型注入一个 skill_listing 附件,包含所有可用技能的名称和描述。这个附件大约占用 4K token。
如果在压缩后重置 sentSkillNames,系统会在下一个请求中重新注入完整的技能列表附件——这会立即消耗 4K token,而且全部是 cache_creation token(需要写入缓存的新内容)。
10.4.2 为什么故意不重置?
源码注释给出了三个理由:
理由一:成本不对称
4K token 的技能列表注入成本大于收益。模型仍然可以通过 SkillTool 的 schema 知道技能工具的存在,它只是不知道具体的技能名称列表。但这不是致命的——当模型尝试调用技能时,系统会通过其他机制(如技能搜索或模糊匹配)找到正确的技能。
理由二:已调用的技能已被恢复
上一节的 invoked_skills 附件已经恢复了实际使用过的技能内容。模型不需要再看到完整的名称列表,因为它已经知道它在这次会话中调用了哪些技能。
理由三:实验性技能搜索
启用了 EXPERIMENTAL_SKILL_SEARCH 的环境本来就跳过技能列表注入——它们使用更智能的技能发现机制,不需要预加载完整的技能列表。
10.4.3 这个决策的启示
这个看似微小的设计决策("不重置" vs "重置")体现了一个深刻的工程哲学:在"恢复的完整性"和"token 经济性"之间,有时应该选择后者。
4K token 看似不多,但在每次压缩后都会累积。对于频繁压缩的长会话(可能经历 5-10 次压缩),这是一笔可观的节省(20-40K token)。
更重要的是,这个决策基于一个清晰的假设:模型的"能力"(能做什么)比"知识"(知道什么)更重要。
- 能力:模型知道有 SkillTool 这个工具,它可以通过调用这个工具来使用技能
- 知识:模型知道具体的技能名称列表,但这不是必需的——它可以通过其他方式发现技能
这种"能力优先于知识"的设计哲学,在整个 Claude Code 的架构中反复出现。
10.5 Plan 和 PlanMode 附件的保留
Claude Code 的计划模式(Plan Mode)允许模型在执行任何操作前先制定详细计划。压缩后,计划状态必须被完整保留,否则模型会"忘记"正在执行的计划。
10.5.1 Plan 附件:恢复计划内容
// services/compact/compact.ts:545-548
const planAttachment = createPlanAttachmentIfNeeded(context.agentId)
if (planAttachment) {
postCompactFileAttachments.push(planAttachment)
}// services/compact/compact.ts:1470-1486
export function createPlanAttachmentIfNeeded(
agentId?: AgentId,
): AttachmentMessage | null {
const planContent = getPlan(agentId)
if (!planContent) {
return null
}
const planFilePath = getPlanFilePath(agentId)
return createAttachmentMessage({
type: 'plan_file_reference',
planFilePath,
planContent,
})
}Plan 附件恢复的是计划内容——模型在 plan 模式下生成的详细计划。这个附件不受 50K 文件预算的限制,它有独立的恢复通道。
为什么要独立恢复?因为:
- 重要性:计划是指导模型后续行为的"路线图",丢失它会让模型迷失方向
- 大小可控:计划文件通常不会太大(几百到几千行),完整恢复是可行的
- 避免重复:Plan 文件在文件恢复阶段被
shouldExcludeFromPostCompactRestore显式排除,避免被恢复两次
10.5.2 PlanMode 附件:恢复模式状态
// services/compact/compact.ts:552-555
const planModeAttachment = await createPlanModeAttachmentIfNeeded(context)
if (planModeAttachment) {
postCompactFileAttachments.push(planModeAttachment)
}// services/compact/compact.ts:1542-1560
export async function createPlanModeAttachmentIfNeeded(
context: ToolUseContext,
): Promise {
const appState = context.getAppState()
if (appState.toolPermissionContext.mode !== 'plan') {
return null
}
const planFilePath = getPlanFilePath(context.agentId)
const planExists = getPlan(context.agentId) !== null
return createAttachmentMessage({
type: 'plan_mode',
reminderType: 'full',
isSubAgent: !!context.agentId,
planFilePath,
planExists,
})
} Plan 附件恢复的是计划内容,PlanMode 附件恢复的是模式状态。
reminderType: 'full' 标记确保模型在压缩后继续在 plan 模式下运行,而不是回退到正常的执行模式。
这两个附件协同工作:
- Plan 附件:告诉模型"你正在执行这个计划"
- PlanMode 附件:告诉模型"你必须继续以计划模式工作"
缺少任何一个都会导致行为偏差。如果只有 Plan 附件而没有 PlanMode 附件,模型可能会看到计划内容,但忘记了自己处于 plan 模式,从而直接开始执行而不是继续完善计划。如果只有 PlanMode 附件而没有 Plan 附件,模型会记得自己处于 plan 模式,但不知道计划的具体内容。
10.5.3 计划模式的压缩前后一致性
sequenceDiagram
participant User as 用户
participant Model as 模型
participant Plan as 计划文件
participant Compact as 压缩机制
Note over Model: 压缩前
Model->>Plan: 生成计划内容
Model->>Model: 处于 plan 模式
User->>Model: 继续对话
Model->>Model: 在 plan 模式下完善计划
Compact->>Compact: 触发压缩
Compact->>Plan: 读取计划文件
Compact->>Model: 生成摘要
Compact->>Model: 清空上下文
Note over Model: 压缩后
Compact->>Model: 注入摘要
Compact->>Model: 注入 Plan 附件(计划内容)
Compact->>Model: 注入 PlanMode 附件(模式状态)
Model->>Model: 恢复 plan 模式
Model->>Plan: 访问计划内容
User->>Model: 继续对话
Model->>Model: 在 plan 模式下继续完善计划
Note over Model: 压缩前后行为一致这个序列图展示了计划模式如何跨越压缩边界保持一致性。关键在于 Plan 和 PlanMode 两个附件的协同作用——它们共同确保了压缩前后的状态一致性。
10.6 Delta 附件:工具和指令的完整重播
压缩不仅清除了文件状态,还清除了所有之前的 delta 附件。Delta 附件是系统在对话过程中逐步告知模型的"增量信息"——新注册的延迟工具、新发现的 agent、新加载的 MCP 指令。压缩后,这些信息随着旧消息一起消失。
10.6.1 三类 Delta 的完整重播
// services/compact/compact.ts:563-585
// Compaction ate prior delta attachments. Re-announce from the current
// state so the model has tool/instruction context on the first
// post-compact turn. Empty message history → diff against nothing →
// announces the full set.
for (const att of getDeferredToolsDeltaAttachment(
context.options.tools,
context.options.mainLoopModel,
[], // 空数组!
{ callSite: 'compact_full' },
)) {
postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getAgentListingDeltaAttachment(context, [])) { // 空数组!
postCompactFileAttachments.push(createAttachmentMessage(att))
}
for (const att of getMcpInstructionsDeltaAttachment(
context.options.mcpClients,
context.options.tools,
context.options.mainLoopModel,
[], // 空数组!
)) {
postCompactFileAttachments.push(createAttachmentMessage(att))
}源码注释揭示了这段代码的精妙设计:传入空数组 [] 作为消息历史。
在正常的对话轮次中,Delta 附件函数会比较当前状态和已出现在消息历史中的内容,只发送"增量"部分。但压缩后没有消息历史可以比较——传入空数组意味着 diff 的基线为空,因此函数会生成完整的工具和指令声明。
表 10-2:三类 Delta 附件的作用
| Delta 类型 | 函数 | 恢复内容 | 为什么需要? |
|---|---|---|---|
| 延迟工具 | getDeferredToolsDeltaAttachment |
尚未加载完整 schema 的工具列表 | 让模型知道可以通过 ToolSearch 按需获取这些工具的完整定义 |
| Agent 列表 | getAgentListingDeltaAttachment |
可用的子 agent 列表 | 让模型知道可以委派任务给哪些 agent |
| MCP 指令 | getMcpInstructionsDeltaAttachment |
MCP 服务器提供的指令和约束 | 确保模型遵守外部服务(如 GitHub、Slack)的使用规则 |
callSite: 'compact_full' 标记用于遥测分析,区分正常的增量声明和压缩后的完整重播。这对监控压缩性能和调试很有帮助。
10.6.2 Delta 重播的"空数组"技巧
让我们深入理解这个技巧。在正常情况下:
// 正常轮次:传入实际的消息历史
const deltas = getDeferredToolsDeltaAttachment(
tools,
model,
messages, // 实际的消息历史,包含之前的工具声明
{ callSite: 'attachments_main' }
)
// 返回:只包含新增的工具(增量)但在压缩后:
// 压缩后:传入空数组
const deltas = getDeferredToolsDeltaAttachment(
tools,
model,
[], // 空数组!没有消息历史可以比较
{ callSite: 'compact_full' }
)
// 返回:包含所有工具(完整声明)这个设计避免了两套逻辑(一套用于增量,一套用于完整),而是通过巧妙地控制输入参数,让同一套逻辑在两种情况下都能正确工作。
flowchart LR
subgraph Normal["正常轮次"]
A1["当前状态:工具 A, B, C"] --> B1["消息历史:已声明 A"]
B1 --> C1["差集计算:B, C"]
C1 --> D1["返回:增量 B, C"]
end
subgraph Compact["压缩后"]
A2["当前状态:工具 A, B, C"] --> B2["消息历史:空数组"]
B2 --> C2["差集计算:A, B, C"]
C2 --> D2["返回:完整 A, B, C"]
end
style D1 fill:#e1f5ff
style D2 fill:#ffe1e1这个技巧体现了软件工程中的一个重要原则:通过统一接口处理不同场景,而不是为每个场景创建专门的逻辑。
10.6.3 异步 Agent 附件
// services/compact/compact.ts:532-539
const [fileAttachments, asyncAgentAttachments] = await Promise.all([
createPostCompactFileAttachments(
preCompactReadFileState,
context,
POST_COMPACT_MAX_FILES_TO_RESTORE,
),
createAsyncAgentAttachmentsIfNeeded(context),
])createAsyncAgentAttachmentsIfNeeded(行 1568-1599)检查是否有正在后台运行的异步 agent 或已完成但结果未被检索的 agent。如果有,它为每个 agent 生成一个 task_status 类型的附件,包含 agent 的描述、状态和进度摘要。
// services/compact/compact.ts:1568-1599
export async function createAsyncAgentAttachmentsIfNeeded(
context: ToolUseContext,
): Promise {
const appState = context.getAppState()
const asyncAgents = Object.values(appState.tasks).filter(
(task): task is LocalAgentTaskState => task.type === 'local_agent',
)
return asyncAgents.flatMap(agent => {
if (
agent.retrieved ||
agent.status === 'pending' ||
agent.agentId === context.agentId
) {
return []
}
return [
createAttachmentMessage({
type: 'task_status',
taskId: agent.agentId,
taskType: 'local_agent',
description: agent.description,
status: agent.status,
deltaSummary:
agent.status === 'running'
? (agent.progress?.summary ?? null)
: (agent.error ?? null),
outputFilePath: getTaskOutputPath(agent.agentId),
}),
]
})
} 这个附件防止了压缩后模型"忘记"有后台任务在运行而重复启动相同的任务。
注意文件恢复和异步 agent 附件的生成是并行执行的(Promise.all),这是一个性能优化——两者互不依赖,没有理由串行等待。
10.7 恢复的完整编排:层次化和选择性
现在让我们将所有恢复步骤放在一起,看看压缩后状态恢复的完整编排:
flowchart TD
subgraph Step1["步骤 1:快照并清空"]
S1A["cacheToObject readFileState
保存文件状态快照"]
S1B["readFileState.clear
清空文件缓存"]
S1C["loadedNestedMemoryPaths.clear
清空记忆路径"]
S1A --> S1B --> S1C
end
subgraph Step2["步骤 2:并行生成附件"]
S2A["createPostCompactFileAttachments
文件恢复附件
最多 5 个文件,50K token"]
S2B["createAsyncAgentAttachmentsIfNeeded
异步 agent 附件
运行中的后台任务"]
end
subgraph Step3["步骤 3:串行生成附件"]
S3A["createPlanAttachmentIfNeeded
计划内容附件
独立的恢复通道"]
S3B["createPlanModeAttachmentIfNeeded
计划模式附件
模式状态恢复"]
S3C["createSkillAttachmentIfNeeded
已调用技能附件
最多 5 个技能,25K token"]
S3A --> S3B --> S3C
end
subgraph Step4["步骤 4:Delta 完整重播"]
S4A["getDeferredToolsDeltaAttachment
延迟工具列表"]
S4B["getAgentListingDeltaAttachment
Agent 列表"]
S4C["getMcpInstructionsDeltaAttachment
MCP 指令"]
end
subgraph Step5["步骤 5:SessionStart Hooks"]
S5A["processSessionStartHooks
会话启动钩子
注入系统级附件"]
end
Step1 --> Step2
Step2 --> Step3
Step3 --> Step4
Step4 --> Step5
Step5 --> Step6["步骤 6:合并为 postCompactFileAttachments
随压缩后第一条消息发送给模型"]
style Step1 fill:#fff4e6
style Step2 fill:#e8f5e9
style Step3 fill:#e3f2fd
style Step4 fill:#f3e5f5
style Step5 fill:#fce4ec
style Step6 fill:#e1f5ff这个编排的关键特征是层次化和选择性。
层次化:不同类型的状态有不同的恢复通道
- 文件状态:通过重新读取恢复
- 技能状态:通过截断重注入恢复
- 计划状态:通过专用附件恢复
- 工具声明:通过 delta 重播恢复
选择性:不是所有状态都被恢复
- 时间戳排序:最近的优先
- 排除规则:Plan 文件、记忆文件独立处理
- 预算控制:总 token 限制
- 刻意不恢复:
sentSkillNames等
每个恢复通道都有其最适合的状态类型,每个状态都有其最适合的恢复策略。这种精心设计的分层结构,确保了压缩后的模型能够无缝继续工作,而不会因为上下文丢失而"失忆"。
10.8 用户能做什么:驾驭压缩恢复的策略
理解了压缩后恢复机制,你可以采取以下策略来优化长会话体验。
10.8.1 保持文件读取的聚焦
压缩后只恢复最近读取的 5 个文件。如果你在一次对话中让模型读取了 20 个文件,压缩后只有最后 5 个会被自动恢复。这意味着你在对话前半段让模型读取的那些"参考文件"——测试用例、类型定义、配置文件——很可能在压缩后全部丢失。
策略:在执行复杂任务时,优先让模型读取它下一步需要编辑的文件,而不是"先把所有相关文件都读一遍"。
场景示例:
# 不好的做法:先批量读取所有参考文件
请阅读以下文件:
- tests/unit/utils.test.ts
- tests/integration/api.test.ts
- src/types/index.ts
- src/config/default.ts
- README.md
- CONTRIBUTING.md
# 然后再开始工作
现在请重构 src/utils/format.ts
# 问题:压缩后,最后读取的 format.ts 被恢复,但前面的 6 个参考文件全部丢失# 好的做法:先读取需要编辑的文件
请阅读 src/utils/format.ts
# 然后在需要时逐步读取参考文件
在重构过程中,我需要查看测试用例。请读取 tests/unit/utils.test.ts
# 优势:format.ts 和 utils.test.ts 都是最近读取的,压缩后很可能都被恢复刷新时间戳的技巧:在压缩可能即将到来时(对话已经进行了 30+ 轮),让模型重新读取关键文件,刷新它的时间戳。
# 对话已经很长了,预感压缩即将发生
请重新读取 src/utils/format.ts,我想确认它的当前状态
# 效果:format.ts 的时间戳被刷新,在压缩后最可能被恢复10.8.2 大文件的截断预期
每个文件恢复上限为 5K token(约 2000-2500 行代码)。如果你正在编辑一个超大文件,压缩后模型只能看到文件的开头部分。
策略:在压缩可能发生的节点,显式提醒模型关注大文件中的特定区域。
# 压缩前:模型刚读取了 src/large-file.ts(10000 行)
# 压缩后:模型只能看到前 2000 行
# 好的做法:压缩前明确关键区域
请重点关注 src/large-file.ts 中的第 3500-4000 行,那里是我正在修改的核心函数
# 压缩后:模型在摘要中看到了这个提示,即使文件被截断,也知道关键区域在哪里更好的做法:将关键约束写入 CLAUDE.md——它永远不受压缩影响。
# CLAUDE.md
## src/large-file.ts 注意事项
- 核心函数在第 3500-4000 行
- 不要修改第 100-500 行的配置部分
- 所有公共 API 必须在第 500 行之前声明10.8.3 压缩后技能的行为变化
技能被截断到 5K token 后,文件尾部的参考内容可能丢失。如果你依赖的技能行为在压缩后发生了变化,这可能是截断导致的。
策略:将最关键的技能指令放在技能文件的开头,而非末尾。
# 技能文件结构:好的做法
## 核心规则(最重要,必须在开头)
- 规则 1:...
- 规则 2:...
- 规则 3:...
## 使用步骤
1. 步骤一:...
2. 步骤二:...
3. 步骤三:...
## 常见问题
- 问题 1:...
- 问题 2:...
## 参考资源(最不重要,可以在末尾)
- 文档链接:...
- 示例代码:...# 技能文件结构:不好的做法
## 介绍(不重要的内容)
- 背景:...
- 历史:...
## 核心规则(重要但被放在后面)
- 规则 1:...
- 规则 2:...
# 问题:压缩后,核心规则被截断,模型只看到介绍部分10.8.4 利用 Plan Mode 跨越压缩
如果你在执行一个多步骤任务,使用 plan 模式可以确保计划在压缩后被完整保留。计划附件不受 50K 文件预算的限制,它有独立的恢复通道。
策略:对于可能跨越压缩边界的复杂任务,先让模型制定计划(/plan),然后逐步执行。
# 任务:重构一个大型模块,可能需要 50+ 轮对话
# 好的做法:使用 plan 模式
/plan
# 模型生成详细计划:
# 1. 重构 src/utils/format.ts
# 2. 更新测试用例
# 3. 更新文档
# 4. 运行测试
# 5. 提交代码
# 然后逐步执行
开始执行第 1 步
# ... 30 轮对话后,压缩发生 ...
# 压缩后:模型仍然记得完整的计划,可以继续执行第 2 步# 不好的做法:不使用 plan 模式
请重构这个模块,包括代码、测试和文档
# ... 30 轮对话后,压缩发生 ...
# 压缩后:模型忘记了完整的任务范围,可能只记得"在修改某个文件"10.8.5 留意"压缩遗忘"的模式
如果压缩后模型突然出现以下行为,这些都是正常的工程权衡:
| 症状 | 原因 | 应对策略 |
|---|---|---|
| 重新读取它"刚刚"读过的文件 | 文件排在第 6 名之后,未被恢复 | 手动让模型重新读取,或刷新时间戳 |
| 忘记了后台 agent 的存在 | Agent 已被标记为 retrieved 或 pending |
检查 agent 状态,必要时重新启动 |
| 不再遵守某个 MCP 工具的约束 | Delta 重播通常能覆盖,但可能有遗漏 | 在后续对话中显式提醒约束 |
| 对之前否决的方案重新提议 | 摘要倾向于保留"做了什么",而非"否决了什么" | 在关键决策点使用 /compact 并附加自定义摘要 |
理解哪些信息"幸存"于压缩、哪些信息会丢失,是驾驭长会话的关键能力。
10.8.6 多次压缩的累积效应
一次极长的会话可能经历多次压缩。每次压缩都会:
- 清空并重建文件状态缓存(最多 5 个文件)
- 重新截断技能内容(每次都从原始内容截断,不会"截断的截断")
- 重新生成 Delta 附件(完整重播)
- 生成新的摘要(基于"上一次的摘要 + 后续对话")
但摘要是不可逆的。第二次压缩的摘要是基于"第一次的摘要 + 后续对话"生成的,信息密度逐次降低。
flowchart LR
A["原始对话
100% 信息"] --> B["第一次压缩
摘要:60% 信息"]
B --> C["后续对话"]
C --> D["第二次压缩
摘要:基于 60% 摘要
最终:40% 信息"]
D --> E["后续对话"]
E --> F["第三次压缩
摘要:基于 40% 摘要
最终:25% 信息"]
style A fill:#e1f5ff
style B fill:#fff4e6
style D fill:#ffe1e1
style F fill:#ffcccc策略:对于预计超长的任务,在关键的中间节点主动使用 /compact 并附加自定义指令,明确列出需要保留的关键信息。
# 对话已经很长了,即将达到一个关键的里程碑
# 好的做法:主动压缩并附加自定义摘要
/compact 请保留以下关键信息:
1. 我们正在重构 src/utils/format.ts
2. 已经完成了前 3 个函数的重构
3. 剩余 2 个函数待重构
4. 不要引入新的依赖
5. 必须保持向后兼容
# 效果:摘要会明确包含这些关键点,压缩后模型不会忘记# 不好的做法:等到系统自动压缩
# 系统自动压缩的摘要可能不够精确,导致关键信息丢失10.9 实战案例分析:重构任务的压缩之旅
让我们通过一个具体的案例,看看压缩恢复机制在实际场景中如何工作。
场景设定
你正在重构一个大型模块,任务包括:
- 重构
src/utils/format.ts(核心文件,3000 行) - 更新
tests/unit/format.test.ts(测试文件,1500 行) - 更新
src/types/index.ts(类型定义,500 行) - 更新
README.md(文档,200 行)
对话过程
# 第 1-10 轮:探索和规划
请读取 src/utils/format.ts
请读取 tests/unit/format.test.ts
请读取 src/types/index.ts
请读取 README.md
# 当前 readFileState(按时间戳排序):
# 1. README.md (最新)
# 2. src/types/index.ts
# 3. tests/unit/format.test.ts
# 4. src/utils/format.ts (最旧)
# 第 11-30 轮:开始重构
请重构 src/utils/format.ts 中的 formatCurrency 函数
请更新 format.test.ts 中对应的测试用例
# 第 30 轮:压缩触发!压缩恢复分析
// 压缩前:readFileState 包含 4 个文件
const preCompactReadFileState = {
'README.md': { content: '...', timestamp: 100 },
'src/types/index.ts': { content: '...', timestamp: 90 },
'tests/unit/format.test.ts': { content: '...', timestamp: 80 },
'src/utils/format.ts': { content: '...', timestamp: 70 }
}
// 压缩后:createPostCompactFileAttachments 执行
const recentFiles = [
'README.md', // 最新,恢复 ✓
'src/types/index.ts', // 第二,恢复 ✓
'tests/unit/format.test.ts', // 第三,恢复 ✓
'src/utils/format.ts' // 第四,恢复 ✓(虽然是最旧,但在前 5 名内)
]
// 假设所有文件都小于 5K token,且总量小于 50K token
// 结果:所有 4 个文件都被恢复压缩后继续对话:
# 第 31 轮:压缩后第一个用户消息
请继续重构 src/utils/format.ts 中的 formatDate 函数
# 模型:
# - 能看到摘要(包含"正在重构 format.ts")
# - 能看到 4 个恢复的文件附件
# - 能看到技能附件(如果调用了技能)
# - 能看到工具声明(Delta 重播)
# 结果:无缝继续工作,不需要重新读取文件如果读取了更多文件
# 第 1-15 轮:读取了更多文件
请读取 src/utils/format.ts
请读取 tests/unit/format.test.ts
请读取 src/types/index.ts
请读取 README.md
请读取 src/config/default.ts # 第 5 个
请读取 src/utils/validation.ts # 第 6 个
请读取 CONTRIBUTING.md # 第 7 个
请读取 .eslintrc.js # 第 8 个
# 第 15-30 轮:开始重构
请重构 src/utils/format.ts 中的 formatCurrency 函数
# 第 30 轮:压缩触发!
# 压缩后:只有前 5 个文件被恢复
const recentFiles = [
'.eslintrc.js', # 最新(第 15 轮读取),恢复 ✓
'CONTRIBUTING.md', # 第 14 轮读取,恢复 ✓
'src/utils/validation.ts', # 第 13 轮读取,恢复 ✓
'src/config/default.ts', # 第 12 轮读取,恢复 ✓
'README.md', # 第 4 轮读取,恢复 ✓
]
# 以下文件未被恢复(第 6-8 名):
# - src/types/index.ts(第 3 轮读取)
# - tests/unit/format.test.ts(第 2 轮读取)
# - src/utils/format.ts(第 1 轮读取)
# 第 31 轮:压缩后继续对话
请继续重构 src/utils/format.ts 中的 formatDate 函数
# 模型:
# - 能看到摘要(包含"正在重构 format.ts")
# - 能看到 5 个恢复的文件(但 format.ts 不在其中!)
# - 可能需要重新读取 format.ts优化策略:
# 第 25 轮:预感压缩即将到来
请重新读取 src/utils/format.ts,我想确认它的当前状态
# 效果:format.ts 的时间戳被刷新到 100,压缩后会被恢复
# 压缩后:
const recentFiles = [
'src/utils/format.ts', # 最新(第 25 轮刷新),恢复 ✓
'.eslintrc.js', # 第 15 轮读取,恢复 ✓
'CONTRIBUTING.md', # 第 14 轮读取,恢复 ✓
# ... 其他 2 个文件
]这个案例展示了:
- 时间戳排序的重要性:最近的文件最可能被恢复
- 刷新时间戳的技巧:在压缩前重新读取关键文件
- 预算限制的影响:即使有 5 个名额,也只恢复最近的 5 个
10.10 小结:有选择的遗忘是智慧
压缩后的状态恢复体现了 Claude Code 在"信息完整性"和"token 经济性"之间的精细平衡。
核心机制回顾
1. 快照-清空模式
- 先保存现场(
cacheToObject),再清空缓存(clear) - 确保恢复有据可依、缓存状态一致
2. 分层预算
- 文件恢复:50K 总预算,5 个文件,每个 5K
- 技能恢复:25K 总预算,每个技能 5K
- 计划恢复:独立的恢复通道,不受预算限制
- 不同类型的状态有不同的恢复预算和策略
3. 选择性恢复
- 时间戳排序:最近的优先
- 排除规则:Plan 文件、记忆文件独立处理
- 预算控制:总 token 限制,单文件截断
- 并行优化:文件恢复和异步 agent 附件并行生成
4. 刻意不恢复
sentSkillNames:4K token 的技能列表注入成本大于收益- 这是一个反直觉但正确的决策
5. Delta 完整重播
- 传入空消息历史触发完整重播
- 复用现有的增量机制,避免两套逻辑
- 三类 Delta:延迟工具、Agent 列表、MCP 指令
设计哲学
压缩不是"遗忘",而是"有选择地记住"。
这个选择体现在多个层面:
- 文件层面:只恢复最近的 5 个文件,而不是全部
- 内容层面:只恢复文件的开头 5K token,而不是完整内容
- 技能层面:只恢复已调用的技能(截断),而不是所有技能
- 状态层面:恢复关键状态(计划、模式),而不恢复次要状态(技能列表)
用户启示
理解了这个选择的逻辑,你就能:
- 预测压缩后模型会记住什么、忘记什么
- 调整自己的工作方式:让关键文件最近读取、关键指令放在开头、使用 plan 模式跨越压缩
- 主动控制压缩过程:在关键节点使用
/compact并附加自定义摘要 - 识别"压缩遗忘"的模式:知道哪些行为是正常的工程权衡
最终思考
在有限的上下文窗口中,"记住一切"是不可能的。压缩承认了这个限制,并提供了一种优雅的应对方式:通过精心设计的恢复机制,在"信息完整性"和"token 经济性"之间取得平衡。
这不仅是工程权衡,更是一种智慧——知道该记住什么、该忘记什么,比记住一切更重要。
版本演化:v2.1.91 变化
以下分析基于 v2.1.91 bundle 信号对比,结合 v2.1.88 源码推断。
staleReadFileStateHint 与文件状态追踪
v2.1.91 在工具结果元数据中新增 staleReadFileStateHint 字段,当工具执行(如 Bash 命令)导致已读取文件的 mtime 发生变化时,系统会向模型发送陈旧提示。
这扩展了本章描述的文件状态追踪体系——从"压缩后恢复文件上下文"延伸到"单轮内检测文件变化"。v2.1.88 中 readFileState 缓存(cli/print.ts:1147-1177)已存在于源码中,v2.1.91 将其暴露为模型可感知的输出字段,进一步提升了文件状态管理的精细度。
延伸阅读
- 第 9 章:压缩触发的时机和摘要生成的机制
- 第 8 章:上下文窗口管理和 token 计数
- 技能系统:技能的加载、调用和内容管理
- 计划模式:Plan Mode 的实现和状态管理
参考资源
- Claude Code 源码:
services/compact/compact.ts - FileStateCache 实现:
utils/fileStateCache.ts - 附件管理:
utils/attachments.ts - 本书 GitHub:https://github.com/ZhangHanDong/harness-engineering-from-cc-to-ai-coding
下一章预告:第 11 章将深入探讨 Claude Code 的多 Agent 协作机制,了解任务如何被委派、同步和协调。