对话历史越来越长,OpenClaw 是怎么「压缩」掉的?——深读 Compaction 机制源码

6 阅读13分钟

本文约 6200 字,结合源码逐层讲解 OpenClaw 的上下文压缩系统,适合对 LLM 应用架构感兴趣的开发者。

从一个真实问题说起

如果你用过 OpenClaw 做过比较长的任务——比如让它帮你重构一批文件、查 bug、或者持续协作几十轮——你可能注意到它某一时刻会「停顿」一下,然后继续响应,但回复里会出现类似「根据之前的工作记录……」这样的措辞。

这不是网络抖动,也不是模型犯迷糊。这是 compaction 在工作:系统检测到对话历史快撑满模型的 context window 了,在你发下一条消息之前,悄悄把历史对话压缩成了一段结构化摘要。

这件事听起来简单,实际做起来有相当多的细节要处理。今天我们就把 compaction 这套机制的源码从头到尾翻一遍,看看它到底做了什么,以及为什么要这么做。


先建立一个基本认知:为什么需要 compaction?

LLM 的输入是有长度上限的,这个上限就是 context window(上下文窗口)。Claude 3 是 200K tokens,GPT-4o 是 128K tokens,Gemini 2.5 Pro 是 1M tokens……每个模型不一样。

每次你发一条消息,OpenClaw 都要把完整的对话历史(所有历史 user + assistant 消息)加上 system prompt,一起打包发给 LLM。随着对话轮数增加,这个包越来越大。当它超过 context window 时,请求直接报错——模型拒绝处理。

所以必须有一个机制,在快撑满之前把历史「消化」掉。

最直接的方案是丢掉老消息,但丢消息会丢失上下文——任务进行到一半,突然不记得之前做了什么,就会出问题。

OpenClaw 的方案是:不丢消息,而是把消息摘要化——用 LLM 把一段对话历史压缩成一段结构化文本,这段文本保留了关键信息,但体积比原始对话小得多。压缩完成后,原始历史消息被这段摘要替换,腾出空间继续对话。

听起来合理,但魔鬼在细节里。

整体架构:三个模块分工明确

先看文件布局:

src/agents/
├── compaction.ts                              # 核心算法层:token 估算、分块、摘要调用
├── pi-extensions/
│   ├── compaction-safeguard.ts               # 事件驱动层:触发时机、质量校验、安全策略
│   └── compaction-instructions.ts            # 指令管理:摘要提示词的生成与优先级
└── pi-embedded-runner/run/
    ├── compaction-timeout.ts                 # 超时处理:压缩本身超时的回退策略
    └── compaction-retry-aggregate-timeout.ts # 聚合超时:防止重试无限等待

分工

  • compaction.ts 是纯算法层,不关心何时触发,只管怎么切分消息、怎么调用摘要 API。
  • compaction-safeguard.ts 是事件处理层,监听 session_before_compact 事件,做触发判断、质量检查、安全策略。
  • compaction-instructions.ts 管理摘要时给 LLM 的指令。
  • 两个 timeout 文件处理压缩过程本身的超时保护。

第一层:核心算法(compaction.ts)

Token 估算

一切的起点是估算消息有多少 token。因为 LLM 的 tokenizer 是各家私有的,没有统一 API,OpenClaw 用了一个经典的粗估方案:

// 调用的是 pi-coding-agent 的 estimateTokens
export function estimateMessagesTokens(messages: AgentMessage[]): number {
  // SECURITY: toolResult.details 可能含敏感/冗长数据,压缩时永不包含
  const safe = stripToolResultDetails(messages);
  return safe.reduce((sum, message) => sum + estimateTokens(message), 0);
}

注释里那句 SECURITY 值得注意:工具调用的 details 字段可能包含文件内容、API 响应等敏感信息,在送进摘要 LLM 之前会被剥离掉。安全意识很扎实。

底层的 estimateTokens 用的是「字符数 / 4」的粗估法,对英文比较准,对中文/代码会偏低。为了补偿这个误差,代码里到处都用了一个修正系数:

export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy

凡是需要把估算值和阈值比较的地方,都先乘以 1.2 再比,相当于把「估算出来的 token 数」当作比实际小 20% 来处理,宁可误判触发,也不要漏判溢出。

消息分块:两种策略

摘要 API 本身也有 context window 限制,如果历史消息太长,一次性全塞进去会超限。所以需要先把消息切成块,逐块摘要,再把多个块的摘要合并。

