[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

4 阅读16分钟

[转][译] 从零开始构建 OpenClaw — 第一部分(智能体核心)

[转][译] 从零开始构建 OpenClaw — 第二部分(技能插件系统)

[转][译] 从零开始构建 OpenClaw — 第三部分(元技能)

[转][译] 从零开始构建 OpenClaw — 第四部分(工具循环检测)

[转][译] 从零开始构建 OpenClaw — 第五部分(对话压缩)

原文:Building Openclaw from Scratch — Part 5 (Conversation Compaction)

在本部分,我们添加了区分玩具智能体和真实智能体的功能:上下文窗口管理。~75 行 TypeScript 代码,零自定义摘要代码。

image.png

在第四部分,我们添加了工具循环检测。在本篇中,我们处理了基于 LLM 的任何智能体中最重要的资源限制:上下文窗口是有限的,你的智能体将撞上墙壁。

问题:上下文窗口作为硬资源限制

这里有一个在使用 AI 编程智能体进行实际工作时会立刻显现出来的问题:对话会不断增长。非常快。

一个典型的编程会话看起来是这样的:

Turn 1:  "Read the auth module and explain it"
         → agent reads 3 files (2,000 tokens of tool results)
Turn 2:  "Now refactor it to use JWT"
         → agent reads, edits, runs tests (8,000 tokens)
Turn 3:  "The tests are failing, fix them"
         → agent reads errors, edits 2 files, re-runs (6,000 tokens)
Turn 4:  "Add refresh token support"
         → agent reads docs, creates new file, edits 3 others (12,000 tokens)
...
Turn 15: "Now update the API docs"
         → ERROR: request_too_large

每一轮都会累积消息——用户提示词、助手响应、工具调用、工具结果。工具结果尤其占用空间:单个 bash("cat src/auth.ts") 就可能向上下文中倾倒 500+ 行内容。经过 15-20 轮的积极编程,你将耗尽整个 200K token 的上下文窗口。

这时,智能体就会崩溃。不是优雅地崩溃——它只是抛出一个 API 错误,而你的会话就结束了。

这是演示智能体和生产智能体之间的区别。演示智能体工作 5 轮。实际编码会话持续 50 轮。

为什么要压缩?内存类比

将上下文窗口视为工作内存,而不是存储。它是会议室中的白板——LLM 可以同时看到和推理的一切。当白板满时,你不能简单地贴上更多的白板。你需要擦除旧内容。

但你不能盲目擦除。如果你删除了四小时架构讨论中前一个小时的笔记,你会做出矛盾的决定。你实际做的事情是任何好的记录员都会做的:总结要点,存档细节,继续进行。

这正是对话压缩的作用。大型语言模型将旧轮次总结为精简的回顾,丢弃原始消息,并继续以摘要作为上下文。智能体“记住”发生了什么——做出的决定、修改的文件、遇到的错误——而无需携带完整的对话记录。

这里有一个重要的权衡需要理解:

                    ┌─────────────────┐
                    │  Session Length │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
    ┌─────────▼──────┐   ┌───▼────┐   ┌─────▼──────┐
    │Context Fidelity│   │  Cost  │   │  Latency   │
    └────────────────┘   └────────┘   └────────────┘
  • 更长的会话需要更多的压缩,这意味着更多的信息丢失
  • 更高的保真度意味着保留更多的消息,这意味着更早地达到限制
  • 成本随上下文大小而变化——一个 200K token 的请求比一个 20K 的请求贵 10 倍

压缩让你选择:长时间段压缩历史记录,或短时间段完美回忆。对于编码智能体,长时间段几乎总是赢家——你宁愿记住第 3 回合的模糊记忆,也不希望在第 15 回合崩溃。

“为什么不直接使用更大的上下文窗口?”这是显而易见的问题。即使使用 1M-token 模型,经济性也不会改变。1M 上下文窗口在 60 回合而不是 15 回合内填满。成本急剧增加。延迟增加。最终,你仍然会撞上墙壁。压缩不是解决小上下文窗口的权宜之计——它是如何在任何规模下管理上下文作为资源的方法。

解决方案:总结,而不是累积

修复在概念上很简单:当对话变得太长时,总结旧的部分并丢弃它们。

Before compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ Turn 1: user + assistant + tool results  │  ← old, summarizable
│ Turn 2: user + assistant + tool results  │  ← old, summarizable
│ Turn 3: user + assistant + tool results  │  ← old, summarizable
│ ...                                      │
│ Turn 14: user + assistant + tool results │  ← recent, keep
│ Turn 15: user + assistant + tool results │  ← recent, keep
└──────────────────────────────────────────┘
  Total: 195,000 tokens (at the limit!)


After compaction:
┌──────────────────────────────────────────┐
│ System prompt                            │
│ [Summary of turns 1-13]                  │  ← 500 tokens
│ Turn 14: user + assistant + tool results │  ← kept intact
│ Turn 15: user + assistant + tool results │  ← kept intact
└──────────────────────────────────────────┘
  Total: 45,000 tokens (back to comfortable)

旧消息已经消失了。取而代之的是一个简洁的摘要,它保留了智能体继续智能工作的关键决策、文件操作和上下文。

智能体甚至没有注意到。它将摘要视为先前的上下文,并继续工作。

摘要链如何工作

压缩不是一次性操作。在一个长时间会话中,它多次触发,每次压缩都基于上一次。这形成了一个摘要链:

Compaction #1 (turn 11):
  Input:  [Turn 1] [Turn 2] ... [Turn 10]
  Output: "Summary A: User refactored auth module, added JWT tokens,
           fixed 3 test failures, created refresh token endpoint."

Compaction #2 (turn 21):
  Input:  [Summary A] [Turn 11] [Turn 12] ... [Turn 20]
  Output: "Summary B: (Builds on Summary A) Also added rate limiting,
           updated API docs, migrated database schema."

Compaction #3 (turn 31):
  Input:  [Summary B] [Turn 21] [Turn 22] ... [Turn 30]
  Output: "Summary C: (Builds on Summary B) Deployed to staging,
           fixed CORS issue, added integration tests."

每个摘要都包含上一个摘要加上新消息。大型语言模型收到一个特殊提示词:“这是上一个摘要。这是从那时起的新消息。创建一个更新的摘要,涵盖所有内容。”这是分层压缩——摘要的摘要。

想象一下 git squash 的样子。你失去了单个提交,但最终结果得到了保留。第一轮询问了认证架构。到第 3 次压缩时,该决定被捕获为“用户选择了基于 JWT 的认证”——讨论过程消失了,但结果仍然存在。

信息丢失是渐进的和前重量的。最近的回合被逐字保留。较旧的回合仅以摘要形式存在。最旧的回合在摘要中被摘要——两级压缩。这反映了人类记忆的工作方式:你详细记得今天早上的事情,上周的大致情况,以及上个月的要点。

对于编码智能体来说,这通常是可以的。智能体不需要记住第 3 回合的确切错误输出。它需要知道“我们通过从会话 cookie 切换到 JWT 修复了认证错误”——决策本身,而不是调试日志。

这为什么很难(以及 SDK 为什么很重要)

概念很简单。实现方面有锋利的边缘:

  1. 你在哪里切割?你不能随意在消息边界处切割。如果你在工具调用及其结果之间切割,API 会拒绝格式错误的对话。如果你在回合中途切割(用户问了问题,助手正在回答过程中),你会丢失关键上下文。切割点必须是回合感知的。

SDK 通过逆向遍历算法解决此问题: findCutPoint() 从最新消息开始逆向遍历,累积 token 估计值。当它拥有足够的 token 以保持( keepRecentTokens ,默认 20K)时停止。关键在于,它仅在回合边界处切割——用户消息或助手消息且没有待处理的工具结果。如果切割发生在回合中间,它会检测到分割并单独总结回合前缀。

  1. 你如何总结?你需要调用 LLM 生成总结——但你已经达到上下文限制。总结请求本身可能会溢出。你需要将消息分块,独立地总结每个块,然后合并总结。

SDK 的 generateSummary() 通过 token 预算处理此问题。它为总结提示词的开销预留 token( reserveTokens ,默认 16K),然后在每次总结调用中尽可能多地适配消息。如果消息太大无法单个调用处理,它会将它们分块,总结每个块,然后将块总结合并为最终总结。之前的压缩总结(如果有)被作为上下文传递,以便新总结在此基础上构建而不是从头开始。

  1. 如果摘要失败会怎样?摘要调用本身就是一个 LLM 调用。它可能会失败(速率限制、超时、溢出)。你需要备用策略。

SDK 实现了一个三级备用链:首先,尝试完整摘要。如果失败(例如,消息太大),尝试部分摘要——排除过大的消息并将其标注为 "[Large message (~XK tokens) omitted]" 。如果仍然失败,则返回一个通用的 "Summary unavailable due to context limits" 标记。会话无论如何都会继续——降低的上下文比死会话要好。

  1. 关于恢复循环?当智能体在 session.prompt() 时遇到上下文溢出,你需要检测它、压缩并重试——可能需要多次使用逐步升级的策略。

SDK 运行一个溢出恢复循环,最多尝试 3 次:

Attempt 1: compact() → retry the prompt
Attempt 2: truncate oversized tool results → retry
Attempt 3: compact again → retry
Give up:   return "context_overflow" error to the application

每次尝试都使用不同的策略。工具结果截断尤为重要——单个 bash("find . -type f") 可能会输出 100K 个文件列表项。SDK 将任何单个工具结果限制在上下文窗口的 30%,并截断其余部分。

  1. 什么时候触发?你不想等到溢出——那是最糟糕的压缩时机(你已经处于错误状态)。你想检测到即将接近限制,并主动压缩。

SDK 使用带安全边界的 token 估计。每轮之后,它估计总上下文使用量(使用 chars / 4 启发式,这会略微高估——在这里保守是正确的)。当 contextTokens > contextWindow - reserveTokens 时触发压缩。对于 200K 模型,保留 16K,这意味着大约 184K。这种主动触发意味着智能体几乎从不达到硬溢出——它在到达墙之前就压缩了。

PI SDK 处理所有这五项。 AgentSession 类提供:

  • shouldCompact() — 主动阈值检测
  • prepareCompaction() → compact() — 完整流程
  • 带溢出恢复循环的自动压缩
  • 用于调优的可配置设置

我们的工作是将其连接起来并展示事件。引擎已经构建完成;我们正在将其连接到仪表盘。

实现细节

整个更改在一个文件中,共 75 行: entry.ts 。

1. 两个新的状态变量

let autoCompact = true;    // auto-compaction enabled by default
let lastSession: any = null; // session reference for manual /compact

为什么是 lastSession ?之前,会话是在每个提示词中创建和销毁的。但是 /compact 需要在提示词之间访问会话。所以我们保持会话活跃,并在下一个提示词开始时销毁它(或在 /new 时销毁)。

2. /compact 命令

一个命令中包含三种模式:

if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
  const arg = trimmed.slice("/compact".length).trim().toLowerCase();
  if (arg === "on") {
    autoCompact = true;
    console.log(`Auto-compaction: on`);
  } else if (arg === "off") {
    autoCompact = false;
    console.log(`Auto-compaction: off`);
  } else {
    if (!lastSession) {
      console.log(`No active session to compact. Send a message first.`);
    } else {
      console.log(`Compacting...`);
      try {
        const result = await lastSession.compact();
        console.log(`Compacted: ${result.tokensBefore} tokens summarized.`);
        const preview = result.summary.length > 200
          ? result.summary.slice(0, 200) + "..."
          : result.summary;
        console.log(`Summary: ${preview}`);
      } catch (compactErr: any) {
        console.error(`Compaction failed: ${compactErr.message}`);
      }
    }
  }
  continue;
}

用法:

  • /compact — 现在手动触发压缩
  • /compact on — 启用自动压缩(默认)
  • /compact off — 禁用自动压缩

手动压缩调用 session.compact() ,该函数:

  1. 找到一个考虑转向的切割点(保留最近的消息,总结其余部分)
  2. 调用 LLM 生成旧消息的摘要
  3. 用会话存储中的摘要替换旧消息
  4. 返回一个 CompactionResult ,包含 summary , tokensBefore 和 firstKeptEntryId

3. 自动压缩线路

在 createAgentSession() 后一行:

session.setAutoCompactionEnabled(autoCompact);

就这样。SDK 现在会在每轮之后监控上下文使用情况。当使用量超过阈值(根据模型的上下文窗口减去安全边界计算得出)时,它会自动压缩。当 API 返回上下文溢出错误时,它会压缩并重试——最多重试 3 次。

4. 压缩事件日志记录

SDK 在压缩发生时会发出事件。我们将它们连接到现有的事件订阅器:

session.subscribe((event: any) => {
  switch (event.type) {
    // Compaction events — always shown (significant lifecycle events)
    case "auto_compaction_start":
      console.error(dim(`[compaction] auto-compacting (${event.reason})...`));
      break;
    case "auto_compaction_end":
      if (event.result) {
        console.error(dim(
          `[compaction] done — ${event.result.tokensBefore} tokens summarized`
        ));
      } else if (event.aborted) {
        console.error(dim(`[compaction] aborted`));
      } else if (event.errorMessage) {
        console.error(dim(`[compaction] failed: ${event.errorMessage}`));
      }
      if (event.willRetry) {
        console.error(dim(`[compaction] will retry...`));
      }
      break;
    // ... verbose-only events (tool calls, thinking, etc.)
  }
});

请注意设计选择:压缩事件总是可见的,不受 /verbose 的限制。工具调用细节大部分时间是噪音,但压缩是一个重要的生命周期事件——这意味着智能体正在重组其内存。用户应该始终知道何时发生这种情况。

这两个事件讲述了一个完整的故事:

  • auto_compaction_start 以 reason: "threshold" (主动)或 reason: "overflow" (被动)的方式触发
  • auto_compaction_end 发送结果或错误信息,如果 SDK 将要重试,还会加上 willRetry

5. 溢出错误检测

当压缩完全失败(3 次重试尝试完毕)时,错误会冒泡到我们的 catch 块中。我们检测到它,并显示一个有用的消息,而不是原始的堆栈跟踪:

} catch (err: any) {
  const msg = err.message ?? "";
  if (/request_too_large|context.*(window|length)|prompt.*too long|request size exceeds/i.test(msg)) {
    console.error("Context overflow: conversation too large for model.");
    console.error("Try /compact to summarize history, or /new to start fresh.");
  } else {
    console.error(`Error: ${msg}`);
  }
}

正则表达式涵盖了来自不同提供者的各种错误格式——Anthropic 说“request_too_large”,OpenAI 说“context length exceeded”,Google 说“prompt is too long”,等等。

6. 会话生命周期变更

之前:

prompt → create session → run → dispose → prompt → create session → ...

现在:

prompt → dispose previous → create session → run → keep alive → prompt → ...

变更虽小但很重要:

// Before the prompt
if (lastSession) { lastSession.dispose(); lastSession = null; }
// ... create session, run prompt ...
// After the prompt (was: session.dispose())
lastSession = session;

会话保持活动状态,以便 /compact 可以访问它。清理操作会在 /new 上、在下一次提示词时或退出 REPL 时发生:

// On /new
if (lastSession) { lastSession.dispose(); lastSession = null; }
// On exit
if (lastSession) lastSession.dispose();

完整的压缩流程

长时间编码会话期间会发生以下情况:

Turn 1-10: Normal operation
  │ Messages accumulate in the session store
  │ Context usage: 20K → 40K → 80K → 120K → 150K tokens
  │
Turn 11: Context hits threshold (~160K of 200K)
  │
  ├─ SDK fires: auto_compaction_start { reason: "threshold" }
  │   └─ You see: [compaction] auto-compacting (threshold)...
  │
  ├─ SDK internally:
  │   1. findCutPoint() — walk backwards, keep ~20K recent tokens
  │   2. prepareCompaction() — extract messages to summarize
  │   3. generateSummary() — LLM call to summarize old messages
  │   4. sessionManager.appendCompaction() — persist summary
  │   5. Reload session with summary + recent messages
  │
  ├─ SDK fires: auto_compaction_end { result, willRetry: false }
  │   └─ You see: [compaction] done — 140,000 tokens summarized
  │
  │ Context usage: 150K → 35K tokens
  │
Turn 12-20: Normal operation again
  │ Context grows from 35K → 130K
  │
Turn 21: Threshold hit again → compaction fires again
  │ This time, the summary includes the PREVIOUS summary
  │ ("Summarize summaries" — hierarchical compaction)
  │
Turn 22+: Continues indefinitely

关键点:压缩会创建一系列摘要。每次压缩都会总结旧消息以及之前的摘要。这是一种分层压缩——摘要的摘要。对话可以无限运行,因为旧上下文会逐渐被压缩。

当事情出错时:

Turn N: API returns "request_too_large"
  │
  ├─ SDK detects: isLikelyContextOverflowError() = true
  │
  ├─ Attempt 1: compact() + retry prompt
  │   └─ Still overflowing? →
  │
  ├─ Attempt 2: truncate oversized tool results + retry
  │   └─ Still overflowing? →
  │
  ├─ Attempt 3: compact again + retry
  │   └─ Still overflowing? →
  │
  └─ Give up: "Context overflow: conversation too large for model."
             "Try /compact to summarize history, or /new to start fresh."

三次重试,策略越来越激进。如果还不够,用户会收到一条清晰的带有可操作选项的消息——而不是原始的 API 错误。

变更内容:差异

src/entry.ts | 75 insertions(+), 9 deletions(-)
 1 file changed

没有新文件。没有新的依赖项。75 行用于支持任意长度的编码会话的连接代码。

以下是分解说明:

改变行目的状态变量 2 autoCompact , lastSession/compact 命令 26 手动压缩 + 开关切换压缩事件 16 始终开启的生命周期日志自动压缩连接 1 session.setAutoCompactionEnabled(autoCompact) 会话生命周期 6 在提示词之间保持活动状态 /compact 溢出错误处理 8 检测溢出,显示有帮助的消息横幅 + 状态 3 显示 /compact 和自动压缩状态

/compact 命令被添加到启动横幅中, /status 现在显示自动压缩状态。

为什么理解这一点比构建它更重要

在大多数教程中,75 行的接线代码不值得用一整篇文章来讲解。但对话压缩则不同,因为概念比代码更重要。

你应该记住以下几点:

  1. 上下文窗口是一个硬资源限制,而不是软限制。它不像 RAM 那样你可以获得交换空间。当你超出它时,API 会拒绝你的请求。就这样。每个严肃的智能体都必须像嵌入式系统对待内存字节一样对待上下文标记——将其视为一个需要预算的有限资源。

  2. 摘要是有损压缩。当你压缩时,你会丢失细节。智能体不会记住第 3 回合的确切错误消息或它在第 7 回合中编辑的具体行号。它会记住它做了什么以及为什么,但不会记住每一个细节。这是一个基本的权衡:对话长度与上下文保真度。

  3. 恢复循环使其达到生产级标准。任何智能体在被要求时都可以进行压缩。区别在于自动检测、使用升级策略重试以及在所有其他方法都失败时进行优雅降级。溢出→压缩→重试→截断→重试→放弃的链式操作是将演示与可用于 8 小时编码会话的东西区分开来的关键。

  4. SDK 级别的关注与应用程序级别的关注。压缩涉及标记估计、上下文感知的切割点、基于 LLM 的摘要、会话存储变异和错误分类。这些都是引擎层面的关注点。我们的应用程序级别的任务是:连接这些组件、展示事件、赋予用户控制权。这种区分——知道什么需要构建与什么需要委托——是智能体开发中的关键技能。

底层的压缩设置

SDK 的压缩行为由三个设置控制(可通过 SettingsManager 进行配置):

interface CompactionSettings {
  enabled: boolean;        // Master switch (default: true)
  reserveTokens: number;   // Reserved for system prompt + overhead (default: 16,384)
  keepRecentTokens: number; // Recent messages to preserve (default: 20,000)
}

算法:

  1. 每轮之后,估计总上下文令牌
  2. 如果 contextTokens > contextWindow - reserveTokens :触发压缩
  3. 从最新消息开始向后遍历,累积令牌估计
  4. 停止当累积 >= keepRecentTokens — 那是切割点
  5. 在切割点之前的内容将被总结
  6. 摘要替换会话存储中的旧消息

默认值对于大多数模型来说是合理的。对于一个 200K 的上下文窗口:

  • 为系统提示词、工具定义和开销预留 16K
  • 保留最近的 2 万条消息
  • 当总数超过~184K 时进行压缩
  • 压缩后,上下文降至约 20K + 摘要(~1-2K)

这为你提供了约 160K 的额外空间,直到下一次压缩——大约有 40-80 轮更多的主动编码空间。

下一步是什么

通过压缩,openclaw-mini 可以处理任意长度的会话。我们从 5 轮的演示转变到了一个可以投入生产的智能体——工具、技能、自我扩展、安全性,现在还有无限内存。

openclaw-mini 的完整源代码可以在 GitHub 上找到。