合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 30%

第 7 章

压缩与记忆:跨过上下文窗口

历史装不下时,先别急着摘要。

第 6 章我们把“不要改测试”放进了系统提示。

但任务一长,规则还是会被挤掉。不是系统提示没了,是中间的历史太厚,模型看不见。

我见过一次很典型的压缩事故:一个重构任务跑到第 20 轮,历史快满了,自动压缩触发。摘要写得很漂亮:

text
Goal: extract a Storage interface.
Done: read storage.py; extracted the interface.
Todo: run tests.

下一轮,它去改测试了。

不是它故意犯规。摘要里没有“不要改测试”这条约束,它看到的“当前状态”就是“提取接口、跑测试”;改测试是最快的路。

这一章讲的就是:压缩不是“把历史变短”,是“决定丢掉什么”。丢错了,比不压更糟。


7.1 先看一笔账:压前压后差多少

agent_compact.py 用假的工具结果把历史撑到 25 条消息,然后压一次:

bash
cd code
python3 -m tiny.agent_compact

输出:

text
before: 6428 chars, 25 messages
after:  897 chars, 4 messages

progress.md:

# Progress

## Done
- [x] read README, storage.py, tests/test_storage.py
- [x] extracted Storage interface
- [x] updated cli.py to use the interface

## Todo
- [ ] run full tests
- [ ] check the second test still passes

6,428 字符压到 897,少了 86%。

代价是中间 21 条消息没了,只剩:

  1. 第一条用户任务;
  2. 一段摘要;
  3. 最近两条消息。

如果你只压不留,模型下一轮就不知道“为什么改到一半”“哪个测试还没过”。所以压缩的关键不是“压得狠”,是“压完之后还能接着干”。


7.2 坏摘要 vs 好摘要

把 7.1 那个坏摘要和好摘要放一起:

坏摘要好摘要
目标extract Storageextract Storage, keep tests green
约束没有do not edit tests; run tests before done
已完成read storage; extractedread README/storage/tests; extracted; updated cli
未完成run testsrun full tests; check the second test
关键文件没有storage.py, cli.py, tests/test_storage.py

差别不在“写得好不好”,在有没有把承重墙留下。

坏摘要只回答了“做了什么”;好摘要还回答了“不能做什么、还差什么、去哪里看”。

模型下一轮是靠这些信息做决定的。你少写一句“不要改测试”,它就可能去改测试;你少写一句“第二个测试还没过”,它就可能宣布完成。

所以别让模型自由发挥。给它一个固定模板:

python
SUMMARY_TEMPLATE = """Goal: <一句话>
Constraints: <不要改测试;日期用 ISO;...>
Done: <改了什么;哪个测试过了>
Todo: <还差什么;下一步>
Files: <关键文件/行号>
"""

模板不是限制它,是防止它漏掉承重墙。尤其是 Constraints 和 Todo,自由发挥时最容易被省略。


7.3 压什么、留什么

压缩不是“把历史变短”,是“把历史变轻,但别把承重墙拆了”。

保留丢掉
目标:要做什么原始的工具输出(大段文件、日志)
约束:不要改测试、要用 ISO 日期重复的思考、寒暄
已完成:改了什么、哪个测试过了失败尝试的全文
未完成:还差什么、卡在哪无关的搜索结果
关键文件/行号旧的、已经被推翻的方案
失败的教训,一句话具体的报错堆栈(除非还在查)

最容易丢的是“约束”。你压完只留下“目标:修好测试”,模型下一轮就可能去改测试。

所以摘要里要明确写:

text
Constraints: do not edit tests; run tests before done.

这一句很便宜,但能挡住后面很多麻烦。


7.4 外置笔记:别把记忆只放在摘要里

摘要有一个问题:它在模型的上下文里,模型可能不看、看漏、或者被后续内容覆盖。

更稳的做法是把进度写到文件里:

python
@dataclass
class Notes:
    path: Path
    done: list[str] = field(default_factory=list)
    todo: list[str] = field(default_factory=list)

    def render(self) -> str:
        done = "\n".join(f"- [x] {x}" for x in self.done) or "- (none)"
        todo = "\n".join(f"- [ ] {x}" for x in self.todo) or "- (none)"
        return f"# Progress\n\n## Done\n{done}\n\n## Todo\n{todo}\n"

    def write(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.path.write_text(self.render(), encoding="utf-8")

progress.md 的好处:

  • 便宜。 它不在每一轮请求里,只在需要时读;
  • 可审计。 你可以打开看它到底做到哪了;
  • 可人工改。 模型卡住时,你可以手动补一条“别改测试”;
  • 可交接。 进程崩了、换会话了,下一个 Agent 读文件就能接着干。

第 8 章会讲会话恢复:摘要随进程走,笔记跟文件走。进程崩了,摘要没了,笔记还在。

这就是为什么我建议“先外置,再压缩”。progress.md 不改变请求前缀,不会让缓存失效;它只是多一个文件,需要时读一下。


7.5 什么时候压

三个合适的时机:

  1. 快到窗口上限时。 比如 70%–80%,别等爆了再压。
  2. 完成一个里程碑时。 一个功能做完、一组测试跑绿,历史里有很多可以丢的中间过程。
  3. 准备读一个大文件之前。 先把旧历史压掉,给新内容腾地方。

三个不合适的时机:

  • 工具调用做到一半。 tool_use 和 tool_result 必须配对,拆开就发不出去。
  • 每一轮都压。 摘要调用本身花钱,缓存也会一直失效。
  • 刚失败一次就压。 失败输出往往是下一步的关键,先留着。

如果你不确定,记住一句话:在“一轮结束”的边界压,不要在“一轮中间”压。


7.6 压缩的代价

压缩不是免费的,至少有三笔成本:

成本什么时候付怎么省
摘要调用每次压挑时机,别每轮压
信息丢失压完保留目标/约束/完成/未完成
缓存失效下一次请求在里程碑压,别在每轮压

第二笔最贵。你省下的是 token,丢掉的是“为什么这么做”。模型下一轮可能重复一个已经失败的方案,或者把已经完成的步骤再做一遍。

所以压缩之后,最好做一次“能不能接着干”的检查:

  • 目标还在吗?
  • 约束还在吗?
  • 下一步是什么?
  • 关键文件/行号还在吗?

四个都答得上来,才算压对了。


7.7 把压缩接进循环

压缩器本身很简单:

python
@dataclass
class Compactor:
    max_chars: int = 4000
    keep_recent: int = 2

    def size(self, messages: list[dict]) -> int:
        return sum(len(str(m)) for m in messages)

    def needed(self, messages: list[dict]) -> bool:
        return self.size(messages) > self.max_chars

    def compact(self, messages: list[dict], summary: str) -> list[dict]:
        if len(messages) <= self.keep_recent + 1:
            return messages
        return [messages[0], {"role": "user", "content": "## Summary so far\n" + summary}, *messages[-self.keep_recent:]]

接进循环,就是在调模型之前判断一次:

python
if compactor.needed(messages):
    summary = summarize(messages)          # 一次额外的模型调用
    messages = compactor.compact(messages, summary)

真实系统还要处理更多细节:

  • 工具配对:不能把 tool_use 和它的 tool_result 拆开;
  • 切分点:要落在一轮结束;
  • 摘要调用:本身也要算钱;
  • 压缩前存档:SDK 的 PreCompact Hook 可以干这个;
  • 压缩后验证:目标/约束/下一步还在不在。

第 14 章会讲 SDK 的自动压缩和 PreCompact。你现在手写的这一版,先把“保留什么”想清楚。


7.8 一次失败压缩的复盘

回到开头那个事故。如果重来,我会改四件事:

第一,先写 progress.md,再压。 摘要是模型写的,笔记是人能看、能改的;先外置,再压缩,丢的东西少一半。

第二,摘要模板固定。 不让模型自由发挥,直接要求四段:Goal / Constraints / Done / Todo。模型自由发挥,最容易漏的就是 Constraints。

第三,保留最近两条。 最近两条里往往有“刚失败的那个测试”“刚读到的关键行”;全压掉,模型会重复劳动。

第四,压完检查。 用四个问题过一遍:目标、约束、下一步、关键文件。答不上来,就补一句再发。

这四件事都不难,难的是“记得做”。所以最好把它们写进压缩器的代码,而不是靠提示词提醒自己。


7.9 安全压缩清单

  • 目标还在吗?
  • 约束还在吗?(不要改测试、日期格式、禁止目录)
  • 已完成/未完成还在吗?
  • 关键文件/行号还在吗?
  • tool_use / tool_result 有没有被拆开?
  • 摘要调用本身算进成本了吗?
  • 压缩前有没有存档?

七条里过不了三条,就别压;先写笔记,或者再撑一轮。


7.10 跑一遍

bash
cd code
python3 -m tiny.agent_compact

这个 demo 不调用模型,用假历史演示压缩和笔记。它会生成 code/tiny/progress.md,想清理就删掉:

bash
rm -f code/tiny/progress.md

真实压缩还要处理工具配对、切分点和摘要调用;第 14 章会把 SDK 的自动压缩和 PreCompact 接上。


本章代码 压缩器:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/compact.py 进度笔记:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/notes.py 离线演示:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/tiny/agent_compact.py 运行:cd code && python3 -m tiny.agent_compact 说明:这个 demo 不调用模型,用假历史演示压缩和笔记;真实压缩还要处理工具配对、切分点和摘要调用。