跳转到内容

Pattern: Context Window Management

每个 LLM 都有有限的 context window。Claude 的上限是 20 万 token,听起来很大 —— 直到一个 agent 已经运行了 20 分钟,不断读取文件、执行命令、积累对话历史。如果不加管理,context window 会被填满,agent 就死掉了

第 1 轮:系统 prompt(4K)+ 用户消息(0.5K) = 4.5K token
第 5 轮:+ 5 条助手消息 + 3 条 tool 结果 = 28K token
第 15 轮:+ 15 条助手消息 + 40 条 tool 结果 = 95K token
第 25 轮:+ 25 条助手消息 + 80 条 tool 结果 = 175K token ← 危险区域
第 30 轮:💥 超出 context window —— 对话失败

Claude Code 的 context 管理遵循三条不可违背的规则:

最近的消息最相关。压缩总是从最老的消息开始,向后推进。

一个 500 行的文件读取结果可以被摘要为 "[读取 /src/index.ts:500 行,TypeScript 模块,12 个导出]"。模型已经处理过了这些内容 —— 它只需要一个曾经存在的提示。

system prompt 永远不会被压缩、截断或修改。它包含身份、能力和行为指令,必须在整个对话中始终完整保留。

graph TB
    subgraph "Context Window (200K tokens)"
        SP["🔒 System Prompt<br/>~4K tokens<br/>NEVER compressed"]
        style SP fill:#fbbf24,stroke:#333

        subgraph "Compressible Zone"
            OLD["Old Messages<br/>Compressed first"]
            MID["Middle Messages<br/>Compressed second"]
            RECENT["Recent Messages<br/>Preserved as-is"]
        end

        style OLD fill:#94a3b8
        style MID fill:#cbd5e1
        style RECENT fill:#4ade80
    end

Claude Code 实现了渐进式四层压缩策略,每一层比上一层更激进。随着 context 增长,各层依次触发。

触发条件:单个 tool 结果超过大小阈值(例如 >10K 字符)。

动作:用 [snipped] 标记替换大型 tool 结果的中间部分,保留开头和结尾。

function snipToolResult(content: string, maxChars: number): string {
if (content.length <= maxChars) return content;
const headSize = Math.floor(maxChars * 0.3); // 保留开头 30%
const tailSize = Math.floor(maxChars * 0.3); // 保留结尾 30%
const head = content.slice(0, headSize);
const tail = content.slice(-tailSize);
const snipped = content.length - headSize - tailSize;
return `${head}\n\n[... ${snipped} characters snipped ...]\n\n${tail}`;
}

为什么同时保留头和尾? 对于代码文件,头部包含 import 和模块声明;尾部包含导出和最近读取的函数。两者都是高价值 context。

第二层:Microcompact(定向摘要)

Section titled “第二层:Microcompact(定向摘要)”

触发条件:总 context 超过窗口容量的约 60%。

动作:使用轻量级模型调用,将旧的 tool 结果摘要为单行描述。

interface MicrocompactResult {
original: ToolResultMessage;
summary: string;
tokensSaved: number;
}
async function microcompact(
toolResult: ToolResultMessage,
model: LLMClient,
): Promise<MicrocompactResult> {
const summary = await model.complete({
system: 'Summarize this tool result in ONE line. Include: tool name, key output, important values.',
messages: [{ role: 'user', content: toolResult.content }],
maxTokens: 100,
});
return {
original: toolResult,
summary: `[Compacted: ${summary}]`,
tokensSaved: countTokens(toolResult.content) - countTokens(summary),
};
}

示例

  • 压缩前(847 个 token):/src/utils/parser.ts 的完整文件内容
  • 压缩后(23 个 token):[Compacted: ReadFile /src/utils/parser.ts — 245 行 TypeScript 文件,导出 parseConfig()、validateSchema(),3 个类型定义]

第三层:Auto Compact(对话摘要)

Section titled “第三层:Auto Compact(对话摘要)”

触发条件:总 context 超过窗口容量的约 80%。

动作:将最老的 N 条消息替换为单条摘要消息。

async function autoCompact(
messages: Message[],
threshold: number,
model: LLMClient,
): Promise<Message[]> {
const totalTokens = countTotalTokens(messages);
if (totalTokens < threshold) return messages;
// 找出需要压缩多少条旧消息
const targetReduction = totalTokens - (threshold * 0.5); // 压缩到 50%
let tokensToCompress = 0;
let compactUpTo = 0;
for (let i = 0; i < messages.length; i++) {
tokensToCompress += countTokens(messages[i]);
compactUpTo = i;
if (tokensToCompress >= targetReduction) break;
}
// 摘要旧消息
const oldMessages = messages.slice(0, compactUpTo + 1);
const summary = await model.complete({
system: `Summarize this conversation segment. Preserve:
- Decisions made and their rationale
- Files modified and key changes
- Current task status and next steps
- Any errors encountered and resolutions`,
messages: [{ role: 'user', content: formatMessages(oldMessages) }],
maxTokens: 1000,
});
const summaryMessage: Message = {
role: 'user',
content: `[Conversation History Summary]\n${summary}\n[End Summary — recent messages follow]`,
};
return [summaryMessage, ...messages.slice(compactUpTo + 1)];
}

第四层:Hard Truncation(紧急丢弃)

Section titled “第四层:Hard Truncation(紧急丢弃)”

触发条件:context 超过窗口容量的约 95%(紧急情况)。

动作:完全丢弃最老的消息,只保留 system prompt 和最近的消息。

function hardTruncate(
messages: Message[],
maxTokens: number,
systemPromptTokens: number,
): Message[] {
const budget = maxTokens - systemPromptTokens - 1000; // 1K 安全裕量
const kept: Message[] = [];
let usedTokens = 0;
// 从最新消息向前遍历
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = countTokens(messages[i]);
if (usedTokens + msgTokens > budget) break;
kept.unshift(messages[i]);
usedTokens += msgTokens;
}
return kept;
}
graph LR
    subgraph "Context 使用率"
        A["0-40%<br/>无操作"] --> B["40-60%<br/>第一层:Snip"]
        B --> C["60-80%<br/>第二层:Microcompact"]
        C --> D["80-95%<br/>第三层:Auto Compact"]
        D --> E["95%+<br/>第四层:Hard Truncate"]
    end

    style A fill:#4ade80
    style B fill:#a3e635
    style C fill:#facc15
    style D fill:#fb923c
    style E fill:#ef4444
interface CompressionThresholds {
// 第一层:截断单个结果
snipMaxChars: number; // 默认:每个结果 10_000 字符
// 第二层:对旧结果进行 microcompact
microcompactTrigger: number; // 默认:0.6(窗口的 60%)
// 第三层:对对话进行 auto compact
autoCompactTrigger: number; // 默认:0.8(窗口的 80%)
// 第四层:紧急截断
hardTruncateTrigger: number; // 默认:0.95(窗口的 95%)
// 窗口容量
maxContextTokens: number; // Claude 默认:200_000
}
const defaultThresholds: CompressionThresholds = {
snipMaxChars: 10_000,
microcompactTrigger: 0.6,
autoCompactTrigger: 0.8,
hardTruncateTrigger: 0.95,
maxContextTokens: 200_000,
};
async function manageContext(
messages: Message[],
systemPrompt: string,
thresholds: CompressionThresholds,
model: LLMClient,
): Promise<Message[]> {
let managed = [...messages];
const systemTokens = countTokens(systemPrompt);
// 第一层:截断过大的 tool 结果(始终激活)
managed = managed.map(msg => {
if (isToolResult(msg) && msg.content.length > thresholds.snipMaxChars) {
return { ...msg, content: snipToolResult(msg.content, thresholds.snipMaxChars) };
}
return msg;
});
const totalTokens = () => systemTokens + countTotalTokens(managed);
const usage = () => totalTokens() / thresholds.maxContextTokens;
// 第二层:对旧 tool 结果进行 microcompact
if (usage() > thresholds.microcompactTrigger) {
const cutoff = Math.floor(managed.length * 0.5); // 压缩最老的 50%
for (let i = 0; i < cutoff; i++) {
if (isToolResult(managed[i]) && !isAlreadyCompacted(managed[i])) {
const compacted = await microcompact(managed[i], model);
managed[i] = { ...managed[i], content: compacted.summary };
}
}
}
// 第三层:对对话进行 auto compact
if (usage() > thresholds.autoCompactTrigger) {
managed = await autoCompact(managed, thresholds.autoCompactTrigger * thresholds.maxContextTokens, model);
}
// 第四层:紧急截断
if (usage() > thresholds.hardTruncateTrigger) {
managed = hardTruncate(managed, thresholds.maxContextTokens, systemTokens);
}
return managed;
}
内容类型优先级压缩行为
System prompt🔒 神圣永不触碰
最近 3 条用户消息🔴 关键永不压缩
最后一条助手消息🔴 关键永不压缩
近期 tool 结果(最近 5 条)🟡 高过大则 snip
旧 tool 结果🟢 低Microcompact → 丢弃
旧助手消息🟢 低摘要 → 丢弃
错误消息🟡 中保留更长(调试价值)
文件内容(大型读取)🔵 最低最先 snip/compact
// ============================================
// 可复用 Context Window 管理器
// ============================================
interface ContextManager {
add(message: Message): void;
getMessages(): Message[];
getUsage(): { tokens: number; percentage: number };
compact(): Promise<void>;
}
function createContextManager(
maxTokens: number,
model: LLMClient,
options?: Partial<CompressionThresholds>,
): ContextManager {
const messages: Message[] = [];
const thresholds = { ...defaultThresholds, maxContextTokens: maxTokens, ...options };
return {
add(message: Message) {
// 插入时自动 snip
if (isToolResult(message) && message.content.length > thresholds.snipMaxChars) {
message = { ...message, content: snipToolResult(message.content, thresholds.snipMaxChars) };
}
messages.push(message);
},
getMessages() {
return [...messages];
},
getUsage() {
const tokens = countTotalTokens(messages);
return { tokens, percentage: tokens / maxTokens };
},
async compact() {
const managed = await manageContext(messages, '', thresholds, model);
messages.length = 0;
messages.push(...managed);
},
};
}

基于对 Claude Code 长会话行为的分析:

会话时长:45 分钟,32 轮
不压缩:
累计 token 总量:287K ← 将超过 20 万 token 窗口
会话将在第 22 轮失败
使用四层压缩:
第一层(Snip): 287K → 198K(从大文件读取中节省 89K)
第二层(Microcompact):198K → 142K(从旧 tool 结果中节省 56K)
第三层(Auto Compact):142K → 89K(从对话摘要中节省 53K)
第四层(Hard Truncate):未触发
最终 context:89K token(窗口的 44%)
会话成功完成 ✅
graph LR
    subgraph "质量-节省权衡"
        S["Snip<br/>质量:95%<br/>节省:30%"] --> M["Microcompact<br/>质量:85%<br/>节省:60%"]
        M --> A["Auto Compact<br/>质量:70%<br/>节省:80%"]
        A --> H["Hard Truncate<br/>质量:40%<br/>节省:95%"]
    end

    style S fill:#4ade80
    style M fill:#a3e635
    style A fill:#facc15
    style H fill:#ef4444

每一层都以更多的质量换取更多的节省。渐进式设计意味着只有在绝对必要时才付出质量代价。

聊天机器人系统

任何积累历史的长对话。没有压缩,聊天会话都有硬上限。

Agent 框架

LangChain、AutoGen、CrewAI —— 任何积累 tool 结果的框架都需要压缩策略。

文档处理

分块处理大型文档的系统。每个块的输出最终都需要压缩。

多轮推理

需要大量来回的复杂推理任务(如代码审查、调试)。

反模式为何失败更好的做法
”换个更大的模型就行”context window 有硬上限;成本随 token 平方增长主动压缩
对所有内容等量压缩近期 context 比旧 context 更有价值渐进式、按近期权重处理
快满时才压缩那时已经太晚;紧急截断损失质量在 60% 容量时就开始
永不压缩 system prompt✅ 这是正确的继续保持
用主模型做摘要费用高昂;用的是你想节省的那个 context使用更小、更快的模型