最近 OpenAI 和 Anthropic 相继把上下文窗口干到了 1M,很多人可能觉得:大模型终于拥有超长记忆了,上下文管理时代结束了

但你有没有想过,1M 上下文时代,为什么我们还需要上下文管理?为什么上下文窗口是不是越大越好?今天 Left 就从底层原理出发,以 Claude Code 为例,彻底揭开大模型的上下文管理的神秘面纱

一、什么是大模型的记忆

抛开工程层面来看,大模型本身的记忆可以归为两类:

1、长期记忆(权重参数)

权重参数在预训练和微调阶段就固化在模型权重里了,就像一个人多年读书积累的通识常识和语言本能。这类记忆在所有会话过程中都稳定生效,但无法在日常聊天时实时动态更新

2、短期记忆(上下文窗口)

动态驻留在当前的上下文窗口(Context Window)里,就像两人当面聊天时摆在桌上的便签纸

这部分记忆完全是会话级的,大模型能否记得某件事,纯粹取决于相关内容是否还在当前的窗口里。一旦开启新会话、手动清空对话,或者聊得太长导致便签纸装不下而被挤出窗口,大模型就当场失忆了

二、为什么需要上下文管理

早期的上下文窗口其实是非常小的。如果是早期 ChatGPT 用户的话,会经历过这种情况,在同一个会话中聊着聊着就会提示上下文窗口已满,请开新会话。所以如何在统一会话中连续处理历史上下文,是上下文管理早期要解决的问题

到后来,随着模型的不断迭代,上下文窗口越来越大,但是又衍生了一系列问题,比如成本控制、注意力稀释以及幻觉问题,这都是在会话过程中经常出现的棘手问题

从工程角度来看,后面出现的问题,我认为主要可以归为两类。第一类是 KV Cache 机制,第二类是注意力机制。围绕着这两个机制去看上下文管理的工程实现,你会更容易理解上下文管理某个工程实现设计

要理解这两个机制,其实看它们的工程表现就足够了:

1、KV Cache 机制

大模型的推理成本是极其昂贵的。大模型生成内容时是单向逐字推理的,正常情况下,每算一个新 token,前面算过的所有 token 都得重新参与一遍计算。如果在推理过程中没有任何优化,算到后面计算量和显存开销会非常恐怖

为了解决昂贵的推理成本,研究人员决定采用时间换空间的模式,用缓存机制来代替昂贵的重复推理过程,把前面算好的中间状态缓存下来,这就是 KV Cache

只要你传入的上下文从头开始有一段是完全一模一样的,这段前缀的计算结果就可以直接复用。翻看各家大模型的缓存读价格会发现,命中缓存的价格通常是非命中缓存价格的 1/10,甚至能达到 1/50,非常便宜

但它有一个物理规则:必须保证最前面的前缀绝对一致。只要你在中间改动了一个字或者插入了一条消息,从改动的位置往后,所有的缓存全部失效,只能重新计算

2、注意力机制

大模型的注意力并非百分百准确,就好比人类的注意力那样,让我们翻阅一整本书后,再复述书中某一页的内容,是非常难的

很多人以为上下文给到 200K,大模型就能把这 200K 的内容全部看懂,这也是一种误解。模型的注意力更像是一盏聚光灯,舞台上只有一两个人的时候,光照过去看得很真切;但当舞台上密密麻麻塞了成千上万个人的时候,聚光灯打过去就分散了

在 Agent 跑任务的过程中,往往会出现注意力下降、注意力污染等问题。如果上下文塞了太长、太杂的内容,比如大量的工具报错、未截断的代码日志或者抓取的废弃标签,关键的约束指令就会被淹没,大模型就容易找不到重点,进而产生幻觉

搞清楚这两个底层机制后,再去理解上下文管理就会非常简单。从工程实现来看,上下文管理本质上就做两件事:第一,顺应 KV Cache 的前缀缓存机制去降低推理成本;第二,通过对上下文进行裁剪和组织,解决大模型的注意力问题

三、从 Claude Code 看上下文的组成

我们先来看一下 Claude Code 新建会话时,上下文到底塞了哪些东西:

plaintext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
系统提示词 (~25k tokens)

├── 1. 工具定义 (Functions) ~10,000 tokens ██████████ 40%
│ └── 27 个工具的 JSON Schema + 使用说明

├── 2. Claude Code 主指令 ~5,000 tokens █████ 20%
│ ├── 身份声明 — "You are Claude Code..."
│ ├── 安全策略 — 授权测试/拒绝恶意请求
│ ├── URL 策略 — 不生成/猜测 URL
│ ├── 系统行为 — 输出显示、工具结果、标签处理
│ ├── 执行任务 — 代码风格、注释规范、测试要求
│ ├── 谨慎操作 — 破坏性操作确认、可逆性评估
│ ├── 工具使用 — 优先级、并行调用、Agent 使用
│ ├── 语气风格 — 简洁、无 emoji、代码引用格式
│ ├── 文本输出 — 进度汇报、结尾摘要规范
│ ├── 会话指引 — ! 前缀、Agent 调度、Skill 调用
│ ├── Git 工作流 — 提交规范、PR 创建流程(含模板)
│ └── JSON 格式说明 — 工具参数格式要求

├── 3. 自动记忆系统 (auto memory) ~3,000 tokens ███ 12%
│ ├── 存储路径 — ~/.claude/projects/.../memory/
│ ├── 记忆类型
│ │ ├── user — 用户角色/偏好/知识背景
│ │ ├── feedback — 用户纠正/确认的工作方式
│ │ ├── project — 项目进展/目标/截止日期
│ │ └── reference— 外部系统资源指针
│ ├── 不该保存的内容 — 代码模式、git 历史、调试方案
│ ├── 存储格式 — frontmatter + MEMORY.md 索引
│ ├── 读取时机 — 相关时/用户要求时
│ └── 验证规则 — 记忆可能过期,需与当前状态核对

