Harness Engineering
第11章 短期记忆:上下文窗口管理
第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 Prompt | 3-5% | 相对固定 | Prompt Caching |
| Tool Definitions | 4-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 上限!
解决方案:
- 在读文件前触发压缩(让对话历史收缩到 50K)
- 分段读文件(使用 offset + limit,每次只读一部分)
- 用 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_memory 和 compact 本身就是 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 个字段:
- Primary Request and Intent
- Key Technical Concepts
- Files and Code Sections
- Errors and fixes
- Problem Solving
- All user messages(关键:列出所有非 tool-result 的 user 消息)
- Pending Tasks
- Current Work
- 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-152 的 shouldAutoCompact 函数签名带一个奇怪的参数:
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
],
})
缓存命中的条件
- 完全一致——字节级别相同(包括空格、顺序)
- 5 分钟 TTL——最后一次访问后 5 分钟失效
- 按 block 匹配——每个 cache_control 标记的 block 独立缓存
- 最长前缀匹配——改了后面的内容不影响前面的缓存
工程含义
不缓存:
每轮成本 = 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
工作记忆的设计原则
- 结构化——字段明确、易解析
- 精简——只放决策必需的信息,不放流程细节
- 可查询——Agent 可以主动读取”当前 task 是什么”
- 可更新——Agent 能通过工具主动修改
- 持久化——会话结束时保存到长期记忆
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_tokens、cache_read_tokens、cache_write_tokens、ttft_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 的实际智能水平:
- 窗口就是记忆 — 窗口外的信息对模型不存在,上下文管理决定了 Agent 能”看到”什么
- 名义 ≠ 有效 — 标称上下文窗口必须扣除系统开销、工具定义、响应空间和压缩缓冲
- Token 零和博弈 — 精心分配给系统提示词、工具、历史和响应,动态调整比例
- 自动压缩是核心 — 用 LLM 摘要旧消息,保留要点丢弃细节,是长对话的唯一出路
- 工具结果是最大消耗源 — 按需读取(offset/limit)、截断、摘要、渐进式衰减
- 工作记忆模式 — 显式维护关键状态,比从历史推断更可靠且更省 token
- 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.”
十个你下周就能做的行动项——
- 把本章 §11.1 的”名义 vs 有效窗口”表格贴进 wiki
- 给你的 Agent 实现
getEffectiveContextWindowSize()函数(留 20K 给输出) - 加三档阈值:auto-compact (−13K) / warning (−20K) / error (−20K) / blocking (−3K)
- 实现压缩 circuit breaker(连续 3 次失败停手)
- 压缩 prompt 包含 §11.5 的 9 字段 + NO_TOOLS_PREAMBLE
- microCompact 白名单只放”结果可安全压缩”的工具
- 图像固定 2000 tokens 上限
- 压缩事件发通知给 prompt cache 系统(invalidate 预期)
- 给压缩行为加 trace attribute(§11.13)
- 用 §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 调用 executePreCompactHooks 和 executePostCompactHooks——压缩前后用户可以注入自定义逻辑。
典型用法——
- 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_source | auto / 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 嵌套——压缩本身应该是一个 span(name: "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 源码锚点速查
| 话题 | 源码位置 |
|---|---|
| getEffectiveContextWindowSize | src/services/compact/autoCompact.ts:30 |
| MAX_OUTPUT_TOKENS_FOR_SUMMARY | autoCompact.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 prompts | compact/prompt.ts:146-168 |
| microCompact 白名单 | compact/microCompact.ts:41-51 |
| IMAGE_MAX_TOKEN_SIZE | microCompact.ts:39 |
| Post-compact 3 个常量 | compact/compact.ts:122-124 |
| Pre/Post hooks | compact/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 题做对、算达标——
- Claude Code 给 compact summary 输出预留多少 tokens?为什么?(20K、基于 p99.99 = 17387)
- 4 档阈值中哪一档最宽松?(MANUAL=3K)
- MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES 的值和理由?(3、避免不可恢复的压缩失败反复重试)
- 为什么 session_memory / compact 不能 auto-compact?(forked agent、递归 fork)
- NO_TOOLS_PREAMBLE 为什么必须放 prompt 开头?(cache-sharing fork 继承 tool set、模型会想调工具)
- 压缩 prompt 的 9 个字段里哪个最容易被忽视?(All user messages、字段 6)
<analysis>和<summary>为什么分两块?(scratchpad 需要 strip、XML 边界清晰)- 图像 token 固定多少?(2000)
- post-compact 水合最多几个文件、每个多少 token、总上限?(5 / 5K / 50K)
- 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 Chat | 5K | 对话短、响应短 |
| 数据分析 Agent | 25K | 输出通常是长 SQL + 表格 |
| 文档生成 | 30K | markdown 输出常几万字 |
| 复杂多步推理(extended thinking) | 40K | thinking 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 的真实内容。
工程素养就是把宣传数字还原成工程数字——本章全部篇幅、都在教你这件事。