构建mini Claude Code:06 - Agent 如何「战略性遗忘」(记忆压缩)
📍 导航指南
这是「从零构建 Claude Code」系列的第六篇。根据你的背景,选择合适的阅读路径:
- 🧠 理论派? → 第一部分:上下文窗口的本质问题 - 理解为什么 Agent 会「撑死」
- ⚙️ 实践派? → 第二部分:三层压缩流水线 - 掌握每层压缩的设计逻辑
- 💻 代码派? → 第三部分:代码实现 - 直接看完整实现
- 🔭 探索派? → 第四部分:其他压缩方向 - 还有哪些思路值得尝试
目录
第一部分:理论基础 🧠
第二部分:三层压缩流水线 ⚙️
第三部分:代码实现 💻
第四部分:其他压缩方向 🔭
附录
引言
想象一个程序员在处理一个复杂任务:他打开了 20 个文件,运行了 30 条命令,看了几百行输出。
两小时后,他的桌面乱成一团。他需要做一件事:清理桌面,只保留当前最需要的东西。
AI Agent 面临同样的问题,但更严峻——它的「桌面」有硬性上限。
上下文窗口(Context Window)是 LLM 能「看到」的全部内容。每次 API 调用,所有历史消息都要塞进去。随着对话轮次增加,这个窗口会被填满——然后 Agent 就卡死了。
v5_agent.py 用三层压缩流水线解决这个问题。本文逐层解析每一层的设计逻辑,以及这个问题还有哪些值得探索的解法。
第一部分:理论基础 🧠
上下文窗口:Agent 的工作记忆
LLM 的上下文窗口,本质上是它的工作记忆。
人类工作记忆:
- 容量有限(7±2 个信息块)
- 短期存储,不持久
- 当前任务相关的信息优先
LLM 上下文窗口:
- 容量有限(几万到几十万 tokens)
- 每次调用重新加载
- 窗口内所有内容都「可见」
对于单轮问答,这不是问题。但 Agent 是多轮交互的——它需要:
- 记住用户的原始任务
- 记住已经做了什么
- 记住工具调用的结果
- 记住中间决策和推理
每一轮对话,这些内容都在累积。
为什么 Agent 会「撑死」
看一个典型的 Agent 对话历史结构:
messages = [
{role: "user", content: "帮我重构这个项目"}, # 50 tokens
{role: "assistant", content: [read_file("main.py"), ...]}, # 100 tokens
{role: "user", content: [tool_result: "...文件内容..."]}, # 2000 tokens ← 大
{role: "assistant", content: [grep("TODO"), ...]}, # 80 tokens
{role: "user", content: [tool_result: "...搜索结果..."]}, # 500 tokens
{role: "assistant", content: [read_file("utils.py"), ...]}, # 100 tokens
{role: "user", content: [tool_result: "...文件内容..."]}, # 1500 tokens ← 大
...(继续累积)
]
问题的根源:tool_result 是上下文膨胀的主要来源。
- 读一个文件:500-3000 tokens
- 运行一条命令:100-5000 tokens(取决于输出)
- 搜索代码:200-2000 tokens
一个复杂任务可能需要几十次工具调用。每次调用的结果都留在上下文里,即使这个结果在后续步骤中已经没有价值。
关键洞察:大多数工具调用结果只在调用后的 1-3 轮内有用。之后,它们就是噪音。
战略性遗忘:核心设计思想
解决方案不是「记住所有东西」,而是战略性遗忘。
人类专家处理复杂任务的方式:
- 不会记住每一行代码的内容
- 记住「我看过这个文件,它做了 X」
- 记住关键决策和发现
- 当前步骤需要什么,才去查什么
Agent 的战略性遗忘:
- 旧的工具结果 → 替换为占位符「[曾经用过 read_file]」
- 整个对话 → 压缩为摘要「已完成 X,当前状态 Y,关键决策 Z」
- 主动触发 → 在关键节点手动清理
这不是「丢失信息」,而是用更高密度的表示替换低密度的原始数据。
第二部分:三层压缩流水线 ⚙️
整体架构:三层流水线
每一轮 Agent 循环:
┌─────────────────────────────────────────────────────┐
│ │
│ 工具调用结果追加到 messages │
│ │ │
│ ▼ │
│ [Layer 1: micro_compact] ← 每轮静默执行 │
│ 旧 tool_result → 占位符 │
│ 成本: 极低(纯字符串替换) │
│ │ │
│ ▼ │
│ [检查: tokens > 50000?] │
│ 否 ↓ 是 ↓ │
│ 继续执行 [Layer 2: auto_compact] │
│ 保存完整记录到 .transcripts/ │
│ LLM 生成摘要 │
│ messages 替换为 [摘要] │
│ │ │
│ ▼ │
│ [Layer 3: compact 工具] │
│ Agent 主动调用 │
│ 逻辑同 auto_compact │
│ │
└─────────────────────────────────────────────────────┘
三层各有分工,覆盖不同的压缩场景。
第一层:micro_compact(静默清理)
解决的问题:旧工具结果占用大量 token,但已无价值。
触发时机:每一轮 LLM 调用前,自动执行,无感知。
做什么:只替换旧 tool_result 块的 content 字段——消息条目本身保留,assistant 的推理文本完整保留,只有工具返回的原始内容被替换为占位符。
替换前(tool_result 块的 content):
"def main():\n parser = argparse.ArgumentParser()\n parser.add_argument('--model'...(2000 tokens)"
替换后(同一个 tool_result 块,只换 content):
"[Previous: used read_file]" ← 6 tokens
压缩范围的边界:
messages 结构:
assistant: "我需要先读取 main.py 来了解结构" ← 不动(推理文本)
[tool_use: read_file("main.py")] ← 不动(工具调用记录)
user: [tool_result: content="...2000 tokens..."] ← 只替换这里的 content
assistant: "文件包含 AuthService 类,我来重构它" ← 不动(推理文本)
[tool_use: edit_file(...)] ← 不动
user: [tool_result: content="Edited auth.py"] ← 保留(最近 3 个之内)
micro_compact 不删消息、不动 assistant 推理、不改消息结构——它只做一件事:把旧 tool_result 的大块原始内容换成小占位符。
为什么只保留最近 3 次?
Agent 的推理通常依赖最近几步的结果。更早的结果要么已经被 Agent 处理过(信息已经内化到后续的推理文本中),要么已经不再相关。保留 3 次是经验值,平衡了「记忆深度」和「token 节省」。
代价:如果 Agent 需要回头查看早期的工具结果,它看到的是占位符,需要重新调用工具。这是可接受的代价——重新调用一次工具,比上下文撑死要好得多。
第二层:auto_compact(自动摘要)
解决的问题:即使有 micro_compact,长时间运行后 token 总量仍会超限。
触发时机:估算 token 数超过阈值(50000)时自动触发。
做什么:
- 把完整对话历史保存到
.transcripts/目录(不丢失原始记录) - 调用 LLM 生成对话摘要(已完成什么、当前状态、关键决策)
- 用摘要替换全部 messages
替换前:
messages = [50+ 条消息,包含大量工具调用结果] ← 60000 tokens
替换后:
messages = [
{role: "user", content: "[对话已压缩。记录: .transcripts/xxx.jsonl]\n\n摘要内容..."},
{role: "assistant", content: "已理解。继续执行。"},
] ← ~2000 tokens
关键设计:摘要由 LLM 生成,而不是简单截断。LLM 知道哪些信息对继续任务最重要——它会保留任务目标、已完成的步骤、当前状态、关键发现,而不是机械地保留最新的 N 条消息。
完整记录保留:原始对话存入 .transcripts/,不会真正丢失。这是「压缩」而非「删除」。
第三层:compact 工具(主动触发)
解决的问题:Agent 自己感知到上下文「乱了」,需要主动清理。
触发时机:Agent 主动调用 compact 工具。
做什么:逻辑与 auto_compact 相同,但由 Agent 自主决策触发。
为什么需要这一层?
auto_compact 是被动的——它等到 token 超限才触发。但有时 Agent 在 token 未超限时就意识到上下文已经「混乱」了:
- 任务阶段切换(从「探索」到「实现」)
- 完成了一个大的子任务,准备开始下一个
- 上下文里有大量不再相关的信息
这时 Agent 可以主动调用 compact,在「合适的时机」压缩,而不是等到「不得不压缩」。
这体现了一个设计哲学:给 Agent 自主管理记忆的能力,而不是完全依赖外部机制。
三层协作:各司其职
问题维度 解决层次
─────────────────────────────────────────────────────
旧工具结果占空间 → Layer 1: micro_compact(每轮静默)
总量超限 → Layer 2: auto_compact(阈值触发)
主动清理时机 → Layer 3: compact 工具(Agent 自主)
触发方式 成本 效果
─────────────────────────────────────────────────────
micro_compact 极低 渐进式,每轮小幅减少
auto_compact 中等 一次性大幅压缩
compact 工具 中等 Agent 自主决策时机
三层不是互斥的,而是互补的:micro_compact 持续「小扫除」,auto_compact 在必要时「大扫除」,compact 工具让 Agent 在合适时机「主动整理」。
第三部分:代码实现 💻
token 估算
def estimate_tokens(messages: list) -> int:
"""Rough token count: ~4 chars per token."""
return len(str(messages)) // 4
这是一个粗略估算:把 messages 序列化为字符串,除以 4(英文约 4 字符/token,中文约 1.5 字符/token)。
不精确,但足够用——触发阈值设为 50000,实际上下文可能在 40000-60000 之间,有足够的缓冲区。精确计算需要调用 tokenizer,成本更高,对这个场景不值得。
micro_compact 实现
KEEP_RECENT = 3 # 保留最近 N 次工具结果
def micro_compact(messages: list) -> list:
"""Layer 1: replace old tool results with placeholders."""
# 收集所有 tool_result 的位置
tool_results = []
for msg_idx, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part_idx, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((msg_idx, part_idx, part))
if len(tool_results) <= KEEP_RECENT:
return messages # 不够多,不需要压缩
# 建立 tool_use_id → tool_name 的映射(用于生成有意义的占位符)
tool_name_map = {}
for msg in messages:
if msg["role"] == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if hasattr(block, "type") and block.type == "tool_use":
tool_name_map[block.id] = block.name
# 替换旧结果(保留最近 KEEP_RECENT 个)
to_clear = tool_results[:-KEEP_RECENT]
for _, _, result in to_clear:
if isinstance(result.get("content"), str) and len(result["content"]) > 100:
tool_id = result.get("tool_use_id", "")
tool_name = tool_name_map.get(tool_id, "unknown")
result["content"] = f"[Previous: used {tool_name}]"
return messages
注意两个细节:
- 只替换长内容(
len > 100):短结果(如"Wrote 42 bytes")本身就很小,替换没有意义。 - 占位符包含工具名:
[Previous: used read_file]比[cleared]更有信息量,Agent 知道「我曾经读过一个文件」。
auto_compact 实现
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
def auto_compact(messages: list) -> list:
"""Layer 2: save transcript, summarize, replace messages."""
# 1. 保存完整记录
TRANSCRIPT_DIR.mkdir(exist_ok=True)
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")
print(f"[transcript saved: {transcript_path}]")
# 2. 调用 LLM 生成摘要
conversation_text = json.dumps(messages, default=str)[:80000]
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,
)
summary = response.content[0].text
# 3. 用摘要替换全部 messages
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."},
]
摘要 prompt 的三个要素:已完成什么、当前状态、关键决策。这三点足以让 Agent 在压缩后继续工作,不会「失忆」。
主循环中的压缩调度
def agent_loop(messages: list) -> list:
while True:
# Layer 1: 每轮静默执行
micro_compact(messages)
# Layer 2: token 超限时自动触发
if estimate_tokens(messages) > THRESHOLD:
print("[auto_compact triggered]")
messages[:] = auto_compact(messages)
response = client.messages.create(...)
# ... 处理工具调用 ...
# Layer 3: Agent 主动触发
if manual_compact:
print("[manual compact]")
messages[:] = auto_compact(messages)
调度逻辑清晰:Layer 1 无条件执行,Layer 2 条件触发,Layer 3 由 Agent 决策触发。
第四部分:其他压缩方向 🔭
v5_agent.py 的三层压缩解决了核心问题,但这个领域还有很多值得探索的方向。
方向一:结构化记忆提取
现有方案的局限:auto_compact 生成的是自由文本摘要,Agent 需要从摘要中「重新理解」状态。
改进思路:把摘要结构化。
# 不是自由文本摘要,而是结构化状态
structured_memory = {
"task": "重构 auth 模块",
"completed": [
"读取了 auth.py(主要逻辑在 AuthService 类)",
"发现 3 个 TODO 注释",
"确认测试覆盖率 62%"
],
"current_step": "重写 AuthService.validate() 方法",
"key_findings": {
"auth.py": "包含 AuthService, TokenManager 两个类",
"tests/test_auth.py": "缺少边界条件测试"
},
"pending": [
"更新测试",
"更新文档"
]
}
优势:Agent 可以直接查询结构化状态,而不是从自由文本中提取信息。压缩后的「记忆」更可靠,更不容易产生幻觉。
代价:需要设计摘要 schema,压缩逻辑更复杂。
方向二:工具结果去重与增量更新
现有方案的局限:同一个文件可能被读取多次,每次都留下完整内容。
改进思路:检测重复,只保留最新版本。
def dedup_tool_results(messages: list) -> list:
"""如果同一个文件被读取多次,只保留最新的结果。"""
seen_reads = {} # path → (msg_idx, part_idx)
for msg_idx, msg in enumerate(messages):
if msg["role"] == "assistant":
for block in msg.get("content", []):
if hasattr(block, "type") and block.type == "tool_use":
if block.name == "read_file":
path = block.input.get("path")
if path in seen_reads:
# 清除旧的读取结果
old_idx, old_part = seen_reads[path]
messages[old_idx]["content"][old_part]["content"] = \
f"[Superseded by later read of {path}]"
seen_reads[path] = (msg_idx + 1, ...) # 记录新位置
return messages
适用场景:Agent 在编辑文件后重新读取验证——旧的读取结果已经过时,可以安全清除。
方向三:分层存储(热/冷数据分离)
现有方案的局限:所有信息要么在上下文里(热),要么被压缩掉(冷)。没有中间状态。
改进思路:引入「温数据」层——不在上下文里,但可以按需检索。
热数据(上下文): 当前步骤直接需要的信息
温数据(外部存储): 可能需要但不确定的信息
冷数据(归档): 已完成步骤的完整记录
热 → 温: micro_compact 触发,结果存入向量数据库
温 → 热: Agent 调用 recall_memory("auth module") 检索
冷: auto_compact 的 transcript 文件
这本质上是给 Agent 加一个外部记忆系统。Agent 不需要记住所有细节,但可以在需要时「想起来」。
实现复杂度:需要向量数据库(如 ChromaDB)和检索工具,比现有方案复杂得多。但对于需要长时间运行的 Agent(几小时甚至几天),这个投入是值得的。
方向四:语义压缩(而非截断)
现有方案的局限:micro_compact 是基于位置的压缩(保留最近 N 个),不考虑内容的语义重要性。
改进思路:基于语义重要性决定保留什么。
def semantic_compact(messages: list, current_task: str) -> list:
"""
根据与当前任务的相关性,决定哪些工具结果值得保留。
"""
# 对每个工具结果,评估与当前任务的相关性
for result in tool_results:
relevance = estimate_relevance(result["content"], current_task)
if relevance < THRESHOLD:
result["content"] = f"[Low relevance: used {result['tool_name']}]"
例子:Agent 正在修复一个 CSS bug,之前读取的 Python 文件内容相关性低,可以优先压缩;而读取的 CSS 文件内容相关性高,应该保留。
代价:需要额外的 LLM 调用来评估相关性,或者用嵌入向量计算相似度。对于高频压缩场景,成本可能不划算。
常见问题 FAQ
Q: 压缩后 Agent 会「忘记」重要信息吗?
A: 有可能,但设计上尽量避免。auto_compact 的摘要 prompt 明确要求保留「关键决策」和「当前状态」。micro_compact 只清除旧的工具结果,不清除 Agent 的推理文本(assistant 消息)。完整记录也保存在 .transcripts/ 里,可以人工查阅。
Q: THRESHOLD 设为 50000 合理吗?
A: 这取决于使用的模型。Claude Sonnet 的上下文窗口是 200K tokens,50000 是 25%。设置这么低是为了给后续对话留足空间,避免在任务进行到一半时突然触发压缩。可以根据实际任务调整。
Q: 为什么不直接用更大的上下文窗口,而要压缩?
A: 两个原因。第一,更大的上下文意味着更高的 API 成本——每次调用都要付所有 token 的费用。第二,研究表明 LLM 在超长上下文中的注意力会分散,「Lost in the Middle」问题——中间的信息容易被忽略。压缩不只是省钱,也是提升质量。
Q: compact 工具什么时候应该主动调用?
A: 几个典型场景:完成一个大的子任务准备开始下一个、任务方向发生重大转变、上下文里有大量已经不相关的探索性工具调用。Agent 需要学会判断「现在是整理记忆的好时机」。
📝 结语
三层压缩流水线的设计,体现了一个核心思想:
不同的压缩问题,需要不同粒度的解法
旧工具结果(高频、小量)→ micro_compact(轻量、每轮)
总量超限(低频、大量) → auto_compact(重量、按需)
主动整理(Agent 自主) → compact 工具(灵活、可控)
这和操作系统的内存管理异曲同工:有页面置换(micro_compact)、有 swap(auto_compact)、有主动释放(compact 工具)。
更深层的洞察是:Agent 需要管理自己的记忆。不是把所有东西都记住,而是知道什么时候该记、什么时候该忘、忘了之后怎么找回来。
这是让 Agent 能「永远工作下去」的关键能力。
系列导航:
- 上一篇: 05 - 用纯文本文件教会 Agent 任何技能(Skills)
- 当前: 06 - Agent 如何「战略性遗忘」
- 下一篇: [07 - 一切皆文件:持久化任务系统]()