├── 4. 环境信息 ~500 tokens █ 2%
│ ├── 工作目录 — C:\Users\...\deploy
│ ├── 平台 — win32 / bash
│ ├── 模型 — Claude Opus 4.6
│ ├── 知识截止 — 2025-05
│ └── Git 状态 — main 分支, clean, 1 commit

├── 5. Skills 可用技能列表 ~1,500 tokens ██ 6%
│ ├── update-config — 配置 settings.json
│ ├── keybindings-help — 键盘快捷键
│ ├── simplify — 代码精简审查
│ ├── fewer-permission-prompts — 减少权限提示
│ ├── loop — 循环执行任务
│ ├── claude-api — Claude API 开发
│ ├── init — 初始化 CLAUDE.md
│ ├── review — PR 审查
│ └── security-review — 安全审查

├── 6. 全局 CLAUDE.md (用户私有) ~100 tokens
│ └── Agent 搜索时长压缩到 20 秒以内

├── 7. 项目 CLAUDE.md (项目级) ~4,000 tokens ████ 16%

├── 8. 当前日期 ~50 tokens
│ └── 2026/04/28

└── 9. 本地命令记录 ~300 tokens
└── /clear

当你刚新建好会话,可能一句话都还没发,系统提示词就已经吃掉了约 25K 的 Token。从工程实现来看,这些内容在逻辑上被划分为清晰的三层:

1、静态指令层(绝对稳定)

这部分属于出厂硬编码,跨会话、跨任务都不会改变。内容主要包括官方设定的身份声明、安全底线、代码风格和执行规范。越稳定的内容越要往死里焊在最前排

2、弱动态层(低频变动)

这部分内容虽然会根据项目变化,但在单个会话周期里几乎是不动的,所以排在静态指令之后。比如当前项目的目录路径、系统环境、Git 分支状态,以及从本地读取的 CLAUDE.md 规则文件

需要注意一个细节:在上面的树状图里,工具定义排在最前面。但用过 Anthropic API 的朋友会知道,工具在请求中是作为独立的 tools 参数提交的,服务端的底层架构有自己固定的一致性拼装逻辑,在概念上把它归为高优先级且稳定的前置层即可

3、强动态层(高频变动)

这部分就是随着我们聊天不断追加的交互消息了,它存放在每轮请求的 Messages 列表里。用户的每次提问、模型返回的思考与代码、工具执行的调用参数和报错日志,全都归在这一层,不断向末尾堆叠

读到这里有的朋友可能会问:Left,为什么会排成这种梯形结构?答案还是我们在第二节讲到的那两个底层逻辑:KV Cache注意力保护

KV Cache 的角度看,核心逻辑只有一个:前缀越稳定,缓存命中率越高。把长年不变的系统人设、工具协议焊死在头部,中间接变动极少的项目规则,把每轮都在变化的对话丢在最后面。这样不管你后面聊了几十轮,最前面的两三万 Token 永远都能吃满缓存折扣,不用掏冤枉钱

注意力保护 的角度看,Claude Code 在这 25K 的初始化阶段,就用上了很克制的剪枝手法:

1、Skill 渐进式加载: 系统提示词里只加载各个 Skill 的名字和一两句话元数据。具体的执行指令不会全量倒进来,直到大模型在后面真正判断需要用它时,才动态抓取进上下文

2、工具延迟加载: 很多低频工具不会直接把完整的 JSON Schema 塞进上下文。Claude Code 只给大模型暴露几个常用工具,剩下的一堆生僻工具只保留名字,外加一个 Tool Search 查询工具。大模型想用冷门工具时,先调 Tool Search 查出该工具的参数定义,再发起真实调用。初始化上下文直接省下一大笔空间

3、Memory 物理硬截断: 本地记忆虽然好用,但如果一股脑全读进来,注意力的聚光灯马上就散了。工程上卡了两个死限制:读取出来的记忆只要超过 200 行,或者体积大于 25KB,直接执行硬截断,绝不妥协

四、CLAUDE.md 的组织形式

我们在第三节把上下文拆成了三层,其中在实际工程里变动最频繁的,就是处于弱动态层里的各种规则文件。而这里面最核心的载体,就是各种各样的 CLAUDE.md

在官方的设计里,规则文件被切成了很清晰的五类形态。理解这五类文件,只要看明白到底是谁在管谁即可:

1、托管策略级(Managed Policy,公司管机器)

文件路径:系统目录(macOS 位于 /Library/Application Support/ClaudeCode/CLAUDE.md;Linux 位于 /etc/claude-code/CLAUDE.md)

核心作用:企业 IT 或系统管理员统一分发的强制合规要求,跨整台机器的所有用户与项目生效,开发者个人无权修改

2、用户全局级(User Global,自己管所有项目)

文件路径:~/.claude/CLAUDE.md 或 ~/.claude.md

核心作用:个人开发者的跨项目通用偏好,用于配置自己的首选语言、代码格式化偏好、全局常用别名等,仅在本机生效,不入代码仓库

3、项目共享级(Project Shared,团队管当前项目)

文件路径:项目根目录下的 ./CLAUDE.md 或 ./.claude/CLAUDE.md(两者完全等价,官方均支持)

核心作用:团队公共契约,纳入 Git 版本控制。记录项目架构、构建与测试命令、统一的代码规范以及分支协作流程

4、项目本地私有级(Project Local Overrides,自己在这个项目的私货)

文件路径:项目根目录下的 ./CLAUDE.local.md(注意:官方标准命名带 .local,而不是简单的子目录隐藏文件)

