【从零手写 ClaudeCode:learn-claude-code 项目实战笔记】(6)Context Compact (上下文压缩)

第六章 Context Compact (上下文压缩)

s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12

"本专栏基于开源项目 learn-claude-code 的官方文档。原文档非常硬核,为了方便像我一样的新手小白理解,我对文档进行了逐行精读 ,并加入了很多中文注释、大白话解释和踩坑记录。希望这套'咀嚼版'教程能帮你推开 AI Agent 开发的大门。"

项目地址:shareAI-lab/learn-claude-code: Bash is all you need - A nano Claude Code--like agent, built from 0 to 1

"上下文总会满, 要有办法腾地方" -- 三层压缩策略, 换来无限会话。

一、问题-上下文窗口膨胀导致的长期运行能力限制

上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。

在之前的版本中,智能体采用的都是简单的消息累积模式,也就是每次工具调用的结果都完整保存在对话历史中;随着交互轮次的增加,messages列表会不断的增长;当token数量在接近LLM上下文窗口限制的时候,就会使它性能急剧下降、关键信息丢失以至于最后无法正常工作。

二、解决方案

三层压缩, 激进程度递增:

sql 复制代码
Every turn:
+------------------+
| Tool call result |
+------------------+
        |
        v
[Layer 1: micro_compact]        (silent, every turn)
  Replace tool_result > 3 turns old
  with "[Previous: used {tool_name}]"
        |
        v
[Check: tokens > 50000?]
   |               |
   no              yes
   |               |
   v               v
continue    [Layer 2: auto_compact]
              Save transcript to .transcripts/
              LLM summarizes conversation.
              Replace all messages with [summary].
                    |
                    v
            [Layer 3: compact tool]
              Model calls compact explicitly.
              Same summarization as auto_compact.

Layer 1: micro_compact(微观压缩)

  • 触发时机:每轮对话前自动执行
  • 压缩策略 :保留最近3次工具调用的完整结果,更早的结果替换为占位符[Previous: used {tool_name}]
  • 优势:轻量级、无感知,显著减少重复工具输出的token消耗

Layer 2: auto_compact(自动压缩)

  • 触发条件:当估算token数超过50,000阈值时

  • 压缩流程

    1. 将完整对话历史持久化到.transcripts/目录
    2. 调用LLM对整个对话进行智能摘要
    3. 用摘要替换原有消息列表,仅保留关键信息
  • 优势:自动化处理,确保智能体永远不会因上下文过长而失效

Layer 3: compact tool(手动压缩)

  • 触发方式 :智能体主动调用compact工具
  • 使用场景:当智能体意识到需要重置上下文或聚焦特定任务时
  • 优势:赋予智能体主动管理上下文的能力,实现更精细的控制

三、工作原理

  1. 第一层 -- micro_compact: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。通过四个阶段实现智能上下文压缩:1、扫描并收集所有工具调用结果;2、然后基于保留策略判断是否需要压缩;3、构建工具ID到名称的映射关系以维持语义完整性;4、将冗余的历史结果替换为简洁的占位符,从而在保持对话连贯性的同时显著减少token消耗。
python 复制代码
def micro_compact(messages: list) -> list:
	# 第一阶段:收集所有工具结果
    tool_results = []
    for i, msg in enumerate(messages):
        if msg["role"] == "user" and isinstance(msg.get("content"), list):
            for j, part in enumerate(msg["content"]):
                if isinstance(part, dict) and part.get("type") == "tool_result":
                    tool_results.append((i, j, part))
    # 第二阶段:早期退出条件
    if len(tool_results) <= KEEP_RECENT: # 当工具结果数量不超过保留阈值(默认3个)时,无需压缩
        return messages
    # 第三阶段:构建工具名称映射表
    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
    # 第四阶段:执行压缩替换
    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. 第二层 -- auto_compact: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。当token估算超过阈值时,自动触发全局压缩。通过三个阶段实现完整的对话历史压缩:1、持久化完整对话历史到.transcripts/目录,确保数据安全和可恢复性;2、调用LLM生成结构化摘要,明确要求包含已完成工作、当前状态和关键决策三个维度;3、用仅包含摘要和状态确认的2条消息替换原始历史,同时保留transcript文件路径以维持可追溯性,从而在保证任务连续性的同时极大降低token消耗。
python 复制代码
def auto_compact(messages: list) -> list:
    # Save full transcript to disk
    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}]")
    # Ask LLM to summarize
    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
    # Replace all messages with compressed summary
    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."},
    ]
  1. 第三层 -- manual compact : compact 工具按需触发同样的摘要机制。这里的manual不是指人工手动调用,而是指由LLM主动调用。不像auto_compact那样基于token阈值自动触发,而是由智能体根据任务需要决定何时压缩。
ini 复制代码
# 检测是否调用了compact工具
manual_compact = False
for block in response.content:
    if block.type == "tool_use":
        if block.name == "compact":
            manual_compact = True # 标记需要压缩
            output = "Compressing..."

# 在工具执行完成后检查是否需要手动压缩
if manual_compact:
    print("[manual compact]")
    messages[:] = auto_compact(messages)
  1. 循环整合三层:
ini 复制代码
def agent_loop(messages: list):
    while True:
        micro_compact(messages)                        # Layer 1
        if estimate_tokens(messages) > THRESHOLD:
            messages[:] = auto_compact(messages)       # Layer 2
        response = client.messages.create(...)
        # ... tool execution ...
        if manual_compact:
            messages[:] = auto_compact(messages)       # Layer 3

完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。

四、相对 s05 的变更

组件 之前 (s05) 之后 (s06)
Tools 5 5 (基础 + compact)
上下文管理 三层压缩
Micro-compact 旧结果 -> 占位符
Auto-compact token 阈值触发
Transcripts 保存到 .transcripts/

五、试一试

bash 复制代码
cd learn-claude-code
python agents/s06_context_compact.py

试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):

  1. Read every Python file in the agents/ directory one by one (观察 micro-compact 替换旧结果)
  2. Keep reading files until compression triggers automatically
  3. Use the compact tool to manually compress the conversation

六、完整代码

python 复制代码
#!/usr/bin/env python3
import json
import os
import subprocess
import time
from pathlib import Path

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv(override=True)

if os.getenv("ANTHROPIC_BASE_URL"):
    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)

WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]

SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."

THRESHOLD = 50000
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
KEEP_RECENT = 3


def estimate_tokens(messages: list) -> int:
    """Rough token count: ~4 chars per token."""
    return len(str(messages)) // 4


# -- Layer 1: micro_compact - replace old tool results with placeholders --
def micro_compact(messages: list) -> list:
    # Collect (msg_index, part_index, tool_result_dict) for all tool_result entries
    # 第一阶段:收集所有工具结果
    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: # 当工具结果数量不超过保留阈值(默认3个)时,无需压缩
        return messages
    # Find tool_name for each result by matching tool_use_id in prior assistant messages
    # 第三阶段:构建工具名称映射表
    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
    # Clear old results (keep last 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


# -- Layer 2: auto_compact - save transcript, summarize, replace messages --
def auto_compact(messages: list) -> list:
    # Save full transcript to disk
    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}]")
    # Ask LLM to summarize
    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
    # Replace all messages with compressed summary
    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."},
    ]


# -- Tool implementations --
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    try:
        r = subprocess.run(command, shell=True, cwd=WORKDIR,
                           capture_output=True, text=True, timeout=120)
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"

def run_read(path: str, limit: int = None) -> str:
    try:
        lines = safe_path(path).read_text().splitlines()
        if limit and limit < len(lines):
            lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
        return "\n".join(lines)[:50000]
    except Exception as e:
        return f"Error: {e}"

def run_write(path: str, content: str) -> str:
    try:
        fp = safe_path(path)
        fp.parent.mkdir(parents=True, exist_ok=True)
        fp.write_text(content)
        return f"Wrote {len(content)} bytes"
    except Exception as e:
        return f"Error: {e}"

def run_edit(path: str, old_text: str, new_text: str) -> str:
    try:
        fp = safe_path(path)
        content = fp.read_text()
        if old_text not in content:
            return f"Error: Text not found in {path}"
        fp.write_text(content.replace(old_text, new_text, 1))
        return f"Edited {path}"
    except Exception as e:
        return f"Error: {e}"


TOOL_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
    "compact":    lambda **kw: "Manual compression requested.",
}

TOOLS = [
    {"name": "bash", "description": "Run a shell command.",
     "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
    {"name": "read_file", "description": "Read file contents.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
    {"name": "write_file", "description": "Write content to file.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
    {"name": "edit_file", "description": "Replace exact text in file.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
    {"name": "compact", "description": "Trigger manual conversation compression.",
     "input_schema": {"type": "object", "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}}},
]


def agent_loop(messages: list):
    while True:
        # Layer 1: micro_compact before each LLM call
        micro_compact(messages)
        # Layer 2: auto_compact if token estimate exceeds threshold
        if estimate_tokens(messages) > THRESHOLD:
            print("[auto_compact triggered]")
            messages[:] = auto_compact(messages)
        response = client.messages.create(
            model=MODEL, system=SYSTEM, messages=messages,
            tools=TOOLS, max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return
        results = []
        manual_compact = False
        for block in response.content:
            if block.type == "tool_use":
                if block.name == "compact":
                    manual_compact = True
                    output = "Compressing..."
                else:
                    handler = TOOL_HANDLERS.get(block.name)
                    try:
                        output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
                    except Exception as e:
                        output = f"Error: {e}"
                print(f"> {block.name}: {str(output)[:200]}")
                results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
        messages.append({"role": "user", "content": results})
        # Layer 3: manual compact triggered by the compact tool
        if manual_compact:
            print("[manual compact]")
            messages[:] = auto_compact(messages)


if __name__ == "__main__":
    history = []
    while True:
        try:
            query = input("\033[36ms06 >> \033[0m")
        except (EOFError, KeyboardInterrupt):
            break
        if query.strip().lower() in ("q", "exit", ""):
            break
        history.append({"role": "user", "content": query})
        agent_loop(history)
        response_content = history[-1]["content"]
        if isinstance(response_content, list):
            for block in response_content:
                if hasattr(block, "text"):
                    print(block.text)
        print()
相关推荐
肥肥旭手记1 小时前
openclaw平替之nanobot源码解析(二):agent命令、消息总线与循环引擎
agent
曲幽12 小时前
FastAPI + PostgreSQL 实战:从入门到不踩坑,一次讲透
python·sql·postgresql·fastapi·web·postgres·db·asyncpg
亚马逊云开发者14 小时前
5 分钟用 Amazon Bedrock 搭一个 AI Agent:从零到能干活
人工智能·agent·amazon
肥肥旭手记16 小时前
opencalw平替之nanobot 源码解析(一):环境搭建、Debug 配置与 onboard 命令详解
agent
阿里云大数据AI技术16 小时前
告别先开发后治理:Agent 驱动的数据质量一体化交付
agent
用户83562907805117 小时前
使用 C# 在 Excel 中创建数据透视表
后端·python
老纪的技术唠嗑局18 小时前
Agent / Skills / Teams 架构演进流程及技术选型之道
人工智能·agent
码路飞20 小时前
FastMCP 实战:一个 .py 文件,给 Claude Code 装上 3 个超实用工具
python·ai编程·mcp
数据智能老司机20 小时前
Kubernetes 上的生成式 AI——模型数据
kubernetes·llm·agent