Transformer 的自注意力机制有一条绕不过去的数学约束,计算复杂度是 O(n²)。上下文长度翻倍,计算成本增加四倍。即使有FlashAttention 等优化降低了常数因子,但没有改变平方增长的数学性质。
更大的窗口解决不了根本问题。即使如今塞进 1M token,LLM 对长文本中不同位置的注意力分配天然不均匀------靠前的、靠后的、重复出现的内容权重更高,中间的信息被稀释。上下文越长,模型的注意力越分散,行为越不可预测。这就是所谓的「上下文腐烂」------不是模型变笨了,是信息密度被稀释了。
上下文治理要做的就是在信息密度和 token 成本之间找平衡------装什么、按什么顺序装、装满了先扔什么、怎么在扔掉之后还能找回关键信息。这涵盖了上下文组装的策略、压缩管线的设计、缓存的经济学、腐烂的检测与恢复。
一、上下文窗口的"账本"
一张 200K 窗口的实际分布
| 区域 | 典型占用 | 说明 |
|---|---|---|
| System Prompt | ~2,600 | 安全指令、行为规则 |
| 工具定义(内置) | ~17,600 | 所有内置工具的 schema 描述 |
| 工具定义(MCP) | 900-51,000 | 多 MCP 叠加后可用空间骤降 |
| CLAUDE.md | ~1-10K | 多层合并后的项目说明 |
| 对话历史 | 持续增长 | 每轮追加 prompt + 回复 + 工具调用记录 |
| 工具结果 | 持续增长 | 文件内容、Shell 输出、搜索结果 |
| 响应缓冲区 | ~40-45K | 模型 thinking + 生成输出 |
| Auto-Compact 缓冲区 | ~33,000 | 压缩触发前的安全垫 |
| 实际可用 | ~114-150K | 取决于 MCP 数量 |
2026 年 3 月起,Opus 4.6 和 Sonnet 4.6 均支持 1M token 上下文窗口。但窗口大了不代表可以随便塞------上下文一满,模型行为退化,token 费用飙涨。管理比扩展重要。多挂几个 MCP server 之后,可用空间会被压到 120K 左右------工具定义本身就成了最大的上下文消耗者。
两块缓冲区的管理方式
预算表中标注了「响应缓冲区」和「Auto-Compact 缓冲区」,这两块是专门预留的空位,不用于存储历史数据。
响应缓冲区(~40-45K) 是为模型的 thinking + 生成输出预留的。模型在输出长回答时需要连续生成几千甚至几万 token------如果上下文在生成过程中被占满,模型会中断响应。这块缓冲区是确保模型能完整输出答案的安全垫,不参与压缩管线的回收。
Auto-Compact 缓冲区(~33K) 是压缩触发前的最后一道阈值。当上下文占用达到 effectiveWindow - 13,000 tokens(200K 窗口约 167K)时,五段压缩管线中代价最高的 AutoCompact 才被触发。这 33K 不是给历史数据用的,是给模型留出的思考余量------让模型在判断「需要压缩了」到「压缩完成」之间还能正常响应。
两块缓冲区的存在说明一个事实:200K 窗口中真正可用于对话历史的,始终低于这个数字。每次看到模型的上限,记得先减去这两块。
上下文就是钱
以 Sonnet 4.6(2026 年 5 月)的 prompt cache 定价为例:
| 操作 | 价格 | 说明 |
|---|---|---|
| 普通输入 | $3.00/M tok | --- |
| 缓存写入(5min TTL) | $3.75/M tok | 1.25 倍,1 次命中回本 |
| 缓存写入(1h TTL) | $6.00/M tok | 2.0 倍,Max 订阅专享,2 次命中回本 |
| 缓存读取 | $0.30/M tok | 统一 0.1 倍 |
具体数字会变,但比例关系是稳定的------缓存命中始终是原价的十分之一,这是 Anthropic 定价策略的长期基线。30 轮会话:不用缓存约 0.90,命中缓存约0.12,差 7 倍多。
上下文管理的每一个决策最终都落在这张定价表上。上下文不变 → prompt 前缀命中缓存 → 费用打一折。上下文一变 → 缓存失效重建 → 全部按原价。这就是为什么上下文组装顺序、cache_edits 透明删除、Microcompact 冷热双路径------这些机制表面是管 token,实际上是管钱。
二、从零组装一次上下文
每次会话启动到第一条 prompt 发出前,经过一个固定顺序的装配流水线。
静态 Prompt 组装
system prompt 是怎么造出来的?入口是 getSystemPrompt()(src/system/prompt.ts),它不返回一个长字符串,返回一个 string[] 数组------每个元素是一个独立的 prompt 分区。分区的目的不是为了组织清晰,是为了缓存。
数组中间有一个 DYNAMIC_BOUNDARY 标记,把整个数组切为两半:标记之前是静态区(安全指令、工具定义、行为规则),标记之后是动态区(用户环境、CLAUDE.md、Skills 元数据)。静态区在 API 调用中被标注为 scope: 'global',所有用户共享一份缓存;动态区 per-user,各自独立。
这样切的好处:静态区占 prompt 体积的大头(~20K+),但因为它对所有用户都一样,可以被全局缓存------每次会话只付十分之一的 token 费。动态区虽然每用户不同,但体量小。
实际结构示意:
ini
getSystemPrompt() → string[]
│
├── [0] "You are Claude Code, Anthropic's official CLI..."
├── [1] "## Doing tasks\n..."
├── [2] "## Executing actions with care\n..."
├── [3] "## Using your tools\n..."
│ ...
├── [N] "__DYNAMIC_BOUNDARY__" ← 切分线
│
├── [N+1] "<system-reminder>" ← 动态区开始
├── [N+2] "## Environment\nOS: darwin\n..."
├── [N+3] CLAUDE.md 合并内容
├── [N+4] Skills 元数据 (name + description)
├── [N+5] MCP 指令 / 特性开关 / token budget
│
└── scope: 'global' (前 N 段) → 所有用户共享缓存,0.1x 价格
scope: per-user (后 N 段) → 各自独立,原价
CLAUDE.md 四层融合
四层搜索合并,后加载的覆盖前面的:企业级 Managed → 用户级 → 项目级 → 本地覆盖(.local.md,gitignore)。.claude/rules/*.md 中带 path/glob 声明的规则按需激活------Agent 读取匹配文件时才注入。子目录下的 CLAUDE.md 同样按需触发。
CLAUDE.md 注入在 system prompt 的尾部------attention 机制的末尾权重最高。代价是被 <system-reminder> 标注为"may or may not be relevant",直接压制了遵循优先级。实践中这导致约 80% 的遵守率,剩余的 20% 必须靠 Hooks 做 100% 确定性强约束。
Skills 三层渐进加载
Skills 不属于记忆机制------它按需注入,不经过 L3/L4 的存储和检索流程。
| 层级 | 内容 | 注入时机 | 成本 |
|---|---|---|---|
| Level 1 | name + description | 启动时注入 system prompt 动态区 | ~100 tokens/skill,15K 总预算 |
| Level 2 | 完整 SKILL.md | 模型调用 Skill() 时,注入为隐藏 user 消息 | 500-5,000+ |
| Level 3 | scripts/references/assets | SKILL.md 引用时才读 | 按需 |
注册 50 个 skill,只有被触发的那个占用上下文预算。模型靠 LLM 推理判断何时调用哪个 skill------不是正则路由,不是关键词检测。script 脚本代码本身不进上下文,只有输出结果能进------token 成本比让模型生成等价代码低得多。
Memory 和 MCP
MEMORY.md 索引全量注入(200 行/25KB)。Sonnet 轻量检索 Agent 在每轮 user 消息后扫描记忆文件,选出 ≤5 条作为上下文注入。
MCP 指令每轮重算------MCP 服务器状态可能随时变化。代价是频繁破坏工具缓存。优化方案是将 Agent 列表从工具描述移到 attachment message 中(不破坏 tool schema cache),这项优化节省了全 fleet 约 10.2% 的缓存创建 token。
15 步完整加载时序
sql
Session Start:
→ getSystemPrompt() 构建静态 array(global cache)
→ DYNAMIC_BOUNDARY 标记切分
→ 搜索 CLAUDE.md(4 层)→ 合并注入 system context 尾部
→ 注入 MEMORY.md 索引(全量)
→ git status 快照(≤2000 字符)
→ 环境信息(date, cwd, OS, model)
→ Skills 元数据注入(name+desc,≤15K 总预算)
→ MCP 指令注入(attachment message,不破坏工具缓存)
→ 特性开关门控段注入
→ system reminders / token budget 追加
用户发消息:
→ user prompt 放置
→ UserPromptSubmit hook 触发
→ Sonnet 检索 Agent 扫描 L3 → ≤5 条记忆注入
执行:
→ Skill() 调用 → SKILL.md 全量注入(隐藏 user 消息)
→ 工具产出 → 追加到消息数组
→ 每轮后五段压缩管线按需运行
Token Budget:谁在为上下文消耗买单
15 步加载时序的最后一步是「system reminders / token budget 追加」。Token Budget 是每轮 API 调用的 token 配额分配方案------决定模型能花多少 token 用于思考(thinking)、多少用于输出(output)、以及工具调用的结果能占多大空间。
这个预算不是全局一刀切的。模型在不同阶段的 thinking 配额不同:深度推理任务中 thinking 配额被放宽,简单操作中被收紧。output token 的预算决定了每次回答的最大长度------写代码、生成文档时需要大量 output token;简单确认时可以压缩到最低。工具调用结果同样受预算约束------单个 tool_result 如果超过 50,000 字符,会被 Budget Reduction(第四章)优先截断。
预算分配的逻辑决定了上下文消耗的速度。thinking 配额越宽 → 模型思考越深入但窗口消耗越快。output 配额越宽 → 每次回答越长但留给历史的 token 越少。设计 Agent 时,Token Budget 是用户最直接的杠杆------收窄配额降低上下文消耗速度,放宽配额给予模型更充分的推理空间。无限制的配额是上下文膨胀的加速器。
三、缓存:省钱的核心杠杆
第一章说了 prompt cache 的定价逻辑------命中缓存只要原价十分之一。Claude Code 围绕 prompt cache 做了四层工程:怎么标记缓存、怎么在清理旧数据时不破坏缓存、怎么判断缓存还活着、缓存断了怎么发现。
prompt cache 的工作机制
Anthropic 的 prompt cache 是这样工作的:API 接收每次请求的 messages 数组,从头开始逐条计算 hash,和上一次请求的前缀对比。前缀相同的部分自动命中缓存------不需要显式标记哪条消息要被缓存,全自动。但代价是,只要前缀里有一条消息变了,从那条开始的所有后续内容全部缓存失效,全部按原价重算。
两次请求的前缀命中对比:
css
第 1 次请求:messages = [A, B, C, D]
↑ 从头逐条 hash
缓存:前缀1(A)、前缀2(A,B)、前缀3(A,B,C)、前缀4(A,B,C,D)
第 2 次请求:messages = [A, B, C, E] ← 只有最后一条变了
↑ 逐条对比
A 相同 → 命中 | 前缀1 被复用
B 相同 → 命中 | 前缀2 被复用
C 相同 → 命中 | 前缀3 被复用
E 不同 → 从 E 开始及之后全部重建,全价
前面三条消息的缓存全命中------只付十分之一。只有 E 重建。但如果第二条就变了(比如用 sed 本地删了一个 tool_result),从 B 开始往后的 C、D、E 全部失效重建。这就是为什么 cache_edits 坚持不改本地消息------动一条,后面全部失效。
三层标记:给缓存分区定价
Claude Code 在三个层级给 prompt 内容打缓存标记,不同标记对应不同的缓存共享范围:
- System Prompt 静态区:scope = 'global'。 所有用户共享同一份缓存。全局缓存意味着 Anthropic 服务端只需要存一份,所有 Claude Code 用户都能命中------这就是为什么要把安全指令和工具定义放在 DYNAMIC_BOUNDARY 之前。
- Messages 区:ephemeral 断点。 每条 user/assistant 消息之间自动插入缓存断点。新消息追加进来时,历史消息的前缀不变------旧消息继续命中缓存。但如果你在中间插入或删除了一条历史消息,从那条往后的所有缓存失效重建。
- Tools 区:session-lock。 工具定义在会话开始时锁定,feature flag 不变则序列化结果不变。为什么子 Agent 不参与 cache_edits?因为它 fork 时共享主线程的工具缓存前缀,一旦主线程在子 Agent 运行期间改了前缀里的 tool_result,子 Agent 面对的缓存状态就不一致了。
cache_edits:删旧数据但不破坏前缀
上下文管理最频繁的操作是清理旧的 tool_result------文件读过的内容、Shell 跑过的输出,用完就占着窗口位置。传统做法是直接把本地 messages 数组里的 tool_result 替换为 [cleared]。但这会导致一个致命问题:前缀 hash 变了,从这条消息开始往后的所有缓存全部失效重建。删掉几千 token 的旧结果,代价可能是几万 token 的缓存重建。
cache_edits 绕过这个问题。它不改 local messages------它构造一个独立的 cache_edits 字段附在 API 请求里,告诉服务端「在缓存视图中把 tool_result_id=xxx 标记为已删除」。而服务端从缓存中读取了之前计算好的 KV Cache,再根据你的修改进行修改试图。结果删一个旧结果只花十分之一的读取价。
冷热感知:缓存过期后的降级路径
cache_edits 再精巧也有前提------缓存本身必须活着。prompt cache 的 TTL 只有 5 分钟(Max 订阅 1 小时)。如果距上次请求超过 TTL,缓存已经死了,再做 cache_edits 没有意义------你面对的是一个已经过期、需要重建的缓存。
这时候走冷路径:直接本地替换 tool_result 为 [Old tool result content cleared],简单直接。Microcompact 用 60 分钟作为冷热判断阈值(不是 5 分钟 TTL),是因为一批 tool_result 的清理涉及多条消息,如果缓存还没完全冷透就改本地,会破坏部分前缀而非全部------这种不一致造成的 cache miss 比全冷状态下更难追查。
双路径对比:
| 路径 | 条件 | 策略 | 成本 |
|---|---|---|---|
| 热路径(Hot) | 缓存存活(≤ TTL) | cache_edits,保持 messages 不变 | ~10% |
| 冷路径(Cold) | 缓存已死(> TTL) | 放弃 cache_edits,直接本地删改 messages | 100% |
两阶段检测:发现非预期的缓存断裂
promptCacheBreakDetection.ts(651-728 行)是缓存系统的健康监控:
- 调用前:记录 12-14 维状态快照------systemHash、toolsHash、model、betas、cacheControlHash 等。
- 调用后 :检查 API 返回的
cache_read_input_tokens。如果比预期下降 >5% 且绝对值 >2,000 token → 缓存断了。 - 归因:12 维全没变 → 可能是 TTL 过期或服务端路由/驱逐。某维变了 → 锁定是什么操作改了前缀。
非计划内的缓存断裂是沉默的------你感觉不到 Agent 变慢了或变蠢了,只有月底的账单会告诉你。
冷热感知和两阶段检测的分工:
| 机制 | 粒度 | 判断逻辑 |
|---|---|---|
| 冷热感知 | 整个缓存会话 | 距上次请求是否超过安全阈值(60 分钟)→ 缓存整体是否还活着 |
| 两阶段检测 | 每条消息 / 每个前缀 | 12-14 维快照对比 + cache_read_input_tokens 变化 → 精准确认哪些前缀仍命中 |
四、自动防线:五段压缩管线
每次模型调用前,从便宜到贵的策略依次执行。五段不是并行备选------是递进降级链,前一阶段顶住了就不触发下一阶段:
Budget Reduction:超大结果先落盘
每次工具调用的返回结果都会被追加到上下文里。大部分结果很小------几行 Shell 输出、一段文件内容。但偶尔会遇到一个结果几十 KB 甚至几百 KB:一个巨大的日志文件、一整张数据库表、一次不加筛选的 grep。这些超大结果如果留在上下文里,会瞬间吃掉几万 token。
Budget Reduction 做的事很简单:单个 tool_result 超过 50,000 字符时,把完整内容写到磁盘上,上下文里只保留前 2KB 的预览。后续如果 Agent 真的需要完整内容,再用 Read 工具读取------按需加载,而不是全量常驻。
不是所有大结果都能直接挪走。Budget Reduction 在决定哪些可以落盘时,会把每个 tool_result 分成三种状态:正在参与缓存重用的不能动(mustReapply),正在被后续工具调用引用的不能动(frozen),其余的都标记为可替换(fresh),按体积从大到小依次落盘。Read 结果的落盘默认关闭------文件内容本来就在磁盘上,再写一份没有意义。
Snip:扔掉最早的对话
对话历史是上下文里增长最快的东西。每轮对话都往消息数组末尾追加,早期的消息逐渐失去时效------但你不会主动删它们,因为你不确定后面会不会引用。
Snip 从历史头部截断最早期的消息。本质是赌一个假设:越早的消息对当前任务越不重要。不总是对的------有些关键决策恰恰发生在会话早期------所以它由特性开关控制,默认不开启。释放出来的 token 数量会传给 AutoCompact------省了多少就让后面的摘要少打多少,避免重复劳动。
Microcompact:删旧工具结果但不破坏缓存
前两段处理了超大结果和历史头部。剩下的问题是中间区域的旧工具结果------文件读过一遍之后内容还留在上下文里,Shell 跑完命令之后输出还留着,但它们已经没用了。
Microcompact 的目标是清理这些旧结果。但它不能直接删除------第三章讲过,直接改本地 messages 会破坏前缀 hash、导致全缓存失效。所以它分两条路径走:
热路径(距上次请求不到 60 分钟,缓存大概率还活着):走 cache_edits。不改本地消息数组,在 API 请求附上一个独立的 cache_edits 字段,告诉服务端在缓存视图中把指定 tool_result 标记为已删除。服务端在之前缓存的 prompt 前缀中直接删掉对应的内容块,前缀不变------缓存继续命中。这个路径有个五步生命周期:先注册哪些 tool_result 可以被清理,然后根据保留阈值(最近 N 条不动)决定删哪些,构建 cache_edits 指令,插入到 API 请求中,之后每次请求都要重新发送(Pin/Replay,不是一次永久生效)。
冷路径(距上次请求超过 60 分钟,缓存大概率已经过期):放弃 cache_edits,直接把本地 messages 里的 tool_result 替换为 [Old tool result content cleared]。缓存反正已经死了,重建不可避免,用最简单的方式清理最划算。
Microcompact 只在 8 种「可重放」工具上运行(Read、Bash、Grep、Glob、WebSearch、WebFetch、FileEdit、FileWrite)------这些工具的输出丢了随时可以重新跑。自定义 MCP 工具默认不碰,因为服务端不知道你的 MCP 工具输出能不能安全重放。只在主线程运行------子 Agent 共享主线的缓存前缀,主线程在子 Agent 运行时改了前缀,子 Agent 面对的就是不一致的缓存状态。
Context Collapse:实验性的非破坏折叠
当上下文使用达到约 90% 时,前面几段已经尽力了。Context Collapse 的定位比 AutoCompact 轻------它不修改磁盘上的任何数据,只是在读时生成一个虚拟的投影视图。
具体怎么做到的:Claude Code 的会话记录是 JSONL 格式,每行一条消息,按时间顺序追加。Context Collapse 通过 projectView() 函数在读取会话历史时,对早期轮次生成摘要式的投影------原始 JSONL 行原封不动保留在磁盘上,但组装上下文时只加载投影版本。相当于在原始数据和 API 调用之间加了一层虚拟的「筛选镜」。模型离早期轮次越远,看到的摘要粒度越粗。
因为是读时投影,所以完全可逆------关了 Context Collapse,模型又看到完整的原始历史。这和 AutoCompact 的本质区别:前者是虚拟的、可逆的投影,后者是实体的、不可逆的替换。两者互斥------如果 AutoCompact 已经替换了历史,Context Collapse 没有原始数据可以投影;如果 Context Collapse 还在运作,说明还没到必须做破坏性替换的临界点。
实验性意味着它当前由特性开关控制------不是默认开启的。触发条件、折叠粒度、摘要方式都在调整中。
AutoCompact:最后的手段
前面四段全部执行完,上下文还是超过阈值(约为 effectiveWindow - 13,000 tokens,200K 窗口约 167K),只剩一条路:让模型自己总结对话历史,用摘要替换原始消息。
先走 Session Memory Compact:复用后台已经维护好的本地会话笔记,不额外调用 API,完全免费。如果本地笔记不够用,再 fork 一个独立的 Agent 做 LLM 摘要------这也是为什么 AutoCompact 要独立为一个 Agent:它本身就要消耗 token,如果和主任务共享同一个上下文,等于火上浇油。
摘要格式是强制性的,不是让模型自由发挥。九个字段------从用户原始意图、技术概念、文件代码、错误修复,到最后一步必须引用原文描述。模型先输出 <analysis> 思考过程帮助自己理清思路,再输出 <summary> 格式化总结,最后系统只保留 summary,丢弃 analysis------思考过程不占 token。
压缩之后不意味着一切从零开始。为了恢复质量,系统会自动把最近改动的 5 个文件重新注入上下文(≤50K token)、已用的 Skills 重载元数据(≤25K token)、从磁盘重读 CLAUDE.md 和 MEMORY.md。
熔断逻辑: AutoCompact 连续 3 次失败(摘要生成超时、输出格式不正确、替换操作失败)后不再重试,降级为强制 Snip------丢掉最早的历史消息直到上下文低于阈值。下一次模型调用前重新尝试 AutoCompact,而非永久禁用。如果 Snip 也触发熔断,最终降级为 /clear 等价行为------清空会话历史但保留 system prompt 和记忆层。压缩管线的最后一道防线不是压缩本身,是承认压缩失败、用更粗暴的方式清理。
五、出问题后的救援:逐层升级的五条路
当自动管线不够用的时候,需要人来选下一步怎么走。从轻到重,逐层升级:
| 策略 | 操作 | 触发条件 | 代价 | 上下文变化 |
|---|---|---|---|---|
| Continue | 直接继续 | 上下文干净,任务未偏离 | 零 | 不变 |
| /rewind | Esc Esc |
Continue 不行------同一问题纠正两次以上 | 丢弃回退点之后的历史 | 缩小 |
| /compact | /compact |
Rewind 救不回来------多个子任务完成、历史太长 | 摘要有损------可能丢关键细节 | 压缩替换 |
| Subagents | 自动/手动 | Compact 只是治标------某些子任务本身就需要大量探索 | 独立窗口、结论回传 | 隔离 |
| /clear | /clear |
上下文已被污染到无法恢复,或切换全新任务 | 全量丢失,需重新描述背景 | 重置 |
从 Continue 到 /clear 是一个递进关系:能继续就继续,继续不了就回退到干净状态,回退还不够就压缩精简,如果是某类子任务本身太重就丢给子代理隔离执行,全都不行了再清空。每一层比上一层代价更高,但比直接跳到 /clear 划算。
六、上下文腐烂治理
(1)指令漂移
指令漂移(instruction drift)是 LLM Agent 在多轮对话中逐渐偏离初始指令的系统性倾向。根因在 Transformer 的自注意力机制本身:系统 prompt 的 token 处于上下文最前端,随着对话轮次增加,新 token 不断追加到尾部,前部 token 的注意力权重被逐步稀释。对话进入第 8 轮后,指令遵循稳定性开始显著下降------模型不是故意忽略指令,是它的注意力已经不在那了。
更麻烦的是自条件效应(self-conditioning):一次略微偏离的回复成了上下文的一部分,影响下一轮生成,偏离逐轮累积。
这不是存储问题。token 还在上下文里,没被删也没被压缩------它只是失去了对模型输出的影响力。这解释了为什么「把 CLAUDE.md 写三遍」没用:重复增加 token 不改变它们所处的注意力位置。写十遍也一样。要在设计 Agent 时解决这个问题,得从工程层面下手。
方案一:指令前置(Instruction Prepend)
最简单的工程手段------在每轮用户消息前强制追加核心指令。不是靠 LLM 自觉去注意,是在每次模型调用前把指令塞进消息数组头部。代价是每轮增加几百 token,但换来的是每轮模型都能「看到」指令。Claude Code 的系统 prompt 分区虽然用了 DYNAMIC_BOUNDARY 做缓存优化,但本质上也是指令前置的一种形式------把安全指令和工具定义放在每轮都命中的缓存前缀里。
方案二:指令重锚(Re-anchoring)
不是简单重复指令,而是在关键决策轮次之前注入压缩版的核心指令。重锚的核心不是重复,是出现在正确的时机。一个常见的工程模式是:在写文件、执行命令、调用外部 API 这类有副作用的操作前,先让 Agent 做一次「指令回顾」------用 2-3 句话确认当前操作是否在允许范围内。这和「每轮都前置」的区别是:重锚是条件性的,只在风险点触发,token 成本更低。
几个落地方式:
- 系统级重锚 :在 Agent 框架里注册一个
preAction钩子,调用工具前自动插入当前生效的约束摘要 - 决策点重锚 :指令写在
CLAUDE.md里,但在工具 schema 的 description 字段里嵌入关键约束------增加约束被「看到」的次数 - 轮次阈值重锚 :每 N 轮对话自动插入一条
<system-reminder>重申核心规则,可配合 prompt cache 减少开销
方案三:上下文切割(Context Segmentation)
把长对话拆成多个独立段落,每个段落有自己独立的上下文窗口。这是在架构层面阻止稀释,而不是在推理层面做补救。
工程上具体怎么做:
- 任务级切割:每个独立任务结束时清空消息数组,只保留结构化摘要传给下一段
- 阶段级切割:用 Plan Mode 的四阶段(Explore → Plan → Execute → Commit)隔离不同性质的上下文
- 边界标记:上下文切换时,在新窗口头部注入旧窗口的结构化交接------「已完成什么、未完成什么、关键决策是什么」
切割的代价是失去了跨段的历史连续性------但大部分场景中,历史连续性本来就不是无限的。失去 200 轮前的上下文比让它继续腐蚀当前决策要好。
方案四:外部状态管理
核心约束和规则不要委身于对话历史,把它们放在 Agent 可以随时读取但不能随意篡改的外部存储中。规则和历史分离------历史可以压缩、可以丢弃,但规则始终保持原始形态。
工程模式很直接:
- 把约束写入独立的外部文件(如
AGENT_RULES.md),Agent 每次执行关键操作前强制重新读取 - 工具链中注册
preToolUse检查------Agent 调用工具前,先读约束文件做比对,不是靠自己记住 - 关键状态(当前阶段、已完成事项、未完成事项)显式写入暂存文件,Agent 通过读文件获得上下文,而不是从对话历史里猜
Claude Code 的 PreToolUse Hook 就是这个思路的程序级实现------它不是 prompt,是代码。Hook 脚本在工具调用前检查命令参数,匹配到禁止模式就拦截,跟模型态度无关、跟对话长度无关、跟注意力稀释无关。Hook 不是软约束,是硬开关。
这四种方案不是互斥的,是分层配合的:
指令前置 → 每轮保底,token 成本固定
指令重锚 → 关键操作前激活,条件性触发
上下文切割 → 架构层面阻断稀释传播
外部状态 → 绕过模型注意力,程序级硬约束
四层分工不同:前置和重锚降低漂移概率,切割限制漂移的传播范围,外部状态在最后一道防线兜底。做自研 Agent 时,根据约束强度选方案------风格偏好类约束靠前置和重锚就够了,安全类约束必须上外部状态和程序级拦截。
(2)续接漂移
续接漂移不是对话中的渐进偏离,而是会话边界处的断裂------压缩或会话结束后,模型靠一条摘要/历史重建上下文,这个重建过程本身就是信息丢失的源头。摘要做不到 100% 还原,总有细节和潜台词被丢掉。模型从摘要中猜「用户刚才到底想让我干什么」,猜对了就继续,猜偏了方向就全歪。
AutoCompact 压缩后、跨会话恢复、Subagent 回传摘要------这三个场景都会触发续接漂移。共同的问题是:恢复上下文的唯一依据是「别人告诉我发生了什么」,而不是自己实际经历过。
方案一:结构化交班协议
压缩或会话结束前,落盘一份结构化交接,而不是让模型自由生成一段摘要。交接必须包含:
diff
- 当前任务目标(引用用户原文)
- 已完成事项(按顺序列出,每条包含修改了什么+为什么)
- 未完成事项(精确到下一步该做什么)
- 关键决策记录(为什么选A不选B,含放弃的方案)
- 待验证假设(什么还没确认)
- 已知问题清单(带文件路径和行号)
结构化交接比自由摘要的质量差异很大------前者是清单,后者是散文。模型读散文的准确率取决于措辞,读清单的准确率取决于清单结构。实践中结构化的恢复准确率显著高于非结构化。
方案二:引用锚点
交班协议中有一个关键约束------描述目标和下一步时,必须引用用户原文中的具体句子,不能自己概括。用户说的是「把 auth 模块的重构收尾」,下一步必须引用这句原文,而不是写成「完成代码整理」或「继续改进代码质量」。前者锁死了范围,后者可以歪到任何方向。
这个技巧的本质是:Agent 自己的概括不可信,用户原文才是恢复方向时唯一的锚点。
方案三:增量恢复
恢复时不把整份交班协议一次塞进上下文,而是先注入结构化快照(任务目标+已完成+未完成),剩余部分(决策记录、问题清单)按需取用。序列化恢复比全量注入恢复的上下文噪音更低------模型不会被不相关的决策记录分散注意力。
方案四:恢复后自检
上下文恢复后不直接开始干活,先让 Agent 基于交班协议做一次方向确认------用 2-3 句话描述「用户要我做什么、我现在做到哪了、接下来第一步做什么」。如果交班协议中有引用锚点,这一步跑通就能确认方向没有歪。
方案五:跨会话恢复补丁
AutoCompact 压缩或跨会话恢复时,系统自动做三件事来提高恢复准确率:重新注入最近改动的 5 个文件的内容(不超过 50K token),重载会话中用过的 Skills 元数据(不超过 25K token),从磁盘重读 CLAUDE.md 和 MEMORY.md。
这三件事的逻辑是:摘要可能会丢细节,但磁盘上的文件、CLAUDE.md、Skills 元数据不会失效。恢复时从原本源头重新加载,比让模型在摘要中猜细节更可靠。代价是每次恢复多花约 75K token 的注入成本,但换来的是恢复后的上下文下限大幅提升。
(3)内容膨胀
上下文预算不是被一次大操作耗尽的,是被成千上万条微小、遗忘的垃圾信息蚕食掉的。废弃插件残留的 CLAUDE.md 片段、三个月前的记忆条目、失效但不清理的工具结果、读过的文件内容、跑过的 Shell 输出------这些信息每轮对话都在消耗上下文,但从来不产出价值。
膨胀不是一次性地满,是每次工具调用追加几百 token 产出,几十轮后多出来几万 token 的负重,Agent 的行为就在这些噪音里悄悄退化。
方案一:输入过滤器
在上下文入口处拦截无用信息,不是让上下文满了再清理。.claudeignore 是最基础的一道------在文件进入上下文之前就过滤掉 node_modules、dist、*.lock 这类 build artifact。但这个粒度太粗。更精细的做法是在工具调用层面加规则:某些目录的读取结果直接丢弃不进入上下文、grep 结果超过阈值只保留统计摘要不保留全文、Shell 命令的 stderr 默认不追加除非显式要求。
方案二:生命周期标记
每条追加到上下文的信息都带一个预期有效期标签。工具调用结果标记为「本轮有效」,下轮自动可回收;记忆索引标记为「会话级常驻」;CLAUDE.md 标记为「跨会话常驻」。Agent 框架在每次模型调用前扫描有效期已过的条目,直接排除出上下文------不需要等到膨胀触发了再触发压缩管线。
代价是框架层需要维护条目的元数据。但好处是回收是确定性的------不是靠模型自己判断什么可以丢,是按预设策略精确回收。
方案三:内容价值评分
不是所有工具调用的产出都值得留在上下文里。Bash 执行结果的输出可能包含几十行日志,但只有最后 2 行是关键的。Read 文件的结果在 Agent 完成对代码的修改后就不再需要了。框架层可以给工具调用附加一个价值标签:当前轮次结束后,低价值的产出标记为可回收,不在后续轮次中加载。
结合生命周期标记来做:低价值 + 有效期短的条目最先被回收,高价值 + 常驻型的条目只在必要时才释放。
方案四:周期性垃圾回收
设定一个固定的上下文使用率阈值(如 70%),达到后触发一轮回收:先回收标记为可回收的条目,再按价值评分从低到高回收。这和压缩管线的区别是:压缩是上下文满了再救,回收是阈值触发,还留有余地。阈值和回收力度应该是可配置的------不同任务场景对接纳噪音的容忍度不一样。
(4)缓存断裂
缓存断裂是成本问题,不是行为问题。前缀变化导致整个缓存视图失效------从缓存命中(十分之一价格)跳回全价重算。这不是渐进式的成本上升,是瞬间的费用跳变。而且断裂是沉默的,你感觉不到 Agent 变慢了,只有月底的账单会告诉你。
根本矛盾在于:上下文管理需要频繁清理旧数据来腾空间,但每次对消息数组的操作都可能改变前缀哈希------而缓存命中的前提就是前缀不变。管理上下文和命中缓存,这两个目标在根源上是冲突的。
方案一:前缀锁定
最直接的做法------把不期望变化的部分固定在缓存前缀中。system prompt、工具 schema、CLAUDE.md 等常驻内容在会话启动时锁定,会话期间不修改。锁定意味着这些段落在会话期间产生任何变化都不会反映到模型上------必须在下次会话生效。代价是灵活性,换来缓存确定性。
方案二:删旧数据不改前缀
核心约束是不能动已缓存的 messages 数组。删除旧 tool_result 时,不走本地修改,而是在 API 请求中附加一个标记字段,告诉服务端在缓存视图中把指定条目标记为已删除。服务端在已计算的 KV Cache 基础上做读时投影------不重新计算,只屏蔽指定条目。代价是每次请求都要携带这个标记字段,但比缓存重建一个数量级便宜。
方案三:归因打点
缓存断裂不可怕,可怕的是不知道断了之后原因是什么。在框架层内置打点:每次 messages 数组发生变更时记录变更源(什么操作、什么时机、改了哪个位置)。缓存断裂后直接关联到具体操作,而不是逐行排查消息数组。三个必打的点:改了哪个 index 的 message、变更类型(增/删/改)、变更前后的前缀哈希片段。
方案四:冷热路径分离
缓存还活着的时候,所有上下文管理操作走非破坏性路径(不改前缀,用标记字段操作)。缓存已死的情况下,直接走本地修改路径------不绕弯子。关键是一个明确的切换阈值:上次活跃请求距今小于 TTL 的走热路径,超过 TTL 的走冷路径。不要在两种模式之间混合操作------在热路径中做了一次本地修改导致缓存断裂,比全冷状态更糟糕(因为花了本地修改的功夫+缓存重建的代价)。
方案五:缓存健康监控
每次 API 调用后自动检查缓存命中状态。如果 cache_read_input_tokens 比上一轮下降超过阈值,自动弹出一条告警附带归因上下文------不等到月底查账单才发现问题。这是框架层应该提供的基础设施,不是运维侧的专项工作。
七、落地实践中的常见陷阱
前六章讲完了上下文管理的机制和方案。这一章换个视角------看四个真实场景中这些设计是怎么落地的,需要做怎样的考量,以及自研 Agent 时能直接复用的设计思路。
成本不可预测
场景: 一个自动化的 CI Agent 每天处理几十个 PR review。开发团队把它接入流水线后,第一周 API 账单 200,第二周跳到1,800。同一个 Agent、同样的代码量、完全不一样的价格。排查发现第二周有几天 PR 密集,Agent 长会话的上下文膨胀触发了频繁的 AutoCompact,每次压缩要多花一次 LLM 调用的成本。累计下来就是十倍差。
根因: 成本不是由模型调用次数决定的,是由上下文稳定性决定的。上下文稳定 → prompt 前缀命中缓存 → 费用十分之一。上下文频繁变化(工具结果堆积 → 触发压缩 → 重建缓存)→ 缓存反复断裂 → 每一步都按全价计算。
取舍: 压缩本身有成本。想要省缓存费用就要保持上下文稳定,但上下文稳定意味着不能随便清理旧数据。一个越俎代庖的压缩策略可能省了窗口空间但花了更多 API 调用费,算总账不一定划算。
借鉴思路: 成本可观测应该是 Agent 框架的原语,不是运维侧的专项工作。框架层内置一个上下文成本追踪器,每次 API 调用后记录 token 消耗、缓存命中率、压缩触发次数。成本跳变时自动告警并附带归因(什么操作触发了 XX 次缓存重建),而不是等月底查账单才发现问题。
上下文膨胀的不可逆性
场景: Agent 在处理「排查生产环境偶发超时」的任务。它读了三十个日志文件、跑了二十次 grep、翻遍了最近一周的部署记录。任务完成后,上下文里堆满了排查过程------日志片段、grep 结果、部署 diff。第二天用户让它做一件完全不相关的事:「给新接口加个参数校验」。Agent 的回复里混入了昨天的日志片段,它以为那些日志跟当前任务有关。
根因: 排查任务留下了大量高 token 密度的工具结果。虽然窗口还没触发压缩阈值,但这些残留内容在推理中被模型注意到了------因为日志内容恰好和当前代码的关键词重叠。上下文的质量不取决于用了多少窗口,取决于剩余空间里噪音占比。一个 60% 满的窗口如果 40% 是上一次任务的残留,退化可能比 90% 满但内容干净的窗口更严重。
取舍: 窗口看着还很空的时候重置上下文是反直觉的------为什么要清掉还有空间的窗口?(追问)但如果窗口里的内容主要来自上一个任务,不清掉的代价是 Agent 在下一个任务中被旧数据持续污染。
借鉴思路: 任务隔离。在架构层引入显式的任务边界------任务切换时上下文自动分段,前一个任务的工具结果不进入下一个任务的消息数组。Claude Code 的 Subagent 模式就是为此设计的:搜索验证类的子任务在独立上下文窗口中运行,所有中间过程留在子窗口内,只有最终摘要回到主会话。主会话只收到几行结论,零污染。实现上不需要复杂的上下文管理:每次任务结束时清空消息数组、重新注入 CLAUDE.md 和 Memory,省 40% 以上的输入 token,成本接近于零。
上下文质量的渐进退化
场景: Agent 在做跨模块重构,涉及六个微服务。会话持续了四个小时、两百多轮对话。前三小时表现稳定,第四小时开始------反复读取已经读过的文件、重复提问已确认过的决策、把早期排除过的方案重新提了出来。团队换模型试了也不行,后来发现是上下文退化------两百轮的对话历史里,前五十轮的关键决策已被后续噪音淹没。
根因: 每一轮工具返回结果、模型输出、中间纠错都在往上下文里加东西。两百轮累积的信息足够把 Agent 的注意力从「刚才决策了什么」稀释到「刚才说了什么」。压缩能止损,但压缩必丢信息。压早了丢细节,压晚了已腐烂。而且压缩质量取决于上下文里的噪音比例------噪音越高,压缩后的摘要也是「高噪声总结」。
取舍: 提前压缩 vs 等待满阈值触发的权衡。提前压缩保质量但牺牲完整性,等待触发的代价是可能已经进入了退化区间。
借鉴思路: 压缩不是「忘掉一切留摘要」,而是「落盘关键状态 + 重建上下文」。具体在自研 Agent 中可以做三件事:压缩时落盘当前任务的决策记录、未完成项清单、最近操作的文件路径;恢复时自动将这些注入新上下文;同时从磁盘重读 CLAUDE.md 和 MEMORY.md 作为固定的上下文锚点。这套压缩+恢复闭环让模型不必从摘要里猜方向。
工具定义的静默膨胀
场景: Agent 平台团队为内部开发者提供了标准配置,内置 12 个 MCP 工具。上线一个月后,开发者反馈 Agent「变慢了」「有时会忘记系统指令」。排查发现 12 个工具中有三个已弃用但不敢关,它们的 schema 在每轮对话中持续占用上下文但从不被调用。更隐蔽的是,团队新增一个部署工具后更新了 schema,导致所有使用这套配置的开发者的缓存全部失效------全公司的账单一起跳。
根因: 每个工具都在吃上下文预算,每个新增工具都可能成为缓存断裂的导火索。工具数量和上下文成本的增加不成比例。弃用工具产生的成本是永恒的,但产生这些成本的人早已忘了它们存在。
取舍: 工具越多能力越强------理论上。但上下文中工具定义的体积直接决定了「对话历史」还剩多少空间。不用的工具定义和占着位置不做事的人一样:成本持续产生,但收益早已归零。
借鉴思路: 工具元数据与核心 prompt 分离存储。Claude Code 的做法是把工具描述从 tool schema cache 移到 attachment message 中------schema 变化不会破坏缓存前缀。自研 Agent 的工具系统设计上可以做同样的分离:核心 prompt 和工具定义分两个缓存区存储。此外需要内置工具调用频率统计------连续 N 天零调用的工具自动标记为待下线,减少工具膨胀对上下文预算的消耗。