核心作用:当前项目的个人本地覆写补丁,写入 .gitignore。用于配置开发者个人的本地端口、私有测试环境变量或临时调试指令,不污染公共仓库

5、子目录级(Subdirectory / Monorepo,只管某个特定模块)

文件路径:具体子模块目录下的 CLAUDE.md(例如 apps/backend/CLAUDE.md)

核心作用:针对多包仓库或特定技术栈子目录,定义只在该路径下生效的具体技术约束

看懂了上面这五类文件的角色,我们再来看看它的底层装配流程

很多人以为 Claude Code 会在底层用 AST 语法树去做深度词法解析,其实并不是,底层的检索链路朴素得令人不敢相信

CLI 在启动和交互过程中,会以当前终端的工作目录为基准,向内向外一路顺藤摸瓜: 系统托管目录 -> 用户家目录 -> 项目根目录 -> 工作子目录。最终整理出来的规则层级,本质上就是下面这棵树:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Claude Code Context Hierarchy

├── 0. Base Instructions(官方硬编码:系统原则、工具定义)

├── 1. Managed Policy(企业托管约束)
│ 路径:系统级策略文件(如 /etc/claude-code/managed_policy.md)

├── 2. User Level(全局个人偏好)
│ 路径:~/.claude/CLAUDE.md

├── 3. Project Level(团队共享规则)
│ 路径:./CLAUDE.md 或 ./.claude/CLAUDE.md

├── 4. Local Override(个人本地覆盖项)
│ 路径:./CLAUDE.local.md

└── 5. Directory Scope(子目录上下文,按需随路径引入)
路径:<subdirectory>/CLAUDE.md

在工程代码层面,程序干的事情极其直白:把所有找到的规则文件读出来,带上文件路径的来源标识,分段拼进上下文(或者通过 等机制按需下发),一股脑全部灌给模型

读到这里你可能要拍桌子了:Left,这不就是暴力全量拼接吗?要是项目里的规则和全局规则写冲突了,底层到底怎么搞?为什么局部规则还能起到覆盖的效果?

搞清楚这个问题,你需要看清工程界的两个真实底色:

1、规则打架:工程界没有银弹,全看注意力分配

说句大实话,因为 CLAUDE.md 本质上全是纯文本的大白话,底层代码根本没法像解析 JSON 或者 YAML 配置文件那样,去做什么键值对的物理覆盖或哈希去重

如果你的全局偏好里写着”优先使用 npm”,而项目根目录的规则写着”严格使用 pnpm”,这两句话是原原本本、一字不漏全被塞进上下文里的。工程界目前对此没有任何确定性的拦截机制,所谓的”局部覆盖全局”,全靠 Transformer 模型的注意力机制在暗中起效:模型对排在靠后位置、语义更具体的句子,天然具备更强的注意力倾向

所以千万别觉得写了 CLAUDE.md 就进了保险箱。只要你的提示词写得含糊不清甚至严重自相矛盾,大模型的注意力照样会被撕裂,给你整出随机漂移来。想靠纯自然语言做到 100% 的物理拦截,根本不现实

2、顺序编排:为什么一定要从全局排到局部?

既然底层无法做物理合并,那代码为什么还要费尽心思,严格按照”系统托管 -> 全局偏好 -> 项目规范 -> 本地私货/子目录”的顺序自顶向下灌给模型?

答案还是我们在前面反复强调的原理:配合服务商的 KV Cache 前缀缓存

在当前的大模型计费和响应耗时体系下,前缀缓存生效的前提是:请求最前面的 Token 必须逐字一致,差一个标点符号整条缓存直接报废

a、前置固定项:企业托管策略和全局用户偏好(~/.claude/CLAUDE.md),在单台开发机上几个月都难得改动一次。把它们焊死在最前面,不管你在哪个项目里跑,永远能以最大概率命中第一层公共缓存

b、后置易变项:项目本身的开发规范、你本地临时用来调试的 .local.md,还有动态加载的子目录规则,这些改动频率非常高。把它们垫在最后面,哪怕你一天改八遍项目规则,破掉的也仅仅是尾部的缓存,绝不会连累最前面已经命中几千 Token 的公共前缀直接失效

五、会话进行中,上下文如何变化

想要彻底搞懂上下文在会话过程中是如何流转变化的,关键是要抓住几个核心时机:

1、新会话初始化

2、用户提示词请求

3、大模型响应

4、工具调用与回传

5、上下文剪枝与压缩

其中第 1 点我们在上一章节已经剖析过,第 5 点会在后面的章节深入展开。这一章节我们重点看在会话推进过程中,阶段性的上下文到底是如何一步步变换与堆叠的

开始之前,我们先锚定一个关键前提:由于大模型依赖 KV Cache 前缀缓存机制,在 Agent 开发中,消息在上下文里的组织形式绝大多数都是单向追加到末尾。只有保证老内容纹丝不动、新内容依次垫后,才能最大化吃满 KV Cache 的缓存红利

场景一:用户输入提示词并发送请求

Anthropic API 对对话内容的组织采用消息列表(Messages)形式,每条消息包含两个核心参数:

1、role:消息发送者角色,取值为 user(用户/外部输入)或 assistant(模型答复)

2、content:消息的具体内容,可以是一段纯文本字符串,也可以是由多个内容块(Content Blocks)组成的列表

一个基础的用户消息示例如下:

1
2
3
4
{
"role": "user",
"content": "你好"
}

在用户输入提示词并点击发送后,Agent 会将用户输入组装为 role: “user” 的消息,追加到当前会话的上下文列表末尾,随后向 Anthropic API 发起请求

场景二:大模型直接生成最终文本回复

如果大模型在评估后认为不需要借助任何外部工具,可以直接作答,API 响应的顶层参数主要包含两个关键字段:

1、stop_reason:值为 end_turn,表示模型已经完成当前轮次的思考与回答,决定交出控制权

