文章

learn-claude-code 学习笔记(六):Context Compact —— 上下文总会满,要有办法腾地方

learn-claude-code 学习笔记(六):Context Compact —— 上下文总会满,要有办法腾地方

这是 learn-claude-code 系列学习笔记的第六篇。前面几课分别解决了规划(s03 todo)、噪声隔离(s04 subagent)、知识加载(s05 skill)的问题,但有一个根本问题一直没正面解决:上下文窗口是有限的,messages 会一直增长。

问题

读一个 1000 行的文件就吃掉 ~4000 token。读 30 个文件、跑 20 条命令,轻松突破 100k token。不压缩,Agent 在大项目里干着干着就把上下文撑爆了——还没做完就没空间了。

s03 的 todo 减缓了注意力稀释,s04 的子 Agent 减少了噪声堆积,但只要对话持续,messages 就会一直增长。s06 直接面对这个问题:主动压缩上下文,换来无限会话。

解决方案:三层压缩

三层策略,激进程度递增:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
每轮循环开始时:
  [第一层 micro_compact]   ← 静默执行,每轮都跑
    把 3 轮前的旧 tool_result 替换成一句话占位符
         |
  [检查:token > 50000?]
    否 → 继续正常工作
    是 → [第二层 auto_compact]   ← 自动触发
           保存完整对话到磁盘
           LLM 做摘要
           用摘要替换全部 messages
         |
工具执行时:
  模型调了 compact 工具?
    是 → [第三层 手动 compact]   ← 模型主动触发
           和 auto_compact 一样的机制

第一层:micro_compact —— 日常清洁

什么时候执行:每轮循环开始时,在调 API 之前。静默执行,模型感知不到。

干了什么:把 3 轮前的旧 tool_result 替换成占位符。

1
2
3
4
5
# 替换前(read_file 返回的几百行代码)
{"type": "tool_result", "content": "import os\nimport sys\ndef main():\n    ...(500 行)"}

# 替换后(一句话)
{"type": "tool_result", "content": "[Previous: used read_file]"}

为什么是 3 轮前?

模型的典型工作链是”读取 → 分析 → 操作”:

1
2
3
4
第 1 轮:read_file("utils.py")     ← 需要完整内容
第 2 轮:分析代码,决定改哪里       ← 可能还要回看
第 3 轮:edit_file("utils.py"...)   ← 可能还需要参考
第 4 轮:开始做别的事了             ← 第 1 轮的结果可以压缩了

3 轮之后,模型大概率已经用完了那个工具结果,不再需要完整内容。

为什么替换成占位符而不是直接删除?

API 要求 tool_result 必须和前面 assistant 消息里的 tool_use 一一对应。删了 tool_result,API 会报错。占位符保持了结构完整性(tool_use_id 还在),但把几千 token 缩成了几个 token。

只压缩超过 100 字符的结果。 "Edited utils.py" 这种短结果本身就几个 token,压不压没区别。只有大的结果(读文件返回的几百行代码、bash 输出的大段日志)才值得压。

效果:假设读了 10 个文件,每个平均 2000 token。保留最近 3 个,压缩 7 个,节省约 14000 token。


第二层:auto_compact —— 紧急清理

什么时候执行estimate_tokens(messages) > 50000 时自动触发。

干了三步:

Step 1:保存完整对话到磁盘

1
2
3
4
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
    for msg in messages:
        f.write(json.dumps(msg, default=str) + "\n")

.transcripts/ 目录下创建带时间戳的文件,完整保存对话。压缩是有损的——摘要不可能保留所有细节。这个文件是安全网,万一丢了关键信息还能恢复。

Step 2:让 LLM 做摘要

1
2
3
4
5
6
7
8
response = client.messages.create(
    model=MODEL,
    messages=[{"role": "user", "content":
        "Summarize this conversation for continuity. Include: "
        "1) What was accomplished, 2) Current state, 3) Key decisions made. "
        "Be concise but preserve critical details.\n\n" + conversation_text}],
    max_tokens=2000,
)

单独发一次 API 调用(不走 agent_loop),让模型总结之前的对话。为什么用 LLM 而不是代码做?因为代码不知道什么信息重要——50000 token 的对话里,哪些是关键决策、哪些是临时过程,只有理解语义的 LLM 能判断。

Step 3:用摘要替换全部 messages

1
2
3
4
return [
    {"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
    {"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
]

50000 token → ~2000 token。整个对话历史变成两条消息。压缩后的消息里还保留了 transcript 文件路径——模型以后可以 read_file 读这个文件来回忆细节。

一个重要的语法细节:循环里用的是 messages[:] = auto_compact(messages) 而不是 messages = auto_compact(messages)。messages 是外部传进来的引用,messages[:] 原地替换列表内容,外部变量也能看到变化;messages = 只改了局部变量的指向,外部不受影响。


第三层:compact 工具 —— 主动断舍离

什么时候执行:模型主动调用 compact 工具时。

和 auto_compact 有什么区别? 压缩机制完全一样,区别在于触发方式:

  • auto_compact:token 计数超过 50000 自动触发
  • compact 工具:模型自己觉得该清理了,主动触发

为什么需要手动触发? 两种场景 token 计数帮不上:

  1. 上下文没满但已经很乱——做了很多失败的尝试,上下文里充满了无关信息,虽然 token 数没到 50000,但噪声已经严重干扰了判断
  2. 想主动翻篇——完成了一个大阶段,想从干净的状态开始下一阶段

compact 工具给了模型这个自主权。


三层的关系

 第一层 micro第二层 auto第三层 manual
触发方式每轮自动token > 50000模型主动调用
激进程度温和激进激进
压缩什么旧 tool_result 内容整个 messages整个 messages
压缩成什么一句话占位符LLM 摘要LLM 摘要
信息损失大(靠摘要保留关键点)
备份不需要保存到 .transcripts/保存到 .transcripts/

第一层是日常清洁,持续发挥作用,延缓上下文增长。第二层是紧急措施,快满了才用。第三层是模型的自主选择——觉得乱了就主动清理。


agent_loop 怎么变的

核心结构不变,但前面插入了压缩逻辑,工具执行部分增加了 compact 的特殊处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def agent_loop(messages: list):
    while True:
        micro_compact(messages)                        # 第一层:每轮清理
        if estimate_tokens(messages) > THRESHOLD:      # 第二层:超阈值压缩
            messages[:] = auto_compact(messages)
        response = client.messages.create(...)         # 正常调模型
        messages.append(...)
        if response.stop_reason != "tool_use":
            return
        # 工具执行...
        manual_compact = False
        for block in response.content:
            if block.name == "compact":
                manual_compact = True                  # 先标记,不立刻压缩
                output = "Compressing..."
            else:
                ...正常执行工具...
        messages.append({"role": "user", "content": results})
        if manual_compact:                             # 第三层:本轮结束后再压缩
            messages[:] = auto_compact(messages)

compact 工具为什么不在遍历 block 时立刻压缩?因为那会导致 messages 在遍历途中被替换——先标记,等所有工具结果追加完毕后再执行。


小结

s06 解决的是上下文的物理限制。前面几课的机制(todo、subagent、skill)都在优化上下文的质量,但 s06 解决的是容量问题——不管优化得多好,对话足够长就会撑满。

三层压缩的类比:

  • 第一层像整理桌面——把不用的旧文件收起来,只留最近在看的
  • 第二层像年终归档——把全年的文件打包存库房,桌上只留一份摘要
  • 第三层像主动断舍离——自己觉得该清理了,手动触发

Agent 可以策略性地遗忘,然后永远工作下去。

下一篇我们进入第三阶段——s07:大目标要拆成小任务,排好序,记在磁盘上。

Go 语言实现s06/main.go


本文是 learn-claude-code 系列学习笔记的第六篇,基于 learn-claude-code 仓库的 s06 课程。

本文由作者按照 CC BY 4.0 进行授权