策略一:按 Token 上限切块chunkMessagesByMaxTokens

export function chunkMessagesByMaxTokens(
  messages: AgentMessage[],
  maxTokens: number,
): AgentMessage[][] {
  const effectiveMax = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN));
  // 按 effectiveMax 逐条累积,超了就新开一个块
  // ...
}

这是最基础的切法:给一个 token 上限,从头到尾累积,超了就截断。注意 maxTokens / SAFETY_MARGIN 这里又除以了安全系数,进一步保守。

策略二:按 Token 份额切块splitMessagesByTokenShare

export function splitMessagesByTokenShare(
  messages: AgentMessage[],
  parts = DEFAULT_PARTS, // 默认 2 份
): AgentMessage[][] {
  const totalTokens = estimateMessagesTokens(messages);
  const targetTokens = totalTokens / normalizedParts;
  // 按目标大小均匀分割,但保证消息完整性(不在消息中间切)
}

这种切法用于「我想把历史均匀分成 N 份」的场景,比如大段历史需要分段摘要再合并时用这个。

自适应 chunk ratio

一个比较精巧的设计:chunk 的大小不是固定的,而是根据消息的平均大小动态调整。

export const BASE_CHUNK_RATIO = 0.4;  // 默认用 40% context window
export const MIN_CHUNK_RATIO = 0.15; // 最低不少于 15%

export function computeAdaptiveChunkRatio(
  messages: AgentMessage[],
  contextWindow: number,
): number {
  const totalTokens = estimateMessagesTokens(messages);
  const avgTokens = totalTokens / messages.length;
  const safeAvgTokens = avgTokens * SAFETY_MARGIN;
  const avgRatio = safeAvgTokens / contextWindow;

  if (avgRatio > 0.1) {
    // 单条消息平均超过 context 的 10%,说明消息普遍偏大,缩小 chunk 比例
    const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
    return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
  }

  return BASE_CHUNK_RATIO;
}

逻辑是:如果对话里的消息普遍比较大(比如充斥着大段代码),每个 chunk 能装的消息数就少,需要缩小 chunk 的 token 比例,否则每个 chunk 可能只有一两条消息,摘要质量下降。

摘要调用:三层 fallback

summarizeChunks 是实际调用摘要 API 的函数,但它上层还包了两层保险:

第一层:  summarizeWithFallback — 完整摘要失败时,跳过单条超大消息,只摘要小消息,超大消息留个占位符:

// 单条消息 > context window 50%,认为太大无法摘要
export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
  const tokens = estimateCompactionMessageTokens(msg) * SAFETY_MARGIN;
  return tokens > contextWindow * 0.5;
}

// fallback: 跳过超大消息
for (const msg of messages) {
  if (isOversizedForSummary(msg, contextWindow)) {
    oversizedNotes.push(
      `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
    );
  } else {
    smallMessages.push(msg);
  }
}

第二层:  如果连跳过超大消息的摘要也失败,返回一段固定文本:

return (
  `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
  `Summary unavailable due to size limits.`
);

第三层:  summarizeInStages — 把消息先分成若干份,分别摘要,再把多个局部摘要合并成一个:

// 分段摘要
for (const chunk of splits) {
  partialSummaries.push(await summarizeWithFallback({ ...params, messages: chunk }));
}

// 把多个局部摘要当作「消息」再次摘要合并
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
  role: "user",
  content: summary,
  timestamp: Date.now(),
}));

return summarizeWithFallback({
  ...params,
  messages: summaryMessages,
  customInstructions: MERGE_SUMMARIES_INSTRUCTIONS,
});

合并摘要时用了专门的合并指令:

const MERGE_SUMMARIES_INSTRUCTIONS = [
  "Merge these partial summaries into a single cohesive summary.",
  "",
  "MUST PRESERVE:",
  "- Active tasks and their current status (in-progress, blocked, pending)",
  "- Batch operation progress (e.g., '5/17 items completed')",
  "- The last thing the user requested and what was being done about it",
  "- Decisions made and their rationale",
  "- TODOs, open questions, and constraints",
  "- Any commitments or follow-ups promised",
  "",
  "PRIORITIZE recent context over older history.",
].join("\n");

这个指令设计得很有经验:最重要的不是「完整记录所有历史」,而是「记住当前在做什么、做到哪了、有什么待办」。对话助手场景里,任务状态比历史事件更重要。


