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

本文约 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 把一段对话历史压缩成一段结构化文本,这段文本保留了关键信息,但体积比原始对话小得多。压缩完成后,原始历史消息被这段摘要替换,腾出空间继续对话。

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

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

先看文件布局:

plaintext 复制代码
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 用了一个经典的粗估方案:

typescript 复制代码
// 调用的是 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」的粗估法,对英文比较准,对中文/代码会偏低。为了补偿这个误差,代码里到处都用了一个修正系数:

typescript 复制代码
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy

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

消息分块:两种策略

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

策略一:按 Token 上限切块 (chunkMessagesByMaxTokens)

typescript 复制代码
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)

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

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

自适应 chunk ratio

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

typescript 复制代码
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 --- 完整摘要失败时,跳过单条超大消息,只摘要小消息,超大消息留个占位符:

typescript 复制代码
// 单条消息 > 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);
  }
}

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

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

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

typescript 复制代码
// 分段摘要
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,
});

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

typescript 复制代码
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 接管具体的压缩过程。

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

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

typescript 复制代码
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,一个字母之差就完全是另一个东西。

typescript 复制代码
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.";

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

typescript 复制代码
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);
}

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

typescript 复制代码
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 轮对话不参与摘要,原文保留。

typescript 复制代码
const DEFAULT_RECENT_TURNS_PRESERVE = 3;
const MAX_RECENT_TURNS_PRESERVE = 12;

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

具体实现:

typescript 复制代码
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)

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

typescript 复制代码
const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false;
const DEFAULT_QUALITY_GUARD_MAX_RETRIES = 1;
const MAX_QUALITY_GUARD_MAX_RETRIES = 3;

质量检查做三件事:

  1. 结构检查:五个强制段落是否都在
  2. 标识符检查:提取出来的精确标识符是否都出现在摘要里
  3. 最新请求覆盖检查:用户最后一个问题的关键词是否在摘要里有体现
typescript 复制代码
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 生成摘要:

typescript 复制代码
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 很大比例,标准的摘要可能还不够用------摘要之后加上这条新消息,依然塞不进去。

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

typescript 复制代码
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. 工具调用失败记录

typescript 复制代码
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. 文件操作记录

typescript 复制代码
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. 工作区关键规则

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

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


第三层:超时保护

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

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

typescript 复制代码
// 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)

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

typescript 复制代码
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 强行打断」的问题。


把所有东西串起来

整个流程是这样的:

plaintext 复制代码
对话继续...
    ↓
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 重新宽裕

如果任何一步失败:

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

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


几个设计细节值得单独说

1. 摘要使用同一个模型

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

2. 摘要指令的优先级链

plaintext 复制代码
事件携带的指令(SDK 级)
  > runtime 配置的指令(config 级)
  > DEFAULT_COMPACTION_INSTRUCTIONS(默认)

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

typescript 复制代码
"Write the summary body in the primary language used in the conversation."

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

typescript 复制代码
const MAX_INSTRUCTION_LENGTH = 800; // ~200 tokens

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

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

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

typescript 复制代码
const customBlock = wrapUntrustedInstructionBlock("Additional context from /compact", custom);

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


结尾

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

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

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


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

相关推荐
JavaGuide3 分钟前
DeepSeek Harness 重磅更新,Codex、Claude Code 子智能体进官方插件列表了,还有 Git 工作树和思考翻译!
前端·后端
WeiXin_DZbishe18 分钟前
基于微信小程序的本地闲置物品交易平台-计算机毕设 附源码【课程设计】26825
vue.js·spring boot·python·微信小程序·架构·php·课程设计
小羊没烦恼!19 分钟前
OS X 上使用.NET开发应用程序
开发语言·前端·数据库·人工智能·c#·策略模式
码云之上1 小时前
Prompt 拦不住 Chatbot 的安全问题:九大风险与分层防护
人工智能·架构·全栈
对讲机数码科普1 小时前
从电路原理到协议栈:模拟对讲机与数字对讲机的技术内核拆解
运维·网络·架构·软件工程
一个有温度的技术博主1 小时前
单点登录(SSO)到底怎么实现?怎么判断用户是否已单点登录?
架构
Eiceblue1 小时前
使用 Python 实现 Excel 行列转置(保留单元格样式)
前端·python·excel
西西学代码1 小时前
像素和颜色之间的转换(Flutter)
前端·javascript·flutter
茶底世界之下2 小时前
羽毛颜色已经很鲜艳,还要继续加色吗?用 Awaking 试一次减饱和
前端·ios·运营
风早爽太2 小时前
Mac 搭建 Vue 开发环境
前端·vue.js