上下文管理:五层压缩管线

Token 是 AI Agent 最稀缺的资源。一个长会话可能产生数百万 Token 的对话历史,但上下文窗口只有 200K。Claude Code 用一套五层压缩管线优雅地解决了这个矛盾——这不是简单的截断,而是一门关于「信息保真度」的精密工程。

前言

如果 Agent Loop 是 Claude Code 的心脏,那么上下文管理系统就是它的血液循环系统

想象一个场景:用户让 Claude Code 帮忙重构一个大型项目。Agent 可能需要读取数十个文件、执行数十条命令、进行数十轮对话。每一轮迭代都会产生大量的 Token——工具调用的结果可能长达数千 Token,LLM 的思考过程可能更长。

在 20 轮迭代之后,对话历史可能已经积累了 50 万 Token。但 Claude 的上下文窗口只有 200K Token。更糟糕的是,你还需要为系统提示词、工具定义、当前用户输入留出空间,实际可用的上下文可能只有 150K Token

这就是Token 爆炸问题。如果不能有效管理上下文,Agent 会在几次迭代后就耗尽上下文窗口,无法继续工作。

Claude Code 的解决方案是一套五层压缩管线——五种不同粒度的压缩策略,从最温和的消息裁剪到最激进的智能丢弃,逐层递进,确保在任何情况下都能将上下文控制在窗口限制内。

Token 爆炸问题

Token 爆炸问题

在深入解决方案之前,让我们先量化问题的严重性。

假设一次典型的 Claude Code 会话:

操作类型平均 Token 数频率(每轮)20 轮累计
用户输入~1000.5 次1,000
LLM 思考~5001 次10,000
LLM 文本输出~3001 次6,000
工具调用(读文件)~2,0002 次80,000
工具调用(执行命令)~1,5001 次30,000
工具结果~3,0003 次180,000
单轮合计~7,400
20 轮累计~307,000

20 轮迭代后,累计 Token 数已经超过 300K,远超 200K 的上下文窗口。如果不进行压缩,Agent 在第 14 轮左右就会遇到 ContextWindowExceeded 错误。

更糟糕的是,Token 的分布是不均匀的。工具调用的结果(比如一个大文件的内容)可能占据单轮 Token 数的 80% 以上。这意味着压缩策略必须能够智能地识别和处理这些「Token 大户」。

五层压缩策略概述

五层压缩策略概述

Claude Code 的五层压缩管线按照信息损失程度从低到高排列:

原始消息历史
Total: ~307K tokens

超过上下文窗口?

直接使用原始历史

第一层:消息裁剪
Message Trimming

仍然超过?

第二层:工具结果截断
Tool Result Truncation

仍然超过?

第三层:历史摘要
History Summarization

仍然超过?

第四层:滑动窗口
Sliding Window

仍然超过?

第五层:智能丢弃
Smart Discard

每一层压缩都会损失一定的信息,但损失的程度逐层递增。管线的设计原则是**「能不压缩就不压缩,能少压缩就少压缩」**——只有当上一层压缩不足以将 Token 数控制在限制内时,才会启用下一层。

第一层:消息裁剪

第一层:消息裁剪

消息裁剪是最温和的压缩策略。它的目标是去除冗余的消息,而不改变任何消息的内容。

// context/trimming.ts - 第一层:消息裁剪(简化还原)
// 目标:去除冗余消息,不改变任何消息的内容
// 信息损失:低——只删除对当前任务无用的消息

class MessageTrimmer {
  // 消息裁剪的主入口
  async trim(messages: Message[], maxTokens: number): Promise<Message[]> {
    let result = [...messages];

    // 步骤 1:移除重复的系统消息
    // 有时候系统消息会被重复注入,去重可以节省大量 Token
    result = this.deduplicateSystemMessages(result);

    // 步骤 2:移除空消息和无内容的消息
    result = result.filter(msg => msg.content && msg.content.length > 0);

    // 步骤 3:移除已被后续消息完全替代的旧消息
    // 例如:如果用户先说「用 Python」,后来说「改用 TypeScript」,
    // 那么第一条消息可以被移除
    result = this.removeSuperseded(result);

    // 步骤 4:如果仍然超过限制,按优先级移除低价值消息
    if (this.countTokens(result) > maxTokens) {
      result = this.removeByPriority(result, maxTokens);
    }

    return result;
  }

