乐于分享
好东西不私藏

Claude Code 源码揭秘:70% 触发压缩、LCS 差分快照、全局/项目双记忆 — 三层架构榨干 200K 窗口

Claude Code 源码揭秘:70% 触发压缩、LCS 差分快照、全局/项目双记忆 — 三层架构榨干 200K 窗口

💡 阅读前记得关注+星标,及时获取更新推送

「Claude Code 源码揭秘」系列的第七篇,上一篇《Claude Code 源码揭秘:6 种权限模式,如何做到该放就放该拦就拦》解决的是”什么操作能做”的问题。这一篇聊一个更基础的问题:上下文窗口就那么大,它是怎么塞下那么多信息的?

让它读几个大文件,跑几个命令,输出几千行日志… 按理说窗口早该爆了。但实际用下来,跑几十轮都没问题。

翻源码才发现,Claude Code 在上下文管理上下了大功夫。不是简单的”满了就清”,而是一套精细的压缩、摘要、记忆机制。

先说压缩触发的时机。网上有人说是 92% 才压缩,但翻源码发现不对,实际是 70%

summarizeThreshold: config.summarizeThreshold ?? 0.7, // 70% 时开始摘要// 检查是否需要压缩const threshold = this.config.maxTokens * this.config.summarizeThreshold;const used = this.getUsedTokens();if (used < threshold) {  return;  // 还没到阈值,不压缩}

压缩不是一刀切的,而是分三层策略。

第一层是增量压缩——消息进来的时候就压。不等到快满了才处理,而是每条消息添加到上下文时就尝试压缩:

if (this.config.enableIncrementalCompression) {  processedUser = compressMessage(user, this.config);  processedAssistant = compressMessage(assistant, this.config);  // 压缩后 token 更少才应用  if (compressedTokens < originalTokens) {    compressed = true;    this.savedTokens += originalTokens - compressedTokens;  }}

这个思路很聪明。不是等问题出现再解决,而是在源头就控制增量。

第二层是工具输出的智能截断。你让 Claude Code 跑一个 grep,输出几千行,全塞进去太浪费了。它会智能处理:

function compressToolOutput(content: string, maxChars: number = 2000): string {  if (content.length <= maxChars) {    return content;  }  // 1. 检测代码块 - 代码优先保留  const codeBlocks = extractCodeBlocks(content);  if (codeBlocks.length > 0) {    // 逐个压缩代码块(代码块内部是60%头 + 40%尾)    let result = content;    for (const block of codeBlocks) {      const compressed = compressCodeBlock(block.code); // 内部:60% + 40%      const marker = block.language ? `\`\`\`${block.language}` : '```';      result = result.replace(        `${marker}\n${block.code}\`\`\``,        `${marker}\n${compressed}\`\`\``      );    }    // 压缩后如果满足大小要求就返回    if (result.length <= maxChars) {      return result;    }  }  // 2. 检测文件内容 - 保留头20行 + 尾10行  if (content.includes('→') || /^\s*\d+\s*[│|]/.test(content)) {    const lines = content.split('\n');    const keepHead = 20;  // 硬编码:前20行    const keepTail = 10;  // 硬编码:后10行    if (lines.length > keepHead + keepTail) {      const head = lines.slice(0, keepHead).join('\n');      const tail = lines.slice(-keepTail).join('\n');      const omitted = lines.length - keepHead - keepTail;      return `${head}\n... [${omitted} lines omitted] ...\n${tail}`;    }  }  // 3. 默认:70%头部 + 30%尾部(字符级)  const keepHead = Math.floor(maxChars * 0.7);  const keepTail = Math.floor(maxChars * 0.3);  const head = content.slice(0, keepHead);  const tail = content.slice(-keepTail);  const omitted = content.length - maxChars;  return `${head}\n\n... [~${omitted} chars omitted] ...\n\n${tail}`;}

为什么是”头多尾少”?因为大部分情况下,开头的信息更重要——文件的开头是声明和导入,日志的开头是上下文,错误的开头是调用栈。尾部保留一些是为了看到最终结果。

注意这里有三个不同的策略:

  1. 1. 代码块:60%头 + 40%尾(行级别)
  2. 2. 文件内容:固定前20行 + 后10行(行级别,通过  或 数字│ 等特征识别)
  3. 3. 普通文本:70%头 + 30%尾(字符级别)

这个分层策略不是拍脑袋的,是经验值。代码块更重视完整性(60/40),文件列表固定保留头尾关键信息,普通文本更激进压缩(70/30)。

第三层是消息级摘要——当总量超过 70% 时,把老的消息压缩成摘要:

const recentCount = this.config.keepRecentMessages; // 保留最近10条const toSummarize = this.turns.slice(0, -recentCount); // 老消息要摘要// 用 AI 生成摘要if (this.config.enableAISummary && this.apiClient) {  summary = await createAISummary(toSummarize, this.apiClient);} else {  summary = createSummary(toSummarize); // 简单版本}

注意最近 10 条消息是不压缩的。因为最近的对话往往是当前任务的上下文,压缩了模型就不知道自己在干嘛了。

把三层串起来看:

说完压缩,再聊记忆系统。

Claude Code 的记忆分两层:全局记忆和项目记忆。

function getGlobalMemoryDir(): string {  return path.join(os.homedir(), '.claude', 'memory');}function getProjectMemoryDir(projectDir?: string): string {  return path.join(projectDir || process.cwd(), '.claude', 'memory');}

查询的时候,项目记忆优先:

get(key: string, scope?: 'global' | 'project'): string | undefined {  if (scope === 'global') {    return this.globalStore.entries[key]?.value;  }  if (scope === 'project') {    return this.projectStore.entries[key]?.value;  }  // 默认:先查项目,再查全局  return this.projectStore.entries[key]?.value ??         this.globalStore.entries[key]?.value;}

这个设计让你可以在不同项目里存不同的记忆。比如项目 A 用 MySQL,项目 B 用 PostgreSQL,各自的数据库配置不会冲突。

每条记忆还带时间戳:

store.entries[key] = {  key,  value,  scope,  createdAt: existing?.createdAt || now,  // 保留创建时间  updatedAt: now,                          // 更新修改时间};

这样可以追踪记忆的”冷热”程度。经常更新的是热数据,很久没动的是冷数据。以后做记忆清理的时候可以参考这个。

接下来是 CLAUDE.md 的加载机制。CLAUDE.md 也是两层架构:个人级和项目级。

function findClaudeMd(startDir?: string): string | null {  let dir = startDir || process.cwd();  // 1. 从当前目录向上遍历,优先查找项目级 CLAUDE.md  while (dir !== path.dirname(dir)) {    for (const filename of [      'CLAUDE.md',              // 项目根目录      '.claude.md',             // 隐藏文件      'claude.md',              // 小写      '.claude/CLAUDE.md',      // .claude 目录      '.claude/instructions.md' // 备选名称    ]) {      const filePath = path.join(dir, filename);      if (fs.existsSync(filePath)) {        return filePath;  // 项目级优先      }    }    dir = path.dirname(dir);  }  // 2. 如果项目级没找到,查找个人级 CLAUDE.md  const homeClaudeMd = path.join(os.homedir(), '.claude', 'CLAUDE.md');  if (fs.existsSync(homeClaudeMd)) {    return homeClaudeMd;  // 个人级兜底  }  return null;}

查找优先级

  1. 1. 项目级优先:从当前目录向上遍历,找到任何一个 CLAUDE.md 就立即返回
  2. 2. 个人级兜底:如果整个目录树都没找到,使用 ~/.claude/CLAUDE.md

这样设计的好处:

  • • 个人级 CLAUDE.md 可以放通用规则(编码习惯、常用工具偏好等)
  • • 项目级 CLAUDE.md 可以覆盖个人规则,适配项目特性
  • • 不同项目的 CLAUDE.md 互不影响

找到 CLAUDE.md 后,会注入到系统提示里:

async function injectClaudeMd(systemPrompt: string, cwd: string): Promise<string> {  const config = await parseClaudeMd(cwd);  if (!config) return systemPrompt;  const parts = [systemPrompt];  parts.push('\n\n## Project-Specific Instructions (CLAUDE.md)\n');  parts.push(config.content);  // 如果引用了其他文件,也读进来(最多5个,每个最多5000字符)  if (config.files.length > 0) {    parts.push('\n\n## Referenced Files\n');    for (const filePath of config.files.slice(0, 5)) {      try {        const content = fs.readFileSync(filePath, 'utf-8');        const relativePath = path.relative(cwd, filePath);        parts.push(`\n### ${relativePath}\n`);        parts.push('```\n');        parts.push(content.slice(0, 5000));        if (content.length > 5000) {          parts.push('\n... (truncated)');        }        parts.push('\n```\n');      } catch (error) {        // 忽略读取失败的文件      }    }  }  return parts.join('');}

这里有个限制:引用的文件最多 5 个,每个最多 5000 字符。不能无限塞,上下文窗口是有限的。这也是为啥很多实践建议,claude.md里面尽量写精华、不是写的越多越好,多了也记不住。

最后说说 Checkpoint 系统,这是文件版本快照。

你让 Claude Code 改了一个文件,改坏了想回滚怎么办?Checkpoint 就是干这个的。

const useFullContent =  existingCheckpoints.length === 0 ||  // 第一个 checkpoint  editCount === 0;                      // 编辑计数为 0if (useFullContent) {  // 第一次:保存完整内容(超过1KB会压缩)  checkpointContent = content.length > 1024    ? compressContent(content).toString('base64')    : content;} else {  // 后续:只保存增量 diff  const lastContent = reconstructContent(path, existingCheckpoints.length - 1);  checkpointDiff = calculateDiff(lastContent, content);}

第一次保存完整内容,后续只保存变化的部分(diff)。这样既能回滚到任意版本,又不会占用太多空间。

Diff 计算用的是经典的 LCS(最长公共子序列)算法:

function calculateDiff(oldContent: string, newContent: string): string {  const oldLines = oldContent.split('\n');  const newLines = newContent.split('\n');  // LCS 算法找出共同的行  const lcs = longestCommonSubsequence(oldLines, newLines);  // 对比计算出 add/del 操作  // ...  return JSON.stringify(diff);}

恢复的时候,从最近的完整内容开始,逐个应用 diff:

function reconstructContent(filePath: string, checkpointIndex: number): string {  // 找到最近的完整内容 checkpoint  let baseIndex = checkpointIndex;  while (baseIndex >= 0 && !checkpoints[baseIndex].content) {    baseIndex--;  }  let content = checkpoints[baseIndex].content;  // 逐个应用 diff  for (let i = baseIndex + 1; i <= checkpointIndex; i++) {    if (checkpoints[i].diff) {      content = applyDiff(content, checkpoints[i].diff);    }  }  return content;}

存储也有限制:每个文件最多 100 个 checkpoint,总共最多 500MB,超过 30 天自动清理。

const MAX_CHECKPOINTS_PER_FILE = 100;const CHECKPOINT_RETENTION_DAYS = 30;const MAX_STORAGE_SIZE_MB = 500;

把整个记忆架构串起来:

上下文压缩的完整原理:逆向分析 + 实际表现

通过逆向官方 Claude Code 2.0.76 的混淆代码(cli.js),结合实际使用表现,可以还原出完整的压缩机制。

核心压缩机制:三层压缩 + 两段式触发

逆向分析出的核心机制(注:逆向可能不全面,某些细节可能有偏差):

三层压缩策略:

  1. 1. 增量压缩:消息添加到上下文时立即尝试压缩(enableIncrementalCompression
  2. 2. 工具输出截断:代码块保留 60%头+40%尾,文件内容保留前20行+后10行,普通文本保留 70%头+30%尾
  3. 3. 消息级摘要:使用率达到 70% 时触发 maybeCompress(),保留最近 10 条消息,老消息压成摘要

基础配置:

  • • 上下文窗口:200,000 tokens
  • • 保留空间:8,192 tokens
  • • 第一次压缩阈值:70%(summarizeThreshold: 0.7
  • • 强制压缩阈值:100%(剩余 0% 时)

官方的高级特性(当前逆向实现尚未完全还原)

1. Prompt Caching 的使用

Prompt Caching 是 Anthropic API 的服务端功能,官方客户端在请求中标记了哪些内容需要缓存:

// API 返回的缓存统计"current_usage": {  "input_tokens": number,  "output_tokens": number,  "cache_creation_input_tokens": number,  // 写入缓存的 tokens  "cache_read_input_tokens": number       // 从缓存读取的 tokens(90%折扣)}

官方实现在系统提示、工具定义、CLAUDE.md 等固定内容上加了 cache_control: {type: "ephemeral"} 标记,让 API 自动缓存这些内容,大幅降低成本。

当前逆向实现尚未在请求中加入 cache_control 标记,因此无法利用缓存降低成本。

2. 输出折叠机制(aboveTheFold)

// 默认只显示前 3 行{aboveTheFold: "前3行内容", remainingLines: 47}// 显示为:// 前3行内容// … +47 lines [ctrl+o to expand]

官方使用行级折叠 + 可交互展开,比当前逆向实现的字符级截断体验更好。(当前逆向实现使用简单的字符截断)

3. Auto-Compact 自动压缩

官方在主循环中集成了自动压缩监控(当前逆向实现尚未完整还原此功能):

// 伪代码(从官方行为推测)const contextUsage = getContextUsage();const remaining = 100 - contextUsage.percentage;// 接近上限时显示警告if (contextUsage.percentage >= 94) {  console.log(`Context left until auto-compact: ${remaining.toFixed(0)}%`);}// 0% 剩余空间时自动压缩if (remaining <= 0 && config.autoCompact) {  console.log('Auto-compacting context...');  console.log('Compressing old messages...');  await compact();  console.log(`Freed tokens: ${freedTokens}`);  console.log(`New usage: ${newPercentage.toFixed(1)}%`);}

实际使用表现

用户在使用官方 Claude Code 时会看到:

情况 1:正常对话

(不显示任何提示)

情况 2:接近上限(94-99% 使用率)

Context left until auto-compact: 6%Context left until auto-compact: 3%Context left until auto-compact: 1%

情况 3:达到上限(100% 使用率,0% 剩余)

Context left until auto-compact: 0%Auto-compacting context...Compressing old messages...  • Turn 1-15: 45,231 tokens → summary (1,234 tokens)  • Turn 16-28: 38,492 tokens → summary (987 tokens)  • Kept recent 10 turns unchangedFreed tokens: 81,502New usage: 65.3%Context compacted successfully

之后继续对话,上下文使用率重新从 65% 左右开始增长。

逆向实现的完整度对比

功能点
逆向实现状态
官方完整功能
70% 静默压缩
✅ 已还原
maybeCompress()

 静默压缩
94-99% 警告提示
❌ 未还原
显示 “Context left until auto-compact: X%”
100% 自动压缩
⚠️ 部分还原(需手动 /compact
自动触发 compact(),输出压缩日志
Prompt Caching 使用
❌ 未加入 cache_control 标记
请求中标记系统提示等内容,成本降低 90%
输出折叠
⚠️ 部分还原(字符截断)
aboveTheFold 行级折叠 + ctrl+o 展开
三层压缩策略
✅ 已还原
增量 + 工具截断 + 消息摘要

为什么是 70% 压缩,0% 强制?

70% 预压缩:给后续对话留出空间。如果等到 90% 才压缩,可能连压缩本身需要的空间都没有了(生成摘要也要消耗 tokens)。

0% 强制压缩:这是最后的兜底机制。当上下文真的满了(剩余 0%),再不压缩就无法继续对话了。这时会强制执行 compact(),大幅压缩老消息,释放空间。

这个两段式设计保证了:

  1. 1. 大部分情况下静默压缩(70%),用户无感知
  2. 2. 极端情况下强制压缩(100%),保证能继续工作
  3. 3. 中间阶段显示警告(94-99%),让用户知道快满了

翻完这部分代码,我最大的感受是:上下文管理不是可有可无的优化,而是 Agent 能否长时间工作的关键

没有压缩,跑几轮就爆了。没有记忆,每次都从头开始。没有快照,改错了没法回滚。

这套系统的设计思路是”空间换时间 + 智能取舍”——通过压缩、摘要、增量存储等手段,在有限的上下文窗口里塞下尽可能多的有效信息。官方完整实现还包括 Prompt Caching 和 Auto-Compact 等高级特性,进一步优化了成本和自动化程度。当前的逆向实现已经还原了核心压缩机制,但在缓存支持和自动监控方面并没有完全逆向出来。

下一篇聊子代理系统。Claude Code 怎么把复杂任务分给不同的子代理?主代理当”包工头”,子代理干活不污染上下文,这个设计也挺有意思的。

本文通过逆向分析官方 Claude Code 2.0.76(混淆文件:node_modules/@anthropic-ai/claude-code/cli.js),还原并实现了核心上下文管理机制。主要逆向实现文件:src/context/index.tssrc/memory/index.tssrc/checkpoint/index.ts

如果这篇文章对你有帮助,欢迎点赞转发,关注不迷路⭐️

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Claude Code 源码揭秘:70% 触发压缩、LCS 差分快照、全局/项目双记忆 — 三层架构榨干 200K 窗口

评论 抢沙发

9 + 4 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