AI Agent 上下文工程:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-08-30 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁:没做过 agent 上下文管理、但需要在短期内既能听懂别人在说什么、 又能自己动手设计一套的人。用途是知识梳理与 agent 开发面试准备。
它和已有那几份研究文档的关系:那几份是执行文档——写给已经懂的人,满篇是 「这个数字是错的」「这一层默认是关的」「我原来写的一半在事实层面不成立」。 信息密度极高,但它们默认你已经知道 compaction、KV Cache、Context Rot、 microcompact 是什么,所以第一次读会卡住。
本文补的正是那一层:先把概念讲通,再把那几份文档里真正值钱的结论放回它该在的位置上。
它不是摘要。 摘要会把结论抽出来变成一句正确但没用的话 (比如"要分层压缩"——对,然后呢?)。本文的写法相反:每个结论都从 「为什么会有人搞错」讲起,因为面试里能拉开差距的从来不是结论本身, 是你能不能说清它的反面为什么诱人。
数据来源标注约定:本文的数字分三类,正文里会明确区分——
【源码】= 从 sid-code 或 Claude Code 源码直接读到的常量与注释;【实测】= 有生产数据或实验支撑的数字;【文献】= 来自论文或厂商博客。没有标注的是推理和类比,请当成观点而非事实。
怎么读这份文档
按顺序读。这份文档是一条链,不是清单——后面每一章都在用前面章节建立的概念。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 | 别人说 compaction / KV Cache / Context Rot 时,你知道指什么 |
| §1 | 为什么需要上下文工程 | 为什么"把 prompt 写好"在 agent 上不够用 |
| §2 | 解剖一次请求:上下文里到底有什么 | 能说出六个组成部分,以及哪个是真正的大头 |
| §3 | 四个核心操作 | 拿到一个上下文问题,你知道它属于哪一类操作 |
| §4 | Context Rot:更大的窗口救不了你 | 为什么 1M 窗口不等于能用 1M 信息 |
| §5 | 组装:上下文是怎么拼出来的 | 能画出从"用户回车"到"发给 API"的完整流程 |
| §6 | Prompt Cache:所有策略的经济地基 ★ | 为什么"清理上下文"这个动作本身可能比不清理更贵 |
| §7 | 压缩是一条管线,不是一个函数 ★ | 能讲清四层各自解决什么、为什么这个顺序 |
| §9 | 压缩的六个隐藏难题 ★ | 这一章是概念文章永远不会写、但生产系统必须有的东西 |
| §10 | 压缩不是终点,重建才是 | 为什么重建的代码量比压缩本身还多 |
| §11 | 隔离:多 agent 是解法还是逃避 | 什么时候该拆、什么时候拆了更糟 |
| §12 | 怎么知道自己做对了 ★ | 压缩质量怎么量,以及为什么"测试全绿"骗得过你 |
| §14 | 动手:从零实现一个 mini 上下文层 | 五个阶段,每个阶段你会亲手撞到哪个坑 |
如果只有 20 分钟:读 §2、§6、§7。这三章是这个领域的骨架,其余都是它们的展开。
如果只有 5 分钟:读下面这五句话,它们是全文的压缩版——
- agent 的上下文里 70–80% 是工具输出,不是你写的 prompt。 优化 prompt 的 ROI 远低于管住工具输出。【文献】
- 更大的窗口不解决问题,18 个前沿模型都在远未填满时就开始退化。方向是精简,不是扩容。【文献】
- 压缩不是一个函数,是一条按成本递增的管线;每一层的真正价值不是"省了多少 token", 而是"让下一层不必执行"——因为下一层的代价是信息粒度的永久损失。
- 所有上下文操作的头号约束是 prompt cache:改动历史内容会让前缀缓存失效, 缓存与非缓存的成本差约 10 倍,所以"清理"这个动作本身是要付费的。【实测】
- 压缩的难点不在压缩。围绕它的失败处理、切分点合法性、缓存保持、压缩后重建、 可观测性,代码量是摘要逻辑本身的十几倍。
§0 名词地图:先把词认全
这一节是查询表,不用背。往后每章第一次用到某个词时都会重新解释, 这里放一份集中的,是为了你读那几份执行文档时能随时回来查。
按「一次请求从组装到压缩」的顺序排列,不按字母序——因为这些词之间是有位置关系的。
0.1 最底层:模型侧的物理事实
| 词 | 中文 | 是什么 | 为什么你必须知道 |
|---|---|---|---|
| token | 词元 | 模型处理文本的最小单位。英文约 4 字符 1 个 token,中文约 1–2 字符 1 个 | 它是计费单位也是窗口单位,所有预算都以它计 |
| context window | 上下文窗口 | 模型单次推理能吃进的最大 token 数 | 它是硬限制,超了 API 直接报错,不是"效果差一点" |
| KV Cache | 键值缓存 | 生成时缓存每个 token 的 Key/Value 向量,避免重复计算 | 窗口有限的物理原因:它的内存占用随 token 数线性增长 |
| self-attention | 自注意力 | Transformer 里每个 token 和所有其他 token 算关系的机制 | 复杂度 O(n²),这是"窗口不能无限大"的算力原因 |
| prefill / decode | 预填充 / 解码 | 读完输入叫 prefill,一个个吐字叫 decode | TTFT(首字延迟)主要由 prefill 决定,而 prefill 时长正比于输入长度 |
0.2 上下文的组成部分(§2 详解)
| 词 | 中文 | 是什么 | 常见误解 |
|---|---|---|---|
| system prompt | 系统提示词 | 定义模型身份、约束、工具用法的开场说明 | 它不是"更重要的 user 消息",多数 API 里它是独立字段 |
| tool definition / schema | 工具定义 | 告诉模型"你有哪些手脚"的 JSON Schema 清单 | 它占的 token 常被低估,20 个工具轻松几千 token |
| tool result / observation | 工具结果 / 观察 | 工具跑完返回给模型的东西(文件内容、命令输出) | 这才是上下文的大头,占 70–80%【文献】 |
| conversation history | 对话历史 | 前面所有轮的 user / assistant 消息 | 聊天机器人时代它是唯一膨胀源,agent 时代它反而相对可控 |
| attachment / 注入 | 附件 | 系统主动塞进去的东西(项目规则、git 状态、todo 列表) | 它是 harness 的决策,用户看不见,也最容易失控 |
| thinking / reasoning block | 思考块 | 模型的内部推理过程 | 它也占 token,也可能需要被清理 |
0.3 四个核心操作(§3 详解)
| 词 | 中文 | 是什么 |
|---|---|---|
| Write | 写出去 | 把信息存到上下文之外(文件、数据库),上下文里只留个路径 |
| Select | 挑进来 | 每轮只把相关的东西放进上下文(RAG、按需加载工具) |
| Compress | 压小 | 让已有内容占更少 token(截断、摘要) |
| Isolate | 隔开 | 让不同关注点待在不同上下文里(子代理、多 agent) |
💡 一个能立刻用上的记忆法(Karpathy 的类比,业界通用): 把 LLM 当 CPU,上下文窗口就是 RAM,文件系统是硬盘。 于是四个操作分别对应:
Write= 换出到磁盘,Select= 页面调入,Compress= 内存压缩,Isolate= 进程隔离。 上下文工程 = 给 LLM 做操作系统级的内存管理。 这个类比在面试里非常好用,因为它一句话就说明了"为什么这是个系统工程问题而不是写作技巧问题"。
0.4 压缩侧(这一组最容易混,注意区分)
| 词 | 中文 | 是什么 | 关键区别 |
|---|---|---|---|
| compaction | 紧缩 | 可逆压缩:剥掉能从环境重新拿到的东西,只留标识符(路径/URL/ID) | 可恢复。优先级高于 summarization |
| summarization | 摘要 | 不可逆压缩:让 LLM 把历史写成一段摘要 | 有损,且要花一次 LLM 调用。最后手段 |
| truncation | 截断 | 直接砍掉超长部分 | 最便宜,也最粗暴 |
| Head-Tail 截断 | 首尾保留 | 只留开头 N 和结尾 M,中间换成省略标记 | 因为报错信息常在开头或结尾,中间是重复内容 |
| microcompact | 微压缩 | 只清理旧工具结果的内容,保留消息结构 | 比 summarization 便宜得多(零 LLM 调用) |
| snip | 裁剪 | 按时间深度删掉最早的若干轮 | 和滑动窗口是同一个意思 |
| masking | 遮罩 | 把旧工具输出替换成摘要占位,原文落盘 | sid-code 的叫法,本质是 compaction 的一种 |
| PTL(prompt too long) | 输入超长 | 请求本身超出窗口被 API 拒绝 | 压缩请求自己也会 PTL,这是个递归问题(§9) |
0.5 失效模式(§4、§12 详解)
| 词 | 中文 | 是什么 | 关键区别 |
|---|---|---|---|
| Context Rot | 上下文腐烂 | 上下文变长时输出质量渐进下降,即使远未填满窗口 | 渐进、隐蔽。它是本领域存在的根因之一 |
| Context Overflow | 上下文溢出 | 超过窗口上限,API 硬拒 | 硬失败、有报错。反而比 Rot 好处理 |
| Lost in the Middle | 中间迷失 | 放在中间位置的信息被系统性忽视,注意力呈 U 型 | 位置问题 |
| Context Pollution | 上下文污染 | 无关/过时信息干扰了有用信息 | 质量问题。和上一条常同时发生但机制不同 |
| Distractor | 干扰项 | 语义相似但事实无关的内容 | 比完全无关的噪声更危险——注意力会被相似性吸引【文献】 |
| Thrashing | 抖动 | agent 重复读同一个文件、重跑同一个命令 | 压缩丢了关键信息的最直接信号,也是最实用的质量指标 |
| Governance Decay | 约束衰减 | 摘要为"任务连续性"优化,把安全约束合理地丢掉了 | 静默失效——agent 不会说"我忘了一条规则"(§12) |
0.6 度量侧
| 词 | 中文 | 是什么 | 为什么重要 |
|---|---|---|---|
| 压缩率 | — | 压缩后 ÷ 压缩前 | ⚠️ 这是个错误指标(§12),高压缩率可能让总成本更高 |
| token-per-task | 单任务 token | 完成一个任务消耗的总 token(含所有轮次和重试) | ✅ 这才是对的指标 |
| cache hit rate | 缓存命中率 | cache_read ÷ 总 input token | 直接决定成本与延迟 |
| 上下文占用率 | — | 已用 token ÷ 窗口 | 建议控制在 50–65%,不是用满(§4) |
| 探针测试 | probe-based eval | 压缩后用针对性问题测"关键信息还在不在" | 唯一能量化信息损失的手段,但要额外花钱 |
§1 为什么需要上下文工程
1.1 先看一个场景
你写了个 agent,让它修一个 bug。前 10 轮一切正常:它读文件、跑测试、改代码。 第 25 轮开始不对了:
- 它重新读了第 8 轮已经读过的那个文件
- 它又跑了一遍第 12 轮跑过的测试,得到一样的结果
- 你在第 3 轮说过"别改
config.py",它现在改了 - 它开始在两个方案之间来回摇摆,每轮都推翻上一轮
你的第一反应可能是"模型不够聪明",或者"该换个更强的模型"。
都不是。 上面四个症状分别对应四个具体的上下文问题:
| 症状 | 根因 | 属于哪一章 |
|---|---|---|
| 重复读同一个文件 | Thrashing——压缩把文件内容丢了,但没留恢复路径 | §10、§12 |
| 重跑同一个命令 | 同上,且没有"这个我做过了"的信号 | §12 |
| 忘了你说过的约束 | 约束在早期消息里,被摘要吞掉了(governance decay) | §12 |
| 方案来回摇摆 | Context Rot——上下文太长,注意力被稀释 | §4 |
这四个都是工程问题,不是模型问题。 换个更强的模型能缓解一点, 但只要上下文管理不变,同样的症状会在第 40 轮而不是第 25 轮出现。
这就是上下文工程存在的理由:agent 的能力上限,很多时候不是由模型定的, 而是由"模型在第 N 轮到底看到了什么"定的。
1.2 一个必须先接受的事实:模型是无状态的
这是所有后续内容的地基,而它反直觉到很多人做了半年 agent 还没真正接受:
模型不记得上一轮。 每一次调用,你都要把全部历史重新发一遍。
所谓"多轮对话"在 API 层面是这样的:
第 1 轮请求:[system, user₁] → assistant₁
第 2 轮请求:[system, user₁, assistant₁, user₂] → assistant₂
第 3 轮请求:[system, user₁, assistant₁, user₂, assistant₂, user₃] → assistant₃没有会话状态,没有服务端记忆。"记住"这件事完全是客户端把数组越攒越长实现的。
这一个事实推出了全部后果:
- 成本是累加的,而且是超线性的。 第 N 轮的输入约等于 N × 第 1 轮。 所以「轮数」是成本的最大杠杆——2 倍轮数约等于 3–4 倍成本,不是 2 倍。
- 延迟也随之增长,因为 prefill 时长正比于输入长度。
- 窗口会被填满,所以必须有压缩,这不是优化而是必需品。
- 前缀是稳定的,所以可以缓存——这就是 prompt cache(§6),也是为什么 "改动历史消息"是一件昂贵的事。
- 你完全控制模型看到什么。 这既是负担也是权力:上下文工程之所以可能存在, 正因为这个数组是你自己拼的。
⚠️ 面试提醒:如果被问「多轮对话怎么实现的」,答"框架帮我管了"是不及格的。 正确答案是「每轮重发全部历史,模型无状态」,然后你才有资格谈压缩和缓存—— 因为这两件事都是从"每轮重发"这个事实里长出来的。
1.3 三重约束:为什么这个矛盾无法消除
上下文工程的根本矛盾一句话就能说清:
上下文窗口是有限的工作记忆,但 agent 需要基于所有历史状态做决策。 你无法同时拥有「完整信息」和「高效推理」。
这个矛盾不可消除,因为它同时受三重约束夹击。理解这三重是关键—— 它解释了为什么"等窗口更大就好了"是错的:
① 物理约束(算力与内存) self-attention 是 O(n²),KV Cache 内存线性增长。窗口翻倍, 注意力计算翻四倍、缓存内存翻倍。这决定了扩窗口的边际成本递增。
② 认知约束(注意力是零和的) 即使窗口无限大,模型的注意力是有限"预算"。token 越多,每个 token 分到的注意力越少。 这就是 Context Rot 和 Lost in the Middle 的根源。 这一条最关键,因为它意味着扩容不解决问题(§4 详解)。
③ 经济约束(token 要花钱) 每个 token 都在计费,而 agent 的输入输出比高达 100:1【文献】—— 绝大部分钱花在输入上。上下文膨胀直接导致经济不可行。
三重约束意味着不存在银弹,只有在特定场景下的最优平衡点。 任何声称"彻底解决了上下文问题"的方案,你都该先问它牺牲了哪一重。
1.4 Prompt Engineering vs Context Engineering
这是面试高频区分题,也是很多人第一次的认知跃迁点。
| 维度 | Prompt Engineering | Context Engineering |
|---|---|---|
| 优化对象 | 你怎么问(一段字符串的措辞) | 模型看到什么(整个信息环境) |
| 作用范围 | 单次调用 | 整个会话 / 整个任务生命周期 |
| 手段 | 措辞、格式、few-shot 示例、角色设定 | 筛选、压缩、隔离、预算分配、缓存 |
| 是什么活 | 写作 | 系统设计 |
| 类比 | 优化一次函数调用的参数 | 设计一个内存管理子系统 |
关系是包含,不是并列:PE 是 CE 的一个子集/一层。你依然需要会写 prompt, 但在 agent 场景下,你亲手写的那部分可能只占上下文的 10%—— 剩下 90% 是工具定义、工具输出、历史、系统注入的附件。 优化那 10% 的措辞,天花板很低。
有一个具体数据能说明这个转向的必要性【文献】:Cursor 做过 A/B, 把工具定义改成按需加载而不是全量预置,token 消耗降低 46.9%, 而模型表现反而更好。这里没有任何一个字的 prompt 措辞被改动—— 纯粹是"少给它看点东西"。
💡 一个值得记住的反直觉:「以防万一」的预加载不只是浪费 token, 它还因为噪声降低了模型表现。这句话在面试里比任何压缩技巧都更能显出你的判断力, 因为它说明你理解了「上下文不是越全越好」。
1.5 2026 年的一个附带教训:旧 prompt 技巧正在失效
这一节不是主线,但它能防止你在面试里说出过时的话【文献】:
- "Think step by step" 现在往往是冗余甚至有害的——新一代模型内部已经处理推理, 显式 CoT 指令变成噪声。
- Few-shot CoT 不再提升推理能力,唯一剩下的功能是格式对齐。
- ALL-CAPS、"YOU MUST"、"NEVER EVER" 会过度触发,产生更差的结果。
- 长 prompt 在约 3000 token 后推理开始退化,实际甜蜜点是 150–300 词。
这几条的共同指向是:把精力从"怎么写 prompt"转向"怎么管上下文"。 这正是本文剩下十三章要讲的东西。
1.6 本章自检
答不上来就回去重读对应小节:
- 为什么说"模型是无状态的"?它推出了哪五个后果?(§1.2)
- 为什么"等窗口变大"不能解决上下文问题?三重约束里哪一重管这件事?(§1.3)
- PE 和 CE 是并列关系还是包含关系?举一个"不改任何措辞但显著改善效果"的例子。(§1.4)
- 上面那个 agent 场景里的四个症状,分别对应什么根因?(§1.1)
§2 解剖一次请求:上下文里到底有什么
这一章的目的是让你在面试里被问「上下文由什么组成」时, 不是答"就是对话历史啊",而是能报出六块、说出各自量级、并指出哪块是真正的大头。
2.1 先看一个真实的请求骨架
剥掉 SDK,一次 agent 请求就是一个 HTTP POST,body 大致长这样:
{
"model": "claude-opus-5",
"max_tokens": 8000, // ← 注意这个字段,§8 会讲它为什么吃掉输入预算
"system": [ // ① 系统提示词(可能是多个 block)
{ "type": "text", "text": "你是一个编码助手…(身份/约束/工具用法)" },
{ "type": "text", "text": "…项目规则 CLAUDE.md 的内容…" }
],
"tools": [ // ② 工具定义
{ "name": "read", "input_schema": { /* JSON Schema */ } },
{ "name": "bash", "input_schema": { /* … */ } }
// … 通常十几到几十个
],
"messages": [ // ③④⑤ 全部塞在这个数组里
{ "role": "user", "content": "帮我修 login 的 bug" },
{ "role": "assistant", "content": [
{ "type": "thinking", "thinking": "先看看 auth 模块…" }, // ⑤ 思考块
{ "type": "tool_use", "id": "t1", "name": "read", "input": {"path":"auth.py"} }
]},
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "t1",
"content": "…整个文件 3000 行…" } // ④ 工具结果 ← 大头
]},
// … 反复几十轮
{ "role": "user", "content": [
{ "type": "text", "text": "<system-reminder>当前 todo 列表…</system-reminder>" }, // ⑥ 注入附件
{ "type": "text", "text": "继续" }
]}
]
}2.2 六个组成部分及其量级
| # | 部分 | 典型量级 | 每轮变化吗 | 谁决定它 |
|---|---|---|---|---|
| ① | system prompt | 1K–10K token | ❌ 会话内基本不变 | harness 作者(你) |
| ② | tool definitions | 500–8K token | ❌ 基本不变 | harness 作者(你) |
| ③ | conversation history | 随轮数线性增长 | ✅ 每轮 +1 对 | 用户与模型 |
| ④ | tool results | 占总量 70–80%【文献】 | ✅ 每次工具调用都涨 | 环境(文件多大就多大) |
| ⑤ | thinking blocks | 每轮几百到几千 | ✅ | 模型 |
| ⑥ | 注入附件 | 500–20K token | ⚠️ 部分每轮变 | harness(用户看不见) |
这张表最重要的一行是 ④。 记住这个数字:70–80%。
2.3 为什么工具输出是大头,而这件事被系统性低估
直觉上你会觉得对话历史是膨胀源——这个直觉来自聊天机器人时代, 那时候确实没有工具调用,历史是唯一的增长项。
但 agent 不一样。看几个具体的量【文献 + 常识估算】:
| 一次操作 | 典型 token 量 |
|---|---|
| 用户说一句话 | 10–50 |
| 模型回一段解释 | 200–800 |
| 读一个 500 行的源文件 | 2,000–4,000 |
| 跑一次测试套件(含失败堆栈) | 1,000–10,000 |
grep 一个常见词 | 500–5,000 |
| 一次网页抓取 | 2,000–20,000 |
一次 read 就顶几十句对话。 十轮工具调用之后, 你亲手写的 prompt 在整个上下文里的占比已经可以忽略了。
这推出了本章最重要的工程结论:
上下文优化的最大杠杆是工具输出管理,不是 prompt 优化。 在工具输出进入上下文之前就控住它,ROI 远高于反复调 prompt 措辞。
有个可以引用的生产数字【文献】:某系统月费 $340,其中大部分来自 80K token 的工具输出, 而其中实际有用的信息只有 12K。也就是说 85% 的钱花在噪声上。
2.4 一个容易漏的坑:并行工具调用的聚合爆炸
这一条是"只有真写过才会知道"的边界条件,面试里说出来很有分量。
假设你给单个工具结果设了 50K 字符的预算上限,看起来很安全。 但现代 agent 会并行调工具——一轮里同时发 10 个 bash。 每个都返回 40K(都在单体预算内,谁都不违规),一轮就是 400K 字符。
所以除了「单个工具结果预算」,还必须有「单条消息内所有工具结果之和的聚合预算」。 Claude Code 的两个常量是这样的【源码】:
DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000 // 单个工具结果
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000 // 一条消息内所有工具结果之和💡 这类"两级预算"是通用范式:凡是"N 个个体各自受限、但可以同时出现"的场景, 都需要个体预算 + 聚合预算两层。数据库连接池、并发请求限流、 甚至日志采样都是同一个形状。
2.5 第六块最值得警惕:你看不见的注入
第 ⑥ 块(注入附件)在概念文章里几乎不被提及,但在真实 harness 里它是一个 持续泄漏源,因为它是 harness 自动加的,用户和作者都不容易察觉。
sid-code 里注入的附件有十几种,每种带一个优先级数字【源码 packages/core/src/config/attachments.ts】:
export const PRIORITY = {
CRITICAL_REMINDER: 1, // 关键系统提醒
DATE_CONTEXT: 2, // 当前日期
MODE_REMINDER: 5, // Plan/Delegate 模式提醒
SKILL_LISTING: 8, // Skill 摘要列表
CLAUDE_MD: 10, // 项目规则
OUTPUT_STYLE: 12, // 输出风格
DIAGNOSTICS: 15, // 诊断信息
IDE_SELECTION: 20, // IDE 选中代码
IDE_OPEN_FILES: 25, // IDE 打开的文件
MEMORY: 30, // 记忆
MEMORY_RECALLED: 32, // 动态召回的记忆
SESSION_MEMORY: 33, // 会话记忆摘要
TODO_LIST: 35, // todo 列表
DENY_RULES: 38, // 权限 deny 规则
GIT_STATUS: 40, // git 状态
APPEND_PROMPT: 50,
FILE_PROMPT: 60,
};为什么要有优先级数字? 两个用途,第二个才是关键:
- 预算不够时按优先级砍——数字小的留下。这个用途很直观。
- 决定它在上下文里的位置——而位置决定它能拿到多少注意力(§4.4), 同时决定它会不会破坏缓存(§6)。
注意 DATE_CONTEXT: 2 那条的注释,它非常能说明问题【源码】:
当前日期等每日变化的易变值。必须落在 DYNAMIC_BOUNDARY 之后(动态区), 否则跨天首次请求会击穿静态前缀缓存(cache_creation 全价重算)。 放在动态区最前部,紧跟静态区,注意力位置最优。
一个「今天是 2026-08-30」这样十几个 token 的东西, 放错位置的后果是每天第一次请求全价重算整个前缀。 这就是 §6 要讲的核心——先记住这个例子,它是理解缓存约束最好的入口。
2.6 本章自检
- 上下文的六个组成部分是哪些?哪一块占 70–80%?
- 为什么"对话历史是主要膨胀源"这个直觉在 agent 上是错的?
- 单个工具结果预算之外为什么还要聚合预算?举一个爆炸场景。
- 一个十几 token 的日期字符串,为什么可能造成很大的成本?
§3 四个核心操作:给上下文问题分类
上一章讲了上下文里有什么,这一章讲你能对它做什么。 业界收敛出的框架是四个操作:Write / Select / Compress / Isolate。
这个框架的价值不是它多深刻,而是它让你能给任何上下文问题归类。 面试里被问一个模糊的问题("上下文太长怎么办"), 你能先分类再回答,就比直接说"做摘要"高一个层级。
3.1 四个操作各自是什么
| 操作 | 一句话 | OS 类比 | 典型手段 | 代价 |
|---|---|---|---|---|
| Write | 把信息移到上下文之外,留个指针 | 换出到磁盘 | 文件系统、todo.md、记忆文件、工具结果落盘 | 需要恢复通道;I/O |
| Select | 每轮只放相关的进来 | 页面调入 | RAG、按需加载工具、动态召回记忆 | 可能漏掉关键信息 |
| Compress | 让已有内容占更少 token | 内存压缩 | 截断、Head-Tail、microcompact、LLM 摘要 | 信息损失;破坏缓存 |
| Isolate | 让不同关注点在不同上下文里 | 进程隔离 | 子代理、多 agent、Skill | token 总量上涨;交接丢信息 |
3.2 Write:为什么"文件系统即记忆"是个关键洞察
Write 的核心思想反直觉但一旦想通就非常自然:
信息不是被丢弃了,而是被移到了更便宜的存储里。
对比一下两种做法。一个 3000 行的文件读进来了,占 4000 token:
| 做法 | 上下文里留什么 | 能恢复吗 | 属于 |
|---|---|---|---|
| 截断 | 前 100 行 + ...(已截断) | ❌ 后面的永久没了 | Compress |
| 落盘 + 指针 | 前 50 行预览 + 完整内容见 /tmp/xxx.txt | ✅ 模型可以再 read | Write |
第二种几乎总是更好,因为它把「不可逆的信息损失」换成了「一次可选的额外读取」。 这就是所谓可恢复外化(restorable externalization)。
文件系统具备上下文窗口不具备的四个属性,这是它成为首选载体的原因:
- 容量无限(相对而言)
- 持久化——跨会话、跨进程重启
- 结构化——目录、命名、可组织
- 可操作——模型能用已有工具(read/grep/ls)去访问它,不需要新机制
第 4 点最容易被忽略但很重要:外部存储必须是模型用现有工具就能访问的。 如果你把内容存进一个只有你的代码能读的内存 Map,模型拿不到,那不叫 Write, 那叫丢弃。
⚠️ 一条实测教训【实测】:有研究把恢复通道完全关掉(只截断、不给路径), 结果能力从 80.9 掉到 77.1,而成本反而上涨($4.03)。 原因是 agent 会"补偿性重试"——它发现信息不够,就重新执行工具去拿, 产生新的输出,上下文涨得更快。
只截断不给恢复通道,是负优化。 这句话在面试里非常有说服力, 因为它说明你理解了 agent 会主动行动这个特性。
3.3 Select:预加载 vs 按需,一条清晰的判据
Select 的核心 trade-off 是预加载(eager)vs 按需检索(lazy)。 纠结的根源是:你无法提前知道 agent 在第 N 步需要什么。
判据其实很清楚,看两个维度:
| 几乎必定需要 | 只有部分场景需要 | |
|---|---|---|
| 量小 & 静态 | ✅ 预加载(还能吃到缓存,接近免费) | 预加载(量小,浪费也有限) |
| 量大 或 动态 | 预加载但要设预算上限 | ✅ 按需检索 |
具体到 coding agent 的混合模型:
- 项目规则(CLAUDE.md):量小、会话内不变、几乎每轮都相关 → 预加载, 且放进静态缓存区,成本接近零。
- 代码文件:量大、只有少数相关 → 按需检索,给模型 glob/grep/read 工具自己找。
那个 46.9% 的 Cursor 数据【文献】就是这条判据的证据: 工具定义原本被当成"量小静态"预加载全量,但实际上只有部分场景需要, 改成按需后 token 降了近一半、效果还更好。
3.4 Compress 与 Isolate:本文后面各有专章
- Compress 是本文最厚的部分,§6–§10 全在讲它。 这里只先埋一个判断:Compress 是四个操作里唯一会"破坏缓存"的, 这个副作用大到足以主宰整个设计(§6)。
- Isolate 在 §11 讲。这里先埋一个反直觉: 信息论上,固定 token 预算下单 agent 的信息效率严格优于多 agent【文献】。 多 agent 不是免费的升级,它是一个用 token 总量换上下文质量的交易。
3.5 四个操作的优先级:一个可以直接说出口的排序
如果面试官问"多个手段怎么选",这个排序是安全且有依据的:
① 一开始就别让它进来 (Select:按需加载、精准检索)
↓ 进来了但太大
② 移到外部存储,留指针 (Write:可恢复外化)
↓ 还是太多
③ 无损/可逆的压缩 (Compress-compaction:截断可再生内容)
↓ 还是不够
④ 有损摘要 (Compress-summarization:花一次 LLM 调用)
↓ 单上下文根本装不下
⑤ 拆成多个上下文 (Isolate:子代理)为什么这个顺序:按「信息损失程度 × 成本」联合递增排。 ①②几乎无损,③可逆,④不可逆,⑤会引入新的一类问题(交接丢信息)。
💡 面试话术:"我的原则是从入口开始往后退:先看能不能不让它进来, 再看能不能移到外部留指针,再看能不能可逆压缩,最后才用 LLM 摘要。 因为摘要不但要花一次调用,它还是不可逆的——一旦走到那一步, 细粒度上下文就永久变成一段文字了。"
3.6 本章自检
- 四个操作分别对应什么 OS 概念?
- "截断"和"落盘+指针"哪个更好,为什么?只截断不给恢复通道会怎样?
- 预加载 vs 按需检索的判据是什么两个维度?
- 四个操作的优先级排序是什么,排序依据是什么?
§4 Context Rot:更大的窗口救不了你
这一章讲本领域最反直觉、也最重要的一个事实。 如果只能记住本文一件事,记这一章。
4.1 直觉与现实
直觉:窗口越大越好。1M > 200K > 128K。窗口大了,把所有东西塞进去就行, 压缩这些麻烦事都不需要了。
现实【文献】:Chroma 2025 测了 18 个前沿模型,全部随输入长度增加而性能下降, 无一例外,而且远在窗口填满之前就开始了。一个 200K 窗口的模型, 在 50K token 时就已经显著退化。1M 窗口的模型,同样在 50K 时退化。
也就是说:问题不是空间不够,是噪声填满了空间。
几个能引用的对照数据【文献】:
| 对照 | 结果 |
|---|---|
| RAG 精选 16K token vs 全量 128K token | 44.43 F1 vs 34.32 F1——少给它看,效果更好 |
| 20–30 个文档场景 vs 完全不给文档(closed-book) | 给了文档反而低于 closed-book 基线 |
| 仅增加长度(保持完美检索质量) | 性能下降 13.9%–85% |
| 某系统 140K token 上下文 → 精简到 6K | 准确率从 <90% 提升到 90%+ |
最后一行值得停一下:140K 压到 6K,准确率提升。 如果你只记一个数字来说服别人"精简比扩容重要",用这个。
4.2 为什么会这样:注意力是零和的
机制不复杂:self-attention 让每个 token 去关注所有其他 token, 而"关注度"的总量是有限的(softmax 归一化,所有权重加起来是 1)。 token 越多,平均每个 token 分到的注意力越少。
所以这不是"模型不够努力",是架构层面的结构性弱点。 换个更强的模型能提高绝对水平,但退化曲线的形状不变。
4.3 三种要区分的失效模式
这三个词经常被混用,但机制不同、修法不同。面试里能区分它们是一个明确的加分项。
| Context Rot | Lost in the Middle | Context Pollution | |
|---|---|---|---|
| 是什么问题 | 长度问题 | 位置问题 | 质量问题 |
| 表现 | 越长越差,渐进 | 中间的信息被忽略,两头记得清 | 无关信息干扰了有用信息 |
| 修法 | 缩短(压缩、隔离) | 挪位置(重要的放两头) | 清理(删掉过时/无关的) |
| 能同时发生吗 | — | ✅ 常与 Rot 同时 | ✅ 常与前两个同时 |
还要区分 Rot 和 Overflow:
- Overflow = 超过窗口上限,API 报错。硬失败、有报错、好处理。
- Rot = 渐进退化,没有任何报错。软失败、静默、更危险。
⚠️ 一个判读经验:如果你的 agent"没报错但越来越蠢", 不要去查 overflow(它会报错的),去查 Rot 和 Pollution。
4.4 注意力偏差:不要对抗,要利用
注意力在位置上的分布是 U 型的:
注意力
↑
高 │ ● ●
│ ● ●
│ ● ●
低 │ ● ● ● ● ● ● ● ● ● ● ● ● ● ●
└────────────────────────────────────────→ 位置
开头 中间 结尾
(首因效应) (中间迷失,准确率降 30%+) (近因效应)成熟的做法不是对抗这个偏差,而是利用它——把信息按重要性排到对应位置:
| 位置 | 放什么 | 理由 |
|---|---|---|
| 开头 | 系统提示词、安全约束、身份定义 | 首因效应;而且它稳定,还能吃缓存 |
| 中间 | RAG 检索的文档、历史工具输出 | 丢了细节不致命,且这些是可恢复的 |
| 结尾 | 用户当前问题、当前目标、todo 列表 | 近因效应,最强的记忆位置 |
这就解释了 §2.5 那张优先级表为什么长那样: CRITICAL_REMINDER: 1 在最前,GIT_STATUS: 40 在最后, 不只是"重要性排序",也是注意力位置分配。
一个可以直接抄的技巧——目标复述【文献,Manus】: 在每一步都把「当前目标」重新追加到上下文末尾(比如维护一个 todo.md 并每轮重写)。 它利用近因效应,防止长循环里的目标漂移。 成本是几十个 token,收益是 agent 不会在第 40 轮忘了自己在干什么。
4.5 一个反直觉里的反直觉:打乱顺序反而更好
【文献】Chroma 还有一个发现:把上下文文本打乱顺序后,所有模型表现反而提升。
这说明 Transformer 的注意力机制不是像人一样"阅读"连贯文本,而是在做"检索"。 逻辑连贯的长文本反而让它产生某种系统性偏差。
工程启示:不要追求上下文的叙事连贯性,要追求信息的可检索性—— 每条信息应该是自包含、可独立理解的。
具体做法上的差别:
❌ 依赖连贯性的写法(前后耦合):
"如上所述,我们决定用方案 B。因此下一步要改那个文件。"
← "如上所述"指哪里?"那个文件"是哪个?压缩之后这句话就废了
✅ 自包含的写法:
"决定:用 PostgreSQL(不用 MySQL),因为需要 JSONB。
下一步:改 src/db/config.py 的连接串。"
← 单独拿出来也完全可读,压缩后依然有效这条对写摘要模板和写 CLAUDE.md 都直接适用。
4.6 于是:占用率该控制在多少
既然远未填满就退化,那实践上该用到几成?
业界经验值和研究指向同一个区间:50–65%,而不是用满。 一个机制层面的依据【文献】:超过约 50% 使用率后, 注意力模式会从 U 型变成纯近因偏差——早期信息几乎被完全忽略。
这有一个很实用的推论:
上下文超过 50% 之后,你为早期信息付的 token 费基本是白花的—— 模型已经不怎么看它了。此时不压缩不是"保守",是浪费钱。
sid-code 的三层阈值可以对照看【源码 packages/core/src/context/manager.ts】:
const BUFFER_THRESHOLDS = {
masking: 80_000, // 剩余 ≤80K → 遮罩工具输出
compression: 60_000, // 剩余 ≤60K → LLM 摘要
emergency: 40_000, // 剩余 ≤40K → 强制截断
};换算到 200K 窗口:masking 在 60% 占用时触发,compression 在 70%。 基本落在上面那个区间的上沿。(为什么用"剩余多少"而不是"占用百分比",是 §8 的主题, 那一章会讲这个选择背后的完整论证。)
4.7 本章自检
- Chroma 的结论是什么?为什么它意味着"扩容不是解法"?
- Context Rot / Lost in the Middle / Context Pollution 三者的机制和修法各是什么?
- Rot 和 Overflow 哪个更危险,为什么?
- 注意力 U 型分布该怎么利用?开头/中间/结尾各放什么?
- 为什么"上下文超过 50% 后不压缩是在浪费钱"?
§5 组装:上下文是怎么拼出来的
前四章讲了「里面有什么」「能做什么」「为什么会坏」。 这一章讲流程:从用户按回车,到 HTTP body 发出去,中间到底发生了什么。
这一章的作用是给后面的 §6–§10 提供一张地图。压缩发生在这条流水线的哪一步、 为什么在那一步,看懂这张图就都清楚了。
5.1 完整流水线
用户回车
│
├─① 收集静态上下文(会话开始时算一次,之后缓存不再重算)
│ ├─ 身份/角色定义
│ ├─ 工具使用指南、约束
│ ├─ 项目规则(CLAUDE.md)
│ └─ 环境信息(OS、cwd、git 分支…)
│
├─② 收集动态附件(每轮可能变)
│ ├─ 当前日期
│ ├─ todo 列表
│ ├─ git status
│ ├─ 动态召回的记忆
│ └─ IDE 选中代码、诊断信息…
│
├─③ 按优先级排序 + 按预算截断
│ (§2.5 那张 PRIORITY 表在这里生效)
│
├─④ 拼装 system prompt:静态区 ┃ DYNAMIC_BOUNDARY ┃ 动态区
│ (这条分界线是 §6 的核心,先记住它存在)
│
├─⑤ 组装 messages 数组 = 全部历史 + 本轮用户输入
│
├─⑥ ★ 压缩管线(这一步是 §7 的全部内容)
│ ① 工具结果预算 → ② 裁剪最早消息 → ③ 清理旧工具结果 → ④ LLM 摘要
│ ↑ 按成本从低到高,够了就停
│
├─⑦ 打 cache 断点(§6)
│
├─⑧ 协议适配(不同 provider 的线格式差异)
│
└─⑨ HTTP POST → 流式接收 → 执行工具 → 回到 ⑤5.2 为什么静态上下文要 memoize(缓存住不重算)
第 ① 步有个细节值得单独讲,因为它是理解整个设计哲学的入口。
静态上下文(项目规则、环境信息)在 Claude Code 里是被 memoize 包起来的 ——整个会话只计算一次。这意味着 git status 的内容是会话开始那一刻的快照, 之后你 commit 了、切了分支,它都不更新。
第一反应会觉得这是 bug:为什么不实时?信息不就过时了吗?
理由是缓存【源码,Claude Code context.ts】:这些内容位于 prompt 最前面, 是缓存前缀的一部分。如果每次请求都重新算,内容可能微妙变化 (git status 变了一行),整个前缀缓存就失效了。
于是这是一个明确的取舍:
宁可用"会话开始时的快照",也不要"实时但破坏缓存"的内容。
同样的逻辑解释了 sid-code 为什么要把「当前日期」单独挪到动态区【源码】—— 日期每天变一次,放静态区会导致每天第一次请求全价重算。
💡 这个取舍的一般形式:准确性 vs 缓存稳定性。 在 prompt 缓存的世界里,"实时更新"不是免费的美德,它有明确的价格。 需要实时的东西,就必须放到分界线之后(成本更高的区域), 而不是"顺手放进去反正都在 system prompt 里"。
5.3 一个不容易想到的坑:动态区在不同 provider 上语义不同
这一条比较进阶,但它是"真做过多 provider"才会遇到的, 面试里说出来分量很足【源码 packages/core/src/config/system-prompt.ts】。
sid-code 的注释里写着这么一条不变量(下面是要点转述):
DYNAMIC_BOUNDARY之后的内容在 OpenAI 族会被搬回 user 消息里 ——所以"放 system 不占 user turn"这个前提在那一族不成立。
拆开说:Anthropic 的 API 里 system 是独立字段,你可以往里塞多个 block; 但 OpenAI 族的协议里 system 的表达能力不同,适配层会把一部分内容搬进 messages。
后果:你在一个 provider 上验证过的"这个位置不影响缓存"的结论, 换一族可能完全不成立。
⚠️ 通用教训:上下文布局的结论是绑定 provider 的。 说"把 X 放进 system 就不占历史空间"之前,先问"哪一族的 system"。 这类跨 provider 的隐性前提是踩过才知道的——所以设计时要把它写成注释里的 显式不变量,而不是让下一个人重新踩。
5.4 为什么压缩挤在第 ⑥ 步(迭代开头)
看流水线你可能会问:压缩为什么放在这么靠后、又在 HTTP 发出之前的位置?
理由有两层,第二层才是关键:
第一层(直观):压缩的目的是给这一次 API 调用腾空间, 所以要在发请求之前的最后时刻做,才知道到底需要压多少。
第二层(关键)【源码,Claude Code query.ts 注释要点】: 每一层压缩跑在下一层之前,是为了如果这一层就够了,下一层就是 no-op。 而下一层的代价不只是"多花点钱",是信息粒度的永久损失。
这句话是 §7 的核心,这里先埋下:
多层管线里,每一层的产出不是"省了多少 token", 而是"让下一层不必执行"。
5.5 本章自检
- 完整流水线的九步是什么?压缩在第几步,为什么在那里?
- 为什么静态上下文要 memoize?它牺牲了什么?
DYNAMIC_BOUNDARY是什么?为什么日期要放在它之后?- 为什么说"上下文布局的结论是绑定 provider 的"?
§6 Prompt Cache:所有上下文策略的经济地基 ★
这一章是全文的转折点。
前面五章你可能已经形成一个印象:上下文管理就是"想办法让它变短"。 这一章要推翻它的一半——因为**"让它变短"这个动作本身是要付费的, 而且账单可能比省下的更贵。**
如果你在面试里只能展示一个"超出教程水平"的认知,选这一章的内容。
6.1 先讲机制:为什么会有缓存这回事
回到 §1.2 那个事实:每轮都要重发全部历史。于是服务端看到的是:
第 3 轮:[system, tools, msg₁, msg₂, msg₃, msg₄, msg₅]
第 4 轮:[system, tools, msg₁, msg₂, msg₃, msg₄, msg₅, msg₆, msg₇]
└────────────── 完全一样的前缀 ──────────────┘ └─ 新增 ─┘前缀完全一样,而 prefill 阶段算的 KV 向量只取决于前面的 token。 所以服务端可以把前缀的 KV 缓存起来,下次直接复用。
这就是 prompt cache(也叫 prefix cache)。 收益是两方面:
| 命中缓存 | 未命中 | |
|---|---|---|
| 价格 | 约为写入价的 1/10【实测】 | 全价 |
| 延迟 | 跳过 prefill,TTFT 显著下降 | 全量 prefill |
约 10 倍的成本差【实测,Manus 生产数据】。这个数量级决定了它不是"优化项", 而是经济可行性的前提。
6.2 核心约束:一个字节都不能变
缓存是前缀匹配的,而且是逐字节的。这推出了一条铁律:
从第一个 token 开始,只要有任何一个字节不同, 从那个位置往后的全部缓存都失效。
画出来是这样:
第 3 轮: [ A ][ B ][ C ][ D ]
第 4 轮: [ A ][ B ][ C ][ D ][ E ] → A B C D 全部命中 ✅
第 5 轮: [ A ][ B*][ C ][ D ][ E ] → 只有 A 命中,B 之后全部重算 ❌
└─ 只改了这里一个字符注意第 5 轮的严重性:你只改了中间一小块,但代价是从那里往后的所有内容 (可能是 150K token)全部按全价重新计算。
于是四类常见操作都是"缓存杀手",而它们看起来都很无辜:
| 操作 | 为什么破坏缓存 | 怎么修 |
|---|---|---|
| 在 system prompt 里放当前时间 | 每次请求都不同,从它往后全废 | 挪到动态区尾部,或干脆去掉秒级精度 |
| 每轮重排工具定义顺序 | 顺序变了就是不同的字节 | 固定顺序(比如按名字排序) |
| 在历史中间插入提醒 | 插入点之后全废 | 追加到末尾,不要插中间 |
| 压缩/修改旧消息 | 这就是本章的主题 | 见 §6.4 |
💡 工具顺序这条最容易漏:如果你的工具列表来自一个
Map或Set的遍历, 顺序可能在不同运行里不同(尤其是插件动态注册时)。 一次不稳定的排序就能让整个工具区每轮都 miss。
6.3 断点该打在哪:分区的思路
大多数支持缓存的 API 需要你显式标记哪里可以缓存(cache_control 之类)。 断点数量通常有硬上限(Anthropic 是 4 个)。所以你必须想清楚分几区。
合理的分法是按变化频率分,而不是按内容类型分:
┌─────────────────────────────────────────┐
│ ① 不缓存区(每次都不同,缓存了也没用) │ ← 用户 ID、请求 ID
├─────────────────────────────────────────┤
│ ② 核心静态区(跨会话都一样) [断点1] │ ← 身份、工具用法、通用约束
├─────────────────────────────────────────┤
│ ③ 组织/项目静态区(本项目内一样) [断点2] │ ← CLAUDE.md、团队规则
├─────────────────────────────────────────┤
│ ④ 工具定义区 [断点3] │ ← 工具 schema(顺序必须稳定!)
├═════════════ DYNAMIC_BOUNDARY ═══════════┤
│ ⑤ 动态区(会话内变) │ ← 日期、todo、git status
├─────────────────────────────────────────┤
│ ⑥ messages(每轮追加) [断点4] │ ← 打在最后一条 user 消息上
└─────────────────────────────────────────┘为什么分区比"打一个大断点"好:变化频率不同的内容混在一区, 那一区就以最快变化的那部分为周期整体失效。分开之后, 日期变了只影响⑤⑥,②③④照旧命中。
sid-code 的分区就是这个形状【源码 packages/core/src/api/cache-strategy.ts】: attribution(不缓存)/ corePrefix(global)/ staticExtensions(org)/ dynamic(会话内)。
6.4 一条容易搞错的规则:system 可以多断点,messages 只能一个
这是个很具体但很能显出功底的细节【源码,同上文件的注释】:
- System blocks:可以有多个
cache_control(每个 block 独立标记)—— OK。 system 总在请求最前面,服务端按序处理,多个 block 边界是合法的前缀缓存分层。- Messages 序列:只放 1 个
cache_control(最后一条或倒数第二条)。 原因:服务端 KV 驱逐策略下,messages 上多断点会导致中间位置的 KV pages 无法被及时释放,降低服务端内存效率。
两个位置规则不同,理由也不同——一个是"合法的分层", 一个是"会拖累服务端内存回收"。这不是同一条规则的两个应用,是两条独立的约束。
同一份注释里还有一条方法论层面很值得学的记录:
这个不变量在生产上由两件事共同保证,
clearCacheBreakpoints不在其中: ① 打标函数只打一个点就 return;② 每轮对新数组打标,跨轮不会累积。 上一版这里写的是"clearCacheBreakpoints 先清场即为此服务" —— 那描述的是 测试链,生产从不经过它。记着:清场之所以在生产上不必要, 是因为每轮数组是新建的;哪天改成复用同一份 messages,就必须重新引入清场。
这段值得单独指出来,因为它示范了一个很高级的文档习惯: 它不只说"现状是什么",还说清了"这个现状依赖什么前提,前提没了会怎样"。 面试里如果你能这样描述自己的设计("这条不变量靠 X 保证,如果哪天改了 Y 就必须补 Z"), 比单纯讲实现高一个档次。
6.5 ★ 于是压缩变成了一个悖论
现在把 §6.2 和压缩放在一起看:
- 压缩 = 修改历史消息
- 修改历史消息 = 前缀变了
- 前缀变了 = 缓存失效
- 缓存失效 = 整个前缀按全价重算
所以:压缩省下了未来的 token,但立刻付出一次全量重算的代价。
这个账要真算一下才有感觉。假设 200K 前缀:
| 成本 | |
|---|---|
| 命中缓存读一次 | 约 $0.003【源码注释里的量级】 |
| 缓存失效后全价重算一次 | 约 $0.60 |
200 倍。 也就是说,一次不必要的压缩, 可能把你后面几十轮省下的钱一次还回去。
这就得到了本章最重要的结论:
"清理上下文"不是一个免费的好事。它有明确的价格, 而且这个价格可能高于它省下的东西。
面试里怎么用这一点:当被问"上下文太长你会怎么做", 如果你的答案里没有提到缓存,那就是一个"只考虑了 token 数、没考虑成本结构"的答案。 一个更好的答案是从先问一个问题开始:
"我第一个要确认的是我们能不能控制 API 服务端。 如果能(自建推理、vLLM、有服务端 hook),可以做保留缓存的驱逐; 如果只能调三方 API,那任何客户端修改都要付缓存失效的账—— 这时候压缩必须攒批做,而不是每轮都做。"
6.6 两种解法:服务端删 vs 客户端改
上一节的悖论有两种解法,它们的可用性差别很大。
解法 A:服务端删除(需要 API 支持)
Anthropic 提供了 context editing 类能力(cache_edits / clear_tool_uses 之类), 语义是:本地消息数组一个字都不改,只在请求里附一句 "这几个 tool_use_id 对应的内容不要了",服务端在缓存查找之后才应用删除。
于是缓存前缀依然命中,同时上下文变小了。两头都要到了。
| 服务端删(A) | 客户端改(B) | |
|---|---|---|
| 删除位置 | 服务端缓存前缀 | 本地消息数组 |
| 缓存影响 | 保持命中 | 前缀失效 |
孤儿 tool_result 问题 | 不产生(服务端知道配对关系) | 需要自己写配对修复代码 |
| 可移植性 | 仅特定厂商 | 任意 provider |
最后一行是关键:这是一个平台独占优势。 通用 harness 做不到,只能走 B。 有社区实现报告服务端方案带来 29–39% 的性能提升【文献】。
解法 B:客户端改(谁都能做,但要付账)
既然要付账,那就想办法让账单最小。两个可行策略:
- 攒批:不要每轮清一点,而是攒到要清很多时一次清。 一次 miss 摊到很多轮上。
- ★ 挑"反正都要 miss"的时刻做——这是下一节,也是全文我最推荐的一个思路。
6.7 ★ 最漂亮的一个设计:把破坏性操作对齐到"代价已经产生"的时刻
这个设计我第一次读到时觉得非常漂亮,它是那种"想通之后觉得显然, 但自己不会想到"的东西。
问题:客户端清理会破坏缓存。那什么时候清理最好?
普通思路:找一个平衡点——清理频率太高则 miss 太多,太低则上下文太大。 于是去调参,测出一个"最优阈值"。
那个漂亮的思路:换个问题——缓存什么时候反正都会失效?
答案是:缓存有 TTL。服务端缓存大约 1 小时过期。 如果距离上次请求已经超过 60 分钟,缓存必然已经没了, 整个前缀无论如何都要重新计算。
既然反正要重写,那就在重写之前先把该丢的丢掉,让要重写的东西变少。
此时清理的成本严格为零——因为它没有制造任何一次本来不会发生的 miss。
Claude Code 的注释把这个论证写得非常清楚【源码 timeBasedMCConfig.ts】:
60 is the safe choice: the server's 1h cache TTL is guaranteed expired for all users, so we never force a miss that wouldn't have happened.
注意最后那半句,它才是这个设计的精髓: 他们没有去调优这个数字(比如测出"45 分钟命中率最高"), 而是选了一个能证明零副作用的值。
💡 这个思路的一般形式,可以迁移到任何地方:
凡是"操作 A 有副作用 B"的场景——缓存失效、索引重建、连接重连、锁升级—— 不要问"什么时候做 A 最好",问"B 什么时候反正都会发生",然后把 A 排到那个时刻。
这把一个需要调参的优化问题变成了一个可证明的免费操作。 调参的结论会随环境漂移,"可证明为零"的结论不会。
同一个论证还有另一个版本,也值得记【文献】: 到了 90%+ 占用非要做全量压缩时,反正也要毁缓存, 所以那一刻顺手做点别的破坏性清理也是免费的。
6.8 缓存感知的双路径:策略应该看缓存状态
把 6.6 和 6.7 合起来,就得到了一个双路径设计:
清理旧工具结果时,先看缓存冷热:
距上次响应 > 60 分钟(缓存必然已冷)
→ 【时间模式】直接改本地内容,激进清理
理由:缓存已经死了,保护它没有意义
距上次响应 < 60 分钟(缓存可能还热)
→ 【缓存模式】用服务端删除 API,本地不动
理由:要保住命中;本地改一个字节就全废
(若 provider 不支持 → 只能攒批,或干脆不清)这两条路径不是竞争关系,是根据缓存状态自动选择。 这是本章的收尾洞察:
压缩策略必须感知缓存状态。 同一个"清理"动作,在缓存热时和冷时应该用完全不同的做法。
💡 这个洞察在业界文献里讨论得很少,所以在面试里它是一个不错的差异化点。 一般形式:操作的成本取决于缓存状态,策略应该据此调整。 CDN 失效策略、数据库查询缓存、Service Worker 都适用同一条。
6.9 一条反直觉的推论:决策不能追溯,配置不能热更新
这一节是缓存约束推出的最反直觉后果,也是"纯函数思维"会栽的地方。
考虑这个场景:一个 60K 的工具结果,当轮因为预算没超所以完整发送了。 三轮之后上下文紧张了——能不能回头把它替换成摘要?
不能。 因为替换会改变消息内容 → 改变前缀 → 缓存失效。
所以真实实现里有一个「决策冻结」机制【源码,Claude Code toolResultStorage.ts 注释要点】:
- 已替换的:每轮从缓存的预览字符串逐字节相同地重新应用(零 I/O)
- 已发送但未替换的:永远不能在之后被替换
这推出三条会让人意外的结论:
① 压缩决策有一个不可逆的时间窗口。 只有在内容第一次进入上下文的那一刻能决定要不要压,错过就永久锁定。 压缩必须是 ingestion-time(入口时)决策,不能是 retroactive(追溯)决策。
② 配置变更不能追溯适用。 中途改了阈值配置,只影响新消息;已经做出的决策全部保持不变。 你学过的"热更新配置立即生效"在这里是错的——立即生效会毁掉缓存。
③ 压缩不是纯函数,是有持久化状态的操作。 "给定消息 → 压缩后消息"这个签名是错的。替换决策必须写进会话记录, 恢复会话(resume)时重建,才能做出和原会话逐字节相同的选择。 而 fork 子会话时也要克隆这份状态,否则子会话和主会话的线上前缀不一致 → 双方都 miss。
💡 面试话术:"这里有个反直觉的点:我们的压缩决策是入口时做的, 而且做完就冻结。一个大工具输出如果当轮没被替换,之后永远不能再替换它—— 因为改动已发送的内容会破坏 prompt cache 前缀。这也意味着压缩不是纯函数, 它的决策要持久化到会话记录里,resume 时重建,保证逐字节相同的选择。 我一开始把压缩当纯函数写,是这个约束把设计逼成了有状态的。"
6.10 一条设计原则:正确性判断不能交给远程配置
顺带一个小而精的点,它跟缓存无关但同属"配置系统设计"。
Claude Code 里有一条豁免规则:Read 工具的输出永不落盘。 理由很直白——把 Read 的输出存成文件、再让模型用 Read 读回来,是循环的。
关键在于这个检查的位置【源码注释要点】:
Checked before the GB override so
tengu_satin_quollcan't force it back on.
它放在远程配置读取之前。也就是说,远程 flag 也不能把它打开。 同一个文件里其他参数(阈值、开关)都是可远程调的,只有这一个是硬的。
💡 可迁移原则:区分调优参数和正确性不变量。 阈值、超时、批大小、采样率 → 可远程调; 会导致逻辑循环、数据损坏、安全绕过的判断 → 硬编码在配置读取之前。
远程配置的价值是快速调参,但它同时也是一个 "任何人都能远程把你的系统改坏"的通道。它该管参数,不该管不变量。
6.11 本章自检
- prompt cache 的机制是什么?命中与未命中的成本差多少量级?
- 四类"缓存杀手"操作分别是什么?工具顺序这条为什么容易漏?
- 为什么 system 可以多断点、messages 只能一个?两条理由不同在哪?
- 压缩的悖论是什么?200K 前缀命中 vs 重算的成本差是多少倍?
- "60 分钟阈值"的论证为什么漂亮?它的一般形式是什么?
- 为什么压缩决策不能追溯、配置不能热更新、压缩不是纯函数?
- 什么样的判断应该硬编码在远程配置之前?
§7 压缩是一条管线,不是一个函数 ★
上一章讲了压缩的代价。这一章讲压缩的结构。
大多数教程会把压缩讲成一件事:「上下文满了就调 LLM 做个摘要」。 这个描述不算错,但它漏掉了本领域最重要的一个架构判断:
压缩不是一个操作,是一条按成本递增的管线。 而排序的依据不是"能省多少 token",是"会损失多少信息粒度"。
7.1 先看"不分层"会发生什么
假设你只实现了一个压缩手段:满了就调 LLM 摘要。会发生什么:
| 场景 | 单一摘要方案的行为 | 问题 |
|---|---|---|
某一次 read 吐了 80K,其他都很小 | 触发全量摘要 | 明明只有一个大块要处理,却把整个历史变成了摘要 |
| 前 30 轮和当前任务无关了 | 触发全量摘要 | 连当前正在做的事也一起被摘要了,细节丢失 |
| 一小时前的旧工具输出堆积 | 触发全量摘要 | 花了一次 LLM 调用(3–10 秒 + 全量输入费用) |
共同问题:用一把最贵、损失最大的锤子敲所有钉子。
摘要是所有手段里最贵(一次完整 LLM 调用)且损失最大(不可逆、细粒度全没)的。 把它当唯一手段,等于每次上下文有压力都付最高的价。
7.2 四层管线:sid-code 的实际实现
sid-code 的管线是四层,注释直接写明了排序原则【源码 packages/core/src/query/compact/index.ts】:
/**
* 渐进式压缩管道入口
*
* 按成本从低到高依次尝试:
* ① applyToolResultBudget — 超大工具结果替换为占位符
* ② snipCompact — 裁剪最早的消息
* ③ microcompactMessages — 清理旧工具结果内容
* ④ autoCompact — 调用模型生成摘要(最后手段)
*/关键在于每一层之后都要重新测一次,够了就立刻返回:
// ① 工具结果预算
const budgetResult = applyToolResultBudget(currentMessages);
if (budgetResult.truncatedCount > 0) {
currentMessages = budgetResult.messages;
currentRatio = estimateRatio(currentMessages, options.maxTokens);
if (currentRatio <= targetRatio) {
// ★ 达标就返回,后面三层根本不执行
return { messages: currentMessages, steps, totalSavedChars, needsAutoCompact: false };
}
}
// ② snipCompact …(同样的形状:做完 → 重测 → 达标就返回)
// ③ microcompact …
// ④ 仍超标 → needsAutoCompact: true四层逐个来看:
| 层 | 做什么 | 成本 | 信息损失 | 可逆吗 |
|---|---|---|---|---|
| ① 工具结果预算 | 超大单个结果换成「预览 + 落盘路径」 | 零 API 调用,一次磁盘写 | 极小(原文在磁盘上) | ✅ 模型可 read 回来 |
| ② snip | 删掉最早的若干轮消息 | 零 | 中(那几轮真没了) | ❌ |
| ③ microcompact | 清空旧工具结果的内容,保留消息结构 | 零 | 中(可重新执行工具找回) | ⚠️ 需重跑工具 |
| ④ LLM 摘要 | 整段历史变成一段文字 | 一次完整 LLM 调用,3–10s | 大(细粒度永久消失) | ❌ |
7.3 ★ 排序依据不是成本,是信息粒度
这是本章的核心,也是我最推荐在面试里说的一句话。
你可能以为分层排序是"先做便宜的"——这只是表面。真正的依据是 【源码,Claude Code query.ts 注释要点】:
collapse 跑在 autocompact 之前——如果 collapse 把我们降到阈值以下, autocompact 就是 no-op,我们保住了细粒度上下文而不是一份摘要。
每一层的产出不是"省了 N 个 token",是"让下一层不必执行"。
为什么这个区分重要?因为如果你只按"省 token"排序,你会得出错的顺序。 按省 token 排,LLM 摘要显然最强(能省 80%+),应该先做。 但按信息粒度排,它必须放最后——因为一旦执行, 你就把"完整的对话细节"永久换成了"一段总结",这个损失是不可撤销的。
💡 面试话术:"我们的上下文压缩是一条按成本递增的管线, 但排序的依据不是'能省多少 token',是'信息粒度损失多少'。 前几层是零 API 调用的内容清理,最后一层是 LLM 全量摘要—— 一旦走到那里,细粒度上下文就永久变成一份摘要了。 所以每一层的真正价值是让下一层不必执行。"
7.4 第①层细看:为什么"落盘 + 指针"比截断好
第①层的做法是:超过预算的工具结果,原文写到磁盘,上下文里换成 「前若干字节预览 + 文件路径」。
对比三种处理方式:
原始:一个 200K 字符的命令输出
方案 A(直接丢): [输出过大,已丢弃]
→ 模型不知道里面有什么,也拿不回来。最差。
方案 B(纯截断): 前 2000 字符... [已截断]
→ 模型只看到开头。如果关键信息在结尾(常见!)就废了,
而且它会倾向于重新执行命令 → §3.2 那个负优化
方案 C(落盘+指针): 前 2000 字符...
[完整输出已保存至 /tmp/sess-x/result-t1.txt]
→ 模型能判断要不要看全文,要就 read 一下。★C 的关键不是"省了 token",是"把不可逆损失换成了一次可选的额外读取"。
实现上有两个容易踩的细节,都值得记:
① 写文件要用排他标志,不要 stat-then-write【源码注释要点】:
// tool_use_id 每次调用唯一,内容对给定 id 是确定的,所以文件已存在就跳过。
// 这防止 microcompact 重放原始消息时每轮重写同样的内容。
// 用 'wx' 而不是先 stat 再 write,避免竞态。
await writeFile(filepath, contentStr, { encoding: 'utf-8', flag: 'wx' })两个点:用 wx(排他创建)避免竞态;靠 tool_use_id 唯一性做天然幂等。
② Read 工具的输出必须豁免——§6.10 讲过: 把 Read 的输出存成文件让模型用 Read 读回来是循环的。
7.5 第③层细看:不是所有工具输出都该清理
microcompact 只清理旧工具结果的内容,但它不能一刀切—— 这就要求一个「哪些工具的输出可以清」的判断。
Claude Code 用的是硬编码白名单【源码】:
const COMPACTABLE_TOOLS = new Set([
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,
])判据是「可恢复性」:文件能重新读、搜索能重新跑、网页能重新抓。 而不在名单里的(比如"问用户"的回答、子代理的返回结果)清掉就真没了。
sid-code 做了一个更细的三档划分,注释里说明了为什么【源码 packages/core/src/query/compact/microcompact.ts】:
/** 可丢弃工具(输出可重新生成,压缩时可完全清空) */
const DISCARDABLE_TOOLS = new Set([
"fileread","read","readmany","bash","grep","glob","ls",
"websearch","webfetch","toolsearch",
]);
/** 不可丢弃工具(输出不可复现,压缩时保留摘要) */
const NON_DISCARDABLE_TOOLS = new Set(["edit","write","memory","askuser"]);注释里给出了理由:
为什么要区分:
edit/write等工具的输出无法靠"重新执行"复现(它们有副作用), 盲目清空会导致后续对话丢失"我改了什么"的关键信息。
三档的处理不同:
| 档 | 例子 | 怎么处理 | 理由 |
|---|---|---|---|
| 可丢弃 | read / bash / grep / glob | 完全清空 | 重新执行就能找回 |
| 不可丢弃 | edit / write / memory / askuser | 保留前 200 字符摘要 | 有副作用,不可复现 |
| 未分类 | 任何新工具 / MCP 工具 | 通用占位符,只标原始长度 | 保守——不认识就别激进 |
第三档的存在是个好设计。 未知工具的默认行为是保守而非激进—— 新接一个 MCP 工具时不会因为"没登记"就被误清。
💡 一个更值得学的地方:这里用的是硬编码白名单, 而不是"用小模型评估每个工具结果的当前价值"。
后者听起来更聪明,但实际更差:评估器本身有延迟和成本、准确性不可靠、 还引入了一个新的失败点。而「工具类型 + 时间」这两个代理指标 在大部分情况下已经足够好,且完全确定。
工程里"足够好且可靠"胜过"理论最优但复杂"——这句话在面试里比任何 精巧方案都更能显出成熟度。
7.6 一个不能不面对的现实:分层可能"名存实亡"
这一节是那几份源码研究文档里最有价值的一个发现,也是本文少数直接讲 "别人的文档骗了你"的地方。
Claude Code 的压缩层在文档和架构图里被画成五层、六层甚至七层。 但如果你去核运行时而不是代码结构,图景是这样的【源码, 外部构建 + 默认 flag 状态下】:
| 层 | 默认状态 | 谁在兜底 |
|---|---|---|
| ① 工具结果预算 | ❌ flag 默认 false | autocompact |
| ② snip | ❌ 5 行 no-op 空壳 | autocompact |
| ③ microcompact 缓存模式 | ❌ 全是 stub(return false) | autocompact |
| ③' microcompact 时间模式 | ❌ 默认 false(远程可开) | autocompact |
| ④ context collapse | ❌ feature-gated | autocompact |
| ⑤ session memory 压缩 | ⚠️ 文件第一行写着 EXPERIMENT | 全量压缩 |
| ⑥ auto compact(LLM 摘要) | ✅ 默认开 | 无(重试失败后放弃) |
七层里六层默认不生效。 也就是说对外部用户而言, "分层压缩顶住上下文压力"这个说法不成立—— autocompact 承担了几乎全部压力。源码注释自己也承认了: autocompact handles context pressure instead。
这不意味着分层架构是假的——它是真实存在、可以逐层灰度放量的实验矩阵。 但它给出了一个读源码的通用检查步骤:
⚠️ 读完实现之后,必须再回答一个问题: 「这段代码在默认配置下会执行吗?」
三道门任意一道关着,这段代码就是死的: ① 编译期常量 / feature flag;② 远程配置默认值是 false; ③
if (env.USER_TYPE !== 'ant') return之类的早退。
这条对调研竞品和评估自己的系统同样适用。 "我们有 X 层压缩"和"我们有 X 层压缩在跑"是两句不同的话, 而后者才是能拿来对比的。
💡 面试里怎么用:如果被问"你怎么调研一个开源项目的能力", 这是个很好的答案:"我会区分三档——代码里有、默认开着、 真实会话里被触发过。只数第一档会系统性高估对方(也会高估自己)。 我见过被文档画进架构图、配了完整说明的一个'压缩层', 在发布产物里是
return null。"
7.7 相邻层之间的两个必须验证的性质
分层不是"把几个函数排成一列"就完事。相邻层之间有两个性质必须验证, 否则会出很难查的 bug。
① 正交性(可交换性):两层的操作互不干扰。
举个具体例子:第①层改内容但不改 tool_use_id; 第③层只看 tool_use_id 不看内容。所以两层的执行顺序可以交换,结果一致。 这个性质要显式论证,不能假设。
如果不正交会怎样:假设①层删掉了整条消息,而③层记录了"要清理 id=t5 的内容", 那③层就会去操作一条已经不存在的消息——报错,或者更糟,静默做错事。
② 互斥性(触发区间不重叠):两层不能在同一个压力水位上同时触发。
这里有个非常典型的真实冲突【源码注释要点】:
context collapse: 90% 开始提交,95% 阻塞
autocompact: 约 93% 触发
↑ 恰好夹在中间后果是:autocompact 会抢先执行,并把 collapse 正要精细保存的 细粒度上下文一把摘要掉。 两个独立开发的子系统, 各自的阈值在数值上重叠,产生了竞争条件。
修法不是调阈值,而是显式互斥:collapse 开启时直接禁用 autocompact 的主动触发, 只留它作为硬错误的兜底。
⚠️ 这类 bug 在单元测试里几乎抓不到——每个模块单测都过, 只有在生产环境的特定水位下才暴露。 所以设计多层策略时,触发区间要画在同一张图上看,而不是各写各的。
💡 可迁移到任何降级链路:缓存层级(L1→L2→DB)、 限流降级(拒绝→排队→降配→熔断)、检索管线(关键词→向量→重排→LLM)。 设计时问三个问题: ① 每层省下的是什么资源(不只是量,还有质)? ② 相邻层可交换吗(正交性)? ③ 触发区间重叠吗(互斥性)?
7.8 本章自检
- 为什么"只用 LLM 摘要"是个坏设计?举两个具体场景。
- 四层管线各是什么?各自的成本与信息损失如何?
- 分层排序的依据是"成本"还是"信息粒度"?这个区分为什么重要?
- 落盘+指针 vs 纯截断 vs 直接丢,三者差别在哪?
- 为什么工具输出要分「可丢弃/不可丢弃/未分类」三档?第三档为什么重要?
- 为什么用硬编码白名单而不是"小模型评估价值"?
- 读源码时"这段代码默认会执行吗"该怎么查?三道门是什么?
- 相邻层要验证哪两个性质?举一个互斥性失败的真实例子。
§8 阈值:为什么"90% 触发压缩"是个假数
这一章讲一个具体但很能拉开差距的点:触发阈值该怎么定。
面试里被问到这个,一般答案是"上下文用到 90% 就压缩"。 这个答案有三个问题,而讲清这三个问题就足以让你的回答比别人好一档。
8.1 先说结论:源码里根本没有百分比
流传最广的说法是「上下文窗口 × 0.90」,官方 cookbook 也建议 「200K 窗口在 180K 处触发」。但真去读源码,Claude Code 的阈值是两次减法 【源码 autoCompact.ts】:
// 为压缩时的输出预留。基于摘要输出长度的 p99.99 = 17,387 tokens。
const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
// 有效窗口 = 真实窗口 − 摘要输出预留
// 触发阈值 = 有效窗口 − 13K也就是 window − 20K − 13K。百分比只是这个减法在 200K 窗口下的巧合:
200K 窗口:
有效窗口 = 200,000 − 20,000 = 180,000
触发阈值 = 180,000 − 13,000 = 167,000
→ 占真实窗口 83.5%
1M 窗口:
有效窗口 = 1,000,000 − 20,000 = 980,000
触发阈值 = 980,000 − 13,000 = 967,000
→ 占真实窗口 96.7%同一份代码,200K 下是 83.5%,1M 下是 96.7%。 所以"90% 触发"这句话,在两种窗口下都不对。
8.2 为什么要为"输出"预留输入空间
上面那个 MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 第一眼很奇怪: 摘要是输出,输出为什么占输入窗口?
答案是 API 契约:多数 API 要求 input_tokens + max_tokens ≤ context_window。 你声明 max_tokens: 20000 的那一刻,可用输入就只剩 window − 20000。
所以这个预留不是"保险起见",是硬要求:
压缩请求本身要输出一份长摘要,所以它必须提前把这块空间从预算里扣掉。 如果等到只剩 3K 才触发压缩,压缩请求自己就会因为输入超长而失败。
这条推出一个漂亮的自指结论,面试里很好用:
压缩的触发点必须留出"够跑一次压缩"的空间。 压缩是一次 LLM 调用,它也要占窗口。
顺带注意 20_000 这个数的来源:摘要输出长度的 p99.99(17,387,向上取整)。 这不是"留点余量",是用生产分布的极值定容。 如果面试里被问"这个常量你怎么定的", "我们取了生产分布的 p99.99 然后向上取整"是个非常好的答案。
8.3 绝对值 vs 百分比:一个真实的取舍
现在的问题是:阈值该用绝对值(window − 33K)还是百分比(window × 0.9)?
绝对值的优点:
- 行为可预测——无论什么模型,都是"剩 33K 时触发"
- 跨会话可直接对比(数据分析友好)
- 数值来自生产分布,有依据
绝对值的问题:它隐含假设了"窗口大小是常量"。
200K 窗口:缓冲 33K = 16.5% 的窗口
1M 窗口: 缓冲 33K = 3.3% 的窗口 ← 相对占比缩水 5 倍而 1M 窗口的会话往往有更大的单轮工具输出(一次 read 就能吐 50K)。 也就是说:窗口越大,"一轮之内直接冲过阈值撞上硬限制"的风险越高。
所以更稳的写法可能是 max(13_000, window × 0.03)——保留绝对下界, 同时让缓冲随窗口线性增长。
但有一个细节说明源码作者知道百分比这个选项,而且刻意限制了它: CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 这个环境变量存在, 但它被包在 Math.min 里:
return Math.min(percentageThreshold, autocompactThreshold)百分比只能把阈值调低,不能调高。 减法的结果是硬上限。
这是个很克制的设计——测试可以让压缩更早触发(好测), 但谁也别想让它更晚触发(危险)。注释也写明它是 Override for easier testing,即给测试用的,不是给 1M 窗口用的。
💡 这里有一条我认为很值得学的判断: 我最初也觉得"绝对值缓冲不随窗口缩放"是个缺陷。 但读到
Math.min那段之后应该改看法——不确定的参数化不如确定的常量,因为常量的失效模式是可预测的, 而参数化的失效模式取决于"谁配了什么"。
本能地把所有常量变成配置项之前,先问一句: "这个参数被错配之后的后果是什么?"
8.4 sid-code 的做法:地板而不是减法
sid-code 面对同样的问题给了一个不同的解,而且它的注释把论证过程完整写下来了 ——这段值得整段读【源码 packages/core/src/context/manager.ts】。
它的三层阈值也是绝对值:
const BUFFER_THRESHOLDS = {
masking: 80_000, // 剩余 ≤80K → 遮罩工具输出
compression: 60_000, // 剩余 ≤60K → LLM 摘要
emergency: 40_000, // 剩余 ≤40K → 强制截断
};注释说明了为什么放弃原先的百分比:
旧值(百分比,已废弃):soft=0.50 / hard=0.70 / emergency=0.94 百分比在不同窗口模型下行为不可预测(32K 窗口 50%=16K 过早, 200K 窗口 50%=100K 过晚)
这是"多模型 provider"场景下的额外压力:窗口从 32K 到 200K 都有, 一个百分比不可能同时适配。
然后是最精彩的一段——「完成缓冲区」为什么是地板而不是减法:
语义:触发压缩时,剩余空间至少要够「把当前这一轮说完 + 跑一次摘要」, 否则压缩本身都可能因窗口不足而失败。
关键设计:缓冲区是「地板」而非「减法」。 直觉做法是
有效窗口 = 窗口 − 缓冲区再套三层阈值,但那会与既有绝对 buffer (80K/60K/40K)叠加——绝对 buffer 本来就是为「留完成空间」设的, 再减一次等于双重预留:200K 窗口的 hard 触发点会从 70% 猛提前到 55%, 128K 更是提前到 38%,白扔掉三成可用上下文。故改为对每层剩余门槛取
max(原门槛, 缓冲区):
- 绝对/相对门槛已足够宽时(200K:hard 门槛 60K > 缓冲 30K)→ 缓冲区不生效,行为零回归;
- 只有当模型输出能力大到原门槛兜不住时才抬高门槛。
这段论证的形状值得单独学,它有三个要素:
- 先说直觉做法是什么(减法)
- 量化直觉做法的代价(200K 从 70% 提前到 55%,白扔三成)
- 给出替代做法并证明它在常见情况下零回归(
max取地板)
面试里如果你能这样描述一个设计决策,比"我们用了 max 而不是减法"强得多。 第 2 步是关键——能把"这样做不好"量化成一个具体数字。
还有一条附带的原则,同样值得记:
缓冲区对所有模型统一计算,不按模型名分档—— 唯一的模型相关输入是注册表里的结构性事实
maxOutputTokens, 属于允许的结构分档。
"按模型名硬编码分档"是个反模式(新模型一出就漏), 而"读注册表里的结构性事实"是可以的。这个区分很实用。
8.5 一层不能省:阻塞底线
三层之外还要有一个绝对底线。sid-code 的是这样【源码 packages/core/src/context/auto-compact.ts】:
export const TOKEN_THRESHOLDS = {
/** 剩余 ≤3K → 阻塞(强制截断,不调用 LLM)——唯一真正参与主循环触发决策的档 */
blocking: 3_000,
} as const;关键是括号里那句「不调用 LLM」。 为什么需要这一档:
前面所有档都可能失败(摘要请求超时、超长、模型拒答)。 而当剩余只有 3K 时,连一次 LLM 往返都是危险的—— 你不能用"再调一次 LLM"来解决"没空间调 LLM"的问题。 所以最后一档必须是纯本地、确定性、不依赖任何外部调用的强制截断。
💡 一般形式:任何降级链的最后一环必须不依赖它正在拯救的那个资源。 内存不足时的处理不能分配内存;连接池耗尽时的处理不能拿连接; 上下文不够时的处理不能调 LLM。这条在面试里适用范围很广。
8.6 另一段值得学的注释:主动标注自己的死代码
auto-compact.ts 里还有一段注释,它的价值不在技术而在习惯:
§12 P2-2 清理:此前这里有 autoCompact(13K)/warning(20K)/error(20K) 三档, 号称「对标 CC
autoCompact.ts:62-65」。但主循环loop.ts只消费blocking一档 ——渐进压缩全部由manager.ts的getCompactionLevel接管, 那三档从不参与触发决策,是事实死代码。 CC 的 autoCompact 是真触发阈值,我们这套只有 blocking 生效, 保留原注释会误导维护者以为 13K 是触发点。 故删除三个死档,只保留真正生效的 blocking 绝对底线。
这做了三件对的事:
- 发现了"照抄对标项目"留下的死代码——常量抄过来了,但消费方没接上
- 说明了为什么不能留着:不是"没用",而是会主动误导下一个人
- 保留了删除的记录,这样下一个人不会再把它加回来
⚠️ 这是个很常见的坑:对标一个成熟项目时, 你会把它的常量表整个抄过来,但只接上其中一部分的消费方。 剩下的常量看起来像"已实现的能力",实际是死的。 §7.6 那个"这段代码默认会执行吗"的检查,对自己的代码也要做。
8.7 本章自检
- Claude Code 的触发阈值是百分比还是减法?200K 和 1M 下各是多少?
- 为什么要为"摘要输出"预留输入窗口?这个数是怎么定的?
- 绝对值 vs 百分比各自的优缺点?为什么
Math.min那个限制很克制? - sid-code 的「完成缓冲区」为什么是地板而不是减法?减法的代价是多少?
- 为什么最后一档必须"不调用 LLM"?这条原则的一般形式是什么?
- 从成熟项目抄常量表会留下什么坑?
§9 压缩的六个隐藏难题 ★
这一章是全文最"生产"的部分——六个问题,概念文章一个都不会提, 但只要你真做一遍就一个都躲不过。
它们有一个共同的形状,先把这个形状说出来,后面六节就都是它的实例:
压缩本身是一次 agent 调用。 所以所有"压缩要解决的问题",都会递归地作用于压缩自己。
压缩要防止自己触发压缩、防止自己的输入超窗口、防止自己的清理逻辑 破坏别人的状态、防止自己带的工具被误调。 「用 agent 解决 agent 的问题」这个模式自带一整类递归 bug。
9.1 难题一:压缩器自己会超窗口(PTL 递归)
问题:压缩请求要把整段历史发给模型做摘要。 但如果历史本来就太长——压缩请求自己就超窗口了。
这不是边界情况,它在主循环里就有处理,说明是常态。 sid-code 的处理【源码 packages/core/src/query/auto-compact.ts】:
const MAX_PTL_RETRIES = 4;
const PTL_RETRY_MIN_KEEP = 4;策略是:从最早的消息开始丢,然后重试,最多 4 次。
丢掉的那部分历史是彻底没了——连摘要都没有。 这是整个压缩系统里唯一一处真正的信息销毁, 其他所有地方都还留着会话记录或磁盘文件。
所以这里要建立一个三分而不是二分的认知:
| 类型 | 例子 | 能拿回来吗 |
|---|---|---|
| 可逆压缩 | 落盘 + 指针 | ✅ 模型 read 一下 |
| 有损压缩 | LLM 摘要 | ⚠️ 细节没了,但要点在 |
| 真正销毁 | PTL 重试时丢弃的最早轮次 | ❌ 连摘要都没有 |
大多数人只知道前两类。 面试里能说出第三类, 说明你想过"压缩失败之后怎么办"。
还有一个降级细节值得看,它示范了「递进放弃」【源码同上】:
const dropRatio = Math.min(0.85, 0.25 + attempt * 0.15);第 1 次丢 25%,第 2 次 40%,第 3 次 55%……上限 85%。 不是每次丢同样多,而是越试越激进。 因为如果丢 25% 还不够,说明超得比预想多,慢慢试只是浪费更多次调用。
另一个必做的预处理:剥离图片。 【源码,Claude Code 注释要点】
图片对生成对话摘要没有用,却会导致压缩 API 调用本身触发 prompt-too-long, 尤其是用户频繁贴图的会话。
一张图约 2000 token,几十张就把压缩请求顶爆了。 所以发压缩请求前先把图片换成 [image] 占位符——这是"压缩前的预压缩"。
sid-code 也有这一步【源码】:stripReinjectedAttachments(stripImages(messages)) ——除了图片,还剥掉了之前重新注入的附件(因为它们会在压缩后重新注入一遍, 留在摘要输入里是重复付费)。
9.2 难题二:不能在任意位置切分(配对不变量)
这个难题是我认为最值得在面试里讲的一个,因为它彻底否定了 「保留最近 N 轮」这种看起来天经地义的说法。
问题:你要删掉最早的消息。删几条?看起来只是个数字。
实际上:你不能在任意位置切。
原因是 API 有配对契约:每个 tool_use 必须有对应的 tool_result。 如果你切在中间,就会产生一个孤儿 tool_result—— 它引用了一个已经不存在的 tool_use,API 直接拒绝请求。
看一个具体的失败场景【源码注释里的复现,Claude Code sessionMemoryCompact.ts】:
索引 N: assistant, message.id: X, content: [thinking]
索引 N+1: assistant, message.id: X, content: [tool_use: ORPHAN_ID]
索引 N+2: assistant, message.id: X, content: [tool_use: VALID_ID]
索引 N+3: user, content: [tool_result: ORPHAN_ID, tool_result: VALID_ID]
如果 startIndex = N+2(从这里开始保留):
- 旧代码:只检查了 N+2 有没有 tool_result,没有,于是返回 N+2
- 结果:ORPHAN_ID 的 tool_result 留下了,但它的 tool_use 被切掉了
- API 报错:孤儿 tool_result 引用了不存在的 tool_use注意 thinking 块也有同样的问题,而且更阴: 流式响应会把 thinking / tool_use 拆成多条共享同一个 message.id 的消息。 切在中间会让 thinking 块永久丢失——因为规范化时按 id 合并, 找不到伙伴就直接没了。这个不会报错,是静默丢失。
正确的切分单位不是"消息",是"API 轮次组"。 边界定义是「新的 assistant 响应开始的地方」(message.id 变化处)。
为什么这是安全切点【源码注释】:
API 契约要求每个
tool_use在下一个 assistant turn 之前被解决, 所以配对合法性会自然地从 assistant-id 边界推导出来。
于是本节的结论:
压缩在实现层面不是"删除 N 个 token", 是"删除若干个完整的、边界对齐的语义单元"。
这个不变量的重要程度可以从代码量看出来: Claude Code 里有一个函数用了约 80 行注释 + 80 行代码专门处理它。 sid-code 也把它抽成了独立模块【源码 packages/core/src/agent/message-invariants.ts,由 snip-compact.ts 调用】:
import {
checkMessageHistoryIntegrity,
describeIntegrityViolation,
} from "../../agent/message-invariants.ts";💡 面试话术(推荐背下来):"我在面试里如果被问压缩, 我会讲一个大部分人不会讲的点:压缩的切分点必须对齐 API 轮次边界, 否则会产生孤儿
tool_result,API 直接拒绝。 更阴的是 thinking 块—— 流式响应把它和 tool_use 拆成多条共享 message.id 的消息, 切错位置会让 thinking 静默消失,不报错。 所以我们的切分单位是「API 轮次组」而不是「消息条数」。 『保留最近 N 轮』这句话在流式 + 工具调用 + thinking 的组合下, 根本不对应一个明确的位置。"
9.3 难题三:压缩会递归触发自己
问题:压缩是一次 LLM 调用,它也会走主循环,主循环也会检查"要不要压缩"。 于是压缩触发压缩,无限递归。
修法是来源守卫【源码,Claude Code autoCompact.ts】:
// 递归守卫。session_memory 和 compact 是 forked agent,会死锁。
if (querySource === 'session_memory' || querySource === 'compact') {
return false
}sid-code 用消息元数据做同一件事【源码 packages/core/src/context/auto-compact.ts】:
/**
* 检查消息是否为压缩来源(session_memory / compact),
* 压缩来源的消息不应再次触发压缩。
*/
export function isCompactSourceMessage(msg: Message): boolean {
if (!msg._meta) return false;
const source = msg._meta.compact_source as string | undefined;
return source === "session_memory" || source === "compact";
}还有一个更隐蔽的变体:子代理压缩会破坏主代理的状态。
原因是子代理和主代理跑在同一个进程里,共享模块级状态。 于是【源码注释要点】:
子代理(
agent:*)在同一进程里跑,与主线程共享模块级状态。 只对主线程的压缩重置主线程的模块级状态。
以及:
只对主线程跑缓存模式清理,防止 forked agent 把它们的 tool_results 注册到全局状态里,导致主线程去删除它自己对话里根本不存在的工具。
这是"agent 里跑 agent"这个架构带来的、单 agent 系统里完全不存在的一类 bug。
顺带一个相关设计:熔断计数不放模块级变量,而是穿在一个显式的状态对象里 由调用方在轮次之间手动传递。第一反应会觉得"为了可测试", 但真正的原因是上面这个——模块级状态在子代理架构里是共享的,会被污染。
💡 可迁移:任何"同进程内跑多个逻辑隔离单元"的架构 (子代理、多租户、协程池),模块级/全局状态都是隐性的跨单元通道。 要么显式传递,要么用
AsyncLocalStorage之类做真隔离。 "它是私有的"这个假设,在同进程多实例下不成立。
9.4 难题四:压缩失败会变成失败风暴(必须熔断)
这一节的价值在于它有一个真实的、量化的生产事故, 在面试里比任何理论都有说服力。
Claude Code 源码里有这么三行【源码 autoCompact.ts】:
// Stop trying autocompact after this many consecutive failures.
// 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翻译一下:在加熔断之前,1,279 个会话陷入了压缩失败循环, 每个连续失败 50 次以上,最多的一个失败了 3,272 次, 全局每天浪费约 25 万次 API 调用。
修复是三行代码:连续失败 3 次就停。
这个案例有三个层次的教训,一层比一层重要:
① 表层:自动恢复路径都需要熔断器。 这是标准模式,没什么新意。
② 中层:为什么这个问题很难被发现。 因为每一次单独的失败看起来都是"正常的重试"。 日志里没有异常,错误率不高(每次失败都被 catch 了), 只有把"同一会话内连续失败次数"这个维度聚合出来,才能看到它。
③ 深层:压缩失败通常是结构性的,不是瞬时的。 上下文已经不可恢复地超限了。第 4 次不会比第 3 次更好。 这就区分了两类错误,而它们的修法相反:
| 错误类型 | 例子 | 该怎么办 |
|---|---|---|
| 瞬时故障 | 网络抖动、限流 429、超时 | 指数退避重试——等一下就好了 |
| 结构性故障 | 输入不可恢复地超限、schema 不兼容 | 熔断——重试永远不会好 |
把结构性故障当瞬时故障处理,就会得到那个 25 万次/天的账单。
sid-code 的对应实现【源码 packages/core/src/query/auto-compact.ts + circuit-breaker.ts】,注意它熔断之后的降级:
const simpleSummary =
`[自动截断] 之前有 ${messages.length - 4} 条消息被截断以释放上下文空间。(autoCompact 熔断中)`;熔断之后不是放弃,而是降级到纯本地截断(不调 LLM)。 这呼应 §8.5:降级链的最后一环不能依赖它正在拯救的资源。
⚠️ 一个必须点破的细节:熔断 ≠ 回滚。 熔断之后上下文还是超限的——只是不再浪费调用了。 它不修复问题,只是给损失封顶。
这个区分很重要:如果你以为熔断解决了问题, 你就不会去修真正的根因。熔断是止损,不是修复。
💡 可迁移的三件套:所有"理论上不该发生但一定会发生"的情况—— 重试风暴、缓存击穿、消息重复消费、自动扩容抖动: ① 承认它会发生;② 打埋点量化它的频率和幅度; ③ 设一个绝对上限,超过就停止而不是继续尝试。
9.5 难题五:本地压缩对"当前 token 数"不可见
这个难题很隐蔽,而且它是那种"不知道就一定会写错"的类型。
问题:你怎么知道当前用了多少 token?
准确的数字来自 API 响应里的 usage 字段。 但注意时序——它是上一次响应里的:
时间轴:
──[API 响应,带 usage: 150K]──→ [本地压缩,删了 40K]──→ [判断要不要压缩]
↑ ↑
这是唯一准确的数字 此时读 usage 还是 150K!
因为那条带 usage 的消息本身没变你删掉了 40K,但读出来的 token 数一点没变。 因为 usage 挂在那条 assistant 消息上,而你删的是别的消息。
后果:明明已经压过了,判定还说"需要压缩"——于是又压一遍。 或者反过来,压缩省下的量没被算进去,导致过度压缩。
修法:每一层压缩省下的量必须显式向下游传递。 Claude Code 里这个补偿参数直接写进了函数签名(snipTokensFreed 之类), sid-code 则做了一个专门的追踪器【源码 packages/core/src/context/auto-compact.ts】:
export class TokenFreedTracker {
recordCompact(tokensFreed: number, strategy: string): void { … }
getTotalFreed(): number { … }
getRecords(): ReadonlyArray<{ strategy: string; tokensFreed: number; timestamp: number }> { … }
}注意它按 strategy 分别记录——这样不只能做补偿, 还能回答"哪一层实际贡献了多少"(这是 §12 要用的)。
💡 一般形式(适用面很广): 所有"服务端返回的用量/配额/水位"驱动本地决策的场景, 只要你在两次观测之间做了本地变更,就必须显式补偿这个差值。
API 限流余量、数据库连接池水位、磁盘配额、消息队列 lag 都一样。 观测值是过去的快照,你的本地动作对它不可见。
⚠️ 顺带一个相关陷阱:token 估算与真实值的差距。 本地估算通常用"4 字符 ≈ 1 token"这种粗算 (sid-code 的管线里就是这么估的,见
estimateRatio)。 这对英文大致成立,对中文和代码会偏差很大(中文常 1–2 字符 1 token)。 所以估算只能用来做"要不要压"的粗判断, 不能用来算钱,也不能用来卡硬边界——那些必须用 API 返回的真值。
9.6 难题六:prompt 和模型版本是耦合的
最后一个难题最容易被忽略,因为它不是代码 bug,是资产会腐烂。
Claude Code 里有段注释记录了一个很有说服力的数字【源码 prompt.ts】:
压缩用 forked agent 跑,为了命中父会话的缓存,必须继承完全一样的工具集 (工具定义是 cache key 的一部分)。但压缩 agent 根本不需要工具——它只要读历史、输出摘要。 于是模型看到一堆工具在 schema 里,有时候真的会去调。 而 maxTurns: 1 下工具调用被拒 → 这一轮没有文本输出 → 压缩失败。
失败率的数据是:Sonnet 4.6 上 2.79%,4.5 上 0.01%——差 279 倍。
同一份 prompt,换个模型小版本,失败率涨了 279 倍。 原因是 4.6 的 adaptive thinking 更倾向主动调工具。
这推出两个结论:
① prompt 不是写完就一劳永逸的资产,它和模型版本耦合。 模型升级可能让原本稳定的 prompt 突然行为异常,而且这种退化通常没有报错 ——它表现为"效果变差了",而不是"报了个错"。
② 缓存经济学会压倒架构纯洁性。 理想设计里压缩 agent 该拿一个空工具集(干净、不会误调)。 但实测数据是:不共享缓存前缀的那条路径是 98% cache miss, 代价约占全舰队 0.76% 的 cache_creation(约 380 亿 token/天)。
于是最终方案是个明显的工程妥协:带着一堆不需要的工具, 再用一段强硬的 prompt 前言(放在最前面)告诉它"不要调用任何工具"。
sid-code 走的是另一条路,用 API 参数而不是 prompt 说服模型【源码 packages/core/src/query/auto-compact.ts】:
? { tools: deps.toolSchemas, toolChoice: "none" as const }toolChoice: "none" 是协议层的硬约束——比 prompt 里写"请不要调工具"可靠得多, 因为它不依赖模型听话。 (前提是 provider 支持这个参数;不支持时就只能退回 prompt 那条路。)
💡 一条可迁移的判断:能用协议层硬约束表达的,不要用 prompt 软约束表达。 prompt 的遵从率取决于模型版本,协议参数不取决于。 这条和 §10 那条「能确定性重建的绝不交给 LLM」是同一个精神。
这个难题对可观测性的要求:如果你的埋点只有容量和成本指标 (token 数、缓存命中率),那么"模型升级让摘要质量掉了 20%"这件事 你完全看不见——只能等用户抱怨"压缩后它变傻了"。§12 会讲怎么补这一块。
9.7 六个难题汇总
| # | 难题 | 一句话 | 修法 |
|---|---|---|---|
| 1 | PTL 递归 | 压缩请求自己会超窗口 | 丢最早轮次 + 递进放弃 + 剥离图片 |
| 2 | 配对不变量 | 不能在任意位置切分 | 按 API 轮次组切,不按消息条数 |
| 3 | 递归触发 | 压缩会触发压缩;子代理污染主代理状态 | 来源守卫 + 状态显式传递 |
| 4 | 失败风暴 | 结构性失败被当瞬时失败重试 | 熔断(止损,非修复)+ 降级到本地截断 |
| 5 | 观测滞后 | 本地压缩对 usage 不可见 | 省下的量显式向下游传递 |
| 6 | prompt 腐烂 | prompt 与模型版本耦合 | 优先用协议层硬约束;补质量埋点 |
把这六个连起来看,就能理解一个事实:
压缩的难点不在压缩。 九段式摘要 prompt 就是一段文本模板; 而围绕它的失败处理、切分合法性、缓存保持、重建、可观测性, 代码量是它的十几倍。
如果你面试时把压缩讲成"调 LLM 生成摘要",你讲的是那 5% 的部分。
9.8 本章自检
- 这六个难题的共同形状是什么?
- 压缩失败的三类信息损失是什么?哪一类最容易被漏掉?
- 为什么"保留最近 N 轮"不对应一个明确的位置?孤儿
tool_result怎么产生? - thinking 块的丢失为什么比孤儿 tool_result 更阴?
- 为什么子代理会破坏主代理的状态?这类 bug 在单 agent 系统里存在吗?
- 瞬时故障和结构性故障的修法为什么相反?熔断和回滚的区别是什么?
- 为什么本地压缩之后读 usage 读到的是旧值?
- 那个 279 倍的数字说明了什么?为什么协议层约束优于 prompt 约束?
§10 压缩不是终点,重建才是
上一章讲了压缩本身有多难。这一章讲一个更反直觉的事实:
压缩后的重建,代码量比压缩本身还多。
Claude Code 的 compact.ts 有 1705 行,但真正调 LLM 生成摘要的逻辑只占很小一部分。 后半个文件全是 createPostCompactFileAttachments、createSkillAttachmentIfNeeded、 createPlanAttachmentIfNeeded 这类重建逻辑。
这个行数分布本身就是结论:压缩本身不难,难的是压缩后怎么继续干活。
10.1 为什么压缩完不能直接继续
压缩完之后上下文长这样:
[system prompt]
[一段摘要:「用户想修 login 的 bug,我们看了 auth.py,发现…」]
[最近几轮原始消息]看起来挺好。但 agent 下一步要干活时会发现:
| 它需要什么 | 摘要里有吗 | 后果 |
|---|---|---|
| 刚才在改的那个文件的当前内容 | ❌ 摘要只说了"我们看了 auth.py" | 它得重新 read 一遍 |
| 正在执行的 Skill 的完整指令 | ❌ 摘要不会抄一整份 Skill | 它按记忆瞎猜怎么用 |
| 当前的计划 / todo 列表 | ⚠️ 可能提到但不完整 | 漏掉几项 |
| 可用的工具清单变化 | ❌ | 调不存在的工具 |
每一项都会导致额外的工具调用,而这些调用产生新的输出, 上下文重新涨起来——这就是 §3.2 那个"补偿性重试"的负优化。
所以压缩后必须主动重建。Claude Code 重建的东西有九类 【源码,compact.ts 的重建函数群】:文件、Skill、Plan、Plan 模式状态、 异步 agent、工具定义 delta、Agent 清单、MCP 指令、SessionStart hook。
10.2 ★ 一条很值得记的原则:能确定性重建的,绝不交给 LLM 摘要
这是本章最核心的判断,而它的论证非常漂亮。
问题:压缩时,要不要把文件内容写进摘要?
直觉:要啊,写进去就不用重新读了,省一次工具调用。
实际做法:不写。只保留文件路径,压缩后重新 read。
理由(关键):
摘要是压缩那一刻的快照。文件在那之后可能被改了—— 而摘要里的旧内容,模型分辨不出来它是旧的。 它会基于过时的内容做判断,而且完全不知道自己在用过时数据。
对比一下两种失败模式,就明白为什么选后者:
| 做法 | 失败模式 | 严重程度 |
|---|---|---|
| 内容写进摘要 | 模型静默使用过时内容,还很自信 | 🔴 静默错误,最难查 |
| 只留路径,重新读 | 多一次 read 调用 | 🟡 多花一点钱和时间 |
"多花一次调用"是可接受的成本;"静默用错数据"不是。
于是得到一条可以直接说出口的原则:
区分「状态」和「事实」。 凡是能从权威源重新获得的(文件、数据库记录、配置、API 响应), 压缩时只保留标识符(路径、ID、URL),恢复时重新拉取。 只有真正一次性的东西(用户的口头约束、已关闭 stream 的内容、 模型的推理过程)才需要进摘要。
sid-code 的重建实现里有个细节,把这条原则做得更彻底了【源码 packages/core/src/query/compact/reattach-files.ts】:
const stat = statSync(filePath);
const expected = tracker.getRecordedMtime(filePath);
// …
const changedNote = changed ? /* 标注「此文件在压缩期间被修改过」 */ : …它不只重新读,还对比 mtime:如果文件在压缩期间被改过, 在重注入的内容里显式标注出来。
这一步很聪明——它把"文件可能变了"这个不确定性 从隐性变成了显性。模型看到标注就知道要重新确认, 而不是默认它还是自己记忆里那个样子。
💡 面试话术:"我们压缩时不把文件内容写进摘要,只保留路径, 压缩后重新读最近的 5 个文件。原因是摘要是压缩那一刻的快照, 文件之后可能被改了,而摘要里的旧内容模型分辨不出来—— 它会基于过时内容做判断,还很自信。多一次 read 是可接受的成本, 静默用错数据不是。我们还会比对 mtime,如果文件在压缩期间变过, 重注入时显式标注出来。"
10.3 重建必须有预算:它可能吃掉窗口的三分之一
重建不是"能恢复多少恢复多少",它必须有硬预算。 sid-code 的三个常量【源码 reattach-files.ts】:
export const POST_COMPACT_MAX_FILES = 5; // 最多 5 个文件
export const POST_COMPACT_PER_FILE_BUDGET = 5_000; // 每个 ≤5K token
export const POST_COMPACT_TOTAL_BUDGET = 50_000; // 累计 ≤50K token注意这是三层嵌套的约束,而且都必要:
- 只有总预算 → 一个巨型文件就能吃掉全部 50K,其他四个一点都恢复不了
- 只有单文件预算 → 5 × 5K = 25K,看起来还行,但如果
MAX_FILES是 20 就爆了 - 只有文件数 → 5 个巨型文件 = 无上限
三层一起才封得住。这和 §2.4 的"个体预算 + 聚合预算"是同一个形状, 只是多了一层"个体数量"。
关键认知:这 50K 是一个有意的信息损失。 压缩后不是恢复所有文件,而是只恢复最近 5 个、总计不超过 50K。 接受丢失一些上下文,换取压缩后的窗口足够干净。
但这里藏着一个必须看到的问题:
200K 窗口,压缩后:
摘要本身 ≈ 5K
文件重注入(上限) ≈ 50K
Skill 重注入(上限) ≈ 25K
保留的最近几轮 ≈ 10-40K
─────────────────────────────
合计可能 ≈ 80-120K ← 占窗口 40-60%压缩刚做完,窗口已经用掉近一半了。 所以必须埋一个指标:「压缩后会不会立刻再次触发压缩」。
Claude Code 就有这个埋点(willRetriggerNextTurn)。 注意它的定位——它不防止压缩死循环,它测量死循环。
💡 这是个很成熟的态度:有些失效模式你消除不了 (重建的内容本来就需要空间),那就量化它的频率, 而不是假装设计得完美就不会发生。
"我们不假设压缩一定能把上下文降到阈值以下, 而是直接测量有多少比例会立刻再次触发"——这句话在面试里很有分量。
10.4 重建时机的一个坑:手动压缩和自动压缩必须走同一条收尾
这是 sid-code 自己踩过的坑,注释把它记得很清楚【源码 packages/core/src/query/compact/post-compact.ts】:
此收尾此前只内联在
query/auto-compact.ts里,手动/compact完全走不到—— 手动压缩后不做文件重注入、不重置 microcompact 状态机、 不抑制 cache break 误报、不记录压缩质量与自适应特征。结果是同一个"压缩"动作在两条路径上语义不一致: 用户手动压缩后模型会"忘掉"刚读过的文件,而自动压缩不会。
现抽成单一事实源,两条路径都调它,只用
trigger区分 hook 事件类型与日志措辞。
这个 bug 的形状值得记住:同一个语义动作有两个入口, 其中一个入口漏掉了收尾工作。用户会观察到"手动压缩效果比自动压缩差", 但这看起来像是模型的问题,而不像是漏了一段代码。
修法是单一事实源:收尾逻辑抽成一个函数,两条路径都调, 只用一个参数区分次要差异(这里是 trigger: "auto" | "manual")。
注释里还有一句很好的工程判断:
全部步骤 best-effort:任何一步异常都不影响已完成的压缩结果。
重建失败不能让压缩失败。 压缩已经成功了(空间已经腾出来了), 重建只是"让接下来更顺"。如果重建里某一步抛异常把整个压缩回滚了, 那就是用一个次要目标毁掉了一个主要成果。
💡 一般形式:区分"主成果"和"锦上添花",后者必须 best-effort。 主流程成功后的增强步骤(发通知、更新缓存、记录指标、预热) 都不该有能力让主流程失败。
10.5 重建之外还要做的三件收尾
post-compact.ts 的头部注释列了完整的收尾清单, 其中三件不是"重建内容"但同样必要【源码】:
① 重置 microcompact 状态机
消息历史已重组,旧
tool_use_id映射全失效。
压缩把消息重组了,之前记录的"哪些 tool_use_id 已经被清理过" 全部对不上了。不重置的话,下一轮 microcompact 会去操作不存在的 id。 这是 §9.3 那个"删除不存在的工具"的另一个来源。
② 抑制一次 cache break 告警
前缀变了,cache_read 骤降是预期的。
如果你有"缓存命中率骤降"的告警(应该有), 压缩后必然触发一次——但这次是预期的,不是 bug。 不抑制的话,你的告警会在每次压缩后误报一次, 然后你就会开始忽略这个告警——这比没有告警更糟。
💡 这一条很实用:任何会主动破坏被监控指标的操作, 都必须同时抑制对应的告警一次。 否则告警会因为反复误报而失去可信度, 真出问题时没人看。
③ 记录压缩质量与自适应特征
这是 §12 的内容,这里先记住:压缩完必须留下可复算的痕迹, 否则你永远不知道压缩做得好不好。
10.6 本章自检
- 为什么压缩完不能直接继续干活?列举需要重建的四类东西。
- 为什么文件内容不写进摘要?两种做法的失败模式各是什么?
- "状态"和"事实"该怎么区分?各自怎么处理?
- mtime 对比这一步解决了什么问题?
- 为什么重建预算要三层嵌套?只有其中一层会怎样?
willRetriggerNextTurn这个埋点的定位是什么?为什么"测量"而不是"防止"?- 手动/自动压缩走两条路的 bug 形状是什么?为什么难发现?
- 为什么重建必须 best-effort?为什么要抑制一次 cache break 告警?
§11 隔离:多 agent 是解法还是逃避
前面十章都在讲"怎么在一个上下文里活下去"。 这一章讲最后一招:把它拆开。
这一章的立场需要提前说清楚,因为它和很多人的直觉相反: 多 agent 不是免费的升级,它是一笔有明确代价的交易。 面试里能说清这个代价,比会背"多 agent 架构"的好处更值钱。
11.1 隔离到底在买什么
隔离的机制很简单:让子任务在独立的、干净的上下文里跑, 只把结果返回给主上下文。
不隔离(单上下文):
主上下文 ← 搜索结果 1(5K)+ 结果 2(5K)+ … + 结果 20(5K)= 100K 噪声
隔离(子代理):
子代理上下文 ← 20 个搜索结果(100K,在它自己那里)
主上下文 ← 子代理返回的「Top-10 摘要」(3K) ★买到的东西:主上下文保持高信噪比。它从来没见过那 100K 的原始噪声。
有个很有代表性的生产数据【文献,Anthropic 多 agent 研究系统】: 搜索子 agent 处理数百个搜索结果,只返回最相关的 10 个, 主 agent 的上下文全程保持在 3K token 左右。
11.2 代价:token 总量可能涨 4–15 倍
代价很大,而且经常被隐藏。 几个数据【文献】:
| 来源 | 数据 |
|---|---|
| Anthropic 多 agent 研究系统 | 复杂研究任务成功率高 90.2%,但 token 消耗 15× |
| UIUC 研究 | 多 agent 消耗 4–220× token;即使优化后仍需 2–12× |
| SWE-bench 对照 | 单 agent 约 48,400 token / 40 步;多 agent 起步 193,600 |
为什么会涨这么多?三个来源,最后一个最容易被漏:
- 系统提示词和工具定义要复制 N 份——每个子代理都要一整套
- 交接的信息要被写两次(子代理写摘要 + 主代理读摘要)
- 重复探索——子代理之间不共享发现,可能各自读了同一个文件
还有一个信息论层面的结论值得记住【文献,arXiv 2604.02460】:
固定 token 预算下,单 agent 的信息效率严格优于多 agent。
这句话很重要,因为它意味着多 agent 在理论上是劣势的。 它能赢,只是因为一个现实原因:Context Rot 会让单 agent 的信息利用率 随上下文增长而退化,到某个临界点,单 agent 的理论优势被退化吃掉了。
💡 面试话术(这段能明显区分水平):"信息论上,固定预算下单 agent 的信息效率严格优于多 agent——所以多 agent 不是免费的升级。 它能赢只有一个原因:Context Rot 让单 agent 的实际利用率随长度退化, 到某个临界点理论优势被吃掉。所以我不会为了'看起来高级'用多 agent, 我只在单 agent 的上下文质量确实退化了之后才用。"
11.3 一条清晰的判据:Read vs Write
「什么时候该拆」这个问题,最好用的判据是 Read vs Write【文献】:
| Read 任务 | Write 任务 | |
|---|---|---|
| 例子 | 研究、搜索、分析、信息收集、代码审查 | 代码编写、文件编辑、重构 |
| 子任务之间 | 独立——各查各的 | 有共享状态——改了 A 影响 B |
| 需要回溯吗 | 很少 | 经常(改错了要回去看) |
| 结论 | ✅ 适合多 agent 并行 | ✅ 适合单 agent + 分层压缩 |
为什么 Write 不适合拆:两个子代理同时改代码, 它们看不到对方改了什么。合并时要么冲突,要么一方的改动被覆盖。 而"共享状态"这件事一旦要在 agent 之间同步, 你付的协调成本很快超过隔离的收益。
混合任务怎么办:在架构层面分离阶段,而不是在同一阶段里混着来。
阶段 1(Read,可并行):多个子代理并行调研 → 各返回结构化发现
↓ 汇总
阶段 2(Write,单 agent):主 agent 拿着汇总结果动手改代码这个"先并行读、再串行写"的形状,是目前最稳的组织方式。
11.4 隔离的隐性成本:交接是有损的
这一节讲一个容易被忽略的点:隔离把「上下文管理问题」 换成了「交接问题」,而后者也会丢信息。
子代理返回的是一份摘要(或结构化结果)。这份摘要:
- 是子代理自己写的——它判断什么重要,可能判断错
- 不可追问——子代理的上下文已经销毁了,主代理没法说"你再看看那个文件的第 200 行"
- 是一次性的——写得不好也没法重写
所以隔离并不是"把问题解决了",是把有损压缩的位置从"主上下文内部" 挪到了"代理边界上"。
这推出两个实践建议:
① 交接必须结构化,不能自由发挥。 和 §12.2 讲的摘要模板同理——给子代理一个固定的返回结构 (发现了什么 / 在哪个文件 / 还有什么没查 / 建议下一步), 比"请总结你的发现"可靠得多。
② 子代理必须留下可追溯的痕迹。 子代理销毁了,但它读过的文件路径、跑过的命令应该留在返回结果里。 这样主代理需要细节时能自己去读,而不是彻底断线。
⚠️ 一个具体的失效模式:如果子代理的返回结果全是「已完成」「SUCCESS」 这类无信息量的标签,那你的隔离等于把子任务变成了黑盒—— 出问题时完全无法归因。这在 sid-code 的观测体系里被明确当成一类要消灭的误判 (子代理成败判定 + 串并行判定)。
11.5 一个更轻的隔离:不拆 agent,只隔离关注点
多 agent 之外还有一个更轻的形态,也是 2026 年比较主流的方向: 同一个 agent,按需激活不同的指令集(Skill)。
它和多 agent 的区别:
| 多 agent | Skill / 动态激活 | |
|---|---|---|
| 上下文 | 多个独立上下文 | 一个上下文 |
| 隔离的是 | 全部信息 | 只隔离指令与工具 |
| token 代价 | 4–15× | 接近 1×(按需加载才占) |
| 状态连续性 | 差(要交接) | 好(本来就是一个上下文) |
| 适合 | Read 任务、并行探索 | 任务阶段切换、专项能力 |
Skill 的核心机制是「渐进披露」: 上下文里平时只放各个 Skill 的一行摘要(几十 token), 模型判断需要哪个,才把那一个的完整内容加载进来。
这其实就是 §3.3 那条 Select 判据的应用: Skill 描述是"量小、几乎必看" → 预加载; Skill 正文是"量大、只有部分场景需要" → 按需加载。
💡 面试里怎么用:"隔离不一定要拆 agent。 更轻的做法是同一个上下文里做关注点隔离—— 平时只放 Skill 的一行摘要,模型判断需要才加载全文。 这样拿到了'不相关指令不占上下文'的好处, 又不付多 agent 那 4–15 倍的 token 代价,也不需要处理交接丢信息。 我会先用这个,只有确实需要并行探索时才上多 agent。"
11.6 一个必须知道的失败归因数据
最后一个数据,它能防止你把多 agent 的问题归错因【文献,UC Berkeley MAST】:
多 agent 系统的失败分解:
| 失败类型 | 占比 |
|---|---|
| 规格设计失败(任务没定义清楚) | 42% |
| Agent 间对齐失败(交接、协调) | 37% |
| 任务验证失败(不知道做完没做好) | 21% |
前两项加起来 79% 是系统工程问题,只有 21% 与模型能力直接相关。
所以多 agent 系统表现不好时,换更强的模型大概率没用。 该去看任务分解是否清晰、交接协议是否明确、 以及有没有办法判断子任务真的完成了。
11.7 本章自检
- 隔离买到了什么,付出了什么?token 代价的三个来源是什么?
- 信息论的结论是什么?多 agent 凭什么还能赢?
- Read vs Write 判据是什么?混合任务怎么组织?
- 为什么说"隔离把上下文问题换成了交接问题"?两个实践建议是什么?
- Skill / 动态激活与多 agent 的核心区别是什么?各自代价?
- 多 agent 失败的 79% 属于哪一类?这对排查方向意味着什么?
§12 怎么知道自己做对了 ★
前面十一章讲了怎么做。这一章讲一个更难的问题:做完之后,你凭什么说它有效?
这一章之所以重要,是因为上下文工程的失效几乎全是静默的。 压缩丢了一条关键约束,agent 不会说"我忘了一条规则"—— 它会自信地做错事。测试全绿、没有报错、日志干净, 而质量已经掉了 20%。
12.1 第一件事:压缩率是个错误指标
这是本章最重要、也最容易在面试里得分的一点。
直觉:压缩率越高越好。99% 压缩率意味着极致的 token 节省。 毕竟我们做 gzip 就是这么评价的。
实际【文献,Factory.ai 评测】:三种压缩策略的对照——
| 方案 | 压缩率 | 质量评分 |
|---|---|---|
| 结构化摘要 | 较低 | 3.70 |
| Anthropic 方案 | 中 | 3.44 |
| OpenAI 方案 | 99.3%(最高) | 3.35(最差) |
压缩率最高的那个质量最差。 而且更关键的是: 丢失的细节导致 agent 不断重新获取信息(thrashing), 总 token 消耗反而更高。
为什么会有这个认知偏差:工程师习惯用压缩率评价压缩算法, 但那个习惯来自无损压缩(gzip)。LLM 上下文压缩是有损的, 而且损失的后果不是"文件小了点看不清",是"agent 要重新去拿"。
所以正确的指标是:
token-per-task —— 完成一个任务消耗的总 token, 含所有轮次、所有重试、所有重新获取。
这个指标能自动惩罚过度压缩:压得太狠 → agent 重新读 → 轮数变多 → 总量上升。
💡 面试话术(强烈推荐):"我评估压缩策略不看压缩率,看 token-per-task。 Factory.ai 的数据里压缩率最高的方案(99.3%)质量评分最差, 而且总 token 反而更高——因为 agent 不断重新获取被压缩掉的信息。 压缩率是个从无损压缩借来的错误指标,它奖励的正是我们最怕的行为。"
有一个源码层面的印证【源码研究结论】: Claude Code 的源码里没有任何一处在优化"压缩率", 所有埋点都是绝对 token 数。这不是疏忽,是刻意的—— 重要的是压缩后模型还能不能干活,以及缓存有没有被毁掉。
12.2 最实用的质量信号:Thrashing
既然压缩率不能用,那什么最实用?答案是 thrashing(抖动):
agent 重复做已经做过的事 —— 重读同一个文件、重跑同一条命令。
它好用的原因有三个:
- 它是压缩丢了关键信息的最直接证据——如果信息还在,它不会重读
- 它可以纯机械检测,不需要 LLM 判断,零成本
- 它同时反映成本——每次重复都是真金白银
怎么检测:记录「工具名 + 参数」的哈希,看有没有重复出现。 更精细一点,sid-code 有个更锋利的判据【源码,trace 层】:
maxUnchangedObservationRun(最长「重复调用且返回值不变」段),≥3 判病态。
注意"且返回值不变"这个限定,它是这个指标的精髓:
- 重复调用但返回值变了 → 正常(比如轮询一个正在变化的状态)
- 重复调用且返回值一模一样 → 纯空转,模型卡住了
只看"重复调用"会把正常的轮询算成病态; 加上"返回值不变"就把它变成了一个真正干净的信号。
💡 这个技巧值得记住它的一般形式: 一个指标误报太多时,往往不是要提高阈值, 而是要加一个正交的限定条件把两种情况分开。 「重复」+「无变化」比「重复」本身精确得多。
12.3 最容易被漏掉的失效:约束衰减(Governance Decay)
这是我认为最值得警惕的一类失效,因为它完全静默, 而且摘要器的行为在它自己的目标下是完全正确的。
机制【文献,arXiv:2606.22528】:
摘要器被要求为"任务连续性"优化——保留任务状态、当前进度、下一步。 而安全约束不属于任务状态。
所以一份质量优秀的摘要,可以完全合理地丢掉 「永远不要把合同发到外部邮箱」这条规则—— 因为它和"当前在做什么"确实无关。
于是:
第 3 轮:用户说「不要碰 config.py」
↓ 40 轮之后压缩
摘要:「用户在重构 auth 模块,已完成 X、Y,下一步改 Z」
↓ 那条约束不在摘要里
第 45 轮:agent 改了 config.py,且完全不知道自己违规了关键在于:这不是摘要质量差,是摘要目标与安全目标不一致。 你把摘要质量提到 100 分,这个问题依然存在。
修法有三个层次:
| 层 | 做法 | 代价 |
|---|---|---|
| ① 模板层 | 摘要模板里设一个专门的槽位给用户约束和反馈 | 零 |
| ② 架构层 | 约束不进摘要通道,作为独立附件每轮重新注入 | 少量 token |
| ③ 验证层 | 压缩后用探针测「那条约束还在不在」 | 一次判定成本 |
②是最稳的——它把约束从"会被压缩的历史"里搬到了 "每轮重新注入的附件"里,压缩根本碰不到它。 这也正是 §2.5 那张 PRIORITY 表里为什么会有 DENY_RULES: 38 和 CRITICAL_REMINDER: 1 这两项。
💡 面试话术:"有一类失效我觉得特别值得警惕——约束衰减。 摘要器为任务连续性优化,而安全约束不属于任务状态, 所以一份质量很好的摘要可以完全合理地把'不要动生产库'这条规则丢掉。 这不是摘要质量问题,是目标不一致问题,提高摘要质量解决不了它。 我们的做法是把约束从摘要通道里拿出来,作为独立附件每轮重新注入—— 压缩碰不到的东西才是真的安全。"
12.4 摘要模板:结构化 vs 自由发挥
上一节提到"模板里设槽位",这一节讲完整的模板设计。
自由格式的问题:你说"请总结这段对话",模型会写一段流畅的散文。 它读起来很好,但会悄悄丢掉文件路径、具体的错误信息、用户的否决意见—— 因为这些东西在"流畅的叙述"里显得琐碎。
结构化模板的做法:强制模型填满每一个槽位。 Claude Code 的压缩 prompt 是九段式【源码 prompt.ts】:
| # | 段 | 它在防什么 |
|---|---|---|
| 1 | 主要请求与意图 | 指令漂移——显式保留用户原始目标 |
| 2 | 关键技术概念 | 上下文断裂 |
| 3 | 文件与代码片段 | 定位信息丢失 |
| 4 | 错误与修复 | 重蹈覆辙——记录已踩过的坑 |
| 5 | 问题解决过程 | 推理链断裂 |
| 6 | 所有用户消息 | 用户反馈丢失——"不要这样做"绝不能丢 |
| 7 | 待完成任务 | 任务遗漏 |
| 8 | 当前工作 | 断点丢失 |
| 9 | 下一步(可选) | 任务漂移 |
第 6 段值得单独说:它要求列出所有非工具结果的用户消息。 这是最粗暴但也最可靠的"防止用户约束丢失"的手段—— 不做判断,全都留下。(呼应 §12.3:与其让模型判断哪条约束重要,不如全留。)
第 9 段的约束也很有意思【源码原文要点】:
重要:确保这一步与用户最近的显式请求直接相关… 不要开始做无关的请求,或者真正很老的、已经完成的请求, 除非先和用户确认。
这是在 prompt 层面防压缩后的任务漂移—— 一种特殊的污染:不是外部噪声,是压缩过程本身引入的偏差。 摘要里同时出现了"第 3 轮的老需求"和"第 40 轮的当前需求", 模型可能挑错一个继续做。
还有一个很值得抄的技巧:<analysis> 草稿纸
Claude Code 要求模型先输出一个 <analysis> 块梳理思路, 再输出 <summary>。然后格式化时把 <analysis> 剥掉,只留 <summary>。
这是 CoT 用于提升压缩质量的做法:
- 草稿让模型先想清楚 → 摘要质量更高
- 草稿不进入压缩后的上下文 → 零上下文成本
- 只付出草稿的输出 token 费用
用一点输出费换摘要质量,且不污染压缩结果。 这个 trade-off 非常划算。
12.5 探针测试:唯一能量化信息损失的手段
前面的指标(token-per-task、thrashing)都是间接的—— 它们测的是后果,不是压缩本身。
直接测法是探针测试(probe-based evaluation): 压缩后,用针对性问题测「关键信息还在不在」。
四类探针【文献,Factory.ai】:
| 探针类型 | 问什么 | 测的是 |
|---|---|---|
| 事实回忆 | "我们决定用哪个数据库?" | 具体事实有没有丢 |
| 决策追溯 | "为什么放弃了方案 A?" | 理由有没有丢(比事实更容易丢) |
| 指令遵循 | "有哪些我明确禁止的操作?" | 约束有没有丢(§12.3) |
| 上下文连贯 | "当前在做的是第几步?" | 状态有没有错乱 |
它的代价:每次探测都要一次额外的 LLM 判定—— 昂贵,而且判定本身有噪声。
所以怎么选:
| 场景 | 用什么 | 理由 |
|---|---|---|
| 日常每次压缩 | token 数 + 是否重触发 + thrashing | 确定、免费、实时 |
| 模型升级时 | 必须加探针 | §9.6 那个 279 倍——容量指标看不见质量退化 |
| 改摘要模板时 | 探针 + 人工抽查 | 模板改动直接影响信息保留 |
Claude Code 的选择是日常只测容量指标,不做探针 ——这和它"分层从便宜到贵"的整体哲学一致。 但这个选择有一个明确的盲区,就是 §9.6 那个: 模型升级导致的摘要质量回归,容量指标完全捕捉不到。
sid-code 补了一个中间档【源码 packages/core/src/query/compact/quality-check.ts
post-compact.ts的收尾】:锚点覆盖率。
做法很朴素,但正因为朴素才便宜:
1. 从被压缩的原始消息里提取「关键锚点」:
- tool_use 的 file_path / path(模型操作过的文件)
- user 文本消息的去重关键词(≥4 字符的非停用词)
- 报错关键词
2. 检查这些锚点有没有出现在摘要文本里
3. 算覆盖率,低于 0.5 就告警
4. 落盘 compact-quality.jsonl,并记录未命中的锚点样例(最多 10 个,诊断用)注释把它的定位说得很清楚:
压缩是"信息有损"操作,但此前完全黑盒——无法知道一次摘要是否丢了关键信息。 覆盖率低于阈值时告警,便于发现"摘要质量塌陷"的会话, 而不是等用户反馈"它忘了我说过的话"。
这是个折中:比纯 token 数信息量大(它至少能发现"摘要漏了一整块文件路径"), 又比探针便宜(纯字符串匹配,零 LLM 调用)。
它的盲区也要说清楚:它只能测「锚点字面出现没出现」, 测不了「摘要有没有把因果关系说对」。一份把所有文件路径都列了、 但把"我们放弃了方案 A"写成"我们采用了方案 A"的摘要, 覆盖率满分。所以它替代不了探针,只是把探针的触发频率降下来。
「未命中锚点样例」这个字段是最实用的部分—— 覆盖率是个数字,告诉你有问题;未命中样例是证据,告诉你丢了什么。 只有前者的告警很难行动。
💡 一个三档的选择框架,可以直接用在面试里: 机械指标(免费,日常跑)→ 覆盖率类指标(便宜,每次压缩跑)→ 探针测试(贵,关键变更时跑)。 关键是想清楚每一档的盲区,然后确保上一档的盲区被下一档覆盖。
12.6 该埋什么:一份清单
如果你要从零建上下文层的可观测性,按这个顺序埋:
必须有(没有这些就是在盲飞):
| 指标 | 为什么 |
|---|---|
| 每轮的 input / output / cache_read / cache_creation token | 成本与缓存的唯一真值来源 |
| 上下文占用率峰值与趋势 | 判断阈值设得对不对 |
| 压缩触发次数 + 每次的触发层 | 分层有没有真的分层(§7.6) |
| 每层实际释放的 token | 判断某一层是不是死功能 |
| 压缩失败次数 + 连续失败次数 | 熔断的依据(§9.4) |
| 压缩后是否立刻重触发 | 重建预算是否失控(§10.3) |
| 轮数(turns per task) | 成本最大杠杆 |
| 工具调用的重复率 / 空转段长度 | thrashing(§12.2) |
很有用但常被漏:
| 指标 | 为什么 |
|---|---|
| cache break 的次数与归因 | 要区分「本地前缀断裂」(我们的 bug)vs「服务端 TTL / 路由抖动」(不是) |
| 摘要输出长度的分布 | §8.2 那个 p99.99 定容就靠它 |
| side-call 成本(压缩、标题、摘要等辅助调用) | 这些影子调用最容易绕过主埋点、被漏计 |
| 摘要覆盖率 | 便宜的质量代理指标 |
注意 cache break 归因那条——它是一个典型的 "一个指标区分不了两种修法不同的故障"的例子。 "缓存命中率降了"这一个数字背后有两种成因:
- 本地前缀断裂 → 我们的 bug,要修代码
- 服务端 TTL 过期 / 路由抖动 → 不是 bug,改代码没用
如果指标不区分这两者,你会花时间去修一个不存在的 bug。
12.7 三个取数陷阱:数字本身可能是错的
最后一节讲一个元问题:你算出来的数字,可能本身就是错的。 这三个陷阱都很具体,而且都很常见。
陷阱一:stock 与 flow 混用
有些字段是末次快照(stock),有些是累加值(flow)。混用就会算出错数:
❌ 错:total_tokens_sent(末次快照)÷ total_cost_usd(累加值)
✅ 对:total_cumulative_prompt_tokens(累积)÷ total_cost_usd(累积)这个错误很隐蔽,因为算出来的数看起来"像个数字", 量级也不荒谬,只是不对。
陷阱二:分母比分子重要
"缓存命中率""压缩触发率""thrashing 率"—— 分母的口径一变,曲线就整体平移。
举个具体的:「防线触发率」的分母如果是全量任务, 那么大量根本用不到这条防线的任务会把信号稀释到接近 0。 分母必须限定在该防线本来就该管的那类任务上。
所以分母口径必须和指标一起写死,而不是"看情况"。
陷阱三:跨口径汇总产出假数
这个最阴,用一个真实案例说明【实测,sid-code 的 TTFB 案例】:
同一个底层模型走不同网关路由,ttfb 的语义不同: 一路是「模型开始出字」,另一路是「网关接单了」。
实测 51 会话 / 1372 对:
| 路由 | (ttft − ttfb) / ttft 中位数 |
|---|---|
deepseek-v4-pro | 86.77%(ttfb 484ms → ttft 3983ms) |
origin-deepseek-v4-pro(同底层模型、同 provider) | 5.02% |
差 17 倍。 而按 provider 汇总出来的 ttfb p50 = 2665ms既不描述前者也不描述后者——它是个假数, 却会让人得出"首字节很快"的结论。
教训:语义不同的东西不能汇总。 而"它们字段名一样、都叫 ttfb"不是可以汇总的理由。
sid-code 的处理很值得学:把这类计算收进一个单一事实源模块, 而且那个模块刻意不提供跨 model 汇总的 TTFB API ——提供了就会有人用。
💡 这是一个很成熟的做法: 防止误用的最好方式不是写注释警告,是让错误的用法在 API 层面不存在。 「刻意不提供某个函数」是一种设计决策,不是遗漏。
12.8 一个必须建立的验收标准
最后给一条判据,它适用于本文讲的所有防线和策略:
新增一层压缩/防线时,验收标准不是「build 过 + 单测过」, 而是「真实会话里被触发过」。
理由是 §7.6 那件事:防线自己会变成它当初要消灭的死功能。 代码在、测试过、但 flag 关着或消费方没接上—— 于是它在架构图里,不在运行时。
具体做法:
- 上线后查真实会话轨迹,确认这一层的触发计数 > 0
- 如果是 0,先判断是"没机会触发"还是"接错了"——这两个的处理完全不同
- 写进埋点,让它长期可查,而不是上线时手工看一次
⚠️ "零触发"有两种成因,必须分清: ① 真的没机会——比如这段时间的会话都没长到需要压缩。这是好事。 ② 接错了 / 关着——代码是死的。这是 bug。
判据:造一个必然应该触发的场景(比如刻意灌一个超长上下文), 看它触发不触发。用变异来自证,而不是等自然流量。
12.9 本章自检
- 为什么压缩率是个错误指标?它奖励了什么错误行为?正确指标是什么?
- Thrashing 为什么好用?「重复且返回值不变」比「重复」精确在哪?
- 约束衰减的机制是什么?为什么提高摘要质量解决不了它?最稳的修法是哪一层?
- 结构化摘要模板的第 6 段和第 9 段各在防什么?
<analysis>草稿纸的 trade-off 是什么?- 四类探针各测什么?什么时候必须上探针?
- cache break 归因为什么必须分两类?
- stock/flow、分母口径、跨口径汇总,三个陷阱各是什么形状?
- "零触发"的两种成因怎么区分?
§14 动手:从零实现一个 mini 上下文层
这一章的用法:不要一次做完。每个阶段都刻意设计成 "能跑通,但会撞到下一阶段要解决的那个坑"。 撞到之后再往下读,比先读完再写记得牢得多。
为什么要亲手写:本文所有结论你现在都"知道"了, 但知道和能设计之间隔着一层——那一层只能靠撞墙补上。 面试里"我实现过"和"我读过"的差别,对方三个问题就能问出来。
总时长:约一周(每天 2–3 小时)。语言不重要,用你最熟的。
阶段 1:能跑通一个循环(半天)
目标:一个最朴素的 agent loop,什么优化都没有。
1. 维护一个 messages 数组
2. 给模型两个工具:read_file、run_command
3. 循环:发请求 → 有 tool_use 就执行 → 把 tool_result 追加进数组 → 再发
4. 每轮打印:messages 条数、估算 token 数、API 返回的真实 token 数第 4 步是这个阶段的全部意义——先建立"看得见"的能力。 不要跳过它去写压缩,那样你压完了也不知道有没有用。
你会观察到(去实测,不要只是相信):
- 让它读三五个文件,token 数怎么涨的
- 你的估算值和 API 真值差多少(用中文和代码分别试—— §9.5 那个"4 字符 ≈ 1 token"的偏差,你要亲眼看到)
- 十几轮之后 token 就到几万了
做完这一步你会撞到:跑不了几十轮就超窗口,API 报错。 于是你需要阶段 2。
阶段 2:加一个最朴素的压缩,然后被两件事教育(一天)
目标:实现最直觉的方案——满了就砍掉最早的消息。
if (estimatedTokens > window * 0.8) {
messages = messages.slice(4) // 砍掉最早 4 条
}你会撞到的第一件事(几乎立刻):
API Error: unexpected `tool_result` — no corresponding `tool_use` found恭喜,你撞到了 §9.2。你切在了 tool_use 和 tool_result 之间。
现在去修它,这是本阶段最有价值的部分:
- 先用最粗的办法:切完之后扫一遍,把孤儿
tool_result删掉 - 然后你会发现更好的办法:根本别切在那种位置—— 找"下一个 assistant 响应开始"的地方作为切点
- 试着写一个
assertMessagesValid(messages)函数, 在每次发请求前跑一遍。这个函数会在后面几个阶段一直救你。
你会撞到的第二件事(慢一点,但更重要): 砍掉早期消息之后,模型开始重复读你已经给它读过的文件。
恭喜,你撞到了 thrashing(§12.2)和"只截断不给恢复通道是负优化"(§3.2)。
去打个埋点:记录 (工具名 + 参数) 的哈希,统计重复率。 你会看到这个数字很难看。 记住它,它是阶段 3 的基线。
阶段 3:把截断换成可恢复外化(一天)
目标:不删信息,只把它移出去。
工具结果超过 N 字符时:
1. 原文写到 /tmp/session-x/{tool_use_id}.txt
2. 上下文里换成:前 2000 字符 + "\n[完整输出见 /tmp/…/{id}.txt]"四个必须做对的细节(都在 §7.4):
| 细节 | 怎么做 | 为什么 |
|---|---|---|
| 写文件用排他标志 | flag: 'wx',不要 stat-then-write | 避免竞态;靠 tool_use_id 唯一性天然幂等 |
read_file 的输出必须豁免 | 硬编码在配置读取之前 | 存成文件让模型 read 回来是循环的(§6.10) |
| 两级预算 | 单个结果 50K + 单条消息聚合 200K | 并行工具会让个体预算失效(§2.4) |
| 保留结尾 | 别只留开头,试试首尾都留 | 报错信息经常在结尾 |
现在回去看阶段 2 的那个重复率埋点。 它应该明显下降了。 这是你第一次用数据证明一个上下文优化有效—— 这个体验比读十篇文章有用。
做完这一步你会撞到:文件落盘救了大工具输出, 但几十轮的对话历史本身还是会把窗口填满。落盘救不了它。 于是你需要阶段 4。
阶段 4:加 LLM 摘要,然后被压缩自己教育(一到两天)
这是最容易低估的阶段。 你以为是"调个 LLM 生成摘要", 实际上你会连续撞到四个坑。
第一步:先写最朴素的版本
1. 取 messages 的前 80%
2. 发给模型:"请总结这段对话"
3. 用 [摘要] + 最近 20% 替换整个数组坑 1:压缩请求自己超窗口了(§9.1)
你会看到 prompt too long —— 压缩请求本身报的。 修法链:① 剥离图片 → ② 从最早的消息开始丢 → ③ 重试, ④ 且越试越激进(dropRatio = min(0.85, 0.25 + attempt × 0.15))。
坑 2:摘要写得很流畅,但把关键信息丢了(§12.4)
你会发现摘要读起来很好,但文件路径没了、你的否决意见没了。 去把"请总结这段对话"换成结构化模板,至少要有这几个槽位:
1. 用户的原始目标
2. 改过哪些文件(要具体路径)
3. 踩过哪些坑 / 修过哪些错
4. ★ 所有用户消息(全列,不做判断)
5. 待完成的事
6. 当前正在做什么第 4 项最重要——不要让模型判断哪条用户约束重要,全留下。 你会亲眼看到加了这一项之后,"它忘了我说过的话"这个问题明显减少。
坑 3:压缩完之后它"忘了"刚才在改的文件(§10)
它有摘要,知道"我们在改 auth.py",但没有文件内容,于是重新 read。 于是你需要重建:压缩后重新读最近的几个文件。
三层预算别省(§10.3):最多 5 个文件 × 每个 ≤5K × 总计 ≤50K。
然后做一件加分的事:比对 mtime, 如果文件在压缩期间被改过,在重注入的内容里显式标注。 把"文件可能变了"从隐性变成显性。
坑 4:压缩失败之后疯狂重试(§9.4)
某次摘要请求超时或返回空,你的代码会再试一次,再一次,再一次。 去加熔断:连续失败 3 次就停,然后降级到纯本地截断(不调 LLM)。
这里体会一下 §8.5 那条原则:为什么最后一档必须不调 LLM。 你会发现如果降级路径还是调 LLM,那它会在同样的原因下再次失败。
阶段 5:分层 + 缓存 + 验收(一到两天)
目标:把前面几层组织成管线,加上缓存,然后验证它们真的在跑。
第一步:组织成管线
① 工具结果预算(阶段 3)
↓ 重测,够了就返回
② 裁剪最早的轮次组(阶段 2 修好的版本)
↓ 重测,够了就返回
③ 清理旧工具结果的内容(新写,见下)
↓ 重测,够了就返回
④ LLM 摘要(阶段 4)注意"重测,够了就返回"这一句——它是分层的全部意义(§7.2)。
③ 是新的一层:清空旧工具结果的内容但保留消息结构。 按 §7.5 分三档:可丢弃(read/bash/grep→ 完全清空)/ 不可丢弃(edit/write→ 留摘要)/ 未分类(保守占位符)。
第二步:加缓存分区
如果你的 provider 支持显式 cache_control:
- 把 system prompt 拆成静态区 ┃ 分界线 ┃ 动态区
- 静态区:身份、约束、项目规则
- 动态区:当前日期、todo、git status
- 工具定义按名字排序(§6.2 那个最容易漏的坑)
- 断点:system 可多个,messages 只打一个(§6.4)
然后做一件事:故意把「当前时间(带秒)」放进静态区, 跑几轮,看 cache_read 变成什么样。然后挪到动态区再看。亲眼看到这个差别,你就永远不会忘记 §6 了。
第三步:验收(最重要,也最容易被跳过)
按 §12.8 的标准——不是 build 过就算完,是每一层真实触发过。
对每一层:
1. 打埋点记触发次数和实际释放的 token
2. 造一个必然应该触发它的场景(灌一个超长上下文)
3. 确认计数 > 0
4. 如果是 0:区分「没机会触发」还是「接错了」你很可能会发现至少有一层是死的。 这不是你写得差—— 这正是 §7.6 那件事,成熟项目里七层有六层默认不生效。 亲手撞到一次,你对"分层架构"这个词的态度就会永久改变。
第四步:算一次总账
跑同一个任务三遍,分别用: ① 阶段 1 的裸版本(如果它能跑完)② 阶段 3 的版本 ③ 阶段 5 的版本。
记录 token-per-task(总 token,含所有轮次和重试), 以及重复工具调用率。
注意别只看单轮 token 数——你要的是总账(§12.1)。 如果某个版本单轮很省但轮数翻倍,它其实更贵。
阶段总览:你会亲手撞到的坑
| 阶段 | 产出 | 你会撞到 | 对应章节 |
|---|---|---|---|
| 1 | 裸 loop + 埋点 | token 涨得比想象快;估算偏差(中文/代码) | §2、§9.5 |
| 2 | 朴素截断 | 孤儿 tool_result;thrashing | §9.2、§12.2 |
| 3 | 可恢复外化 | Read 豁免的循环;并行工具聚合爆炸 | §7.4、§2.4 |
| 4 | LLM 摘要 | 压缩自己超窗口;摘要丢关键信息;压缩后"忘了"文件;失败风暴 | §9.1、§12.4、§10、§9.4 |
| 5 | 分层 + 缓存 + 验收 | 缓存被日期击穿;某一层其实是死的 | §6、§7.6、§12.8 |
做完这五个阶段,你在面试里能说的不是"我读过 Claude Code 源码", 而是"我自己踩过这几个坑,是这么修的"。 后者的可信度高一个数量级。
附录
A. 术语速查表
按首字母排,方便回查。详细解释见 §0。
| 术语 | 一句话 | 详见 |
|---|---|---|
| Compaction | 可逆压缩:剥掉可从环境恢复的内容,只留标识符 | §0.4 |
| Context Overflow | 超窗口,API 硬拒。硬失败、有报错 | §4.3 |
| Context Pollution | 无关/过时信息干扰有用信息。质量问题 | §4.3 |
| Context Rot | 越长越差,远在填满前就开始。渐进、静默 | §4 |
| Distractor | 语义相似但事实无关的内容。比无关噪声更危险 | §4.3 |
| DYNAMIC_BOUNDARY | system prompt 的静态/动态分区线 | §5.2、§6.3 |
| Governance Decay | 摘要为任务连续性优化,合理地丢掉了安全约束 | §12.3 |
| Head-Tail 截断 | 只留首尾,中间省略(因为报错常在两头) | §0.4 |
| Isolate | 四操作之一:让不同关注点在不同上下文里 | §11 |
| KV Cache | 缓存每个 token 的 K/V 向量。窗口有限的物理原因 | §0.1 |
| Lost in the Middle | 中间位置被系统性忽视,注意力 U 型。位置问题 | §4.3 |
| masking | 旧工具输出换成摘要占位,原文落盘 | §0.4 |
| microcompact | 只清理旧工具结果内容,保留消息结构。零 LLM 调用 | §7.5 |
| PTL (prompt too long) | 输入超长被拒。压缩请求自己也会 PTL | §9.1 |
| prompt cache | 前缀 KV 缓存。命中约为全价 1/10 | §6 |
| Select | 四操作之一:每轮只放相关的进来 | §3.3 |
| snip | 按时间深度删掉最早的若干轮 | §7.2 |
| Summarization | 不可逆压缩:LLM 生成摘要。最后手段 | §0.4 |
| Thrashing | agent 重复做已做过的事。最实用的质量信号 | §12.2 |
| token-per-task | 完成一个任务的总 token。正确的成本指标 | §12.1 |
| Write | 四操作之一:移到上下文之外,留指针 | §3.2 |
| 压缩率 | 压缩后÷压缩前。⚠️ 错误指标 | §12.1 |
| 探针测试 | 压缩后问"关键信息还在不在" | §12.5 |
B. 三十秒自检清单
设计或 review 一个上下文层时,快速过一遍。打不了勾的地方就是风险点。
入口控制
- [ ] 工具结果有单体预算吗?
- [ ] 有聚合预算吗(防并行工具爆炸)?
- [ ] 超预算是落盘留路径,还是直接截断丢弃?
- [ ]
read类工具的输出豁免了落盘吗(否则循环)?
缓存
- [ ] system prompt 分了静态/动态区吗?
- [ ] 易变值(日期、时间戳)在动态区吗?
- [ ] 工具定义的顺序稳定吗?
- [ ] messages 上只有一个断点吗?
- [ ] 压缩后抑制了一次 cache break 告警吗?
压缩
- [ ] 是分层的吗,还是只有 LLM 摘要一条路?
- [ ] 每层之后重测、够了就停吗?
- [ ] 排序依据是信息粒度还是"省 token"?
- [ ] 切分点对齐API 轮次组吗(不是消息条数)?
- [ ] 有
assertMessagesValid之类的配对校验吗?
失败处理
- [ ] 压缩请求自己超窗口时怎么办(递进丢弃)?
- [ ] 有熔断吗(连续 N 次失败就停)?
- [ ] 熔断后的降级路径不调 LLM 吗?
- [ ] 分清了瞬时故障(退避)和结构性故障(熔断)吗?
递归与隔离
- [ ] 压缩会触发自己吗(来源守卫)?
- [ ] 子代理会污染主代理的模块级状态吗?
- [ ] 本地压缩省下的量显式传递给下游了吗?
重建
- [ ] 压缩后重新读关键文件了吗?
- [ ] 重建有三层预算(文件数 × 单文件 × 总计)吗?
- [ ] 文件在压缩期间变过会标注吗(mtime 对比)?
- [ ] 手动压缩和自动压缩走同一条收尾吗?
- [ ] 重建是 best-effort(失败不影响压缩成果)吗?
安全
- [ ] 用户约束不走摘要通道,而是每轮重新注入吗?
- [ ] 想清楚约束衰减的风险了吗?
度量
- [ ] 看的是 token-per-task 而不是压缩率吗?
- [ ] 有 thrashing 检测吗(重复且返回值不变)?
- [ ] 每层的实际释放量有埋点吗?
- [ ] 压缩后是否立刻重触发有埋点吗?
- [ ] 每一层在真实会话里触发过吗(不是"代码里有")?
- [ ] cache break 分了本地断裂 vs 服务端 TTL 两类吗?
C. 常见误区速查
面试和实践里最常见的十个错误说法,附一句纠正。
| ❌ 常见说法 | ✅ 纠正 | 详见 |
|---|---|---|
| "窗口越大越好" | 18 个模型全部远在填满前就退化;140K→6K 准确率反而提升 | §4.1 |
| "对话历史是主要膨胀源" | 工具输出占 70–80% | §2.3 |
| "上下文用到 90% 才压缩" | 真实实现多是减法;同一代码 200K 下 83.5%、1M 下 96.7% | §8.1 |
| "压缩就是让 LLM 做个摘要" | 那是难点的约 5%;失败处理/切分/缓存/重建才是主体 | §9.7 |
| "压缩能省钱" | 压缩会毁缓存;200K 前缀命中 vs 重算差约 200 倍 | §6.5 |
| "压缩率越高越好" | 99.3% 压缩率那个方案质量最差、总 token 最高 | §12.1 |
| "保留最近 N 轮就行" | 不能在任意位置切;孤儿 tool_result 会被 API 拒 | §9.2 |
| "热更新配置立即生效" | 追溯适用会毁缓存;压缩决策必须冻结 | §6.9 |
| "多 agent 更高级" | 信息论上单 agent 效率严格更优;代价 4–15× token | §11.2 |
| "测试全绿就说明没问题" | 上下文失效几乎全是静默的;要看真实触发计数 | §12.8 |
最后:这份文档想让你记住的三件事
① 上下文工程是内存管理,不是写作。
把 LLM 当 CPU、上下文当 RAM、文件系统当硬盘, 四个操作就分别是换出、调入、内存压缩、进程隔离。 这个类比之所以值得记,是因为它一句话说明了为什么这是系统设计问题 而不是措辞技巧问题——而这恰好是从 PE 跨到 CE 的那道认知门槛。
② 每一个"显然的好事"都有价格,先算价格再做。
- "清理上下文" → 破坏缓存,约 10 倍成本差
- "实时更新 git status" → 每轮击穿前缀
- "压缩率做到 99%" → 质量最差,总 token 最高
- "热更新配置" → 追溯适用毁掉缓存
- "多 agent 架构" → 4–15 倍 token
- "只截断省空间" → agent 补偿性重试,能力降、成本涨
这份文档里没有一个免费的优化。 面试里能主动说出代价的人,比只会说好处的人可信得多。
③ 难点从来不在核心算法,在它周围。
摘要 prompt 就是一段文本模板。而围绕它的: 压缩自己会超窗口、不能在任意位置切分、会递归触发自己、 失败会变成风暴、本地变更对观测不可见、prompt 会随模型版本腐烂、 压缩完必须重建、以及怎么知道这一切真的在生效—— 这些加起来是模板本身代码量的十几倍。
如果你只带走一句话:压缩不是一个函数,是一条按信息粒度损失递增排序的管线; 每一层的价值不是省了多少 token,而是让下一层不必执行。
本文的数字来源分三类:【源码】来自 sid-code 与 Claude Code 的实际代码与注释, 【实测】来自生产数据或实验,【文献】来自论文与厂商博客。 未标注的段落是推理、类比与工程判断——请当成观点,不要当成事实引用。