第二层:事件处理与安全策略(compaction-safeguard.ts)

这个文件有 1000 多行,是整个 compaction 系统里最复杂的部分。它通过 Pi agent 的扩展 API 注册了一个 session_before_compact 事件监听器——当 Pi agent 判断需要压缩时,会触发这个事件,compaction-safeguard 接管具体的压缩过程。

最终摘要的结构:五个强制段落

摘要不是自由文本,而是有固定结构的:

const REQUIRED_SUMMARY_SECTIONS = [
  "## Decisions",        // 已做的决策
  "## Open TODOs",       // 待办事项
  "## Constraints/Rules",// 约束条件
  "## Pending user asks",// 用户未完成的请求
  "## Exact identifiers",// 重要的精确标识符(UUID、路径、端口等)
] as const;

为什么要强制结构?因为无结构的自由摘要,模型很容易写成「流水账」——把发生过的事情都列出来,但丢失了「现在最重要的是什么」的信息。结构化强制模型思考:「哪些决策影响后续行动」「有哪些还没做完的事」「有哪些精确值不能记错」。

标识符保护:一个容易被忽视的坑

摘要里有一个专门的 ## Exact identifiers 段落,专门存储 UUID、哈希、URL、文件路径、端口号这类精确值。

为什么要单独处理这些?

因为 LLM 在做摘要时,会对长字符串做「重建」——它知道这是个哈希值,但可能在输出时悄悄改掉几个字母,生成一个「看起来像」但实际不一样的值。对于普通文字,这无所谓;但对于一个 git commit SHA 或者一个 UUID,一个字母之差就完全是另一个东西。

const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
  "Preserve all opaque identifiers exactly as written (no shortening or reconstruction), " +
  "including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.";

系统还会自动从待压缩消息里提取这些标识符:

function sanitizeExtractedIdentifier(value: string): string {
  return value
    .trim()
    .replace(/^[("'`[{<]+/, "")
    .replace(/[)\]"'`,;:.!?<>]+$/, "");
}
function isPureHexIdentifier(value: string): boolean {
  return /^[A-Fa-f0-9]{8,}$/.test(value);
}

function normalizeOpaqueIdentifier(value: string): string {
  return isPureHexIdentifier(value) ? value.toUpperCase() : value;
}
function extractOpaqueIdentifiers(text: string): string[] {
  const matches =
    text.match(
      /([A-Fa-f0-9]{8,}|https?:\/\/\S+|\/[\w.-]{2,}(?:\/[\w.-]+)+|[A-Za-z]:\\[\w\\.-]+|[A-Za-z0-9._-]+\.[A-Za-z0-9._/-]+:\d{1,5}|\b\d{6,}\b)/g,
    ) ?? [];
  return Array.from(
    new Set(
      matches
        .map((value) => sanitizeExtractedIdentifier(value))
        .map((value) => normalizeOpaqueIdentifier(value))
        .filter((value) => value.length >= 4),
    ),
  ).slice(0, MAX_EXTRACTED_IDENTIFIERS);
}

然后在质量校验时,逐一检查这些标识符是否出现在摘要里:

if (enforceIdentifiers) {
  const missingIdentifiers = params.identifiers.filter(
    (id) => !summaryIncludesIdentifier(params.summary, id),
  );
  if (missingIdentifiers.length > 0) {
    reasons.push(`missing_identifiers:${missingIdentifiers.slice(0, 3).join(",")}`);
  }
}

检验不通过,可以触发重试(quality guard 机制,下面讲)。

最近轮次保留:不压缩最新内容

有一个非常实际的设计:最近 N 轮对话不参与摘要,原文保留

const DEFAULT_RECENT_TURNS_PRESERVE = 3;
const MAX_RECENT_TURNS_PRESERVE = 12;

为什么?因为最近的对话是「当下的上下文」——用户刚说了什么、模型刚回了什么,如果把这些也压缩成摘要,模型接下来的回复就会缺少即时感,像是在讲述一段历史而不是继续一个对话。

具体实现:

function clampNonNegativeInt(value: unknown, fallback: number): number {
  const normalized = typeof value === "number" && Number.isFinite(value) ? value : fallback;
  return Math.max(0, Math.floor(normalized));
}
export function extractToolCallsFromAssistant(
  msg: Extract<AgentMessage, { role: "assistant" }>,
): ToolCallLike[] {
  const content = msg.content;
  if (!Array.isArray(content)) {
    return [];
  }

  const toolCalls: ToolCallLike[] = [];
  for (const block of content) {
    if (!block || typeof block !== "object") {
      continue;
    }
    const rec = block as { type?: unknown; id?: unknown; name?: unknown };
    if (typeof rec.id !== "string" || !rec.id) {
      continue;
    }
    if (typeof rec.type === "string" && TOOL_CALL_TYPES.has(rec.type)) {
      toolCalls.push({
        id: rec.id,
        name: typeof rec.name === "string" ? rec.name : undefined,
      });
    }
  }
  return toolCalls;
}
export function extractToolResultId(
  msg: Extract<AgentMessage, { role: "toolResult" }>,
): string | null {
  const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
  if (typeof toolCallId === "string" && toolCallId) {
    return toolCallId;
  }
  const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
  if (typeof toolUseId === "string" && toolUseId) {
    return toolUseId;
  }
  return null;
}
// src/agents/session-transcript-repair.ts
export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport {
  // Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
  // immediately followed by matching tool results. Session files can end up with results
  // displaced (e.g. after user turns) or duplicated. Repair by:
  // - moving matching toolResult messages directly after their assistant toolCall turn
  // - inserting synthetic error toolResults for missing ids
  // - dropping duplicate toolResults for the same id (anywhere in the transcript)
  const out: AgentMessage[] = [];
  const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
  const seenToolResultIds = new Set<string>();
  let droppedDuplicateCount = 0;
  let droppedOrphanCount = 0;
  let moved = false;
  let changed = false;

  const pushToolResult = (msg: Extract<AgentMessage, { role: "toolResult" }>) => {
    const id = extractToolResultId(msg);
    if (id && seenToolResultIds.has(id)) {
      droppedDuplicateCount += 1;
      changed = true;
      return;
    }
    if (id) {
      seenToolResultIds.add(id);
    }
    out.push(msg);
  };

  for (let i = 0; i < messages.length; i += 1) {
    const msg = messages[i];
    if (!msg || typeof msg !== "object") {
      out.push(msg);
      continue;
    }

    const role = (msg as { role?: unknown }).role;
    if (role !== "assistant") {
      // Tool results must only appear directly after the matching assistant tool call turn.
      // Any "free-floating" toolResult entries in session history can make strict providers
      // (Anthropic-compatible APIs, MiniMax, Cloud Code Assist) reject the entire request.
      if (role !== "toolResult") {
        out.push(msg);
      } else {
        droppedOrphanCount += 1;
        changed = true;
      }
      continue;
    }

    const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;

    // Skip tool call extraction for aborted or errored assistant messages.
    // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
    // (e.g., partialJson: true) and should not have synthetic tool_results created.
    // Creating synthetic results for incomplete tool calls causes API 400 errors:
    // "unexpected tool_use_id found in tool_result blocks"
    // See: https://github.com/openclaw/openclaw/issues/4597
    const stopReason = (assistant as { stopReason?: string }).stopReason;
    if (stopReason === "error" || stopReason === "aborted") {
      out.push(msg);
      continue;
    }

    const toolCalls = extractToolCallsFromAssistant(assistant);
    if (toolCalls.length === 0) {
      out.push(msg);
      continue;
    }

    const toolCallIds = new Set(toolCalls.map((t) => t.id));
    const toolCallNamesById = new Map(toolCalls.map((t) => [t.id, t.name] as const));

    const spanResultsById = new Map<string, Extract<AgentMessage, { role: "toolResult" }>>();
    const remainder: AgentMessage[] = [];

    let j = i + 1;
    for (; j < messages.length; j += 1) {
      const next = messages[j];
      if (!next || typeof next !== "object") {
        remainder.push(next);
        continue;
      }

      const nextRole = (next as { role?: unknown }).role;
      if (nextRole === "assistant") {
        break;
      }

      if (nextRole === "toolResult") {
        const toolResult = next as Extract<AgentMessage, { role: "toolResult" }>;
        const id = extractToolResultId(toolResult);
        if (id && toolCallIds.has(id)) {
          if (seenToolResultIds.has(id)) {
            droppedDuplicateCount += 1;
            changed = true;
            continue;
          }
          const normalizedToolResult = normalizeToolResultName(
            toolResult,
            toolCallNamesById.get(id),
          );
          if (normalizedToolResult !== toolResult) {
            changed = true;
          }
          if (!spanResultsById.has(id)) {
            spanResultsById.set(id, normalizedToolResult);
          }
          continue;
        }
      }

      // Drop tool results that don't match the current assistant tool calls.
      if (nextRole !== "toolResult") {
        remainder.push(next);
      } else {
        droppedOrphanCount += 1;
        changed = true;
      }
    }

    out.push(msg);

    if (spanResultsById.size > 0 && remainder.length > 0) {
      moved = true;
      changed = true;
    }

    for (const call of toolCalls) {
      const existing = spanResultsById.get(call.id);
      if (existing) {
        pushToolResult(existing);
      } else {
        const missing = makeMissingToolResult({
          toolCallId: call.id,
          toolName: call.name,
        });
        added.push(missing);
        changed = true;
        pushToolResult(missing);
      }
    }

    for (const rem of remainder) {
      if (!rem || typeof rem !== "object") {
        out.push(rem);
        continue;
      }
      out.push(rem);
    }
    i = j - 1;
  }

  const changedOrMoved = changed || moved;
  return {
    messages: changedOrMoved ? out : messages,
    added,
    droppedDuplicateCount,
    droppedOrphanCount,
    moved: changedOrMoved,
  };
}

function splitPreservedRecentTurns(params: {
  messages: AgentMessage[];
  recentTurnsPreserve: number;
}): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } {

  // 找到最后 recentTurnsPreserve 个 user 消息的起始位置
  // 从那个位置之后的所有消息标记为「保留」
  // 保留的助手消息对应的工具调用结果也一起保留(避免 tool_use/tool_result 配对断裂)

  const preserveTurns = Math.min(
    MAX_RECENT_TURNS_PRESERVE,
    clampNonNegativeInt(params.recentTurnsPreserve, 0),
  );
  if (preserveTurns <= 0) {
    return { summarizableMessages: params.messages, preservedMessages: [] };
  }
  const conversationIndexes: number[] = [];
  const userIndexes: number[] = [];
  for (let i = 0; i < params.messages.length; i += 1) {
    const role = (params.messages[i] as { role?: unknown }).role;
    if (role === "user" || role === "assistant") {
      conversationIndexes.push(i);
      if (role === "user") {
        userIndexes.push(i);
      }
    }
  }
  if (conversationIndexes.length === 0) {
    return { summarizableMessages: params.messages, preservedMessages: [] };
  }

  const preservedIndexSet = new Set<number>();
  if (userIndexes.length >= preserveTurns) {
    const boundaryStartIndex = userIndexes[userIndexes.length - preserveTurns] ?? -1;
    if (boundaryStartIndex >= 0) {
      for (const index of conversationIndexes) {
        if (index >= boundaryStartIndex) {
          preservedIndexSet.add(index);
        }
      }
    }
  } else {
    const fallbackMessageCount = preserveTurns * 2;
    for (const userIndex of userIndexes) {
      preservedIndexSet.add(userIndex);
    }
    for (let i = conversationIndexes.length - 1; i >= 0; i -= 1) {
      const index = conversationIndexes[i];
      if (index === undefined) {
        continue;
      }
      preservedIndexSet.add(index);
      if (preservedIndexSet.size >= fallbackMessageCount) {
        break;
      }
    }
  }
  if (preservedIndexSet.size === 0) {
    return { summarizableMessages: params.messages, preservedMessages: [] };
  }
  const preservedToolCallIds = new Set<string>();
  for (let i = 0; i < params.messages.length; i += 1) {
    if (!preservedIndexSet.has(i)) {
      continue;
    }
    const message = params.messages[i];
    const role = (message as { role?: unknown }).role;
    if (role !== "assistant") {
      continue;
    }
    const toolCalls = extractToolCallsFromAssistant(
      message as Extract<AgentMessage, { role: "assistant" }>,
    );
    for (const toolCall of toolCalls) {
      preservedToolCallIds.add(toolCall.id);
    }
  }
  if (preservedToolCallIds.size > 0) {
    let preservedStartIndex = -1;
    for (let i = 0; i < params.messages.length; i += 1) {
      if (preservedIndexSet.has(i)) {
        preservedStartIndex = i;
        break;
      }
    }
    if (preservedStartIndex >= 0) {
      for (let i = preservedStartIndex; i < params.messages.length; i += 1) {
        const message = params.messages[i];
        if ((message as { role?: unknown }).role !== "toolResult") {
          continue;
        }
        const toolResultId = extractToolResultId(
          message as Extract<AgentMessage, { role: "toolResult" }>,
        );
        if (toolResultId && preservedToolCallIds.has(toolResultId)) {
          preservedIndexSet.add(i);
        }
      }
    }
  }
  const summarizableMessages = params.messages.filter((_, idx) => !preservedIndexSet.has(idx));
  // Preserving recent assistant turns can orphan downstream toolResult messages.
  // Repair pairings here so compaction summarization doesn't trip strict providers.
  const repairedSummarizableMessages = repairToolUseResultPairing(summarizableMessages).messages;
  const preservedMessages = params.messages
    .filter((_, idx) => preservedIndexSet.has(idx))
    .filter((msg) => {
      const role = (msg as { role?: unknown }).role;
      return role === "user" || role === "assistant" || role === "toolResult";
    });
  return { summarizableMessages: repairedSummarizableMessages, preservedMessages };
}

处理工具调用配对的逻辑尤其细:如果一个 assistant 消息里有工具调用,它对应的 tool_result 消息也必须跟着保留,否则会产生「孤儿 tool_result」,严格的 LLM 提供商(比如 Anthropic)会直接报 API 错误。

质量检查与重试(quality guard)

压缩完成后,可以选择开启质量检查:

const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false;
const DEFAULT_QUALITY_GUARD_MAX_RETRIES = 1;
const MAX_QUALITY_GUARD_MAX_RETRIES = 3;

质量检查做三件事:

  1. 结构检查:五个强制段落是否都在
  2. 标识符检查:提取出来的精确标识符是否都出现在摘要里
  3. 最新请求覆盖检查:用户最后一个问题的关键词是否在摘要里有体现
function hasAskOverlap(summary: string, latestAsk: string | null): boolean {
  // 从最后一个用户消息提取关键词(去停用词)
  // 检查这些关键词是否出现在摘要里
  // 如果关键词 >= 3 个,至少要有 2 个匹配;否则 1 个就行
  if (!latestAsk) {
    return true;
  }
  const askTokens = Array.from(new Set(tokenizeAskOverlapText(latestAsk))).slice(
    0,
    MAX_ASK_OVERLAP_TOKENS,
  );
  if (askTokens.length === 0) {
    return true;
  }
  const meaningfulAskTokens = askTokens.filter((token) => {
    if (token.length <= 1) {
      return false;
    }
    if (isQueryStopWordToken(token)) {
      return false;
    }
    return true;
  });
  const tokensToCheck = meaningfulAskTokens.length > 0 ? meaningfulAskTokens : askTokens;
  if (tokensToCheck.length === 0) {
    return true;
  }
  const summaryTokens = new Set(tokenizeAskOverlapText(summary));
  let overlapCount = 0;
  for (const token of tokensToCheck) {
    if (summaryTokens.has(token)) {
      overlapCount += 1;
    }
  }
  const requiredMatches = tokensToCheck.length >= MIN_ASK_OVERLAP_TOKENS_FOR_DOUBLE_MATCH ? 2 : 1;
  return overlapCount >= requiredMatches;
}

检查不通过时,把失败原因作为反馈重新让 LLM 生成摘要:

const qualityFeedbackInstruction =
  "Fix all issues and include every required section with exact identifiers preserved.";
const qualityFeedbackReasons = wrapUntrustedInstructionBlock(
  "Quality check feedback",
  `Previous summary failed quality checks (${reasons}).`,
);
currentInstructions = `${structuredInstructions}\n\n${qualityFeedbackInstruction}\n\n${qualityFeedbackReasons}`;

这里有个细节:用户提供的自定义指令和质量反馈都被 wrapUntrustedPromptDataBlock 包裹——防止 prompt injection(用户在自定义指令里藏了「忽略之前所有指令」之类的内容)。

大消息的「历史修剪」策略

当一次新的消息(比如粘贴了大段代码或者工具返回了大量内容)本身就已经占了 context 很大比例,标准的摘要可能还不够用——摘要之后加上这条新消息,依然塞不进去。

这种情况下,系统会主动修剪历史:

const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
// maxHistoryShare 默认 0.5,即历史最多占 context 的 50%

if (newContentTokens > maxHistoryTokens) {
  const pruned = pruneHistoryForContextShare({
    messages: messagesToSummarize,
    maxContextTokens: contextWindowTokens,
    maxHistoryShare,
    parts: 2,
  });
  // 被删掉的部分,单独摘要一次,作为 previousSummary 传给后续摘要
}

pruneHistoryForContextShare 的做法是把消息按 token 均分成两半,扔掉旧的那半,但被扔掉的那半不是真的丢掉——会先把它摘要一次,作为「前情提要」传给接下来的摘要步骤,这样整个链条上的信息损失是最小的。

压缩完的摘要还附加了什么

最终摘要输出前,还会追加两块内容:

1. 工具调用失败记录

function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
  // 从 toolResult 消息里找 isError === true 的
  // 每条: toolName、错误摘要(最多 240 字符)、exitCode/status
  const failures: ToolFailure[] = [];
  const seen = new Set<string>();

  for (const message of messages) {
    if (!message || typeof message !== "object") {
      continue;
    }
    const role = (message as { role?: unknown }).role;
    if (role !== "toolResult") {
      continue;
    }
    const toolResult = message as {
      toolCallId?: unknown;
      toolName?: unknown;
      content?: unknown;
      details?: unknown;
      isError?: unknown;
    };
    if (toolResult.isError !== true) {
      continue;
    }
    const toolCallId = typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : "";
    if (!toolCallId || seen.has(toolCallId)) {
      continue;
    }
    seen.add(toolCallId);

    const toolName =
      typeof toolResult.toolName === "string" && toolResult.toolName.trim()
        ? toolResult.toolName
        : "tool";
    const rawText = extractToolResultText(toolResult.content);
    const meta = formatToolFailureMeta(toolResult.details);
    const normalized = normalizeFailureText(rawText);
    const summary = truncateFailureText(
      normalized || (meta ? "failed" : "failed (no output)"),
      MAX_TOOL_FAILURE_CHARS,
    );
    failures.push({ toolCallId, toolName, summary, meta });
  }

  return failures;
}

// 最多记录 8 条失败 src/agents/pi-extensions/compaction-safeguard.ts
const MAX_TOOL_FAILURES = 8;
function formatToolFailuresSection(failures: ToolFailure[]): string {
  if (failures.length === 0) {
    return "";
  }
  const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => {
    const meta = failure.meta ? ` (${failure.meta})` : "";
    return `- ${failure.toolName}${meta}: ${failure.summary}`;
  });
  if (failures.length > MAX_TOOL_FAILURES) {
    lines.push(`- ...and ${failures.length - MAX_TOOL_FAILURES} more`);
  }
  return `\n\n## Tool Failures\n${lines.join("\n")}`;
}

