Context Compact
这是 Claude Code 记忆管理的精髓。
上下文窗口是 Agent 的"工作记忆"——有限、昂贵、需要主动管理。
三层压缩让 Agent 学会策略性遗忘,换来无限会话。
为什么上下文压缩是 Agent 工程的基石问题
如果你只学 s01-s05 搭建了一个 Agent,它会在一个小时内撞墙——上下文窗口满了。这不是"优化"问题,是生存问题。没有压缩机制,Agent 根本不能在大项目里持续工作。
上下文窗口的三个硬约束
① 物理上限:Claude 当前支持 200k token。一个 500 行的 Python 文件 = ~2,000 token。读 30 个文件 + 跑 20 条命令 + system prompt + 对话历史 → 轻松突破 100k。在大型项目里(几百个文件、几千行代码),Agent 可能读不到 10% 的代码库就满了。
② 有效注意力衰减:LLM 的注意力机制不是完美的。即使技术上能塞 200k token,模型对早期内容的关注度随距离衰减。"大海捞针"测试表明,当信息被埋在大量无关内容中时,LLM 的召回率下降。上下文越长,噪声越多,信号越弱。
③ 边际成本递增:每次 API 调用都把整个 messages 数组发给 LLM。100k token 的 messages × 10 轮 = 1M token 传输。这是真金白银的成本。而且响应时间随输入长度增长——100k token 的请求比 10k token 的请求慢得多。
为什么之前的课程没遇到这个问题?
s01-s05 的任务都很短——读几个文件、改几行代码。messages 最多几千 token。但在真实使用中(比如你现在和我对话的这个会话),上下文可能已经累积了数万甚至数十万 token。
压缩不是"优化",是 Agent 从 demo 走向产品的必经之路。没有压缩的 Agent 就像没有垃圾回收的程序——能跑,但早晚会死。
🔄 可再生信息(文件内容、命令输出)→ 可以丢掉,需要时重新获取
💎 不可再生信息(用户意图、关键决策、当前状态)→ 必须保留
好的压缩 = 精准区分这两类,大胆丢前者,小心保后者。
三层压缩架构:轻量清理 → 阈值触发 → 主动请求
s06 没有用"一种压缩算法解决所有问题"。它用了三层——每层解决不同场景,激进程度递增。这就像人的记忆系统:几秒前的细节还在(L1 未压缩)、昨天的对话只剩要点(L2 摘要)、需要时可以翻日记(L3 + transcript)。
micro_compact(微压缩)
每次 LLM 调用前执行。遍历 messages,将超过 3 轮的旧 tool_result 替换为占位符 [Previous: used {tool}]。保留 read_file 输出(参考资料不应压缩)。修改原数组,零内存分配。模型感知不到——它只看到旧结果变短了。
auto_compact(自动压缩)
当 estimate_tokens(messages) > 50,000 时触发。① 完整对话保存到 .transcripts/(JSONL 格式,可恢复);② 调用 LLM 做摘要(完成了什么 → 当前状态 → 关键决策);③ 将所有 messages 替换为单条摘要消息。从几万 token → ~500 token。
compact 工具(手动压缩)
Agent 调用 compact 工具 → 和 auto_compact 同样的摘要机制 → 替换 messages → 退出当前循环。由 Agent 的判断而非阈值触发。让 Agent 拥有感知并管理自己记忆的能力。
案例追踪:读 30 个文件,有压缩 vs 无压缩的完整对比
任务:"读 agents/ 目录下所有 .py 文件,总结每个文件的功能"。假设该目录有 12 个 Python 文件(s01-s12),每个约 150 行。
场景 A:没有压缩(s05 及之前版本的默认行为)
每读一个文件,~2,000 token 永久进入 messages。读到第 8 个时 messages 已有 ~16,000 token。读到第 12 个时 ~24,000 token。
具体会发生什么:Agent 在读第 10 个文件时,第 1-3 个文件的内容在注意力边缘。最终写总结时,由于早期文件内容已被后来的大量文本"推到注意力深处",LLM 可能模糊处理甚至遗漏它们。而且这 12 个文件的全文在后续对话中永久占据上下文——用户说"帮我改 s01",Agent 不需要记得 s02-s12 的每一个函数实现。
场景 B:有压缩(s06)——跟踪 messages 的演变
📦 压缩前(读完 12 个文件)
[system] ...[user] 读 agents/ 目录下所有 .py
[assistant] tool_use: read_file s01
[tool_result] s01 完整代码 (2000t)
[assistant] tool_use: read_file s02
[tool_result] s02 完整代码 (2000t)
... 读到 s10 ...
[tool_result] s10 完整代码 (2000t)
[tool_result] s11 完整代码 ← 最近3个保留全文
[tool_result] s12 完整代码 ← 最新
总计:~28,000 token
📦 压缩后(micro_compact 执行后)
[system] ...[user] 读 agents/ 目录下所有 .py
[assistant] tool_use: read_file s01
[Previous: used read_file] ← s01 被压
[assistant] tool_use: read_file s02
[Previous: used read_file] ← s02 被压
... s03-s09 全部被压 ...
[Previous: used read_file] ← s10 被压
[tool_result] s11 完整代码 ← KEEP_RECENT=3
[tool_result] s12 完整代码 ← 保留全文
总计:~6,000 token(省了 78%)
关键观察:s01-s10 的文件内容虽然被替换成了占位符,但 Agent 知道"我以前读过 s01"——这是工具调用记录(assistant 的 tool_use block)没有被删除,只是 tool_result 内容被压缩了。如果后续需要 s01 的具体内容——重新读一遍磁盘上的文件就行。信息没有丢失,只是从上下文移到了文件系统。
Layer 1 深度解析:micro_compact 的算法细节和设计考量
micro_compact 是三层中最常执行的(每轮一次),也是设计最精巧的。它做了四个看似简单但每个都有深意的决策。
算法完整步骤
步骤① — 收集 tool_result 的位置:遍历 messages 的每条消息。如果消息 role 是 user 且 content 是列表(意味着它包含 tool_result block),遍历 content 中的每个 block,找到 type=="tool_result" 的,记录 (msg索引, block索引, block对象)。
为什么要遍历两层(消息层 + content 层)?因为一条 user 消息可能包含多个 tool_result block(如果 LLM 一次调了多个工具)。不能只遍历消息——需要精确到 block 级别。
步骤② — 构建 tool_name_map:遍历所有 assistant 消息,找到 tool_use block,建立 {tool_use_id: tool_name} 映射。这样在压缩时才知道每个 tool_result 对应哪个工具。
为什么不在步骤①一起做?因为 tool_use_id 和 tool_result 在不同的消息里——一个是 assistant 的响应,一个是后续 user 消息的一部分。需要先建立映射表。
步骤③ — 筛选要压缩的 result:to_clear = tool_results[:-KEEP_RECENT](取所有 tool_result 除最后 3 个)。对每个要压缩的 result:检查 content 长度 > 100 字符(太短的不值得压缩),检查 tool_name 不在 PRESERVE_RESULT_TOOLS 中(read_file 的输出保留),检查 content 是字符串类型。
步骤④ — 原地替换:result["content"] = f"[Previous: used {tool_name}]"。不创建新对象,直接修改原字典。因为 messages 可能非常大。
为什么保留最近 3 个(KEEP_RECENT=3)?
这个数字是经过深思熟虑的,不是随便选的:
保留 1 个不够:Agent 经常需要看"上一个操作的结果"和"上上个操作的结果"来理解上下文。比如 Agent 先 ls 找到文件 → 然后 read_file 读内容 → 然后 edit_file 修改。这一步需要同时看到 ls 的输出(知道文件存在)和 read_file 的输出(知道文件内容)。
保留 5 个太多:如果保留 5 个,对于读 30 个文件的场景,只有 25 个被压缩。考虑到每个文件约 2,000 token,5 个保留 = 10,000 token 占据,压缩效果打折。
3 是平衡点:对于大多数编码任务,最近 3 个工具结果足以让 Agent 理解"当前在做什么"。同时压缩了足够多的旧结果。
为什么 PRESERVE_RESULT_TOOLS = {"read_file"}?
这是一个关键的 tradeoff 决策。假设 Agent 读了 s01_agent_loop.py(2000 token),然后 micro_compact 把它压缩成 [Previous: used read_file](20 token)。省了 1980 token。但三回合后,Agent 需要引用 s01 的代码——它不记得了。
它有两个选择:
① 压缩 read_file → 20 token。需要时重读 → 再花 2000 token + 1 次额外 API 调用。
② 保留 read_file → 2000 token。不需要重读。
s06 选择了②。对于参考资料来说,保留的成本(多占 2000 token 上下文)低于重读的成本(额外的 LLM 推理 + API 调用)。bash 命令输出则相反——这些输出通常信息密度低("Tests passed. 3 items."),不值得保留全文。
如果你构建自己的 Agent,这个列表值得自定义:如果你的 Agent 经常需要引用之前的 grep 结果,可以把 "grep" 也加入 PRESERVE_RESULT_TOOLS。如果你的 read_file 输出特别大(比如读几千行的文件),也许不该保留。
原地修改 vs 创建新数组:性能考量
micro_compact 选择原地修改 messages(修改 result["content"] 的值),而不是创建新的 messages 副本。原因是 messages 数组可能非常庞大——几万甚至十万 token——复制整个数组的内存和时间开销不可忽略。
但原地修改有一个隐藏风险:如果 agent_loop 中的其他代码依赖原始 content(比如保存 transcript),它看到的已经是压缩后的版本。不过 s06 的设计中,transcript 保存发生在 auto_compact 中,而 auto_compact 在 micro_compact 之前执行——所以保存的是完整内容。
这是 harness 工程的一个重要原则:理解数据流动的顺序。压缩函数的调用时机、transcript 保存的时机、LLM 调用的时机——它们的先后顺序直接影响数据完整性。
Layer 2 深度解析:auto_compact 的摘要策略和信息保存
micro_compact 能延缓但不能阻止上下文增长。当占位符也堆满了几十条,token 数迟早会突破阈值。这时 auto_compact 出手——它不做修修补补,而是把整个 messages 替换为一份摘要。
auto_compact 三步详解
第一步:保存完整记录 — 信息不是"丢失",是"搬家"
将 messages 的完整内容写入 .transcripts/transcript_{timestamp}.jsonl。JSONL 格式(每行一个独立的 JSON 对象)有几个好处:可以用 tail 逐行读取,可以用 jq 查询,可以按行流式处理。时间戳在文件名里,方便按时间查找。
⚠️ 注意:s06 的 transcript 保存在 .transcripts/ 目录,但没有自动清理机制。在真实使用中,你可能需要定期清理或归档旧的 transcript 文件。
第二步:调用 LLM 做摘要 — 精心设计的 prompt
摘要 prompt 问了三个具体问题:
1. What was accomplished(完成了什么)——具体的产出。比如"创建了 auth.py,实现了登录逻辑"。不是抽象的"做了一些代码改动"。
2. Current state(当前状态)——任务进行到哪了。比如"Todo #3 in_progress,需要对接 API"。这保证压缩后 Agent 不会"失忆"——它知道自己正在做什么。
3. Key decisions made(关键决策)——做了什么选择以及为什么。比如"选择了 JWT 而非 session 认证,因为需要无状态 API"。这是最容易被压缩丢失但也最重要的上下文——忘了"为什么这样做"比忘了"做了什么"更致命。
这三个问题不是随便写的。它们覆盖了 Agent 继续工作所需的三个最小信息维度:产出(做了什么)、状态(做到哪了)、决策背景(为什么这样做)。
第三步:替换整个 messages
messages[:] = [summary_message]。messages 从几万 token 变成一条包含摘要的 user 消息(约 500 token)。Agent 从"记住所有细节"变成"记住关键结论"。这不是完美的——细节确实丢失了。但如果 Agent 需要某个细节,它可以:① 重新读文件(可再生信息);② 查看 transcript 文件(完整记录可恢复)。
为什么取文本尾部而不是头部?
摘要时传给 LLM 的是 json.dumps(messages)[-80000:]——最后 80,000 字符,而不是开头。这看似小细节,实则反映了对 LLM 行为模式的深刻理解:
对话的头部:初始系统提示 + 第一个用户问题 + 早期的工具调用。这些内容的信息密度被 micro_compact 大幅降低——早期 tool_result 已被替换为占位符。传头部给 LLM 做摘要 ≈ 让它读一堆 [Previous: used read_file]。
对话的尾部:最近的操作、最新的工具结果、当前的上下文。这些是"Agent 现在在做什么"的最直接依据。传尾部给 LLM = 让它基于最新状态做摘要。
实际考虑:80,000 字符 ≈ 20,000 token。对 LLM 来说是一个舒适的输入长度——足够捕捉关键上下文,又不会太贵。
token 估算:为什么用 len(str(messages)) // 4?
s06 没有用精确的 tokenizer 来数 token(那需要加载 tokenizer,增加依赖和启动时间)。它用了最简单的估算:约 4 个字符 = 1 个 token。
这个估算有多准?对英文代码和对话来说,4 chars/token 是合理的——Claude 的 tokenizer 大约 3.5-4.5 个英文字符一个 token。对中文来说,一个汉字通常 = 1-2 token,所以这个估算会偏低(实际 token 数可能比估算的高)。
这是一个工程上的实用主义决策:宁愿估算偏低(早一点触发压缩),也不要估算偏高(晚了,上下文已经严重超载)。反正 50,000 只是一个阈值,不是硬限制。估算偏低的后果是"多压缩了一次"——成本可忽略。
Layer 3 & 循环集成:compact 工具 + agent_loop 中三层如何协作
compact 工具:Agent 主动要求整理记忆
这是 s06 的范式转变——之前的工具都是"帮 Agent 做事"(读文件、写文件、跑命令),compact 是第一个"帮 Agent 管理自己"的工具。Agent 调用它不是为了操作外部世界,而是为了整理自己的内部状态。
compact 在 dispatch map 中的 handler 只是一个 lambda 返回 "Compressing..."——真正的压缩逻辑在 agent_loop 中。因为压缩操作需要修改 messages 数组本身(替换其全部内容),这不是普通工具 handler 能做的——handler 只能返回字符串,不能修改循环状态。
agent_loop 中三层如何编排
编排顺序的精妙之处
L1 在 LLM 调用之前执行,让 LLM 看到的是压缩后的 messages(占位符取代旧结果)。L2 也在 LLM 调用之前,如果触发,LLM 看到的是摘要后的单条消息。L3 在 LLM 调用之后、循环末尾执行,因为 LLM 必须先在本轮中决定调用 compact 工具,然后压缩发生在下一轮开始前。
注意 L2 执行后,messages 只剩一条摘要——但 agent_loop 不退出。LLM 继续用这条摘要作为上下文,执行下一轮。这意味着 Agent 可以在一次 agent_loop 调用中经历多次压缩——非常适合"连续多任务"的场景。
而 L3(manual compact)执行后 退出循环。这是因为 manual compact 是 Agent 主动说"我需要整理思路"——通常意味着一个阶段性任务已完成,自然应该结束本次循环,让外层 REPL 给用户报告或等下一个指令。
参数调优 & 记忆模型:压缩策略如何影响 Agent 行为
三层压缩的三个关键参数——KEEP_RECENT、THRESHOLD、PRESERVE_RESULT_TOOLS——决定了 Agent 的"遗忘曲线"。调得太激进,Agent 变成金鱼;调得太保守,上下文被垃圾填满。
⚠️ 太激进:KEEP_RECENT=1, THRESHOLD=20k
症状:Agent 频繁"忘记"刚读过的东西,反复重读同一个文件。对话中出现大量重复的 read_file 调用。
表现:Agent 看起来"很笨"——明明刚才读过,又问同样的问题。压缩太频繁导致 Agent 的"工作记忆"过短。
修复:增加 KEEP_RECENT(3→5),提高 THRESHOLD(20k→60k),或把更多工具加入 PRESERVE_RESULT_TOOLS。
⚠️ 太保守:KEEP_RECENT=10, THRESHOLD=120k
症状:messages 持续增长,API 调用越来越慢、越来越贵。Agent 后期的决策质量因为上下文噪声而下降。
表现:Agent 看起来"很慢"——每次调用要等很久。偶尔在长对话中做出奇怪的决定(因为早期信息占据了太多注意力空间)。
修复:减少 KEEP_RECENT(10→3),降低 THRESHOLD(120k→60k),或把非关键工具从 PRESERVE 中移除。
Agent 的"记忆模型":类比人类的三种记忆
s06 的设计可以映射到人类记忆的认知模型上,这有助于理解三个参数如何协作:
🧠 工作记忆(Working Memory)= KEEP_RECENT 个未压缩的 tool_result
就像你正在思考时"脑海里的东西"。容量极小(人类 7±2,Agent 3 个 tool_result),但访问最快。Agent 的"当前注意力"。
📝 短期记忆(Short-term Memory)= micro_compact 产生的占位符
就像你记得"我今天早上读了那篇文章",但不记得具体内容。占位符保留了"做过什么"但不保留详细结果。需要时可以回溯(重新读文件 / 查看 transcript)。
📚 长期记忆(Long-term Memory)= auto_compact 的摘要 + transcript 文件
就像你的日记——经过总结的关键事件。不保留所有细节,但保留了"发生了什么"的要点。需要细节时可以翻日记(读 transcript 文件)。
调试压缩的实用技巧
① 观察工具调用模式:如果 Agent 反复读同一个文件,说明压缩太激进——它在重读被压缩掉的内容。增加 KEEP_RECENT 或把该工具的产出去 PRESERVE_RESULT_TOOLS。
② 监控 token 增长曲线:如果 messages 的 token 数在几次调用后直线上升且不回落,说明 L2 的 THRESHOLD 设得太高或根本没触发。检查 estimate_tokens 的计算是否正确。
③ 检查摘要质量:打开 transcript 文件,看 LLM 的摘要是否保留了关键信息。如果摘要丢失了重要决策("为什么选了方案 A 而不是 B"),考虑改进摘要 prompt。
④ 在对话中测试"失忆"程度:Agent 工作一段时间后,问它一个关于早期操作的问题——"我最早让你改的那个文件叫什么名字?"如果它答不上来,压缩太激进了。
从 s06 带走的五个核心认识
压缩是生存必需
没有压缩的 Agent 是 demo。真实项目中的上下文窗口会在几分钟内被填满。压缩不是优化——是 Agent 从"玩具"走向"工具"的必经关卡。
信息分两类对待
可再生信息(文件、命令输出)大胆丢——磁盘上还有。不可再生信息(用户意图、决策理由)小心保——丢了就永远没了。压缩策略的优劣取决于"分得多准"。
三层各司其职
L1 微压缩(每轮)、L2 阈值压缩(按需)、L3 主动压缩(Agent 判断)。不是冗余——是覆盖了三种不同的压缩触发场景。
压缩的是结果不是历史
tool_use 请求不被压缩,只有 tool_result 内容被替换为占位符。Agent 的行动记忆完整保留——它知道做过什么,只是不记得每个操作的完整输出。
摘要 prompt 决定压缩质量
auto_compact 的好坏取决于 LLM 摘要的质量。摘要 prompt 的三个问题(完成/状态/决策)覆盖了 Agent 继续工作所需的最小信息集。改进 prompt = 改进 Agent 的长期记忆。
落地案例 & 在你的 Agent 中实现压缩
📝 Claude Code:你每天都在经历的三层压缩
你在 Claude Code 中正在进行的长对话就是一个压缩系统的实例。当你看到对话中突然出现一条 [Conversation compressed]——那就是 auto_compact 触发了。Claude Code 的压缩实现比 s06 更复杂(它会智能保留最近 N 条完整消息而非全部替换为摘要),但三层结构完全一致。
你自己就能观察到压缩的效果:在一个很长的 Claude Code 会话中,早期讨论的细节会逐渐"模糊"——Claude 记得结论("我们决定用 JWT"),但可能不记得讨论过程("你最初提议的是 OAuth")。这不是 Claude 变笨了——是它的"工作记忆"被后续内容替换了,而"长期记忆"(摘要)只保留了结论。
🛠️ 在你的 Agent 项目中实现压缩的最小可行版本
如果你想在自己搭建的 Agent(比如基于 s05 的代码)中加入压缩,不需要从零写三层:
- 最小版本:只在达到 token 阈值时调 LLM 做摘要(就是只有 L2)。~30 行代码。
- 标准版本:L1(micro_compact)+ L2(auto_compact)。~80 行代码。s06 的水平。
- 完整版本:L1+L2+L3+transcript 持久化。~120 行代码。s06 的完整实现。
起步建议:先实现 L2(auto_compact),加一个 50k token 阈值。这是投入产出比最高的——10 行代码换来无限会话的能力。L1 是锦上添花,当你发现 Agent 在阈值触发前就开始"遗忘"时再加上。
🏠 智能家居 Agent 的日志压缩
一个管理智能家居的 Agent 每天产生大量传感器数据——温度变化、门锁状态、电量消耗。s06 的三层模式直接映射:
L1(micro):每小时保留温度统计(均值、极值),丢弃原始读数。保留最近 3 小时。
L2(auto):每天触发一次摘要——"今日总用电 12.3 kWh,比昨天高 5%,空调运行 8 小时"。
L3(manual):用户问"这个月怎么样?"→ Agent 主动压缩本月数据生成月报。
同样的三层结构,不同的数据。压缩不限于代码 Agent。
s01-s05 教你给 Agent 加能力——更多工具、规划、外包、知识。
s06 教你给 Agent 加约束——管理它最稀缺的资源:注意力。
能加能力让你成为一个 harness 工程师。
能管理注意力让你成为一个好的 harness 工程师。