第 7 章
压缩与记忆:跨过上下文窗口
历史装不下时,先别急着摘要。
第 6 章我们把“不要改测试”放进了系统提示。
但任务一长,规则还是会被挤掉。不是系统提示没了,是中间的历史太厚,模型看不见。
我见过一次很典型的压缩事故:一个重构任务跑到第 20 轮,历史快满了,自动压缩触发。摘要写得很漂亮:
Goal: extract a Storage interface.
Done: read storage.py; extracted the interface.
Todo: run tests.下一轮,它去改测试了。
不是它故意犯规。摘要里没有“不要改测试”这条约束,它看到的“当前状态”就是“提取接口、跑测试”;改测试是最快的路。
这一章讲的就是:压缩不是“把历史变短”,是“决定丢掉什么”。丢错了,比不压更糟。
7.1 先看一笔账:压前压后差多少
agent_compact.py 用假的工具结果把历史撑到 25 条消息,然后压一次:
cd code
python3 -m tiny.agent_compact输出:
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 passes6,428 字符压到 897,少了 86%。
代价是中间 21 条消息没了,只剩:
- 第一条用户任务;
- 一段摘要;
- 最近两条消息。
如果你只压不留,模型下一轮就不知道“为什么改到一半”“哪个测试还没过”。所以压缩的关键不是“压得狠”,是“压完之后还能接着干”。
7.2 坏摘要 vs 好摘要
把 7.1 那个坏摘要和好摘要放一起:
| 坏摘要 | 好摘要 | |
|---|---|---|
| 目标 | extract Storage | extract Storage, keep tests green |
| 约束 | 没有 | do not edit tests; run tests before done |
| 已完成 | read storage; extracted | read README/storage/tests; extracted; updated cli |
| 未完成 | run tests | run full tests; check the second test |
| 关键文件 | 没有 | storage.py, cli.py, tests/test_storage.py |
差别不在“写得好不好”,在有没有把承重墙留下。
坏摘要只回答了“做了什么”;好摘要还回答了“不能做什么、还差什么、去哪里看”。
模型下一轮是靠这些信息做决定的。你少写一句“不要改测试”,它就可能去改测试;你少写一句“第二个测试还没过”,它就可能宣布完成。
所以别让模型自由发挥。给它一个固定模板:
SUMMARY_TEMPLATE = """Goal: <一句话>
Constraints: <不要改测试;日期用 ISO;...>
Done: <改了什么;哪个测试过了>
Todo: <还差什么;下一步>
Files: <关键文件/行号>
"""模板不是限制它,是防止它漏掉承重墙。尤其是 Constraints 和 Todo,自由发挥时最容易被省略。
7.3 压什么、留什么
压缩不是“把历史变短”,是“把历史变轻,但别把承重墙拆了”。
| 保留 | 丢掉 |
|---|---|
| 目标:要做什么 | 原始的工具输出(大段文件、日志) |
| 约束:不要改测试、要用 ISO 日期 | 重复的思考、寒暄 |
| 已完成:改了什么、哪个测试过了 | 失败尝试的全文 |
| 未完成:还差什么、卡在哪 | 无关的搜索结果 |
| 关键文件/行号 | 旧的、已经被推翻的方案 |
| 失败的教训,一句话 | 具体的报错堆栈(除非还在查) |
最容易丢的是“约束”。你压完只留下“目标:修好测试”,模型下一轮就可能去改测试。
所以摘要里要明确写:
Constraints: do not edit tests; run tests before done.这一句很便宜,但能挡住后面很多麻烦。
7.4 外置笔记:别把记忆只放在摘要里
摘要有一个问题:它在模型的上下文里,模型可能不看、看漏、或者被后续内容覆盖。
更稳的做法是把进度写到文件里:
@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 什么时候压
三个合适的时机:
- 快到窗口上限时。 比如 70%–80%,别等爆了再压。
- 完成一个里程碑时。 一个功能做完、一组测试跑绿,历史里有很多可以丢的中间过程。
- 准备读一个大文件之前。 先把旧历史压掉,给新内容腾地方。
三个不合适的时机:
- 工具调用做到一半。
tool_use和tool_result必须配对,拆开就发不出去。 - 每一轮都压。 摘要调用本身花钱,缓存也会一直失效。
- 刚失败一次就压。 失败输出往往是下一步的关键,先留着。
如果你不确定,记住一句话:在“一轮结束”的边界压,不要在“一轮中间”压。
7.6 压缩的代价
压缩不是免费的,至少有三笔成本:
| 成本 | 什么时候付 | 怎么省 |
|---|---|---|
| 摘要调用 | 每次压 | 挑时机,别每轮压 |
| 信息丢失 | 压完 | 保留目标/约束/完成/未完成 |
| 缓存失效 | 下一次请求 | 在里程碑压,别在每轮压 |
第二笔最贵。你省下的是 token,丢掉的是“为什么这么做”。模型下一轮可能重复一个已经失败的方案,或者把已经完成的步骤再做一遍。
所以压缩之后,最好做一次“能不能接着干”的检查:
- 目标还在吗?
- 约束还在吗?
- 下一步是什么?
- 关键文件/行号还在吗?
四个都答得上来,才算压对了。
7.7 把压缩接进循环
压缩器本身很简单:
@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:]]接进循环,就是在调模型之前判断一次:
if compactor.needed(messages):
summary = summarize(messages) # 一次额外的模型调用
messages = compactor.compact(messages, summary)真实系统还要处理更多细节:
- 工具配对:不能把
tool_use和它的tool_result拆开; - 切分点:要落在一轮结束;
- 摘要调用:本身也要算钱;
- 压缩前存档:SDK 的
PreCompactHook 可以干这个; - 压缩后验证:目标/约束/下一步还在不在。
第 14 章会讲 SDK 的自动压缩和 PreCompact。你现在手写的这一版,先把“保留什么”想清楚。
7.8 一次失败压缩的复盘
回到开头那个事故。如果重来,我会改四件事:
第一,先写 progress.md,再压。 摘要是模型写的,笔记是人能看、能改的;先外置,再压缩,丢的东西少一半。
第二,摘要模板固定。 不让模型自由发挥,直接要求四段:Goal / Constraints / Done / Todo。模型自由发挥,最容易漏的就是 Constraints。
第三,保留最近两条。 最近两条里往往有“刚失败的那个测试”“刚读到的关键行”;全压掉,模型会重复劳动。
第四,压完检查。 用四个问题过一遍:目标、约束、下一步、关键文件。答不上来,就补一句再发。
这四件事都不难,难的是“记得做”。所以最好把它们写进压缩器的代码,而不是靠提示词提醒自己。
7.9 安全压缩清单
- 目标还在吗?
- 约束还在吗?(不要改测试、日期格式、禁止目录)
- 已完成/未完成还在吗?
- 关键文件/行号还在吗?
-
tool_use/tool_result有没有被拆开? - 摘要调用本身算进成本了吗?
- 压缩前有没有存档?
七条里过不了三条,就别压;先写笔记,或者再撑一轮。
7.10 跑一遍
cd code
python3 -m tiny.agent_compact这个 demo 不调用模型,用假历史演示压缩和笔记。它会生成 code/tiny/progress.md,想清理就删掉:
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 不调用模型,用假历史演示压缩和笔记;真实压缩还要处理工具配对、切分点和摘要调用。