2、content:模型生成的具体内容,通常包含类型为 text 的文本块

此时大模型返回的消息体如下:

1
2
3
4
5
6
7
8
9
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "你好,请问有什么能帮到您?"
}
]
}

Agent 客户端一旦检测到 stop_reason: “end_turn”,就会把这段响应作为 role: “assistant” 的消息直接追加到上下文中,同时将文本渲染给终端用户。这一轮简单的问答交互就此结束

场景三:大模型决定发起工具调用

如果大模型判断手头的信息不够、必须调用外部工具(比如查文件、跑命令),返回的 stop_reason 就会被标记为 “tool_use”

在这种状态下,大模型返回的 content 数组里通常会包含两类内容:一类是模型在调用工具前吐出的一小段说明或思考(type: “text”),紧随其后的便是一个或多个 type: “tool_use” 的工具调用块

每个 tool_use 块包含以下关键参数:

1、id:本次工具调用的唯一标识符(例如 toolu_01…),供后续回传执行结果时精确对齐

2、name:需要调用的工具名称(如 get_weather)

3、input:传递给工具的实际参数对象,其数据结构严格遵循系统请求中预先定义的 JSON Schema

此时大模型的响应结构示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "正在为您查询天气,请稍候。"
},
{
"type": "tool_use",
"id": "toolu_01A09q90tc1ztqqe",
"name": "get_weather",
"input": {
"location": "深圳"
}
}
]
}

Agent Loop 捕获到 stop_reason: “tool_use” 后,同样会将整条包含 tool_use 块的助手消息完整追加到上下文末尾,随后暂停向用户输出,转入本地运行环境去执行对应的工具

场景四:执行工具并回传结果

Agent 拿到工具名 name 和参数 input 后,在本地 Runtime 中执行具体操作(例如发网络请求或跑一段脚本)。跑完拿到结果后,必须按照特定格式把结果回传给模型

这里有一个非常反直觉的细节:不同于 OpenAI 专门设计了独立的 role: “tool”,Anthropic 规定工具的执行结果必须封装为一条 role: “user” 的消息

在 Anthropic 的设计理念里,除了模型自己吐出来的内容是 assistant,外部环境注入的所有反馈——不管是真人输入的文字,还是终端跑出来的输出或报错——在身份抽象上统一算作外部输入,也就是 user。在这条消息的 content 数组里,放置类型为 tool_result 的内容块:

1、tool_use_id:必须与场景三中大模型返回的 tool_use.id 保持完全一致,严丝合缝

2、content:工具执行后返回的原始文本、JSON 字符串或报错信息

回传工具结果的消息结构示例如下:

1
2
3
4
5
6
7
8
9
10
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90tc1ztqqe",
"content": "{\"temperature\": \"26°C\", \"weather\": \"多云\"}"
}
]
}

Agent 将这条封装好的结果消息再次追加到上下文末尾,重新向 Anthropic API 发起请求

到了这一步,整个 Agent Loop 完成了一次完整的往返。大模型带着前面完整的提问记录、调用声明以及最新的工具执行结果,再次进行推理。如果信息已经齐全,模型就会进入场景二给出最终答复;如果发现还需要更多数据,它会继续触发下一个场景三,直到整个任务彻底闭环

六、警惕六大 “Token 杀手”:上下文是如何被悄悄吃光的?

从上一章节的交互循环可以看出,Agent 跑起来是极其吃上下文的。只要工具结果稍微大一点,或者对话轮次一多,窗口就会飞速膨胀

很多看似随手的操作,底下其实都在疯狂占用上下文。窗口一旦过长,随之而来的就是推理速度变慢、API 账单飙升、甚至模型注意力涣散导致代码质量断崖式下跌。在日常使用 Claude Code 时,真正值得警惕的 “Token 杀手” 主要潜伏在下面这六个场景:

1、膨胀的 CLAUDE.md

项目维护久了,大家特别容易往里面无脑堆砌各种架构说明、团队规矩和测试细节。这玩意儿可是每次请求都会被全量塞进系统提示词里的,哪怕你只是让它改个函数名或者修个错别字,这几千 Token 的 “开机费” 也是次次必交

工程上目前没有太好的物理拦截手段,防范的关键全在定期维护:

a、长度硬约束:把根目录的 CLAUDE.md 严格压在 200 行以内,只写反复需要强调的规则,不要写成 Wiki

b、定期做除草:及时清理已经废弃的旧脚本命令,剔除互相打架的规则,只保留高频命令与核心契约

2、贪多挂载的 MCP

Claude Code 在接入外部 MCP Server 时,会把每个工具的名称、描述以及极其详尽的参数结构(Tool JSON Schema)统统灌进上下文。一个 MCP 服务往往自带十几个工具,要是图省事随手挂上三四个 Server,你连一句需求都还没打,两三万 Token 就已经白白垫进去了

好在官方在后期的版本中引入了工具延迟加载机制来缓解这一痛点:

a、初始化精简:默认只在上下文里保留核心常用工具,冷门 MCP 工具只登记名称,暴露出一个轻量的 tool_search 搜索入口

b、按需召回:当 Agent 在任务中判定必须使用某个冷门工具时,会先主动调用 tool_search 把具体参数 Schema 动态拉取下来,再发起真实调用。虽然多走了一步,但给全局上下文省下了海量空间

3、滥用的 Skills 与斜杠命令

我们在前面讲过,Skill 在初始化时是渐进式加载的,默认只暴露简要的元数据,但这绝不意味着它可以无上限添加

不少人觉得 Skill 不占空间就疯狂堆砌,这种搞法不仅会随着数量暴涨逐渐吃掉上下文,更致命的是容易引发工具冲突,让大模型陷入严重的”选择困难症”

