第六章 Context Compact (上下文压缩)
s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12
“本专栏基于开源项目
learn-claude-code的官方文档。原文档非常硬核,为了方便像我一样的新手小白理解,我对文档进行了逐行精读,并加入了很多中文注释、大白话解释和踩坑记录。希望这套‘咀嚼版’教程能帮你推开 AI Agent 开发的大门。”
"上下文总会满, 要有办法腾地方" -- 三层压缩策略, 换来无限会话。
一、问题-上下文窗口膨胀导致的长期运行能力限制
上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。
在之前的版本中,智能体采用的都是简单的消息累积模式,也就是每次工具调用的结果都完整保存在对话历史中;随着交互轮次的增加,messages列表会不断的增长;当token数量在接近LLM上下文窗口限制的时候,就会使它性能急剧下降、关键信息丢失以至于最后无法正常工作。
二、解决方案
三层压缩, 激进程度递增:
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阈值时
-
压缩流程:
- 将完整对话历史持久化到
.transcripts/目录 - 调用LLM对整个对话进行智能摘要
- 用摘要替换原有消息列表,仅保留关键信息
- 将完整对话历史持久化到
-
优势:自动化处理,确保智能体永远不会因上下文过长而失效
Layer 3: compact tool(手动压缩)
- 触发方式:智能体主动调用
compact工具 - 使用场景:当智能体意识到需要重置上下文或聚焦特定任务时
- 优势:赋予智能体主动管理上下文的能力,实现更精细的控制
三、工作原理
- 第一层 -- micro_compact: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。通过四个阶段实现智能上下文压缩:1、扫描并收集所有工具调用结果;2、然后基于保留策略判断是否需要压缩;3、构建工具ID到名称的映射关系以维持语义完整性;4、将冗余的历史结果替换为简洁的占位符,从而在保持对话连贯性的同时显著减少token消耗。
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
- 第二层 -- auto_compact: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。当token估算超过阈值时,自动触发全局压缩。通过三个阶段实现完整的对话历史压缩:1、持久化完整对话历史到.transcripts/目录,确保数据安全和可恢复性;2、调用LLM生成结构化摘要,明确要求包含已完成工作、当前状态和关键决策三个维度;3、用仅包含摘要和状态确认的2条消息替换原始历史,同时保留transcript文件路径以维持可追溯性,从而在保证任务连续性的同时极大降低token消耗。
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."},
]
- 第三层 -- manual compact:
compact工具按需触发同样的摘要机制。这里的manual不是指人工手动调用,而是指由LLM主动调用。不像auto_compact那样基于token阈值自动触发,而是由智能体根据任务需要决定何时压缩。
# 检测是否调用了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)
- 循环整合三层:
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/ |
五、试一试
cd learn-claude-code
python agents/s06_context_compact.py
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
Read every Python file in the agents/ directory one by one(观察 micro-compact 替换旧结果)Keep reading files until compression triggers automaticallyUse the compact tool to manually compress the conversation
六、完整代码
#!/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()