构建mini Claude Code:06 - Agent 如何「战略性遗忘」(上下文压缩)

5 阅读16分钟

构建mini Claude Code:06 - Agent 如何「战略性遗忘」(记忆压缩)

📍 导航指南

这是「从零构建 Claude Code」系列的第六篇。根据你的背景,选择合适的阅读路径:


目录

第一部分:理论基础 🧠

第二部分:三层压缩流水线 ⚙️

第三部分:代码实现 💻

第四部分:其他压缩方向 🔭

附录


引言

想象一个程序员在处理一个复杂任务:他打开了 20 个文件,运行了 30 条命令,看了几百行输出。

两小时后,他的桌面乱成一团。他需要做一件事:清理桌面,只保留当前最需要的东西

AI Agent 面临同样的问题,但更严峻——它的「桌面」有硬性上限。

上下文窗口(Context Window)是 LLM 能「看到」的全部内容。每次 API 调用,所有历史消息都要塞进去。随着对话轮次增加,这个窗口会被填满——然后 Agent 就卡死了。

v5_agent.py 用三层压缩流水线解决这个问题。本文逐层解析每一层的设计逻辑,以及这个问题还有哪些值得探索的解法。


第一部分:理论基础 🧠

上下文窗口:Agent 的工作记忆

LLM 的上下文窗口,本质上是它的工作记忆

人类工作记忆:
  - 容量有限(7±2 个信息块)
  - 短期存储,不持久
  - 当前任务相关的信息优先

LLM 上下文窗口:
  - 容量有限(几万到几十万 tokens)
  - 每次调用重新加载
  - 窗口内所有内容都「可见」

对于单轮问答,这不是问题。但 Agent 是多轮交互的——它需要:

  1. 记住用户的原始任务
  2. 记住已经做了什么
  3. 记住工具调用的结果
  4. 记住中间决策和推理

每一轮对话,这些内容都在累积。

为什么 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)时自动触发。

做什么

  1. 把完整对话历史保存到 .transcripts/ 目录(不丢失原始记录)
  2. 调用 LLM 生成对话摘要(已完成什么、当前状态、关键决策)
  3. 用摘要替换全部 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

注意两个细节:

  1. 只替换长内容len > 100):短结果(如 "Wrote 42 bytes")本身就很小,替换没有意义。
  2. 占位符包含工具名[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 能「永远工作下去」的关键能力。

系列导航