动机:为什么需要 Context Window 管理
LLM 的 context window 是有限的。即使是 200K token 的模型,一个长对话也会很快把它填满——特别是 tool result 往往包含大量文件内容和命令输出。
如果不管理 context window,会发生两件事:
- API 报错:超过最大 token 限制,请求直接失败
- 质量下降:即使没超限,过长的上下文会稀释关键信息,模型表现变差
OpenClaw 的 src/agents/compaction.ts 实现了完整的 context 压缩机制。它用 splitMessagesByTokenShare() 把旧消息分块,通过 generateSummary() 让 LLM 总结,然后用摘要替换原始消息。压缩后的摘要会写入工作区文件作为长期记忆。
点击”下一条消息”,观察 context window 逐步填满,到 80% 阈值时自动触发压缩:
token 条从绿色变黄色再变红色。压缩触发时,多条旧消息被替换为一行摘要,token 使用率骤降。右侧日志记录了每一步发生了什么。
也可以直接对比压缩前后的静态结果——点击”压缩前/压缩后”切换:
13 条消息变成 6 条,token 数减半。旧消息被一行摘要替代。代价是丢失了细节——但 LLM 能看到最近的对话,加上摘要中保留的关键决策。
Ground Truth:真实的压缩策略
Context 压缩——跨代码库对比
不同代码库如何处理 context window 溢出
1class MemoryStore:2 """两层记忆: MEMORY.md (长期事实) + HISTORY.md (可搜索日志)"""34 async def consolidate(self, session, provider, model,5 archive_all=False, memory_window=50):6 """通过 LLM tool call 把旧消息压缩到文件"""7 if archive_all:8 old_messages = session.messages9 else:10 keep_count = memory_window // 211 old_messages = session.messages[12 session.last_consolidated:-keep_count13 ]1415 # 让 LLM 总结旧消息16 prompt = f"""Process this conversation:17## Current Long-term Memory18{current_memory or "(empty)"}19## Conversation to Process20{formatted_messages}"""2122 response = await provider.chat(23 messages=[...],24 tools=_SAVE_MEMORY_TOOL, # save_memory tool25 model=model,26 )27 # LLM 返回 history_entry + memory_update28 args = response.tool_calls[0].arguments29 self.append_history(args["history_entry"])30 self.write_long_term(args["memory_update"])31 session.last_consolidated = len(session.messages) - keep_count
1pub struct ContextCompactor {2 llm: Arc<dyn LlmProvider>,3 safety: Arc<SafetyLayer>,4}56impl ContextCompactor {7 pub async fn compact(8 &self, thread: &mut Thread,9 strategy: CompactionStrategy,10 workspace: Option<&Workspace>,11 ) -> Result<CompactionResult> {12 match strategy {13 CompactionStrategy::Summarize { keep_recent } =>14 self.compact_with_summary(15 thread, keep_recent, workspace16 ).await?,17 CompactionStrategy::Truncate { keep_recent } =>18 self.compact_truncate(19 thread, keep_recent20 ),21 CompactionStrategy::MoveToWorkspace =>22 self.compact_to_workspace(23 thread, workspace24 ).await?,25 }26 }27}2829// 三种策略:30// Summarize: LLM 总结旧 turns, 保留摘要31// Truncate: 直接截断, 只保留最近 N 条32// MoveToWorkspace: 转存到文件系统
1// OpenClaw context 压缩 (开源 TypeScript)23const BASE_CHUNK_RATIO = 0.4;4const MIN_CHUNK_RATIO = 0.15;5const SAFETY_MARGIN = 1.2; // 20% buffer67function estimateMessagesTokens(messages: AgentMessage[]) {8 const safe = stripToolResultDetails(messages);9 return safe.reduce((sum, m) => sum + estimateTokens(m), 0);10}1112function splitMessagesByTokenShare(messages, parts = 2) {13 const totalTokens = estimateMessagesTokens(messages);14 const targetTokens = totalTokens / parts;15 // 按 token 配额拆分为多个 chunk16 // 每个 chunk 独立摘要,最后合并17}1819// 摘要生成: 使用 LLM 的 generateSummary()20// 摘要合并: MERGE_SUMMARIES_INSTRUCTIONS21// "Merge partial summaries into a single22// cohesive summary. Preserve decisions,23// TODOs, open questions, and constraints."
关键观察:
- nanobot 的双层设计很精巧:MEMORY.md 存长期事实(会被更新),HISTORY.md 存时间线日志(只追加)
- ironclaw 提供三种策略——总结、截断、转存——可以根据场景选择
- OpenClaw 在 92% 水位触发压缩,并在压缩后注入提示让模型知道丢失了上下文
- 所有实现都用 LLM 自己做总结——用 AI 压缩 AI 的上下文
构建:Context Window 管理
class ContextWindowManager:
"""管理 context window 使用率"""
def __init__(self, max_tokens: int = 128000, compact_threshold: float = 0.9):
self.max_tokens = max_tokens
self.compact_threshold = compact_threshold
def estimate_tokens(self, messages: list[dict]) -> int:
"""粗略估算: 1 token ≈ 4 characters (英文) / 2 characters (中文)"""
total_chars = sum(len(str(m.get("content", ""))) for m in messages)
return total_chars // 3 # 取中间值
def should_compact(self, messages: list[dict]) -> bool:
usage = self.estimate_tokens(messages) / self.max_tokens
return usage > self.compact_threshold
def compact(self, messages: list[dict], keep_recent: int = 10) -> list[dict]:
"""保留 system prompt + 最近 N 条消息, 中间用摘要替代"""
if len(messages) <= keep_recent + 1:
return messages
system = messages[0] if messages[0]["role"] == "system" else None
old = messages[1:-keep_recent] if system else messages[:-keep_recent]
recent = messages[-keep_recent:]
summary = self._summarize(old)
result = []
if system:
result.append(system)
result.append({"role": "user", "content": f"[上下文摘要] {summary}"})
result.extend(recent)
return result
测试:模拟 Context 压缩
cwm = ContextWindowManager(max_tokens=1000, compact_threshold=0.8)
messages = [{"role": "system", "content": "You are an agent."}]
for i in range(50):
messages.append({"role": "user", "content": f"Message {i} " * 20})
assert cwm.should_compact(messages) == True
compacted = cwm.compact(messages, keep_recent=5)
assert len(compacted) < len(messages)
assert compacted[0]["role"] == "system"
assert "[上下文摘要]" in compacted[1]["content"]
变更内容
| 组件 | 之前 (s04) | 之后 (s05) |
|---|---|---|
| Context 管理 | 无限增长 | ContextWindowManager |
| Token 估算 | 无 | 字符数近似估算 |
| 压缩策略 | 无 | 阈值触发 + LLM 摘要替换 |
本课代码: agents/s05_context_window.py — 210 行 (新增 42 行)
试一试
cd public/code
python agents/s05_context_window.py "帮我写一个很长的故事,然后我们继续聊"
可以尝试的提示:
- 进行多轮长对话,观察何时触发压缩
- 压缩后问 agent 之前聊了什么——看摘要质量
- 调小
max_tokens参数让压缩更容易触发
距离生产
我们的压缩是一次性的——把所有旧消息交给 LLM 总结。OpenClaw 的 compaction.ts 用了一个更精细的策略,值得仔细分析。
分块摘要。splitMessagesByTokenShare() 把旧消息按 token 配额切成多个 chunk,每个 chunk 独立摘要,最后用 MERGE_SUMMARIES_INSTRUCTIONS 合并。为什么不一次性总结?因为 LLM 在处理超长输入时,总结质量会随输入长度下降——开头和结尾的内容被记住,中间的被遗忘(lost in the middle 问题)。分块总结是对这个已知弱点的工程补偿。
安全边距。SAFETY_MARGIN = 1.2 给 token 估算留了 20% 的 buffer。因为 estimateTokens() 是客户端估算,和实际 tokenizer 的结果不完全一致。如果估算偏小,压缩后发给 API 仍然会超限报错。20% 的余量是经验值——在估算精度和 context 利用率之间取平衡。
Tool result 安全处理。stripToolResultDetails() 在压缩前先去掉 tool result 中的 details 字段。为什么?因为 tool result 可能包含用户上传的不可信内容(文件内容、命令输出),直接喂给压缩 LLM 可能造成 prompt injection——让压缩 LLM 执行意外指令。这是安全意识渗透到每个子系统的体现。
第一性原理思考:Context window 管理本质上是一个信息压缩问题——用有限的 token 预算表达无限增长的对话历史。完美的压缩是不存在的(信息论保证了这一点),所以所有策略都是在”保留什么、丢弃什么”之间做 trade-off。我们的教学版选择”摘要替换”——简单但粗暴,会丢失细节。OpenClaw 选择”分块摘要 + 合并”——更精细但也更贵(多次 LLM 调用)。一个有趣的问题是:为什么不像数据库那样用索引?因为 LLM 的”查询”是自然语言,无法预先建立有效的索引。这是 agent memory 和传统数据库的根本差异。