万字长文 | Agent 工程解析(一):上下文管理
最近 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 | 系统提示词 (~25k tokens) |
当你刚新建好会话,可能一句话都还没发,系统提示词就已经吃掉了约 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 | Claude Code Context Hierarchy |
在工程代码层面,程序干的事情极其直白:把所有找到的规则文件读出来,带上文件路径的来源标识,分段拼进上下文(或者通过
读到这里你可能要拍桌子了: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 | { |
在用户输入提示词并点击发送后,Agent 会将用户输入组装为 role: “user” 的消息,追加到当前会话的上下文列表末尾,随后向 Anthropic API 发起请求
场景二:大模型直接生成最终文本回复
如果大模型在评估后认为不需要借助任何外部工具,可以直接作答,API 响应的顶层参数主要包含两个关键字段:
1、stop_reason:值为 end_turn,表示模型已经完成当前轮次的思考与回答,决定交出控制权
2、content:模型生成的具体内容,通常包含类型为 text 的文本块
此时大模型返回的消息体如下:
1 | { |
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 | { |
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 | { |
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 | 上下文超限解法演进史 |
方案一:摆烂报错流(最原始的硬拦截)
做法:黑板写满后什么都不做,直接抛异常,弹窗提示用户”当前上下文已超限,请新开会话”
举个栗子:就像去医院看病,医生拿了一张单页病历本,上面记满了你的化验单和检查结果。写到最后一行时,医生两手一摊:本子写满了,你出去重新挂个号吧,我们从头再聊一次
局限:开发起来最省事,但体验极其断裂。在复杂的代码工程里,你花了几十分钟才帮模型建立好上下文、对齐了架构思路,弹个窗就得重头再来,直接让连续协作变得不可能
方案二:滑动窗口流(金鱼般的局部记忆)
做法:维护一个固定容量的队列,当新消息进来导致超限时,自动把队列头部最老的消息剔除,永远只保留最近的 N 条消息
举个栗子:类似在黑板底部每写一行新字,就拿板擦把黑板最顶上的一行字给擦掉,黑板上永远只保留最近的几句话
局限:会话确实能一直聊下去了,但模型直接变成了”金鱼脑”。在写代码场景下,最老的那几条消息,往往正是用户最初下达的全局业务需求或架构铁律。一旦最上面的需求被无情擦掉,模型聊到后面就会彻底忘了最初的任务目标,开始放飞自我、答非所问
方案三:摘要压缩流(信息失真的速记员)
做法:当上下文达到阈值时,停下来调一次大模型,把前面大半截的历史对话总结成一段几百字的”上下文摘要”,用这段精炼的摘要替换掉原来的长文本,腾出空间继续聊
举个栗子:老板和团队开了三个小时的技术评审会,争论了各种方案和边界细节。散会后秘书整理了一份 200 字的”会议纪要”,后面所有人不再看录音和记录,只凭这份速记继续推进工作
局限:思路很巧妙,空间也省下来了,但摘要的质量完全不可控。在软件工程里,细节往往决定成败:报错堆栈里的一个关键报错行号、接口返回的一段不起眼的字段结构、或者上一轮随手改的一个环境变量,都很容易被大模型在做摘要时当成”无用废话”给优化掉。丢了这些关键线索,后续的代码排错就会陷入死胡同
方案四:外挂 RAG 流(昂贵且看运气的图书馆)
做法:把超出窗口的历史会话全量切片,算成向量存入向量数据库(Vector DB)。每当用户问新问题或者调新工具时,先去数据库里做一次语义检索(Retrieval),把最相关的几条历史切片捞出来喂给大模型
举个栗子:医生看病时不再当场翻病历,而是把你的所有历史化验单、既往病史一股脑堆进后方的资料档案室。每开一次药,就让实习生跑去档案室按关键词帮你翻两张单子出来
局限:相当于给模型挂了个无限大的外部硬盘,看着很美好,但代价极其昂贵:
a、延迟与成本双重暴击:写一条消息要先做 Embedding 写入,读一条消息要先向量计算加召回。对于写代码这种争分夺秒的命令行场景,每一轮都要等待检索完成,卡顿感会非常明显
b、召回精度全靠运气:自然语言检索不是关系型数据库的主键查询。如果代码里到处都是相似的变量名或者反复出现的相同函数名,检索器很容易把三轮之前的一段错误代码切片当成”最相关内容”召回出来,反而给大模型喂了过期的毒药
看到这里大家就会发现:传统的这套方案,要么在丢弃细节,要么在牺牲速度与成本
但在像终端编码这种严苛的环境下,我们既不能丢弃前文的关键代码细节,又无法容忍昂贵的检索延迟。到底有没有一种既能保留关键指令、又能极度压缩开销的工程解法?
这就是我们下一章要聊的:Claude Code 在上下文治理上的工程解法
八、上下文满了怎么办:Claude Code 的工程解法
看完前面的滑动窗口、摘要和 RAG 就会发现,传统做法总是在”保留记忆”和”节省空间”之间打架
Claude Code 的处理方式更务实,它没有只选某一种方案,而是做了一套由轻到重的五级防御机制,针对不同的膨胀程度一步步拆招:
plaintext
1 | Claude Code 五级防御机制 |
第一级:大工具输出落盘(单次防爆,留存指针)
在终端写代码时,最怕一次操作没控住,工具返回了几万行测试日志或海量未分页的 JSON。这种单次暴击如果直接塞进上下文,几个回合就能把窗口打穿
Claude Code 的第一道防线非常直接:给工具输出设定物理阈值(比如 20KB)。一旦超过这个上限,立刻在本地拦截并替换内容:
- 完整落盘:把原始的超大输出原封不动保存到本地硬盘(~/.claude/projects/…/tool-results/)
- 截断内容:只保留开头约 2KB 的预览摘要(Preview)
- 注入指针:在回传的工具结果里附上本地文件的绝对路径,告诉模型 “完整内容在硬盘里,按需读取”
最终喂给大模型的工具调用结果长这样:
1 | <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 | const COMPACTABLE_TOOLS = new Set<string>([ |
读文件、搜代码、跑只读 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 | { |
我们看两轮实际交互的伪代码演进,就能看懂它是怎么在复用前缀的同时完成修剪的:
第一轮 Micro Compact 执行:
1 | 原始状态: |
第二轮 Micro Compact 继续推进:
1 | 新增轮次后: |
这样处理下来,既通知服务端删掉了中间的大块无用数据,又把第一轮的提示词前缀完整保住了
第四级:Context Collapse(消息折叠)
当会话继续变长,单靠删工具结果也不够用了,就轮到消息折叠登场
假设原始上下文长这样: [U1, A1, U2, A2, U3, A3, U4, A4, U5]
Claude 在交互过程中会不定期在后台起一个轻量任务:把一段连续的历史消息提前总结好,存成临时草稿备用:
1 | staged = [ |
生成草稿时并不会立刻改动上下文。直到会话往前走、上下文真正撞到阈值时,才会把准备好的总结直接换上去: 触发折叠后: [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 | CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. |
为什么要这样反复警告?源码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 | <analysis> |
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 | export interface CompactionResult { |
我们来解读一下压缩后的消息结构:
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 自己那套本地落盘与索引机制,我后面单独拉一期出来好好扒一扒
十一、最后的话
很高兴你能看到这里,如果这篇文章对你来说有收获,我在这里跪求一个小小的赞



