本文约 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;
质量检查做三件事:
- 结构检查:五个强制段落是否都在
- 标识符检查:提取出来的精确标识符是否都出现在摘要里
- 最新请求覆盖检查:用户最后一个问题的关键词是否在摘要里有体现
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,分析文件: