Phase 2 / Memory & Context / s05

Context Window

Token 计数、预算管理与压缩策略——在有限窗口里做无限的事

动机:为什么需要 Context Window 管理

LLM 的 context window 是有限的。即使是 200K token 的模型,一个长对话也会很快把它填满——特别是 tool result 往往包含大量文件内容和命令输出。

如果不管理 context window,会发生两件事:

  1. API 报错:超过最大 token 限制,请求直接失败
  2. 质量下降:即使没超限,过长的上下文会稀释关键信息,模型表现变差

OpenClaw 的 src/agents/compaction.ts 实现了完整的 context 压缩机制。它用 splitMessagesByTokenShare() 把旧消息分块,通过 generateSummary() 让 LLM 总结,然后用摘要替换原始消息。压缩后的摘要会写入工作区文件作为长期记忆。

点击”下一条消息”,观察 context window 逐步填满,到 80% 阈值时自动触发压缩:

Context Window 填充模拟
120t / 500t (24%)0 条消息 + system prompt
systemYou are a helpful assistant... (120t)
点击"下一条消息"开始
事件日志

token 条从绿色变黄色再变红色。压缩触发时,多条旧消息被替换为一行摘要,token 使用率骤降。右侧日志记录了每一步发生了什么。

也可以直接对比压缩前后的静态结果——点击”压缩前/压缩后”切换:

Context Window 使用量
665 tokens13 条消息预算 800t (83%)
system
200t
user
15t
assistant
80t
tool
30t
assistant
60t
tool
25t
assistant
40t
user
10t
assistant
90t
tool
30t
assistant
50t
tool
20t
assistant
15t

13 条消息变成 6 条,token 数减半。旧消息被一行摘要替代。代价是丢失了细节——但 LLM 能看到最近的对话,加上摘要中保留的关键决策。

Ground Truth:真实的压缩策略

Context 压缩——跨代码库对比

不同代码库如何处理 context window 溢出

pythonagent/memory.py
1class MemoryStore:
2 """两层记忆: MEMORY.md (长期事实) + HISTORY.md (可搜索日志)"""
3
4 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.messages
9 else:
10 keep_count = memory_window // 2
11 old_messages = session.messages[
12 session.last_consolidated:-keep_count
13 ]
14
15 # 让 LLM 总结旧消息
16 prompt = f"""Process this conversation:
17## Current Long-term Memory
18{current_memory or "(empty)"}
19## Conversation to Process
20{formatted_messages}"""
21
22 response = await provider.chat(
23 messages=[...],
24 tools=_SAVE_MEMORY_TOOL, # save_memory tool
25 model=model,
26 )
27 # LLM 返回 history_entry + memory_update
28 args = response.tool_calls[0].arguments
29 self.append_history(args["history_entry"])
30 self.write_long_term(args["memory_update"])
31 session.last_consolidated = len(session.messages) - keep_count
rustagent/compaction.rs
1pub struct ContextCompactor {
2 llm: Arc<dyn LlmProvider>,
3 safety: Arc<SafetyLayer>,
4}
5
6impl 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, workspace
16 ).await?,
17 CompactionStrategy::Truncate { keep_recent } =>
18 self.compact_truncate(
19 thread, keep_recent
20 ),
21 CompactionStrategy::MoveToWorkspace =>
22 self.compact_to_workspace(
23 thread, workspace
24 ).await?,
25 }
26 }
27}
28
29// 三种策略:
30// Summarize: LLM 总结旧 turns, 保留摘要
31// Truncate: 直接截断, 只保留最近 N 条
32// MoveToWorkspace: 转存到文件系统
typescriptsrc/agents/compaction.ts
1// OpenClaw context 压缩 (开源 TypeScript)
2
3const BASE_CHUNK_RATIO = 0.4;
4const MIN_CHUNK_RATIO = 0.15;
5const SAFETY_MARGIN = 1.2; // 20% buffer
6
7function estimateMessagesTokens(messages: AgentMessage[]) {
8 const safe = stripToolResultDetails(messages);
9 return safe.reduce((sum, m) => sum + estimateTokens(m), 0);
10}
11
12function splitMessagesByTokenShare(messages, parts = 2) {
13 const totalTokens = estimateMessagesTokens(messages);
14 const targetTokens = totalTokens / parts;
15 // 按 token 配额拆分为多个 chunk
16 // 每个 chunk 独立摘要,最后合并
17}
18
19// 摘要生成: 使用 LLM 的 generateSummary()
20// 摘要合并: MERGE_SUMMARIES_INSTRUCTIONS
21// "Merge partial summaries into a single
22// cohesive summary. Preserve decisions,
23// TODOs, open questions, and constraints."

关键观察:

构建: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 "帮我写一个很长的故事,然后我们继续聊"

可以尝试的提示:

  1. 进行多轮长对话,观察何时触发压缩
  2. 压缩后问 agent 之前聊了什么——看摘要质量
  3. 调小 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 和传统数据库的根本差异。