为什么压缩时特别记工具失败?因为工具失败往往是调试线索——「之前试过 X 方案但报了 ENOENT 错误」这类信息,在下一轮对话里可能仍然有参考价值。

2. 文件操作记录

function computeFileLists(fileOps: FileOperations): {
  readFiles: string[];
  modifiedFiles: string[];
} {
  const modified = new Set([...fileOps.edited, ...fileOps.written]);
  const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).toSorted();
  const modifiedFiles = [...modified].toSorted();
}

读过哪些文件、修改过哪些文件,以 XML 格式附在摘要末尾。这让后续对话中模型知道「这个文件之前已经看过了/改过了」,避免重复劳动。

3. 工作区关键规则

// 从 AGENTS.md 里提取 "Session Startup" 和 "Red Lines" 两个段落
// 限制 2000 字符
const workspaceContext = await readWorkspaceContextForSummary();

每次压缩时,都会重新读一下 AGENTS.md 里的关键约束(会话启动规则和红线规则),附在摘要里。这是一个很聪明的设计——即使对话历史被压缩了,核心的行为规范依然会持续注入,不会因为「那条规则只在开始时提过一次」而被忘掉。


第三层:超时保护

压缩本身是一次 LLM API 调用,本身也可能失败或超时。这里有两个专门处理超时的文件。