因此给工具、Skill 命名也是很重要的,编写 Skill 的最佳原则是:

a、命名与描述极简:Description 仅围绕核心职责精炼交代,说清楚什么时机调用、输入产出是什么,别写长篇大论的 “散文”

b、及时整理工具集:把相似场景的 Skill 合并,长期不用的及时清理,避免干扰模型的决策链路

4、失控的 Tool Result

这是瞬间冲垮上下文的最常见凶手。比如跑 Bash 命令直接倾倒出上万行的构建日志、查数据库倒出几兆未分页的原始 JSON,或者爬虫抓取了塞满 Base64 和内联脚本的网页。只要一次命令没收住,半个上下文可能瞬间就没了

Claude Code 在工程上对过长的输出做了兜底策略:中间截断。抓包时经常能看到工具输出保留了头尾,中间夹着一段 […truncated…]

但指望工具端兜底依然是被动的,更稳妥的做法是在下达命令时主动加上管道防御,比如养成随手加上 | head -n 50、grep 过滤或者控制 SQL LIMIT 的习惯

5、未触发压缩前的历史包袱

Claude Code 内部虽然自带自动压缩机制,但在这把大剪刀真正落下来之前,前面几轮排错、读错文件拉出来的大段无用中间结果,会在后续的每一次交互中作为输入被反复计费

更肉痛的是,一旦触发了上下文压缩,Agent 调动大模型去提炼、总结之前那一大坨历史记录的过程,本身也是一次吞吐极大的昂贵调用。如果在前面几轮就任由垃圾上下文堆积,到了压缩阶段不仅烧钱,提炼出来的摘要质量也会严重失真

6、暗中狂飙的 Thinking Tokens

当配合深度思考模式使用时,模型在动手写代码之前,会在后台进行极深的多步逻辑推演。表面上它最终只在终端敲了几行 Diff,底下可能已经暗搓搓跑掉了大几千甚至上万的思考 Token

这其实是一个架构上的权衡取舍:

a、复杂场景放权思考:面对棘手的系统重构、偶发并发 Bug 这种硬骨头,开启思维链能大幅提升方案命中率,这笔 Token 花得物有所值

b、简单任务果断降级:如果是改个配置、写个小单测或者查下文档这种低延迟任务,完全没必要开着长思维链空转。根据任务复杂度动态开关,或者结合自身项目跑几个回合对比一下 ROI,再决定要不要把思考开关常驻打开

七、上下文满了怎么办:传统方案的局限

上下文窗口爆满这件事,从大模型问世的第一天起就困扰着整个工程界

如果把大模型的上下文窗口比作一块固定大小的黑板,Agent 在干活时写下的每句提示词、调用的每个工具、打印的每行日志,都是在用粉笔往黑板上写字。黑板面积就那么大,写满是迟早的事

面对这块写满的黑板,工业界在过去几年里经历了四次主要的解法迭代,但每一种方案在解决旧问题的同时,都带来了新的代价:

1
2
3
4
5
6
7
8
9
上下文超限解法演进史

├── 1. 摆烂报错流 ── 弹出拦截,要求用户新开窗口

├── 2. 滑动窗口流 ── 擦掉最早的粉笔字,只留最新的 N 轮

├── 3. 摘要压缩流 ── 把上半块黑板擦掉,缩写成几行会议纪要

└── 4. 外挂 RAG 流 ── 把擦掉的内容存进图书馆,用时翻书检索

方案一:摆烂报错流(最原始的硬拦截)

做法:黑板写满后什么都不做,直接抛异常,弹窗提示用户”当前上下文已超限,请新开会话”

举个栗子:就像去医院看病,医生拿了一张单页病历本,上面记满了你的化验单和检查结果。写到最后一行时,医生两手一摊:本子写满了,你出去重新挂个号吧,我们从头再聊一次

局限:开发起来最省事,但体验极其断裂。在复杂的代码工程里,你花了几十分钟才帮模型建立好上下文、对齐了架构思路,弹个窗就得重头再来,直接让连续协作变得不可能

方案二:滑动窗口流(金鱼般的局部记忆)

做法:维护一个固定容量的队列,当新消息进来导致超限时,自动把队列头部最老的消息剔除,永远只保留最近的 N 条消息

举个栗子:类似在黑板底部每写一行新字,就拿板擦把黑板最顶上的一行字给擦掉,黑板上永远只保留最近的几句话

局限:会话确实能一直聊下去了,但模型直接变成了”金鱼脑”。在写代码场景下,最老的那几条消息,往往正是用户最初下达的全局业务需求或架构铁律。一旦最上面的需求被无情擦掉,模型聊到后面就会彻底忘了最初的任务目标,开始放飞自我、答非所问

方案三:摘要压缩流(信息失真的速记员)

做法:当上下文达到阈值时,停下来调一次大模型,把前面大半截的历史对话总结成一段几百字的”上下文摘要”,用这段精炼的摘要替换掉原来的长文本,腾出空间继续聊

举个栗子:老板和团队开了三个小时的技术评审会,争论了各种方案和边界细节。散会后秘书整理了一份 200 字的”会议纪要”,后面所有人不再看录音和记录,只凭这份速记继续推进工作

局限:思路很巧妙,空间也省下来了,但摘要的质量完全不可控。在软件工程里,细节往往决定成败:报错堆栈里的一个关键报错行号、接口返回的一段不起眼的字段结构、或者上一轮随手改的一个环境变量,都很容易被大模型在做摘要时当成”无用废话”给优化掉。丢了这些关键线索,后续的代码排错就会陷入死胡同

方案四:外挂 RAG 流(昂贵且看运气的图书馆)

做法:把超出窗口的历史会话全量切片,算成向量存入向量数据库(Vector DB)。每当用户问新问题或者调新工具时,先去数据库里做一次语义检索(Retrieval),把最相关的几条历史切片捞出来喂给大模型

举个栗子:医生看病时不再当场翻病历,而是把你的所有历史化验单、既往病史一股脑堆进后方的资料档案室。每开一次药,就让实习生跑去档案室按关键词帮你翻两张单子出来

局限:相当于给模型挂了个无限大的外部硬盘,看着很美好,但代价极其昂贵:

a、延迟与成本双重暴击:写一条消息要先做 Embedding 写入,读一条消息要先向量计算加召回。对于写代码这种争分夺秒的命令行场景,每一轮都要等待检索完成,卡顿感会非常明显

b、召回精度全靠运气:自然语言检索不是关系型数据库的主键查询。如果代码里到处都是相似的变量名或者反复出现的相同函数名,检索器很容易把三轮之前的一段错误代码切片当成”最相关内容”召回出来,反而给大模型喂了过期的毒药

看到这里大家就会发现:传统的这套方案,要么在丢弃细节,要么在牺牲速度与成本

但在像终端编码这种严苛的环境下,我们既不能丢弃前文的关键代码细节,又无法容忍昂贵的检索延迟。到底有没有一种既能保留关键指令、又能极度压缩开销的工程解法?

这就是我们下一章要聊的:Claude Code 在上下文治理上的工程解法

八、上下文满了怎么办:Claude Code 的工程解法

看完前面的滑动窗口、摘要和 RAG 就会发现,传统做法总是在”保留记忆”和”节省空间”之间打架

Claude Code 的处理方式更务实,它没有只选某一种方案,而是做了一套由轻到重的五级防御机制,针对不同的膨胀程度一步步拆招:

plaintext

1
2
3
4
5
6
7
Claude Code 五级防御机制

├── 1. 大工具输出落盘(单次防爆,超限落盘留文件路径)
├── 2. Snip compact(增量巡检,定向抹除冗余消息)
├── 3. Micro compact(清空可重复获取的历史工具结果)
├── 4. Context collapse(后台预计算,静默折叠消息区间)
└── 5. Full compact(全量压缩,9 维结构化深度总结)

第一级:大工具输出落盘(单次防爆,留存指针)

在终端写代码时,最怕一次操作没控住,工具返回了几万行测试日志或海量未分页的 JSON。这种单次暴击如果直接塞进上下文,几个回合就能把窗口打穿

Claude Code 的第一道防线非常直接:给工具输出设定物理阈值(比如 20KB)。一旦超过这个上限,立刻在本地拦截并替换内容

  1. 完整落盘:把原始的超大输出原封不动保存到本地硬盘(~/.claude/projects/…/tool-results/)
  2. 截断内容:只保留开头约 2KB 的预览摘要(Preview)
  3. 注入指针:在回传的工具结果里附上本地文件的绝对路径,告诉模型 “完整内容在硬盘里,按需读取”

最终喂给大模型的工具调用结果长这样:

1
2
3
4
5
6
7
8
9
10
<persisted-output>
Output too large (45.2KB).
Full output saved to: ~/.claude/projects/my-app/sessions/s1/tool-results/toolu_01A09.txt

Preview (first 2KB):
[Jest Test Suite]
PASS src/auth.test.ts
PASS src/user.test.ts
... 略过中间海量堆栈 ...
</persisted-output>

这一招极其漂亮:大模型既知道命令跑通了、看到了关键的头尾信息,又保留了调取全量细节的后路,还死死守住了上下文的预算

第二级:Snip Compact(增量巡检,神秘的定点切除)

这是公开资料里最神秘的一套轻量级清理机制。翻遍公开实现与逆向片段,官方并没有开放完整的实现细则,但我们能从公开的线索中拼出它的轮廓:

1、触发时机:每轮 Agent Loop 执行时都会主动巡检。一旦检测到当前上下文的 Token 增量达到 10K,就会触发一次 Snip 评估

2、底层机制:运行时会给每条 message 分配一个唯一的 Message ID。根据目前的机制设计合理推测,系统会调动一个轻量级的判定逻辑,根据当前的上下文负担,针对性地挑选并抹掉某些低价值的 Message ID

它属于触发频率极高、动作极轻的微操,在上下文刚出现膨胀苗头时就悄悄切掉一些毛刺

第三级:Micro Compact(静默清洗,专挑工具结果动手)

当会话继续推进,上下文压力进一步上升时,第三级防御 Micro Compact 就会介入。它的核心思路是:消息本身不动,专门清理历史工具调用产生的结果(tool_result)

但删工具结果不能瞎删。如果把一次外部不可复现的 API 返回给删了,大模型后续推理就会精神分裂。因此 Claude Code 圈定了一份极其严苛的白名单:只删除那些可重复读取、可幂等调用的工具结果

1
2
3
4
5
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, // ← 注意:编辑和写入也在列
])

读文件、搜代码、跑只读 Shell,哪怕内容被删了,模型需要时完全可以再调一次工具重新读出来。更值得玩味的是,Micro Compact 在内部演进出了两种截然不同的执行模式:

1、模式 A:TimeBasedMicrocompact(基于时间的空闲清洗)

这个模式比较好理解。用过 Anthropic API 的朋友应该知道,它的 Prompt 缓存是有生命周期的,一般是 5 分钟,部分高权限组织可以到 1 小时

如果用户挂在终端前闲置超过了 60 分钟,服务端的缓存本来就已经过期失效了。既然前缀缓存已经没了,程序就可以随意删掉历史工具结果来省空间,完全不用担心破坏缓存

2、模式 B:CachedMicrocompact(带标记的服务端原地裁剪)

这是工程实现上非常巧妙的一种模式,在不破坏已有 KV Cache 前缀的前提下,利用服务端的缓存编辑能力原地扣掉工具结果:

1、发给 API 的未压缩工具结果达到数量阈值时触发

2、从最旧的一批开始找,排除掉之前已经删过的部分

3、给需要删除的工具结果打上 cache_reference 标记

4、在最后一条 role 为 user 的消息里,塞入 cache_edits 声明:

1
2
3
4
5
6
{
type: 'cache_edits',
edits: [
{ type: 'delete', cache_reference: 'toolu_123' }
]
}

我们看两轮实际交互的伪代码演进,就能看懂它是怎么在复用前缀的同时完成修剪的:

第一轮 Micro Compact 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
原始状态:
[0] user(question)
[1] assistant(tool_use A)
[2] user(tool_result A)
[3] user(comment)

处理逻辑:
1. A 是新的工具结果,需要删除
2. 找到最后一条 user 消息(序号 3),加上 cache_control 标记
3. 给序号 2 的工具结果加上 cache_reference
4. 把针对 A 的 cache_edits 塞到序号 3 消息里

修剪后的请求结构:
[0] user(question)
[1] assistant(tool_use A)
[2] user(tool_result A, cache_reference="toolu_A")
[3] user(
cache_edits: delete "toolu_A",
text: comment + cache_control
)

第二轮 Micro Compact 继续推进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
新增轮次后:
[0] user(question)
[1] assistant(tool_use A)
[2] user(tool_result A, cache_reference="toolu_A")
[3] user(
cache_edits: delete "toolu_A", -----> 保持不变(pinned)
text: comment
)
[4] user(question)
[5] assistant(tool_use B)
[6] user(tool_result B)
[7] assistant(tool_use C)
[8] user(tool_result C)

处理逻辑:
1. B 和 C 是新的工具结果
2. 找到最后一条消息(序号 8),加上 cache_control 标记
3. 给前面的工具结果标上 cache_reference。注意:因为 tool_result C 就在最后一条消息里,所以不删它,最终只删 B
4. 第一轮针对 A 的 cache_edits 原样保留作为固定前缀(pinnedEdits)
5. 针对 B 的新 cache_edits 插入到序号 8 的 user 消息里

修剪后的请求结构:
[0] user(question)
[1] assistant(tool_use A)
[2] user(tool_result A, cache_reference="toolu_A")
[3] user(
cache_edits: delete "toolu_A", -----> 保持不变(pinned)
text: comment
)
[4] user(question)
[5] assistant(tool_use B)
[6] user(tool_result B, cache_reference="toolu_B")
[7] assistant(tool_use C)
[8] user(
cache_edits: delete "toolu_B", -----> 新追加
tool_result C + cache_control
)

这样处理下来,既通知服务端删掉了中间的大块无用数据,又把第一轮的提示词前缀完整保住了

第四级:Context Collapse(消息折叠)

当会话继续变长,单靠删工具结果也不够用了,就轮到消息折叠登场

假设原始上下文长这样: [U1, A1, U2, A2, U3, A3, U4, A4, U5]

Claude 在交互过程中会不定期在后台起一个轻量任务:把一段连续的历史消息提前总结好,存成临时草稿备用:

1
2
3
4
5
6
7
8
9
staged = [
{
startUuid: U2.uuid,
endUuid: A4.uuid,
summary: "之前已经完成了对 query.ts、microcompact、sessionStorage 的分析,结论是 ...",
risk: 0.12,
stagedAt: 1712345678901
}
]

生成草稿时并不会立刻改动上下文。直到会话往前走、上下文真正撞到阈值时,才会把准备好的总结直接换上去: 触发折叠后: [U1, A1, S1, U5]

因为摘要是在后台提前跑完的,前台替换时用户基本感觉不到卡顿

第五级:Full Compact(全量压缩)

当以上所有小修小补全部用尽、上下文依然被吃到了天花板,系统就会祭出终极武器:Full Compact(全量压缩)

这可不是简单的截断并随便总结两句,而是一场极度严谨的上下文大手术:

压缩阈值设计

源码里定义了几个核心阈值:

1
// Reserve this many tokens for output during compaction// Based on p99.99 of compact summary output being 17,387 tokens.const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000

为什么预留 20K?因为线上统计数据显示,99.99% 的总结任务消耗的 Token 都没有超过 17,387。加上一点安全余量,最终定在 20,000

基于这个数值,系统排出了三道水位线:

1、有效上下文窗口:总大小减去 20,000 Token

2、压缩触发阈值:有效窗口减 13,000 Token(大约占有效窗口的 93%)

3、告警阈值:压缩阈值再减 20,000 Token(超过 80% 时在终端提示用户上下文快满了)

触发全量压缩时,系统会单独起一个子 Agent 来跑总结。它的提示词里有一条非常显眼的规则:头部和尾部各出现一次禁止调用工具的硬警告

1
2
3
4
5
6
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.

- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- 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.

为什么要这样反复警告?源码prompt.ts:12-26里注释里给了解释:

The cache-sharing fork path inherits the parent’s full tool set (required for cache-key match), and on Sonnet 4.6+ adaptive-thinking models the model sometimes attempts a tool call despite the weaker trailer instruction. With maxTurns: 1, a denied tool call means no text output → falls through to the streaming fallback (2.79% on 4.6 vs 0.01% on 4.5).

意思是,压缩子 Agent 会继承父级完整的工具集以保证缓存命中。但在 Sonnet 4.6 这种开启了自适应思考的模型上,模型做总结时容易习惯性地想调工具去确认细节。因为总结任务限制了只有 1 轮(maxTurns: 1),一旦工具调用被拦下,模型就吐不出任何文本,导致整个压缩任务直接失败。所以必须在提示词里把工具调用彻底堵死

摘要的输出格式长这样:

1
2
3
4
5
6
<analysis>
[模型的推理草稿,分析对话里哪些关键]
</analysis>
<summary>
[结构化摘要,按 9 个清单分块]
</summary>

块是给模型打草稿的地方,让它先想明白再落笔,最后会被丢弃,不进正式上下文。真正保留到新会话的是

块,里面按 9 个维度严格划分:

1、Primary Request and Intent:用户最开始提的真实意图

2、Key Technical Concepts:聊到的关键技术概念

3、Files and Code Sections:信息量最大的一块,要求附带完整核心代码片段

4、Errors and fixes:排查过的报错和用户的纠偏记录

5、Problem Solving:具体的解题和排错步骤

6、All user messages:用户的每条提问逐条保留

7、Pending Tasks:没做完的待办事项

8、Current Work:当前正在写的具体代码

9、Optional Next Step:下一步操作建议,必须附带原文引用防止跑偏

拿到总结文本后,程序还会跑一套标准的收尾工作:

1、清空旧状态:执行 readFileState.clear() 等,把上一轮读文件的内部缓存清空

2、重挂上下文附件:重新带上最近的文件引用、计划文件、激活的 Skill 等

3、跑 Hook 脚本:执行用户在配置里写的 SessionStart 钩子

4、组装新结构:生成包含边界标记(时间戳、压缩前 Token 数)、摘要主体、环境附件的最终结果,作为新一轮对话的开头继续往下跑

压缩结果的消息结构长这样子:

1
2
3
4
5
6
7
8
9
10
11
12
export interface CompactionResult {
boundaryMarker: SystemMessage
summaryMessages: UserMessage[]
attachments: AttachmentMessage[]
hookResults: HookResultMessage[]
messagesToKeep?: Message[]
userDisplayMessage?: string
preCompactTokenCount?: number
postCompactTokenCount?: number
truePostCompactTokenCount?: number
compactionUsage?: ReturnType
}

我们来解读一下压缩后的消息结构:

1、boundaryMarker:边界标记。一个特殊消息,记录这次压缩是自动还是手动、压缩前 token 是多少、最后一条消息的 ID 是啥

2、summaryMessages:摘要消息。这就是大头,前面 200 轮全部被压缩进这里

3、attachments:附件。包括最近读过的文件、当前的计划文件、激活的技能、正在运行的异步任务状态等等。这就是另外的恢复通道

4、hookResults:hook 结果。用户配置的 hooks 在压缩时也会执行,结果一并注入

九、人机协同的上下文管理技巧

搞明白了上下文管理底层是怎么跑的,在日常上下文治理落地中,其实就下面这几条很朴素的原则:

1、结构编排:静态前置,易变垫后

设计 Prompt 或组织项目配置时,尽量参考 “静态指令 -> 弱动态规则 -> 强动态交互” 的顺序去排布。把长期不变的系统人设、基础约定焊死在最前面,变动频繁的项目私货和对话内容垫在最后,保证最前面的大头永远能稳定命中缓存

2、能延迟加载,就绝不一次性全倒进去

渐进式加载是解决上下文膨胀最实用的思路。不管是设计 Agent 工具还是写复杂逻辑,尽量只在开头暴露极简的索引或元数据,让模型在真正需要的时候再去动态拉取详细定义,既省下了初始化空间,又避免了无关信息对模型推理造成干扰

3、严格克制 CLAUDE.md 的体积

CLAUDE.md 属于每轮对话都要全量带上的静态上下文,千万别把它当成 Wiki 来写:

a、行数尽量压在 100 到 200 行以内,只写高频命令与核心规范

b、定期清理过期的命令与打架的旧规则

c、如果项目很复杂,可以把详细架构说明拆成独立的 Markdown 文档,只在主文件里留个目录索引,让模型在需要时自己调工具去读

4、精简挂载的 MCP 与 Skill

工具和 Skill 并不是挂得越多越好。非高频使用的外部 MCP 尽量不常驻,冷门工具尽量走延迟检索;Skill 的命名和描述保持精炼,说清楚功能和产出即可,避免工具过多导致大模型在决策时左右为难

5、养成随手限制工具输出的习惯

在终端让 Agent 跑命令时,尽量避免直接 dump 出海量无用信息。跑测试、查日志或者看目录时,顺手带上 grep、head -n 过滤或者控制下分页,把过载输出拦截在本地,这是防止上下文意外被打穿成本最低的手段

十、上下文管理能解决记忆问题吗

折腾了这么多压缩、折叠、落盘的手段,不知道大家发现没有:这玩意儿根本算不上真正的记忆

搞上下文治理,说白了就是在当前这一个窗口里拆东墙补西墙。大模型能聊的内容就那么长,为了不让程序当场崩掉,我们只能变着法子删工具日志、做局部缩写。不管中间做得多精妙,信息都在不可逆地往外丢,纯粹是用丢失细节在硬换生存时间

更扎心的是,一旦你关掉终端,或者顺手敲个 /clear,刚才辛辛苦苦建立起来的所有默契、所有回话中的关键决策,瞬间全没了。下一秒再新开窗口,你又得像带刚入职的新实习生一样,重新把项目规矩教它一遍

它记不住你的偏好,记不住这个任务上个月推倒重来的血泪史,更记不住你到底是谁

上下文管理充其量只是在管 “这几小时的短期草稿”,真正要让 AI 变得顺手,靠的是跨越会话的长期记忆

怎么让 Agent 在会话关掉之后还能把有用的东西沉淀下来?怎么在几天后重新唤醒时,它一下就能记得任务进展和你的偏好?

这块东西才是真正好玩的地方。关于主流 Agent 到底是怎么外挂长期记忆的,包括 Claude 自己那套本地落盘与索引机制,我后面单独拉一期出来好好扒一扒

十一、最后的话

很高兴你能看到这里,如果这篇文章对你来说有收获,我在这里跪求一个小小的赞