  // 按优先级移除消息
  // 优先保留:系统提示 > 最近的对话 > 工具结果 > 旧的对话
  private removeByPriority(messages: Message[], maxTokens: number): Message[] {
    // 为每条消息计算「重要性分数」
    const scored = messages.map(msg => ({
      message: msg,
      score: this.calculateImportance(msg),
    }));

    // 按重要性排序,保留最重要的消息
    scored.sort((a, b) => b.score - a.score);

    const result: Message[] = [];
    let tokenCount = 0;

    for (const { message } of scored) {
      const msgTokens = this.countMessageTokens(message);
      if (tokenCount + msgTokens <= maxTokens) {
        result.push(message);
        tokenCount += msgTokens;
      }
    }

    // 恢复原始顺序(重要性排序打乱了时间顺序)
    return this.restoreOrder(result, messages);
  }

  // 计算消息的重要性分数
  private calculateImportance(message: Message): number {
    let score = 0;

    // 系统消息:最高优先级
    if (message.role === 'system') score += 1000;

    // 最近的消息:高优先级(时间衰减)
    const age = Date.now() - message.timestamp;
    score += Math.max(0, 100 - age / 60000);  // 每分钟衰减 1 分

    // 包含工具调用的消息:中等优先级
    if (message.content.some(c => c.type === 'tool_use')) score += 50;
    if (message.content.some(c => c.type === 'tool_result')) score += 40;

    // 用户消息:中等优先级
    if (message.role === 'user') score += 30;

    // 包含代码的消息:较高优先级
    if (this.containsCode(message)) score += 20;

    return score;
  }
}

消息裁剪的关键在于重要性分数的计算。一个好的重要性函数需要考虑多个因素:消息的类型、时间、内容、以及与其他消息的关系。Claude Code 的重要性函数考虑了以上所有因素,确保最重要的消息被保留。

第二层:工具结果截断

工具结果是 Token 爆炸的最大来源。一个 read file 操作可能返回数千行代码,一个 bash 命令可能输出大量的日志。第二层压缩专门处理这些「Token 大户」。

// context/truncation.ts - 第二层:工具结果截断(简化还原)
// 目标:截断过长的工具结果,保留最有价值的部分
// 信息损失:中等——丢失了工具结果的完整内容

class ToolResultTruncator {
  // 截断策略配置
  private strategies: Map<string, TruncationStrategy> = new Map([
    // 每种工具类型都有专门的截断策略
    ['read_file', new FileTruncationStrategy()],
    ['bash', new BashTruncationStrategy()],
    ['search', new SearchTruncationStrategy()],
    ['list_files', new ListTruncationStrategy()],
  ]);

  async truncate(messages: Message[], maxTokens: number): Promise<Message[]> {
    // 找出所有工具结果,按 Token 数排序
    const toolResults = this.extractToolResults(messages);
    toolResults.sort((a, b) => b.tokenCount - a.tokenCount);

    let result = [...messages];
    let currentTokens = this.countTokens(result);

    // 从最大的工具结果开始截断
    for (const toolResult of toolResults) {
      if (currentTokens <= maxTokens) break;

      const strategy = this.strategies.get(toolResult.toolName)
        || this.strategies.get('default')!;

      // 计算可以节省的 Token 数
      const targetTokens = Math.min(
        toolResult.tokenCount * 0.5,  // 最多截断 50%
        currentTokens - maxTokens + 1000,  // 留一些余量
      );

      if (targetTokens > 0) {
        const truncated = await strategy.truncate(toolResult, targetTokens);
        result = this.replaceToolResult(result, toolResult.id, truncated);
        currentTokens -= (toolResult.tokenCount - truncated.tokenCount);
      }
    }

    return result;
  }
}

// 文件截断策略——保留开头和结尾,中间用摘要替代
class FileTruncationStrategy implements TruncationStrategy {
  async truncate(result: ToolResult, targetTokens: number): Promise<ToolResult> {
    const content = result.content;
    const lines = content.split('\n');

    if (lines.length <= 50) {
      // 文件很短,不截断
      return result;
    }

    // 保留策略:
    // - 前 20 行(通常是 imports、文件头等重要信息)
    // - 后 20 行(可能是 return 语句、导出等)
    // - 中间部分用摘要替代
    const head = lines.slice(0, 20).join('\n');
    const tail = lines.slice(-20).join('\n');
    const middleLineCount = lines.length - 40;

    const truncatedContent = [
      head,
      `\n... [省略 ${middleLineCount} 行] ...\n`,
      tail,
    ].join('\n');

    return {
      ...result,
      content: truncatedContent,
      tokenCount: countTokens(truncatedContent),
      truncated: true,
      truncationMeta: {
        originalLines: lines.length,
        removedLines: middleLineCount,
        strategy: 'head-tail',
      },
    };
  }
}

// Bash 命令输出截断策略——保留最后 N 行(最新的输出通常最重要)
class BashTruncationStrategy implements TruncationStrategy {
  async truncate(result: ToolResult, targetTokens: number): Promise<ToolResult> {
    const content = result.content;
    const lines = content.split('\n');

    if (lines.length <= 30) {
      return result;
    }

    // 对于命令输出,最新的输出通常最重要
    // 保留最后 30 行,截断前面的输出
    const tail = lines.slice(-30).join('\n');
    const removedCount = lines.length - 30;

    const truncatedContent = [
      `[... 前 ${removedCount} 行输出已截断 ...]`,
      tail,
    ].join('\n');

    return {
      ...result,
      content: truncatedContent,
      tokenCount: countTokens(truncatedContent),
      truncated: true,
      truncationMeta: {
        originalLines: lines.length,
        removedLines: removedCount,
        strategy: 'tail-only',
      },
    };
  }
}

工具结果截断的设计中有一个重要的权衡:保留哪些内容。对于文件内容,开头和结尾通常包含最重要的信息(imports、类定义、return 语句);对于命令输出,最新的几行通常最重要。这种差异化的截断策略确保了在大幅减少 Token 的同时,保留了最有价值的信息。

第三层:历史摘要

当消息裁剪和工具结果截断都不足以将 Token 数控制在限制内时,第三层压缩——历史摘要——开始发挥作用。

历史摘要的核心思想是:用一段简短的摘要替代一大段对话历史。这类似于人类在回忆过去对话时的方式——我们不会逐字逐句地回忆每一句话,而是提取关键信息形成摘要。

// context/summarization.ts - 第三层:历史摘要(简化还原)
// 目标:用 LLM 生成的摘要替代旧的对话历史
// 信息损失:较高——丢失了对话的细节,保留了关键信息

class HistorySummarizer {
  // 摘要生成的 prompt 模板
  private static SUMMARIZE_PROMPT = `请将以下对话历史压缩为简洁的摘要。
要求:
1. 保留所有关键决策和结论
2. 保留所有文件路径和代码修改的记录
3. 保留所有未完成的任务和待解决的问题
4. 删除冗余的对话和重复的信息
5. 使用结构化的格式(要点列表)

对话历史:
{history}

请生成摘要:`;

  async summarize(messages: Message[], targetTokens: number): Promise<Message[]> {
    // 将消息分为「可以摘要的」和「必须保留的」
    const { summarizable, mustKeep } = this.partitionMessages(messages);

    // 计算需要摘要多少消息
    const mustKeepTokens = this.countTokens(mustKeep);
    const availableTokens = targetTokens - mustKeepTokens;
    
    // 从最旧的消息开始摘要,直到 Token 数足够
    const toSummarize: Message[] = [];
    let summarizableTokens = 0;

    for (const msg of summarizable) {
      toSummarize.push(msg);
      summarizableTokens += this.countMessageTokens(msg);
      if (summarizableTokens >= availableTokens * 2) {
        // 摘要通常能压缩 50-70%,所以预留 2 倍空间
        break;
      }
    }

    // 使用 LLM 生成摘要
    const summary = await this.generateSummary(toSummarize);

    // 构建新的消息列表:摘要 + 必须保留的消息
    const summaryMessage: Message = {
      role: 'user',
      content: [{
        type: 'text',
        text: `[以下是之前对话的摘要]\n\n${summary}`,
      }],
      timestamp: toSummarize[0].timestamp,
      metadata: { isSummary: true },
    };

    return [summaryMessage, ...mustKeep];
  }

  // 将消息分为「可以摘要的」和「必须保留的」
  private partitionMessages(messages: Message[]): {
    summarizable: Message[];
    mustKeep: Message[];
  } {
    const mustKeep: Message[] = [];
    const summarizable: Message[] = [];

    for (const msg of messages) {
      // 必须保留的消息:
      // - 系统提示词
      // - 最近 3 轮的完整对话
      // - 包含未完成工具调用的消息
      // - 用户明确标记为重要的消息
      if (
        msg.role === 'system' ||
        this.isRecent(msg, 3) ||
        this.hasPendingToolCalls(msg) ||
        msg.metadata?.important
      ) {
        mustKeep.push(msg);
      } else {
        summarizable.push(msg);
      }
    }

    return { summarizable, mustKeep };
  }

  // 使用 LLM 生成摘要
  private async generateSummary(messages: Message[]): Promise<string> {
    const historyText = messages.map(msg =>
      `[${msg.role}] ${this.extractTextContent(msg)}`
    ).join('\n\n');

    const prompt = HistorySummarizer.SUMMARIZE_PROMPT.replace(
      '{history}', historyText
    );

    // 使用一个较小的模型来生成摘要,节省成本
    const response = await this.apiClient.createMessage({
      model: 'claude-haiku-3',  // 使用小模型生成摘要
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 1000,
    });

    return response.content[0].text;
  }
}

历史摘要的设计中有几个值得注意的细节:

  1. 使用小模型生成摘要:摘要任务不需要最强的模型,使用 Haiku 级别的小模型可以节省成本和时间
  2. 必须保留的消息:系统提示词和最近几轮对话不会被摘要,确保当前对话的连贯性
  3. 结构化摘要:要求 LLM 使用要点列表格式,便于后续解析和引用

第四层:滑动窗口

当历史摘要仍然不够时,第四层压缩——滑动窗口——开始生效。滑动窗口是最简单也最暴力的压缩策略:只保留最近的 N 条消息,丢弃所有更早的消息

// context/sliding-window.ts - 第四层:滑动窗口(简化还原)
// 目标:只保留最近的消息,丢弃更早的历史
// 信息损失:高——丢失了所有早期对话的细节

class SlidingWindow {
  async compress(messages: Message[], maxTokens: number): Promise<Message[]> {
    // 系统提示词必须保留
    const systemMessages = messages.filter(m => m.role === 'system');
    const nonSystemMessages = messages.filter(m => m.role !== 'system');

    // 从最新的消息开始,向前保留,直到达到 Token 限制
    const kept: Message[] = [];
    let tokenCount = this.countTokens(systemMessages);

    for (let i = nonSystemMessages.length - 1; i >= 0; i--) {
      const msg = nonSystemMessages[i];
      const msgTokens = this.countMessageTokens(msg);
      
      if (tokenCount + msgTokens > maxTokens) {
        break;
      }
      
      kept.unshift(msg);
      tokenCount += msgTokens;
    }

    // 如果丢弃了消息,在开头插入一个丢弃通知
    const discardedCount = nonSystemMessages.length - kept.length;
    if (discardedCount > 0) {
      const notice: Message = {
        role: 'user',
        content: [{
          type: 'text',
          text: `[注意:之前的 ${discardedCount} 条消息已被滑动窗口丢弃。
如果需要参考早期的对话内容,请告知用户。]`,
        }],
        metadata: { isSlidingWindowNotice: true },
      };
      return [...systemMessages, notice, ...kept];
    }

    return [...systemMessages, ...kept];
  }
}

滑动窗口虽然简单,但它有一个重要的副作用:Agent 会「忘记」早期的对话内容。这可能导致 Agent 重复已经做过的操作,或者忘记用户早期的指令。为了缓解这个问题,滑动窗口会在丢弃消息时插入一个丢弃通知,提醒 Agent 可能丢失了上下文。

第五层:智能丢弃

最后一层压缩——智能丢弃——是最后的防线。它只在极端情况下使用(比如用户一次性粘贴了一个巨大的文件),目标是不惜一切代价将 Token 数控制在限制内。

// context/smart-discard.ts - 第五层:智能丢弃(简化还原)
// 目标:在极端情况下,不惜一切代价将 Token 数控制在限制内
// 信息损失:极高——只保留最关键的信息

class SmartDiscard {
  async compress(messages: Message[], maxTokens: number): Promise<Message[]> {
    // 策略 1:移除所有工具结果,只保留工具调用的摘要
    let result = this.removeAllToolResults(messages);
    
    if (this.countTokens(result) <= maxTokens) {
      return result;
    }

    // 策略 2:移除所有非最近一轮的消息
    result = this.keepOnlyLastTurn(result);
    
    if (this.countTokens(result) <= maxTokens) {
      return result;
    }

    // 策略 3:截断系统提示词(保留核心部分)
    result = this.truncateSystemPrompt(result, maxTokens);
    
    if (this.countTokens(result) <= maxTokens) {
      return result;
    }

    // 策略 4:最后手段——截断最近的消息
    result = this.truncateLastMessages(result, maxTokens);
    
    return result;
  }

  // 移除所有工具结果,用一行摘要替代
  private removeAllToolResults(messages: Message[]): Message[] {
    return messages.map(msg => {
      if (msg.role === 'assistant' && msg.content.some(c => c.type === 'tool_use')) {
        // 保留工具调用的名称和参数摘要
        return {
          ...msg,
          content: msg.content.map(c => {
            if (c.type === 'tool_use') {
              return {
                type: 'text',
                text: `[调用了工具: ${c.name}]`,
              };
            }
            return c;
          }),
        };
      }
      if (msg.role === 'user' && msg.content.some(c => c.type === 'tool_result')) {
        // 移除工具结果
        return {
          ...msg,
          content: msg.content
            .filter(c => c.type !== 'tool_result')
            .concat([{
              type: 'text',
              text: '[工具结果已被丢弃以节省空间]',
            }]),
        };
      }
      return msg;
    });
  }
}

智能丢弃是最后的手段。它会牺牲大量的信息来确保系统不会因为 Token 超限而崩溃。在实际使用中,这种情况很少发生——前四层压缩通常已经足够。

代码示例:压缩管线实现

让我们看看这五层压缩是如何被串联成一个完整的管线的:

// context/pipeline.ts - 五层压缩管线的完整实现(简化还原)
// 这是上下文管理的核心——五层压缩策略的编排器

class ContextCompressionPipeline {
  private trimmer: MessageTrimmer;
  private truncator: ToolResultTruncator;
  private summarizer: HistorySummarizer;
  private slidingWindow: SlidingWindow;
  private smartDiscard: SmartDiscard;

  constructor(private config: Config, private apiClient: APIClient) {
    this.trimmer = new MessageTrimmer();
    this.truncator = new ToolResultTruncator();
    this.summarizer = new HistorySummarizer(apiClient);
    this.slidingWindow = new SlidingWindow();
    this.smartDiscard = new SmartDiscard();
  }

  async compress(
    messages: Message[],
    maxTokens: number,
  ): Promise<CompressedContext> {
    let current = [...messages];
    let currentTokens = this.countTokens(current);
    const appliedLayers: string[] = [];

    // 如果没有超过限制,直接返回
    if (currentTokens <= maxTokens) {
      return {
        messages: current,
        tokens: currentTokens,
        layersApplied: [],
        originalTokens: currentTokens,
      };
    }

    const originalTokens = currentTokens;

    // ===== 第一层:消息裁剪 =====
    current = await this.trimmer.trim(current, maxTokens);
    currentTokens = this.countTokens(current);
    if (currentTokens <= maxTokens) {
      appliedLayers.push('trimming');
      return { messages: current, tokens: currentTokens, layersApplied: appliedLayers, originalTokens };
    }
    appliedLayers.push('trimming');

    // ===== 第二层:工具结果截断 =====
    current = await this.truncator.truncate(current, maxTokens);
    currentTokens = this.countTokens(current);
    if (currentTokens <= maxTokens) {
      appliedLayers.push('truncation');
      return { messages: current, tokens: currentTokens, layersApplied: appliedLayers, originalTokens };
    }
    appliedLayers.push('truncation');

    // ===== 第三层:历史摘要 =====
    // 注意:摘要需要调用 LLM,是最昂贵的操作
    current = await this.summarizer.summarize(current, maxTokens);
    currentTokens = this.countTokens(current);
    if (currentTokens <= maxTokens) {
      appliedLayers.push('summarization');
      return { messages: current, tokens: currentTokens, layersApplied: appliedLayers, originalTokens };
    }
    appliedLayers.push('summarization');

    // ===== 第四层:滑动窗口 =====
    current = await this.slidingWindow.compress(current, maxTokens);
    currentTokens = this.countTokens(current);
    if (currentTokens <= maxTokens) {
      appliedLayers.push('sliding-window');
      return { messages: current, tokens: currentTokens, layersApplied: appliedLayers, originalTokens };
    }
    appliedLayers.push('sliding-window');

    // ===== 第五层:智能丢弃 =====
    // 这是最后的防线,应该极少触发
    current = await this.smartDiscard.compress(current, maxTokens);
    currentTokens = this.countTokens(current);
    appliedLayers.push('smart-discard');

    // 记录警告——如果到了第五层,说明上下文管理需要优化
    console.warn(
      `[上下文警告] 触发了第五层压缩(智能丢弃)。\n` +
      `原始 Token 数: ${originalTokens}, 目标: ${maxTokens}, 最终: ${currentTokens}\n` +
      `已应用的压缩层: ${appliedLayers.join(' → ')}`
    );

    return { messages: current, tokens: currentTokens, layersApplied: appliedLayers, originalTokens };
  }
}

这个管线的设计体现了渐进式压缩的理念。每一层压缩都会尝试将 Token 数控制在限制内,只有当上一层不够时才会启用下一层。这种设计最大化了信息保留——大多数会话只需要前一两层压缩就足够了。

总结

Claude Code 的五层压缩管线是一个精心设计的上下文管理系统,它通过五种不同粒度的压缩策略,优雅地解决了 Token 爆炸问题。

关键设计决策包括:

  1. 渐进式压缩:从最温和的消息裁剪到最激进的智能丢弃,逐层递进
  2. 差异化策略:不同类型的内容(文件、命令输出、对话)使用不同的压缩策略
  3. 智能重要性评估:通过重要性分数决定保留哪些消息
  4. LLM 辅助摘要:使用小模型生成对话摘要,平衡成本和质量
  5. 可观测性:记录每层压缩的效果,便于监控和优化

这套管线的核心思想是**「信息保真度」**——在有限的 Token 预算内,保留尽可能多的有价值信息。这不仅是一个技术问题,更是一个关于「什么信息最重要」的认知问题。


参考资料

  1. Claude Code v2.1.88 源码分析 — 基于 2025 年 3 月泄露的 npm 包逆向分析
  2. Anthropic (2024). “Long Context Window” — https://docs.anthropic.com/en/docs/build-with-claude/context-windows — Claude 上下文窗口的官方文档
  3. Anthropic (2024). “Claude 3.5 Sonnet Technical Report” — https://www.anthropic.com/claude — 模型上下文能力的技术报告
  4. Xu, P. et al. (2023). “Retrieval meets Long Context Large Language Models” — arXiv 2023 — 长上下文管理的研究
  5. LangChain (2024). “Memory Management for Conversational Agents” — https://python.langchain.com/docs/modules/memory/ — 对比参考:LangChain 的内存管理方案

本文是「Claude Code 源码深度解析」系列的第五篇,也是上下文管理专题的完结篇。回顾整个系列,我们从 50 万行源码的全景开始,逐步深入到 Agent 主循环、启动链路、Prompt 工程和上下文管理,揭示了生产级 AI Agent 的工程全貌。


本系列覆盖 AI 大模型基础、Agent 开发、MCP 协议、Skill 开发、RAG、模型微调、部署推理 七大方向,从入门到实战的全栈内容持续更新中。

所有文章的 Markdown 源文件、可运行代码、高清配图已整理成完整资料包。

👍 点赞 + ⭐ 关注,评论区扣「1」,挨个发你领取方式 👇

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