压缩超时的状态管理(compaction-timeout.ts)

// src/agents/pi-embedded-runner/run/compaction-timeout.ts
export function selectCompactionTimeoutSnapshot(
  params: SnapshotSelectionParams,
): SnapshotSelection {
  if (!params.timedOutDuringCompaction) {
    return { messagesSnapshot: params.currentSnapshot, source: "current" };
  }

  if (params.preCompactionSnapshot) {
    // 压缩超时时,回退到压缩前的消息快照
    return { messagesSnapshot: params.preCompactionSnapshot, source: "pre-compaction" };
  }

  return { messagesSnapshot: params.currentSnapshot, source: "current" };
}

压缩开始前,系统会保存一个「压缩前快照」。如果压缩超时,回退到这个快照继续,而不是使用半成品的压缩结果——宁可 context 稍微长一点,也不要用一个不完整的摘要。

聚合超时(compaction-retry-aggregate-timeout.ts)

压缩失败后会重试,但重试本身也可能一直挂着。这个文件处理的是「等待重试完成」的超时:

export async function waitForCompactionRetryWithAggregateTimeout(params: {
  waitForCompactionRetry: () => Promise<void>;
  aggregateTimeoutMs: number;
  isCompactionStillInFlight?: () => boolean;
}) {
  while (true) {
    const result = await Promise.race([waitPromise, timeoutPromise]);
    if (result === "done") break;

    // 如果压缩还在飞行中(API 还没超时),继续等
    if (params.isCompactionStillInFlight?.()) continue;

    timedOut = true;
    params.onTimeout?.();
    break;
  }
}

关键逻辑:只要压缩 API 请求还在跑(isCompactionStillInFlight 返回 true),就不断延长等待窗口。只有「请求已经结束但结果还没处理完」这种异常状态,才触发聚合超时。避免了「API 明明还在跑,结果被 timeout 强行打断」的问题。


把所有东西串起来

整个流程是这样的:

对话继续...
    ↓
Pi agent 检测到 context 快满了
    ↓
触发 session_before_compact 事件
    ↓
compaction-safeguard.ts 接管
    ↓
1. 保留最近 3 轮(原文)
2. 剩余历史 → splitPreservedRecentTurns 分离
3. 如果新内容太大 → pruneHistoryForContextShare 先修剪历史
4. summarizeInStages 分块摘要 → 合并
5. 追加:工具失败记录 + 文件操作记录 + AGENTS.md 关键规则
6. quality guard 检查,不通过则重试(最多 3 次)
7. 返回结构化摘要
    ↓
Pi agent 用摘要替换旧历史消息
    ↓
对话继续,context 重新宽裕

如果任何一步失败:

失败
  ↓
return { cancel: true }  ← 取消压缩,保留原始历史,宁可 context 长一点

这个「宁可不压缩也不用烂摘要」的保守策略,贯穿了整个 compaction 设计。


几个设计细节值得单独说

1. 摘要使用同一个模型

压缩时调用的是当前会话使用的同一个 LLM,而不是单独配置一个「便宜的摘要模型」。好处是不用维护双重配置,坏处是压缩本身也会消耗 token,成本不低。

2. 摘要指令的优先级链

事件携带的指令(SDK 级)
  > runtime 配置的指令(config 级)
  > DEFAULT_COMPACTION_INSTRUCTIONS(默认)

DEFAULT_COMPACTION_INSTRUCTIONS 里有一条有意思的规定:

"Write the summary body in the primary language used in the conversation."

对话用中文,摘要就用中文;对话用英文,摘要就用英文。这避免了「对话是中文,但内部摘要是英文,下一轮对话时模型突然切换语言风格」的奇怪问题。 3. 自定义指令的字符上限

const MAX_INSTRUCTION_LENGTH = 800; // ~200 tokens

用户可以自定义摘要指令,但超过 800 字符会被截断。注释说明:过长的自定义指令会「bloat」摘要的提示词,影响摘要质量。有上限的设计,而不是无限制接受用户输入,这是防 prompt 膨胀的考虑。

4. 摘要指令对不可信内容的处理

用户通过 /compact 命令提供的自定义指令,被当作「不可信内容」处理:

const customBlock = wrapUntrustedInstructionBlock("Additional context from /compact", custom);

wrapUntrustedPromptDataBlock 会给这段内容加上一个特殊的包装,告诉模型「这是用户提供的外部数据,不是系统指令」,防止通过自定义压缩指令来篡改系统行为。


结尾

compaction 这套机制,大概是 OpenClaw 里工程复杂度最高的模块之一。

它要解决的问题在表面上很简单:对话太长了,压缩一下。但实际做到「压缩后不丢失关键信息、摘要有结构有质量保证、精确标识符不出错、最近上下文保持原样、压缩失败有降级策略、超时有回退」——每一条单独看都不复杂,但同时做好所有这些,代码量上去了,复杂度也上去了。

这种复杂度不是过度设计,而是真实场景里踩过坑之后的积累。每一个常量、每一个 fallback、每一个安全系数背后,都有一个「不这样做的话会出什么问题」。


本文源码版本:OpenClaw main branch,分析文件: