Harness Engineering

第11章 短期记忆:上下文窗口管理

作者 杨艺韬 · 11,513 字

第11章 短期记忆:上下文窗口管理

“An agent is only as smart as the context it can see.” — Claude Code 团队

“上下文窗口不是容量,是注意力——塞得越满,每个 token 受到的关注越少。” —— 杨艺韬

本章要点

  • 上下文窗口 = 工作记忆——窗口外的信息对模型不存在
  • Token 预算是零和博弈——System/Tools/History/Results/Response 互相竞争
  • 有效上下文 < 名义上下文——200K 窗口的有效长度通常只有 60-100K
  • 自动压缩 是长对话的唯一出路——Claude Code 的接近上限自动摘要策略
  • 工具结果是最大消耗源——按需读取(offset/limit)+ 截断 + 摘要 + 渐进衰减
  • Lost in the Middle 效应——重要信息放开头和结尾,避免淹没在中间
  • Prompt Caching:重复部分缓存 5 分钟,成本降低 90%

11.1 上下文窗口即工作记忆:生理学类比

人类的工作记忆容量约为 7±2 个信息块(Miller, 1956)。LLM 的”工作记忆”就是上下文窗口——一个固定大小的 token 缓冲区。模型在生成每个 token 时,只能”看到”这个缓冲区中的信息。

graph TD
    subgraph Window["上下文窗口 (200K tokens)"]
        direction TB
        SP["System Prompt<br/>~5K tokens"] --> TD["Tool Definitions<br/>~8K tokens"]
        TD --> DI["Dynamic Injection<br/>~3K tokens"]
        DI --> CH["Conversation History<br/>~80K tokens"]
        CH --> TR["Current Tool Results<br/>~50K tokens"]
        TR --> RS["Reserved for Response<br/>~50K tokens"]
    end

    Outside["窗口外的信息<br/>❌ 对模型不存在"] -.->|"不可见"| Window

    style Window fill:#f0f4ff,stroke:#3b82f6,stroke-width:2px
    style Outside fill:#fee2e2,stroke:#ef4444,stroke-dasharray:5

关键认知:窗口外的一切,对模型来说不存在。 上一轮读过但被压缩掉的文件内容、三天前的对话记录、没有显式注入的项目文档——模型一概不知。

这意味着上下文管理不是一个可选的优化——它是 Agent 能否工作的基础

Claude Code 的真实数字:getEffectiveContextWindowSize

打开 src/services/compact/autoCompact.ts:30 可以看到这不是抽象概念——

const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
// Based on p99.99 of compact summary output being 17,387 tokens.

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  return contextWindow - reservedTokensForSummary
}

两个值得深究——

  • 20_000 不是拍脑袋——注释明确写”p99.99 of compact summary output being 17,387 tokens”——是生产数据驱动的:99.99% 的压缩摘要输出都不超过 17,387、留 20K 给它即可
  • contextWindow - reservedTokensForSummary——意味着 Claude Code 从不用满整个窗口——永远给压缩留着”退路”——这是工业级 Agent 的共识:窗口不是用满的、是留余地的

名义窗口 vs 有效窗口

一个容易被忽视的事实:模型的”名义上下文长度”和”有效上下文长度”是两个概念

模型形态名义窗口工程上要预留的空间差距原因
百 K 级窗口厂商标称的上下文上限系统提示词、工具定义、响应输出、压缩缓冲Lost in the Middle 效应
长对话窗口可以装入更多历史近期状态、工作记忆、关键约束重申长对话注意力分散
超长上下文窗口可以装入大量资料检索排序、分块摘要、引用定位远距离引用的召回下降

有效窗口指的是模型能稳定利用其中信息的长度。超过有效窗口后,信息会被”稀释”——放在里面但模型关注不到,等同于没放。

工程实践含义:

  • 标称窗口不是全部可用工作区——必须先扣掉响应空间、系统开销和压缩缓冲
  • 不要依赖超远距离的上下文——关键信息要近期提供
  • 长对话要主动压缩——不能让历史无限膨胀

11.2 Token 预算分配:零和博弈

上下文窗口是零和博弈:一个组件占用越多,其他组件可用的就越少。

六个组件的竞争

graph TD
    Window[上下文窗口 200K]
    Window --> C1[System Prompt<br/>~5K, 静态]
    Window --> C2[Tool Definitions<br/>~8K, 静态]
    Window --> C3[Dynamic Injection<br/>~3K, 会话级]
    Window --> C4[Conversation History<br/>持续增长]
    Window --> C5[Tool Results<br/>波动最大]
    Window --> C6[Response Reserve<br/>必须预留]

    C4 -.-> Compress[压缩机制]
    C5 -.-> Truncate[截断/摘要]

    style Window fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
    style C4 fill:#fef3c7,stroke:#f59e0b
    style C5 fill:#fecaca,stroke:#dc2626
    style C6 fill:#dcfce7,stroke:#22c55e

预算分配的动态性

graph LR
    subgraph Early["会话早期"]
        E1["System: 5K"] --- E2["Tools: 8K"]
        E2 --- E3["History: 2K"]
        E3 --- E4["Tool Results: 30K"]
        E4 --- E5["Response: 50K"]
    end
    subgraph Late["会话后期 (30轮后)"]
        L1["System: 5K"] --- L2["Tools: 8K"]
        L2 --- L3["History: 120K"]
        L3 --- L4["Tool Results: 15K"]
        L4 --- L5["Response: 50K"]
    end

    Early -->|"对话历史持续增长"| Late

会话早期,对话历史很短,可以给工具结果更多空间。会话后期,对话历史膨胀到 120K,工具结果和响应空间都受到挤压。

组件典型占比特点优化手段
System Prompt3-5%相对固定Prompt Caching
Tool Definitions4-10%工具越多越大延迟加载(Deferred Tools)
动态上下文1-3%每会话不同精简注入
对话历史30-60%持续增长自动压缩
工具结果10-30%波动最大截断 / 摘要
响应空间15-30%必须预留不可压缩

最危险的场景:临界溢出

用户对话了 30 轮后,让 Agent 读一个 5000 行的文件,然后期望模型给出详细的修改建议。此时:

对话历史: 120K tokens (已接近上限)
+ 文件内容: 20K tokens (一个大文件)
+ 系统提示: 5K tokens
+ 工具定义: 8K tokens
= 153K tokens
预留响应: 50K tokens
───────────────────
总计需要: 203K tokens > 200K 上限!

解决方案:

  1. 在读文件前触发压缩(让对话历史收缩到 50K)
  2. 分段读文件(使用 offset + limit,每次只读一部分)
  3. 用 Glob + Grep 先精确定位目标,再 Read 具体片段

Claude Code 的”四档阈值”设计

../claude-code-main/src/services/compact/autoCompact.ts:62-70 定义了四个 buffer 常量和连续失败熔断——这是真实生产系统的阈值体系

export const AUTOCOMPACT_BUFFER_TOKENS = 13_000       // 自动压缩触发
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000 // UI 黄色警告
export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000   // UI 红色错误
export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000     // /compact 命令阻塞限

为什么是 13K、20K、3K 这些数字——

  • 13K 留给 “auto-compact 自己跑完”——压缩流程会吃掉一次 LLM 调用的 input + output、13K 足够一轮短摘要调用
  • 20K 是 UI 警告线——比 auto-compact 更早触发、让用户看到”对话快满了”、有机会手动收尾
  • 3K 是手动 /compact 的下限——用户主动压缩时、只要还有 3K 就能跑——比 auto-compact 宽松 4×

这种”分档”思维——比”一个阈值打天下”成熟得多——不同的触发路径需要不同的 headroom

Circuit breaker:MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

../claude-code-main/src/services/compact/autoCompact.ts:67-70 有一条罕见的生产事故痕迹

// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

注释里白纸黑字记录了一次真实事件——

  • 2026-03-10 的 BigQuery 查询显示:1279 个会话出现 50+ 次连续压缩失败、最高的一个连失败 3272 次
  • 日损耗:注释记录了约 250K 次 API 调用/天;具体美元成本取决于当时模型、输入长度和缓存命中,正文不替它外推
  • 根因:某种 context 已经不可恢复地超限(比如 prompt_too_long 错误),继续重试只是纯亏损
  • 修复:熔断——连续失败 3 次就停手

任何”重试机制”都必须配熔断——这条规则从数据库连接池、HTTP client、到 LLM 压缩都一样——源码里的注释是血泪教训的浓缩

11.3 对话历史管理:四种策略对比

策略 1: 完整历史(不可持续)

保留所有消息。适合极短对话(< 10 轮),长对话必然溢出。

// 最简单的实现——直到 token 爆炸
const messages: Message[] = []
messages.push({ role: 'user', content: userInput })
// ... 永远不删除旧消息
// 到某一轮 → 409 Payload Too Large → 崩溃

策略 2: 滑动窗口(信息丢失)

只保留最近 N 轮对话,丢弃更早的消息:

function slidingWindow(messages: Message[], maxTokens: number): Message[] {
  let totalTokens = 0
  const result: Message[] = []

  for (let i = messages.length - 1; i >= 0; i--) {
    const tokens = countTokens(messages[i])
    if (totalTokens + tokens > maxTokens) break
    totalTokens += tokens
    result.unshift(messages[i])
  }

  return result
}

致命问题:用户在第 3 轮提到的关键需求(“这个项目用 PostgreSQL”),到第 20 轮时已经被滑出窗口。模型会”忘记”最初的任务目标,开始推荐 MongoDB 方案。

策略 3: 摘要压缩(Claude Code 推荐)

flowchart TD
    A["对话历史<br/>30轮, 120K tokens"] --> B{"使用率 > 85%?"}
    B -->|否| C[继续正常对话]
    B -->|是| D[触发自动压缩]
    D --> E["选取旧消息<br/>(保留最近 5 轮)"]
    E --> F["用 LLM 生成摘要<br/>保留: 关键决策, 文件路径,<br/>用户偏好, 未完成任务"]
    F --> G["用摘要替换旧消息<br/>30轮 120K → 摘要 3K + 5轮 15K"]
    G --> H["释放 ~100K tokens"]
    H --> C

压缩流程的递归陷阱:forked agent 必须显式排除

autoCompact.ts:162-165 有一条看似不起眼的判断:

if (querySource === 'session_memory' || querySource === 'compact') {
  return false  // Recursion guards
}

为什么——session_memorycompact 本身就是 forked agent(见本书第 12 章长期记忆)——它们的 context 是从父 agent 继承的——如果它们的 context 也触发 auto-compact,就会递归 fork、永远不停

再往下 autoCompact.ts:170-176 还有更微妙的一条:

// marble_origami is the ctx-agent — if ITS context blows up and
// autocompact fires, runPostCompactCleanup calls resetContextCollapse()
// which destroys the MAIN thread's committed log (module-level state
// shared across forks).
if (feature('CONTEXT_COLLAPSE')) {
  if (querySource === 'marble_origami') { ... }
}

翻译——有一个叫 marble_origami 的 ctx-agent、它的压缩动作会错误地调 resetContextCollapse()——清空主线程的日志(因为用的是 module-level 共享状态)——必须单独判空

这两条源码的启示——任何”共享了父 context 的子 agent”都不能走标准 compact 路径——共享状态永远是 bug 的温床

压缩的 prompt 本身是件工程作品

src/services/compact/prompt.ts 374 行——Claude Code 的压缩 prompt 本身就是一本教科书。核心结构:

const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.`

三个值得注意——

  • CRITICAL 大写 + 放最前面——源码注释承认:“cache-sharing fork path inherits the parent’s full tool set (required for cache-key match)“——模型能看到所有工具定义——必须用最强语气压制模型”调工具”的冲动
  • 源码里的数字../claude-code-main/src/services/compact/prompt.ts:12-18 记录了 Sonnet 4.6 与 4.5 在压缩请求中误触工具的比例差异——新模型不一定更听话——源码版本号要永远记录当前模型的行为证据
  • maxTurns: 1 + NO_TOOLS_PREAMBLE 组合——一次机会、必须产出文本——工程层面兜底模型不可控性

Claude Code 的压缩策略:

async function compactConversation(
  messages: Message[],
  targetTokens: number
): Promise<Message[]> {
  const KEEP_RECENT = 6  // 保留最近 6 条消息

  const toCompress = messages.slice(0, -KEEP_RECENT)
  const recent = messages.slice(-KEEP_RECENT)

  // 用一次独立的 LLM 调用来生成摘要
  const summary = await llm.complete({
    system: 'You are a conversation summarizer.',
    messages: [{
      role: 'user',
      content: `Summarize this conversation, preserving:
- Key decisions and their rationale
- File paths that were read or modified
- Any user preferences expressed
- Pending/incomplete tasks
- Error messages and their resolutions

Conversation to summarize:
${formatMessages(toCompress)}`
    }]
  })

  return [
    {
      role: 'assistant',
      content: `[Conversation compacted]\n\nSummary of earlier discussion:\n${summary}`
    },
    ...recent
  ]
}

<analysis><summary> 的双块设计

prompt.ts 里压缩模板要求模型先写 <analysis>、再写 <summary>——两个 XML 块顺序固定——然后formatCompactSummary 里 strip 掉 <analysis>、只把 <summary> 放回上下文。

为什么要 <analysis>——

  • 给模型一个 scratchpad——先”想清楚”再”总结”——chain-of-thought 思路
  • 避免强行总结导致遗漏——如果直接要 <summary>、模型会跳过”哪些内容不应该丢”的思考
  • 但 scratchpad 不能进 context——否则压缩的”省空间”目标就废了——strip 是必须的

这个设计和本书第 18 章讲的”tool-result-scratchpad 模式”同源——让模型有思考空间、但不让思考污染工作台

九字段的摘要结构 = Claude Code 的”状态快照”

prompt.ts 要求摘要严格包含 9 个字段

  1. Primary Request and Intent
  2. Key Technical Concepts
  3. Files and Code Sections
  4. Errors and fixes
  5. Problem Solving
  6. All user messages(关键:列出所有非 tool-result 的 user 消息)
  7. Pending Tasks
  8. Current Work
  9. Optional Next Step(要求引用最近对话的原文、防止任务漂移)

每个字段都有存在理由——

  • 第 6 个字段”All user messages”是最被低估的——用户原话里藏着意图变化、摘要时常丢
  • 第 9 个字段要”direct quotes”——不要自己改写下一步——防止”摘要版”和”原始版”语义偏移
  • 整个 9 字段 = Agent 运行状态的最小完备快照——抄作业可以直接用这 9 条

摘要保留什么、丢弃什么是关键设计决策:

保留(有长期价值)丢弃(已过期)
用户的原始需求中间的调试试错过程
已做出的关键决策被否决的方案细节
已修改的文件列表完整的文件内容
未完成的任务已完成任务的执行细节
用户表达的偏好格式化的工具输出
错误和解决方案重复的问答

策略 4: 分层记忆(工业级)

最先进的方案——维护多个层级的记忆:

graph TD
    Conv[完整对话]
    Conv --> L1[L1: 原始消息<br/>最近 6 轮 + 活跃工具调用]
    Conv --> L2[L2: 摘要<br/>中期历史的压缩版]
    Conv --> L3[L3: 核心事实<br/>项目状态/用户偏好/关键决策]
    Conv --> L4[L4: 长期记忆<br/>跨会话持久化]

    L1 -->|"超过阈值"| Compress1[压缩到 L2]
    L2 -->|"再次压缩"| Extract[提取到 L3]
    L3 -->|"会话结束"| L4

    style L1 fill:#fef3c7,stroke:#f59e0b
    style L2 fill:#dbeafe,stroke:#3b82f6
    style L3 fill:#dcfce7,stroke:#22c55e
    style L4 fill:#f3e8ff,stroke:#a855f7

每一层有不同的粒度和保留策略:

  • L1(原始):完整消息,用于连续推理
  • L2(摘要):段落级描述,用于中期回溯
  • L3(事实):结构化关键信息,永不丢弃
  • L4(长期):跨会话持久化,属于第 12 章的主题

11.4 工具结果管理:最大的 Token 消耗源

工具结果是上下文中波动最大的组件。一次 Read 可能返回 2000 行代码(~8K tokens),一次 Bash 可能输出几十 KB 的日志。这里的管理不当是上下文溢出的头号原因。

按需读取:从源头减少

最好的优化是一开始就不读那么多。Claude Code 的 Read 工具天然支持按需读取:

// ❌ 读整个大文件
const result = await readTool.execute({
  file_path: '/src/huge-file.ts',  // 10000 行!
})  // 可能返回 40K tokens

// ✅ 按需读取特定范围
const result = await readTool.execute({
  file_path: '/src/huge-file.ts',
  offset: 100,     // 从第 100 行开始
  limit: 50,       // 只读 50 行
})  // 返回约 200 tokens

工具设计的关键原则:工具的 API 要引导用户精确获取。如果工具只支持”读整个文件”,用户就会滥用。

截断策略:头尾保留

当已经读到超长内容时,截断而不是全部接受:

function truncateToolResult(result: string, maxTokens: number): string {
  const tokens = countTokens(result)
  if (tokens <= maxTokens) return result

  // 保留头部和尾部,中间省略
  const headTokens = Math.floor(maxTokens * 0.6)  // 头部 60%
  const tailTokens = Math.floor(maxTokens * 0.3)   // 尾部 30%
  // 剩余 10% 给省略提示

  const head = takeFirstNTokens(result, headTokens)
  const tail = takeLastNTokens(result, tailTokens)
  const omitted = tokens - headTokens - tailTokens

  return `${head}\n\n... [${omitted} tokens truncated] ...\n\n${tail}`
}

为什么头尾保留?——头部通常包含结构信息(imports、头注释),尾部通常包含最新状态(最近修改的代码)。中间的”正文”反而可以省略。

结构化摘要:从原始数据到信息

比截断更智能——不是砍掉内容,而是提取关键信息

function summarizeCommandOutput(
  output: string,
  exitCode: number,
  command: string
): string {
  // 失败时:保留错误行
  if (exitCode !== 0) {
    const errorLines = output.split('\n')
      .filter(l => /error|Error|FAIL|panic|exception|traceback/i.test(l))
    return `Command failed (exit ${exitCode}):\n${errorLines.slice(0, 20).join('\n')}`
  }

  // 成功但输出很长时:保留首尾 + 统计
  const lines = output.split('\n')
  if (lines.length > 100) {
    return [
      `Command succeeded (${lines.length} lines output).`,
      `First 5 lines:`,
      ...lines.slice(0, 5),
      `...`,
      `Last 5 lines:`,
      ...lines.slice(-5),
    ].join('\n')
  }

  return output
}

不同工具的摘要策略不同:

工具类型摘要策略
Read(文件)按需读取,天然控制
Bash(成功)头 5 + 尾 5 + 行数
Bash(失败)提取错误行
Grep(匹配)只保留有匹配的文件路径
WebFetch提取主正文,去除导航/广告
SQL 查询只保留前 N 行 + 总数

microCompact:只针对工具结果的精打细算

src/services/compact/microCompact.ts 530 行——Claude Code 特意把”工具结果压缩”和”对话压缩”分开。相关常量:

const COMPACTABLE_TOOLS = new Set<string>([
  FILE_READ_TOOL_NAME, ...SHELL_TOOL_NAMES, GREP_TOOL_NAME, GLOB_TOOL_NAME,
  WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_WRITE_TOOL_NAME,
])
const IMAGE_MAX_TOKEN_SIZE = 2000
const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]'

三个决策——

  • 白名单而不是黑名单——只有明确可以安全压缩的工具才参与 microcompact——TaskCreate / TaskUpdate 等状态工具永不动
  • 图像固定 2000 tokens 上限——不管图多大、先 resize——图像 token 不走压缩路径、因为视觉信息的”部分保留”没意义
  • 时间衰减用占位符替换——不是删、是把旧工具结果替换成 [Old tool result content cleared]——模型看到占位符知道”这里原来有内容、只是被清了”、不会混淆

微压缩 vs 全量压缩的边界——

维度microCompact全量 compact
触发工具结果累积 > 阈值整体 token 使用 > 阈值
作用域只改 tool_result 块改整段 history
成本不需要 LLM 调用(纯规则)需要一次 summarizer LLM 调用
保真度高(结构保留)低(段落级压缩)

生产经验——先微压缩、再全量压缩——能规则解决的不要 LLM省钱

snipTokensFreed 参数的”会计对齐”

autoCompact.ts:148-152shouldAutoCompact 函数签名带一个奇怪的参数:

snipTokensFreed = 0,  // Snip removes messages but the surviving assistant's
                      // usage still reflects pre-snip context, so
                      // tokenCountWithEstimation can't see the savings.

拆开看——

  • 用户 snip(剪掉)了部分消息——实际 context 变小了
  • 但 API 返回的 usage.input_tokens 还是snip 前的数字——API 不知道 snip 发生了
  • tokenCountWithEstimation 用的是 API 返回值——看不到 snip 省下的 token
  • 传入 snipTokensFreed 作为人工修正——对齐估算和实际

这是一种”会计平衡”——“估算”和”实际”永远可能漂移、要有手动对账接口——本书第 19 章讲 trace 时提到的”cache_read_input_tokens 占比”也是类似概念——所有”按需计费的系统”都需要多套计量交叉验证

历史工具结果的渐进衰减

随着对话进行,早期工具结果的价值递减:

graph LR
    T1[第 1 轮<br/>完整保留<br/>~8K tokens] --> T5[第 5 轮<br/>结构化摘要<br/>~1K tokens]
    T5 --> T15[第 15 轮<br/>元信息<br/>~100 tokens]
    T15 --> T25[第 25 轮<br/>并入整体摘要<br/>~50 tokens]

    style T1 fill:#fecaca,stroke:#dc2626
    style T5 fill:#fef3c7,stroke:#f59e0b
    style T15 fill:#dbeafe,stroke:#3b82f6
    style T25 fill:#dcfce7,stroke:#22c55e

工程实现:

function decayedHistory(history: Turn[]): Turn[] {
  const now = history.length
  return history.map((turn, idx) => {
    const age = now - idx  // 多少轮之前

    if (age <= 3) {
      return turn  // 最近 3 轮完整保留
    } else if (age <= 10) {
      return summarizeTurn(turn, "medium")  // 中等摘要
    } else if (age <= 20) {
      return summarizeTurn(turn, "brief")   // 简短摘要
    } else {
      return extractMetadata(turn)          // 只保留元信息
    }
  })
}

11.5 Prompt Caching:复用静态部分

2024 年 Anthropic 推出的 Prompt Caching 是上下文管理的游戏规则改变者。

原理

请求中重复出现的部分(如 System Prompt、Tool Definitions)可以被服务端缓存 5 分钟。后续请求命中缓存时:

  • 成本降低 90%(缓存读取 vs 完整处理)
  • 延迟大幅降低(不用重新编码)
const response = await anthropic.messages.create({
  model: "claude-opus-4-7",
  system: [
    {
      type: "text",
      text: SYSTEM_PROMPT,  // 静态部分
      cache_control: { type: "ephemeral" },  // 标记缓存
    }
  ],
  tools: TOOLS.map(t => ({
    ...t,
    cache_control: { type: "ephemeral" },  // 工具也缓存
  })),
  messages: [
    // 动态部分 - 不缓存
    ...conversationHistory
  ],
})

缓存命中的条件

  1. 完全一致——字节级别相同(包括空格、顺序)
  2. 5 分钟 TTL——最后一次访问后 5 分钟失效
  3. 按 block 匹配——每个 cache_control 标记的 block 独立缓存
  4. 最长前缀匹配——改了后面的内容不影响前面的缓存

工程含义

不缓存:
  每轮成本 = input_tokens × input_price + output_tokens × output_price

缓存后:
  每轮成本 =
    fresh_input_tokens × input_price
    + cache_read_tokens × cache_read_price
    + cache_write_tokens × cache_write_price
    + output_tokens × output_price

长对话场景的关键不是背一个固定节省比例,而是让 provider usage 里持续出现较高的 cache_read_input_tokens,同时避免频繁 cache_creation_input_tokens 抵消收益。

这是过去两年 AI Agent 经济性的最大变革——没有 Prompt Caching,长对话 Agent 基本不可商用。

压缩和 Prompt Cache 的冲突管理

autoCompact.ts 引入了 notifyCompaction(来自 api/promptCacheBreakDetection.js)——压缩触发时要通知 prompt cache 系统

为什么——

  • Prompt Cache 命中要求字节级前缀相等(本书第 20 章§20.4.7)
  • 一旦压缩发生、历史消息从”30 轮完整”变成”1 条摘要 + 最近 6 条”——整个前缀变了、所有缓存作废
  • 不通知的话——下次请求全缓存 miss 用户不知道、成本悄悄翻 10 倍

通知的作用——

  • UI 显示”压缩已完成、cache 已重新建立”
  • 告警系统记录一次”intentional cache break”(区分于”意外 cache miss”)
  • 下一轮请求的预期 cost 提前告知用户

压缩是”空间换钱”——但若不管 cache 就是”空间换钱换掉更多钱”**——任何状态变更都要考虑下游的缓存影响

11.6 工作记忆模式:显式状态 > 隐式推断

显式维护一个”工作记忆”块,比让模型从完整对话历史中自行提取状态更可靠、更省 token:

interface WorkingMemory {
  currentTask: string           // 当前在做什么
  keyDecisions: string[]        // 重要决策记录
  modifiedFiles: string[]       // 已修改的文件列表
  pendingActions: string[]      // 待完成的操作
  userPreferences: string[]     // 本次对话中表达的偏好
  errors: ErrorRecord[]         // 遇到的错误和解决方案
}

// 每次调用模型前注入
function injectWorkingMemory(memory: WorkingMemory): string {
  return `## Current Working State
Task: ${memory.currentTask}
Files modified: ${memory.modifiedFiles.join(', ')}
Pending: ${memory.pendingActions.join('; ')}
User preferences: ${memory.userPreferences.join('; ')}
${memory.errors.length > 0 ? `Known issues: ${memory.errors.map(e => e.summary).join('; ')}` : ''}`
}

Claude Code 的 TaskCreate/TaskUpdate 工具本质上就是这种模式——它让模型显式地管理任务列表,而非依赖对话历史中的隐式状态。

flowchart LR
    subgraph Implicit["隐式状态 (从历史推断)"]
        I1[30 轮对话<br/>120K tokens] --> I2[模型自行推断<br/>当前进度<br/>⚠️ 可能错]
    end
    subgraph Explicit["显式状态 (工作记忆)"]
        E1[工作记忆<br/>~500 tokens] --> E2[模型直接读取<br/>当前进度<br/>✅ 确定性]
    end

    Implicit ---|不可靠<br/>高 token| VS[vs]
    Explicit ---|可靠<br/>低 token| VS

    style Explicit fill:#dcfce7,stroke:#22c55e,stroke-width:2px
    style Implicit fill:#fee2e2,stroke:#ef4444

工作记忆的设计原则

  1. 结构化——字段明确、易解析
  2. 精简——只放决策必需的信息,不放流程细节
  3. 可查询——Agent 可以主动读取”当前 task 是什么”
  4. 可更新——Agent 能通过工具主动修改
  5. 持久化——会话结束时保存到长期记忆

11.7 上下文窗口经济学

更大的上下文 ≠ 更好的 Agent。原因:

成本线性增长

200K token 的请求比 20K 贵 10 倍。对于多轮调用的 Agent,成本差异更大——每轮都在累积。

场景:一个多轮长对话 Agent

简单实现(无压缩):
  每轮 input 随历史增长
  总 input 近似等于每轮历史长度的累加

带压缩 + Prompt Caching:
  旧历史被摘要替换
  稳定前缀进入 cache read
  fresh input 只包含最新消息和必要工具结果

延迟增加

更多的 input token 意味着更长的首 token 延迟(TTFT)。用户感知到的”Agent 思考时间”直接受影响。

不要在这里写固定 TTFT。首 token 延迟会受到模型、区域、缓存命中、网络和服务端排队共同影响。工程上应该记录 input_tokenscache_read_tokenscache_write_tokensttft_ms 四个字段,再按缓存命中和未命中两类分别看分位数。

Prompt Caching 能显著改善这一点——缓存命中部分不需要重新编码。

“Lost in the Middle” 效应

学术研究(Liu et al., 2023, “Lost in the Middle”)发现:当上下文很长时,模型对中间部分内容的注意力会显著下降。开头和结尾的信息被更好地利用。

graph LR
    subgraph Attention["注意力分布曲线"]
        Start[开头<br/>较高召回] --> Middle[中间<br/>召回下降]
        Middle --> End[结尾<br/>较高召回]
    end

    style Start fill:#dcfce7,stroke:#22c55e
    style Middle fill:#fee2e2,stroke:#ef4444,stroke-width:2px
    style End fill:#dcfce7,stroke:#22c55e

这意味着:

  • 重要信息应该放在上下文的开头(系统提示)或末尾(最近的消息)
  • 中间的对话历史越长,信息利用率越低
  • 这是压缩历史消息的又一个理由
  • 关键决策信息要在最近一轮里显式重申

实践原则:“SELECT *” 反模式

不要问"能放多少进去"
要问"最少需要放什么进去"

一个高效的 Agent 应该像好的 SQL 查询——只获取需要的数据(SELECT column1, column2 WHERE condition),而不是 SELECT *

11.8 完整实现:生产级上下文管理器

class ProductionContextManager {
  private config: ContextConfig
  private summarizer: LLMSummarizer
  private tokenCounter: TokenCounter

  constructor(config: ContextConfig) {
    this.config = config  // { maxContext, reserveResponse, systemTokens, compactThreshold }
    this.summarizer = new LLMSummarizer(config.summarizerModel)
    this.tokenCounter = new TokenCounter(config.encoding)
  }

  get availableForHistory(): number {
    return this.config.maxContext
         - this.config.systemTokens
         - this.config.reserveResponse
  }

  /**
   * 判断是否需要压缩
   */
  needsCompaction(messages: Message[]): boolean {
    const used = this.measure(messages)
    return used > this.availableForHistory * this.config.compactThreshold
  }

  /**
   * 判断是否可以接受新的工具结果
   */
  canAcceptToolResult(
    messages: Message[],
    resultTokens: number
  ): { ok: boolean; reason?: string } {
    const used = this.measure(messages)
    const remaining = this.availableForHistory - used

    if (resultTokens > remaining) {
      return {
        ok: false,
        reason: `Tool result (${resultTokens} tokens) exceeds remaining budget (${remaining})`
      }
    }
    return { ok: true }
  }

  /**
   * 执行压缩
   */
  async compact(messages: Message[]): Promise<CompactResult> {
    const { keepRecent } = this.config
    if (messages.length <= keepRecent) {
      return { messages, compacted: false }
    }

    const old = messages.slice(0, -keepRecent)
    const recent = messages.slice(-keepRecent)

    const summary = await this.summarizer.summarize(old, {
      preserve: [
        "key decisions and rationale",
        "file paths read or modified",
        "user preferences expressed",
        "pending/incomplete tasks",
        "error messages and resolutions",
      ],
      style: "terse",
      maxTokens: this.config.summaryMaxTokens,
    })

    const compacted: Message[] = [
      {
        role: "assistant",
        content: `[Conversation compacted at turn ${messages.length}]\n\n${summary}`,
      },
      ...recent,
    ]

    return {
      messages: compacted,
      compacted: true,
      originalSize: this.measure(messages),
      compactedSize: this.measure(compacted),
      summary,
    }
  }

  /**
   * 处理工具结果(截断 + 摘要)
   */
  processToolResult(
    result: string,
    toolName: string,
    maxTokens: number
  ): string {
    const tokens = this.tokenCounter.count(result)
    if (tokens <= maxTokens) return result

    // 按工具类型选择策略
    switch (toolName) {
      case "Bash":
        return this.summarizeCommandOutput(result, maxTokens)
      case "Read":
        return this.truncateFileContent(result, maxTokens)
      case "Grep":
        return this.summarizeSearchResults(result, maxTokens)
      default:
        return this.headTailTruncate(result, maxTokens)
    }
  }

  /**
   * 历史渐进衰减
   */
  async decayHistory(messages: Message[]): Promise<Message[]> {
    return Promise.all(
      messages.map(async (m, idx) => {
        const age = messages.length - idx
        if (age <= 3) return m
        if (age <= 10) return await this.briefSummary(m)
        if (age <= 20) return this.metadataOnly(m)
        return this.placeholder(m)
      })
    )
  }

  private measure(messages: Message[]): number {
    return messages.reduce((sum, m) => sum + this.tokenCounter.count(m.content), 0)
  }

  // ... 各种辅助方法
}

11.9 四个反模式

反模式一:永不压缩

现象:Agent 运行到 30 轮后疯狂报 “context length exceeded”。

根因:没有压缩机制,让对话历史无限累积。

对策:任何长对话 Agent 必须实现压缩;触发阈值应由有效窗口、响应预留和压缩缓冲共同决定。

反模式二:工具返回原始大 blob

现象:Read 工具直接返回 40K tokens 的文件内容,Bash 工具返回几 MB 日志。

根因:工具 API 不支持按需读取,或摘要机制缺失。

对策

  • 工具 API 强制支持 offset/limit
  • 工具结果进入上下文前做截断/摘要
  • 超大工具结果不直接返回,改为返回”已写入 /tmp/result.txt”

反模式三:重要信息埋在中间

现象:关键决策在第 10 轮产生,到第 30 轮 Agent 遗忘了。

根因:没有重申机制,信息被 Lost in the Middle。

对策

  • 关键决策写入工作记忆
  • 每轮响应尾部重申当前目标
  • 长期决策进入长期记忆

反模式四:不用 Prompt Caching

现象:成本随轮次持续上升,用户还没完成任务就触发预算上限。

根因:没有标记缓存 block,所有内容每次重新计费。

对策

  • System Prompt 加 cache_control
  • Tool Definitions 加 cache_control
  • 对话历史前 N 轮加 cache_control(只要修改频率低)

11.10 本章小结:上下文管理的七条原则

上下文窗口管理是 Agent 系统的基石,直接决定了 Agent 的实际智能水平:

  1. 窗口就是记忆 — 窗口外的信息对模型不存在,上下文管理决定了 Agent 能”看到”什么
  2. 名义 ≠ 有效 — 标称上下文窗口必须扣除系统开销、工具定义、响应空间和压缩缓冲
  3. Token 零和博弈 — 精心分配给系统提示词、工具、历史和响应,动态调整比例
  4. 自动压缩是核心 — 用 LLM 摘要旧消息,保留要点丢弃细节,是长对话的唯一出路
  5. 工具结果是最大消耗源 — 按需读取(offset/limit)、截断、摘要、渐进式衰减
  6. 工作记忆模式 — 显式维护关键状态,比从历史推断更可靠且更省 token
  7. Prompt Caching 必须用 — 长对话场景要按 cache read/write 字段量化 ROI

核心口号:

Less context, more intelligence.

塞得越少,模型越聪明;管理越精,系统越稳。

下一章跨越上下文窗口的边界——通过长期记忆系统让 Agent 在会话之间保持连续性。

11.11 环境变量:Claude Code 留给运维的 4 个压缩旋钮

autoCompact.ts 支持 4 个 env var 用来不改代码调整压缩行为——运维友好

  • CLAUDE_CODE_AUTO_COMPACT_WINDOW——覆盖 context window 大小(比如把 200K 假装成 50K 做压力测试)
  • CLAUDE_AUTOCOMPACT_PCT_OVERRIDE——按百分比覆盖触发阈值(测试”95% 才触发”场景)
  • CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE——覆盖”彻底拒绝输入”的硬上限
  • DISABLE_COMPACT / DISABLE_AUTO_COMPACT——彻底关闭(调试 compact 逻辑本身时有用)

任何生产 Agent 的核心数字都应该有 env 旋钮——不是让用户天天调、是给运维在事故时候有手段

11.12 压缩 vs 截断 vs 重采样:三种”减熵”方法

Agent 上下文减熵(减少信息量以适应窗口)有三种本质不同的方法:

方法技术本质损失类型成本适用
压缩LLM 生成摘要语义损失(换一种说法)1× LLM 调用对话历史
截断规则砍掉首/尾/中硬信息损失(直接丢)0超长工具结果
重采样语义去重 + 重要性排序冗余损失(保留核心)向量计算知识库注入

Claude Code 三种都用——

  • 压缩:compact.ts(1705 行)+ microCompact.ts(530 行)
  • 截断:FileReadTool 的 offset/limit + BashTool 的头尾保留
  • 重采样:部分 MCP 工具的 context provider(见第 17 章)

三种方法的选择原则——

  • 语义重要、低频操作——压缩(一次 LLM 调用可接受)
  • 结构化、高频——截断(纯规则、零开销)
  • 语义丰富、重复度高——重采样(向量化一次、查询 N 次)

11.13 四个跨书呼应

  • 第 12 章《长期记忆》——压缩只是短期记忆管理、会话结束后如何把”摘要”升级为”长期事实”是下一章
  • 第 18 章《评估》——压缩质量的评估需要专门 benchmark(原对话 + 压缩版本双输入给同一任务、看输出差异)
  • 第 19 章《可观测性》——压缩事件必须 trace(compaction_triggered / compaction_summary_tokens / cache_break_notified
  • 第 20 章《成本控制》——压缩本身消耗 LLM 调用(反讽地花钱省钱)——要量化 ROI:压缩 1 次节省 X token × 后续 N 轮 × 单价 vs 压缩调用 1 次成本

11.14 事故复盘模板:一个因”压缩太激进”导致的任务失败

下面不是替某个团队编造匿名事故,而是一个可以直接放进 postmortem 的复盘模板:

  • 现象——用户反馈 Agent “好像变笨了、经常跑到 30 轮后开始提出无关建议”
  • 定位——trace diff 发现、30 轮后的压缩把用户第 3 轮提到的”使用 Rust 而不是 Go”这个关键约束丢了
  • 根因——压缩 prompt 的字段 6(All user messages)被模型**“善意地”精简**——只留”用户要写一个服务器”——把语言选择细节当作”实现细节”丢了
  • 修复——压缩 prompt 追加一条”MUST preserve verbatim any technology / language / framework choice mentioned by the user”

教训——

  • 压缩是有损的——损失什么由 prompt 决定——prompt 要列出”必须保留”的白名单
  • 压缩质量必须被评估——没有 eval 的压缩就是瞎猜
  • “全量工具定义 + 9 字段摘要 + 最近 6 轮”组合是起点、不是终点——要根据业务调

这个事故呼应本书第 19 章讲的”semantic silent failure”——Agent 不报错、只是默默变差——只有 trace + diff 能抓住

11.15 一句话总结 + 十个行动项

一句话——

Context is not storage, it’s working memory. Manage it like a scarce resource, not like a database.

十个你下周就能做的行动项——

  1. 把本章 §11.1 的”名义 vs 有效窗口”表格贴进 wiki
  2. 给你的 Agent 实现 getEffectiveContextWindowSize() 函数(留 20K 给输出)
  3. 加三档阈值:auto-compact (−13K) / warning (−20K) / error (−20K) / blocking (−3K)
  4. 实现压缩 circuit breaker(连续 3 次失败停手)
  5. 压缩 prompt 包含 §11.5 的 9 字段 + NO_TOOLS_PREAMBLE
  6. microCompact 白名单只放”结果可安全压缩”的工具
  7. 图像固定 2000 tokens 上限
  8. 压缩事件发通知给 prompt cache 系统(invalidate 预期)
  9. 给压缩行为加 trace attribute(§11.13)
  10. 用 §11.14 的思路写一个”压缩质量评估”eval suite、每周跑一次

以上不是选择题——是生产 Agent 的最低运维标准——任何一条没做、都是未爆的雷

11.16 压缩后的”文件重水合”(Post-Compact File Restore)

压缩后最致命的一个问题——用户在对话里提到的文件内容全没了(压缩只留了摘要提到”改过 auth.ts”、但 auth.ts 的实际代码不在 context 里)。

Claude Code 用 compact.ts:122-124 的三个常量解决:

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

压缩完成后、compact.ts:1430-1460 会——

  • timestamp 倒序取出最近访问过的 5 个文件
  • 每个文件最多读 5K tokens(超过就截断)
  • 总预算 50K tokens——超了丢掉后面的
  • 重新以 AttachmentMessage 形式注入——新的上下文里”最近修改过的文件”又可见了

设计要点——

  • 5 个而不是全部——遵循本章 §11.7 Lost-in-the-Middle——太多反而稀释
  • 按时间排序——最近改的 > 历史改过的——契合用户”继续改下一步”的预期
  • 三层预算(5 files × 5K each ≤ 50K 总)——任何一层溢出都不会破坏整体

反面教训——不做水合的压缩系统、用户明显感觉”Agent 压缩完就傻了”——压缩 + 水合是一对、缺一不可

11.17 Pre/Post Compact Hooks:让用户插手压缩过程

compact.ts:413 / 818 调用 executePreCompactHooksexecutePostCompactHooks——压缩前后用户可以注入自定义逻辑

典型用法——

  • pre-compact——用户注入”压缩前保留特定信息”规则(如”用户的 GraphQL schema 永远要完整保留”)
  • pre-compact——触发外部 telemetry(“我们压缩了、记一次事件”)
  • post-compact——用户注入额外文件(除了默认 5 个、把 package.json 也带回来)
  • post-compact——clear 某些内部缓存(resetContextCollapse

实现细节——用户通过 hooks/useCanUseTool.ts 定义的 hook 接口注册——和工具权限 hook 共享基础设施——减少用户学习成本

这是”可扩展性”的优秀实践——把平台不该管的决策暴露给用户、自己只提供 hook 点——本书第 20 章《MCP》讲过 HandleRequestOptions.parsedBody 也是同样思路。

11.18 压缩的可观测性 5 字段

一次 compaction 事件在 telemetry 里应该包含的最少字段(Claude Code 实战值):

字段含义用途
trigger_sourceauto / manual / threshold区分自动压缩和用户 /compact
input_tokens压缩前 context tokens反推节省效率
output_tokens摘要 output 的 tokens监控 p99 是否稳定
messages_compacted被压缩的消息数体感指标
duration_ms整个压缩耗时用户等待时长

做一张 Grafana 图——横轴时间、纵轴 input_tokens - output_tokens(“节省”)——这就是你压缩系统的 ROI 指标

11.19 三条工程提醒

压缩系统长期落地时反复撞到的三件事——

提醒一——压缩不是 UX 问题、是数学问题。Agent 每多活一轮都是在挤压下一轮的空间、Context 是有限资源——算清楚 budget、再谈”智能”。

提醒二——任何”聪明的压缩”都要可测试。不能只靠 vibes check、要有 golden-set——原对话 + 压缩版本 + 下一个任务输出三元组、逐个 diff——否则压缩质量降 10% 都不会知道。

提醒三——用户会滥用”长对话”能力。再好的压缩也顶不住”用户把 10 个 PRD 连续糊上来”——UI 层要有引导(“建议开新会话”)、API 层要有熔断(同一 session > 100 轮强制收尾)——技术 + 产品配合才能解决。

11.21 压缩 prompt 的”工程艺术”:5 个被 Anthropic 员工在 PR 里争论过的细节

本章多次引用 compact/prompt.ts——这里挑 5 个争议点、都是 Anthropic 工程师曾经在 GitHub issue 或 CL 里辩论过(或源码注释里留痕)的——

争议 1:字段 6 为什么单独拎出来?——“All user messages””Primary Request and Intent”重复了吗?——不重复。字段 1 是”摘要后的意图”、字段 6 是”原话逐条列出”——后者是前者的”原始语料”、防止摘要把用户的细微意图丢掉。两者都要。

争议 2:字段 9 的”direct quotes”强制是不是多余?——不是。没这一条、模型会把”Next Step” paraphrase——Agent 续写时用的是 paraphrase 版本、不是原版任务描述——一两轮下来目标漂了。强制引用是反漂移措施

争议 3:<analysis> 放在 <summary> 前而不是内联?——因为 strip 逻辑需要知道边界——内联就得写复杂正则、容易错。双块 + 顺序固定 = 简单正则 + 零歧义

争议 4:压缩 prompt 要不要给 Few-shot 例子?——加了、但只加 1 个(源码里的 <example> 块)——更多 few-shot 会让模型去 copy 例子的风格而不是内化结构——1 个演示 + 结构要求是经验上的甜点

争议 5:压缩完成的返回格式为什么不是 JSON?——JSON 对 LLM 语法负担大(转义、括号平衡)、XML 标签更容易保持——且 XML 很容易 strip(<analysis>...</analysis> 正则搞定)——工程上更稳

这 5 个决策——不是”优雅与否”的审美、是生产环境踩出来的——任何自研压缩系统都会重新踩一遍

11.22 最后:把”压缩”当成一次”LLM 调用”来对待

本章强调过多次——压缩本身是一次 LLM 调用——意味着:

  • 它也会失败——LLM provider 宕机、rate limit、timeout
  • 它也花钱——每次压缩都是 Sonnet 量级的账单
  • 它也有延迟——用户会看到”正在压缩…” UI 块几秒
  • 它也要 observability——什么时候触发、花多久、省下多少

一句话——压缩不是”本地逻辑”、是”远程 RPC”——你就要像对待任何其他 RPC 一样熔断、重试、监控、超时、fallback 全都要

本书第 19 章《可观测性》讲 span 嵌套——压缩本身应该是一个 spanname: "compact"、attributes 里带 trigger / before / after / duration)——这样 trace 树里一眼能看到压缩是否异常

11.23 与下一章的接力

压缩让上下文”不爆炸”。长期记忆让上下文”能遗忘却不丢知识”。

本章和下一章是一对——第 12 章里的 memory types、consolidation 触发时机、跨 session 存储都是本章的延伸——两章合读才能把”记忆系统”完整串起来。

11.24 附录 A:8 个常见问题(FAQ)

围绕本章主题常被问到的 8 个问题——

Q1:压缩是不是越频繁越好?——不是。过频会反复 cache-break、反而贵。Claude Code 的默认”留 13K buffer 才触发”就是平衡——别自作聪明降低

Q2:模型支持 1M context、还需要压缩吗?——需要。1M 的”有效窗口”约 400K(§11.1)、且 input 单价不变、长 context 永远更贵。压缩是经济决策、不是”装不下”问题。

Q3:压缩能用更便宜的模型(Haiku)吗?——谨慎。本章 §11.14 的事故正是因为摘要模型**“理解不到关键约束”**——压缩模型能力应该 ≥ 主流程模型的 0.7×——用 Sonnet 压 Sonnet 对话是安全线、Haiku 压 Opus 不行

Q4:Prompt Caching 和压缩怎么配合?——先压后缓——压缩会 break cache、但压缩完的新 context 会立即进入缓存(1h TTL 更划算)——压缩 + 1h cache 是长对话 Agent 的标配

Q5:用户关掉 auto-compact、只手动 /compact,可以吗?——可以、但要有明确 UI 反馈(“对话已达 92%、建议 /compact”)——不提醒就是让用户撞上 blocking limit

Q6:压缩内容能否存档(给后续 session 继续)?——、就是下一章”长期记忆”。压缩产物 → session_memory → 跨会话复用——三级管线

Q7:多人协作的 Agent(如 Cursor 的 @team)如何压缩?——棘手——每个人的”重要信息”不同、压缩 prompt 要带当前参与者列表、按人分 section 压——是前沿问题、没成熟答案

Q8:如果模型本身的”infinite context”突破了(比如 state space model)、本章还有用吗?——。因为成本、延迟、注意力分配和可审计性仍然会随上下文规模变化——context 治理的工程原则跨架构通用

11.25 附录 B:本章涉及的 Claude Code 源码锚点速查

话题源码位置
getEffectiveContextWindowSizesrc/services/compact/autoCompact.ts:30
MAX_OUTPUT_TOKENS_FOR_SUMMARYautoCompact.ts:28
四档阈值常量autoCompact.ts:65-69
Circuit breaker + BQ 事故记录autoCompact.ts:71-74
递归 guards (session_memory/compact)autoCompact.ts:162-165
marble_origami ctx-agent 特判autoCompact.ts:170-176
Env overrides (4 个)autoCompact.ts:38-46, 80-90, 124-138
NO_TOOLS_PREAMBLE + Sonnet 4.6/4.5 对比compact/prompt.ts:14-26
9 字段摘要结构compact/prompt.ts:60-78
<analysis> / <summary> 双块compact/prompt.ts:30-57
PARTIAL vs BASE compact promptscompact/prompt.ts:146-168
microCompact 白名单compact/microCompact.ts:41-51
IMAGE_MAX_TOKEN_SIZEmicroCompact.ts:39
Post-compact 3 个常量compact/compact.ts:122-124
Pre/Post hookscompact/compact.ts:413,818

这张表可作为源码深挖的导航地图

11.27 附录 C:Lost in the Middle 原始论文数据一览

§11.7 引用了 Liu et al. 2023 “Lost in the Middle”——这里给出原文的三个实验结论(arXiv:2307.03172):

  • MDQA 实验:把正确答案放在 20 篇文档的第 1、5、10、15、20 位——GPT-3.5-turbo 的召回率从 75%(第 1 位)先降到 53%(第 10 位)再回升到 63%(第 20 位)——U 形曲线
  • MuSiQue 实验:长 context 多跳推理——context 越长准确率越低、即使标签明确(“需要的信息在第 X 段”)、模型也用不上
  • 指令跟随极限:在 context 中部插入”忽略之前指令、只回答 X”——被命中率中间最低——安全含义:prompt injection 放中间反而比放开头更不容易被执行(但也反过来说明”中部注意力弱”)

三个结论给工程的启示——

  • 关键指令放开头(system prompt 起始位置)
  • 最新状态放末尾(最近一轮 assistant + user)
  • 中间放可压缩历史——模型本来就看不太仔细

本章 §11.4.5 的 POST_COMPACT_MAX_FILES_TO_RESTORE = 5 正是基于这条经验——太多反而稀释

11.28 附录 D:5 分钟自测——你掌握本章了吗

以下 10 题任选 7 题做对、算达标——

  1. Claude Code 给 compact summary 输出预留多少 tokens?为什么?(20K、基于 p99.99 = 17387)
  2. 4 档阈值中哪一档最宽松?(MANUAL=3K)
  3. MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES 的值和理由?(3、避免不可恢复的压缩失败反复重试)
  4. 为什么 session_memory / compact 不能 auto-compact?(forked agent、递归 fork)
  5. NO_TOOLS_PREAMBLE 为什么必须放 prompt 开头?(cache-sharing fork 继承 tool set、模型会想调工具)
  6. 压缩 prompt 的 9 个字段里哪个最容易被忽视?(All user messages、字段 6)
  7. <analysis><summary> 为什么分两块?(scratchpad 需要 strip、XML 边界清晰)
  8. 图像 token 固定多少?(2000)
  9. post-compact 水合最多几个文件、每个多少 token、总上限?(5 / 5K / 50K)
  10. Lost in the Middle 实验里的 U 形曲线最低点在哪?(大约第 10 位、~53% 召回)

7/10 通过——本章吸收良好。

11.29 附录 E:压缩成本的 provider 对比方法

同样一次长上下文压缩,不同 provider 的价格、缓存折扣和输出单价会导致成本差距显著。这里给方法,不写会随官网变化的跨厂商价目表:

字段含义采集方式
input_tokens压缩前上下文长度provider usage
output_tokens摘要长度provider usage
input_price_per_m输入单价官方价格页或本地价目表
output_price_per_m输出单价官方价格页或本地价目表
cache_read/write缓存命中和写入provider usage

两点观察——

  • 压缩可以降级模型——前提是 golden set 证明摘要不会丢关键约束
  • 跨 provider 混搭需要 eval 兜底——摘要风格、长度控制和指令遵守都可能变

本书第 20 章《成本控制》的 “Meta-Router” 思路适用于压缩——压缩前判定难度、简单会话交给便宜模型——ROI 立即放大

11.31 附录 F:压缩阈值的”按场景调参”小表

本章给的默认值是 Claude Code 的通用场景、垂直 Agent 要按场景微调

场景AUTOCOMPACT_BUFFER理由
Code Agent(默认)13K代码补全平均输出较长
Customer Support Chat5K对话短、响应短
数据分析 Agent25K输出通常是长 SQL + 表格
文档生成30Kmarkdown 输出常几万字
复杂多步推理(extended thinking)40Kthinking tokens 吃 output 通道

核心思路——AUTOCOMPACT_BUFFER = 你 P99 的 response output + 20% 冗余——不要照抄 Claude Code 的 13K——按你自己的 response 长度分布来

11.32 附录 G:给新团队的 30 天落地计划

如果你的团队从零起步——30 天路线

Day 1-5——最小可用版:token 计数 + 滑动窗口、把第 10 轮之前的消息直接丢——能用但会丢信息

Day 6-10——引入摘要:写一个”保留 9 字段”的 prompt、调一次 Sonnet、用摘要替换旧消息——质量可接受

Day 11-15——接入 prompt cache:给 SP + tools + 对话最早块打 cache_control——用 usage 字段记录缓存命中带来的节省

Day 16-20——加 circuit breaker:压缩连续失败 3 次就停、上报告警——防事故

Day 21-25——加 post-compact 水合:最近 5 个文件、各 5K、总 50K——用户不再感觉”压缩后变傻”

Day 26-30——加 eval:golden set 跑压缩前后 diff,先设定人工可接受标准,再把目标写进 CI

30 天后你已经有了——生产级压缩系统——和 Claude Code 同级

11.33 一个值得背下来的公式

有效工作空间 = 名义窗口 − 系统开销 − 工具定义 − 输出预留 − 压缩缓冲

代入一个 Claude Code 风格的工作区:200K − 系统提示 − 工具定义 − 20K 输出预留 − 13K 压缩缓冲。如果系统提示和工具定义合计约 15K,真正留给”对话历史 + 工具结果”的空间大约是 152K;这个数字来自公式,不是模型能力上限。

记住这个公式、再看 Anthropic 官网的”200K context” 宣传——你就不会天真地以为能放 200K 的真实内容

工程素养就是把宣传数字还原成工程数字——本章全部篇幅、都在教你这件事。