TL;DRCompaction 完整機制:shouldCompact 觸發條件(token 佔比、訊息數)、estimateTokens 計算(字符/單字近似)、findCutPoint 尋找切點(保留最近 N 輪)、generateSummary 生成摘要(LLM 呼叫)、prepareCompaction 整理上下文、Branch Summary 生成、Structured Compaction(Extension 自訂 via fromHook)、CompactionEntry 細節、fromHook 機制、Compaction Settings。
系列: pi-mono 深度導讀 (12 / 17)
TL;DR
- 觸發條件:
shouldCompact檢查 token 佔比(預設 75%)、訊息數、模型 Context Window - Token 估算:
estimateTokens字符近似(英文 4 chars/token、中文 1.5 chars/token)、estimateContextTokens含 system prompt、tools - 切點尋找:
findCutPoint從舊到新累加、保留最近 N 輪對話、最少保留 1 輪 - 摘要生成:
generateSummary呼叫 LLM(可指定模型)、輸出結構化摘要 - Branch Summary:
generateBranchSummary記錄被捨棄分支的關鍵資訊 - Structured Compaction:
fromHook=true讓 Extension 自訂壓縮邏輯、保留結構化資料 - CompactionEntry:
firstKeptEntryId、tokensBefore、usage、details、fromHook
為什麼需要 Compaction?
| 問題 | 解法 |
|---|---|
| Context Window 有限(4k~200k tokens) | 壓縮舊對話、騰出空間給新訊息 |
| 長對話 Token 成本高 | 摘要替代原文、大幅減少 Token |
| 關鍵資訊不能丟 | 保留最近輪次、生成結構化摘要 |
| 分支實驗產生大量歷史 | Branch Summary 記錄棄用路徑 |
核心流程:prepareNextTurn → shouldCompact → compact
1. shouldCompact:觸發判斷
// packages/agent/src/harness/compaction/compaction.ts
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
// Token 佔比閾值(預設 75%)
threshold: 0.75,
// 最少保留輪次
minTurnsToKeep: 2,
// 最大摘要長度
maxSummaryTokens: 4096,
// 摘要生成模型(可指定較小模型省錢)
summaryModel: undefined, // undefined = 使用當前模型
};
export async function shouldCompact(
context: AgentContext,
settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS
): Promise<boolean> {
// 1. 估算目前 Context Tokens
const currentTokens = await estimateContextTokens(context);
// 2. 取得模型 Context Window
const modelConfig = context.model;
const contextWindow = modelConfig?.contextWindow ?? 128000;
// 3. 計算佔比
const ratio = currentTokens / contextWindow;
// 4. 檢查訊息數
const messageCount = context.messages.filter(m => m.role === "user" || m.role === "assistant").length;
return ratio >= settings.threshold || messageCount > settings.minTurnsToKeep * 10;
}
2. estimateTokens:Token 估算
// packages/agent/src/harness/compaction/compaction.ts
export function estimateTokens(text: string): number {
if (!text) return 0;
// 英文:~4 chars/token,中文:~1.5 chars/token
// 簡化:總字符數 / 3.5
return Math.ceil(text.length / 3.5);
}
export function estimateContextTokens(context: AgentContext): number {
let total = 0;
// System Prompt
total += estimateTokens(context.systemPrompt);
// Tools Schema
for (const tool of context.tools ?? []) {
total += estimateTokens(JSON.stringify(tool.parameters));
}
// Messages
for (const message of context.messages) {
if (typeof message.content === "string") {
total += estimateTokens(message.content);
} else {
for (const block of message.content) {
if (block.type === "text") total += estimateTokens(block.text);
else if (block.type === "toolCall") total += estimateTokens(JSON.stringify(block.arguments));
else if (block.type === "toolResult") total += estimateTokens(JSON.stringify(block.content));
else if (block.type === "thinking") total += estimateTokens(block.thinking);
}
}
}
return total;
}
3. findCutPoint:尋找切點
export interface CutPointResult {
cutIndex: number; // 從 0 開始的索引,切在這之後
tokensBefore: number; // 切點前的 token 數
tokensAfter: number; // 切點後的 token 數
}
export function findCutPoint(
messages: AgentMessage[],
targetTokens: number, // 目標保留 token 數
settings: CompactionSettings
): CutPointResult {
// 從最新往舊累加,直到超過 targetTokens
let accumulated = 0;
let cutIndex = messages.length - 1;
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = estimateMessageTokens(messages[i]);
if (accumulated + msgTokens > targetTokens) {
// 找到切點:保留 i+1 之後的訊息
cutIndex = i;
break;
}
accumulated += msgTokens;
}
// 確保最少保留 minTurnsToKeep 輪 user/assistant
const userAssistantIndices = messages
.map((m, i) => (m.role === "user" || m.role === "assistant") ? i : -1)
.filter(i => i >= 0);
if (userAssistantIndices.length > settings.minTurnsToKeep * 2) {
const minKeepIndex = userAssistantIndices[userAssistantIndices.length - settings.minTurnsToKeep * 2];
cutIndex = Math.min(cutIndex, minKeepIndex - 1);
}
const tokensBefore = messages.slice(0, cutIndex + 1).reduce((sum, m) => sum + estimateMessageTokens(m), 0);
const tokensAfter = messages.slice(cutIndex + 1).reduce((sum, m) => sum + estimateMessageTokens(m), 0);
return { cutIndex, tokensBefore, tokensAfter };
}
4. generateSummary:生成摘要
export async function generateSummary(
messages: AgentMessage[],
model: ModelConfig,
streamFn: StreamFn,
settings: CompactionSettings,
signal: AbortSignal
): Promise<{ summary: string; usage?: Usage }> {
// 建構摘要 Prompt
const summaryPrompt = buildSummaryPrompt(messages);
// 呼叫 LLM 生成摘要(可用較小模型)
const summaryModel = settings.summaryModel ?? model;
const summaryContext: Context = {
systemPrompt: SUMMARY_SYSTEM_PROMPT,
messages: [{ role: "user", content: summaryPrompt }],
tools: [],
};
const stream = streamFn(summaryModel, summaryContext, { signal });
let summary = "";
for await (const event of stream) {
if (event.type === "text_delta") {
summary += event.partial.content[0]?.text ?? "";
} else if (event.type === "done") {
const result = await event.result();
return { summary: result.content[0]?.text ?? "", usage: result.usage };
}
}
return { summary };
}
const SUMMARY_SYSTEM_PROMPT = `你是一個對話摘要助手。請將以下對話壓縮成結構化摘要,包含:
1. 關鍵決策與結論
2. 重要的程式碼變更或檔案操作
3. 解決的問題與未解決事項
4. 使用的工具與技術
請用繁體中文,保持簡潔但資訊完整。`;
5. compact:完整流程
export interface CompactionResult {
newContext: AgentContext;
summary: string;
tokensBefore: number;
tokensAfter: number;
cutIndex: number;
usage?: Usage;
}
export async function compact(
context: AgentContext,
settings: CompactionSettings = DEFAULT_COMPACTION_SETTINGS,
streamFn: StreamFn,
signal: AbortSignal
): Promise<CompactionResult> {
// 1. 估算 Tokens
const tokensBefore = await estimateContextTokens(context);
const contextWindow = context.model?.contextWindow ?? 128000;
const targetTokens = Math.floor(contextWindow * (1 - settings.threshold));
// 2. 找切點
const { cutIndex, tokensBefore: tb, tokensAfter } = findCutPoint(
context.messages,
targetTokens,
settings
);
// 3. 生成摘要(被切掉的訊息)
const messagesToSummarize = context.messages.slice(0, cutIndex + 1);
const { summary, usage } = await generateSummary(
messagesToSummarize,
context.model,
streamFn,
settings,
signal
);
// 4. 重構 Context:[摘要] + [保留的訊息]
const keptMessages = context.messages.slice(cutIndex + 1);
const summaryMessage: AgentMessage = {
role: "assistant",
content: [
{ type: "text", text: `[Compaction Summary]\n${summary}` }
],
provider: context.model.provider,
model: context.model.id,
};
const newContext: AgentContext = {
...context,
messages: [summaryMessage, ...keptMessages],
};
return {
newContext,
summary,
tokensBefore,
tokensAfter: tokensAfter + estimateTokens(summary),
cutIndex,
usage,
};
}
在 Agent Loop 中整合
prepareNextTurn 呼叫 Compact
// packages/coding-agent/src/core/agent-session.ts
prepareNextTurn: async (turn: PrepareNextTurnContext) => {
// 1. 檢查 Compaction
const shouldCompactResult = await shouldCompact(turn.context);
if (shouldCompactResult) {
const compactionResult = await compact(
turn.context,
this.compactionSettings,
this.streamFunction,
turn.signal
);
// 寫入 Session
const firstKeptEntryId = turn.context.messages[compactionResult.cutIndex + 1]?.id;
this.sessionManager.appendCompaction(
compactionResult.summary,
firstKeptEntryId,
compactionResult.tokensBefore,
undefined, // details
false, // fromHook
compactionResult.usage
);
return { context: compactionResult.newContext };
}
// 2. Model Switch、Thinking Level 等...
return undefined;
}
SessionManager 寫入 CompactionEntry
// packages/coding-agent/src/core/session-manager.ts
appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,
tokensBefore: number,
details?: T,
fromHook?: boolean,
usage?: Usage
): string {
const entry: CompactionEntry<T> = {
type: "compaction",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore,
details,
usage,
fromHook,
};
this._appendEntry(entry);
return entry.id;
}
Branch Summary:記錄被捨棄路徑
生成 Branch Summary
// packages/agent/src/harness/compaction/branch-summarization.ts
export async function generateBranchSummary(
options: GenerateBranchSummaryOptions,
model: ModelConfig,
streamFn: StreamFn,
signal: AbortSignal
): Promise<BranchSummaryResult> {
const { fromId, toId, context } = options;
// 1. 取得被捨棄路徑的訊息
const abandonedMessages = getAbandonedMessages(context, fromId, toId);
// 2. 生成摘要
const { summary, usage } = await generateSummary(
abandonedMessages,
model,
streamFn,
{ ...DEFAULT_COMPACTION_SETTINGS, maxSummaryTokens: 2048 },
signal
);
return { summary, usage };
}
export async function collectEntriesForBranchSummary(
fromId: string,
toId: string | null,
sessionManager: SessionManager
): Promise<SessionEntry[]> {
// 收集 fromId 到 toId 之間的 entries
const entries: SessionEntry[] = [];
let current = fromId;
while (current && current !== toId) {
const entry = sessionManager.getEntry(current);
if (entry) entries.push(entry);
current = entry.parentId ?? null;
}
return entries.reverse();
}
Branch Summary Entry
// SessionManager.appendCompaction -> branchWithSummary
branchWithSummary(
branchFromId: string | null,
summary: string,
details?: unknown,
fromHook?: boolean,
usage?: Usage
): string {
const fromId = this.leafId ?? "root";
this.leafId = branchFromId;
const entry: BranchSummaryEntry = {
type: "branch_summary",
id: generateId(this.byId),
parentId: branchFromId,
timestamp: new Date().toISOString(),
fromId,
summary,
details,
usage,
fromHook,
};
this._appendEntry(entry);
return entry.id;
}
Structured Compaction:Extension 自訂壓縮
fromHook 機制
// Extension 可實作自訂 Compaction
interface Extension {
// ...
onCompaction?: (context: AgentContext, signal: AbortSignal) => Promise<{
summary: string;
keptMessages: AgentMessage[];
details?: unknown;
usage?: Usage;
}>;
}
AgentSession 整合
// packages/coding-agent/src/core/agent-session.ts
private async runCompaction(context: AgentContext, signal: AbortSignal): Promise<CompactionResult> {
// 1. 嘗試 Extension Compaction
for (const ext of this.extensions) {
if (ext.onCompaction) {
const result = await ext.onCompaction(context, signal);
if (result) {
// Extension 回傳自訂結果
const firstKeptEntryId = result.keptMessages[0]?.id;
this.sessionManager.appendCompaction(
result.summary,
firstKeptEntryId,
estimateContextTokens(context),
result.details,
true, // fromHook = true
result.usage
);
return {
newContext: { ...context, messages: [createCompactionMessage(result.summary), ...result.keptMessages] },
summary: result.summary,
tokensBefore: estimateContextTokens(context),
tokensAfter: estimateTokens(result.summary) + estimateContextTokens({ ...context, messages: result.keptMessages }),
cutIndex: context.messages.length - result.keptMessages.length,
usage: result.usage,
};
}
}
}
// 2. Fallback 到內建 Compact
return compact(context, this.compactionSettings, this.streamFunction, signal);
}
範例:Git-aware Compaction Extension
// 保留 Git 相關訊息、壓縮其他
const gitAwareCompactionExtension: Extension = {
name: "git-aware-compaction",
version: "1.0.0",
onCompaction: async (context, signal) => {
const messages = context.messages;
const gitMessages = messages.filter(m =>
m.content.some(c => c.type === "text" && c.text.includes("git "))
);
const otherMessages = messages.filter(m => !gitMessages.includes(m));
if (otherMessages.length > 10) {
const { summary } = await generateSummary(otherMessages, /* ... */);
return {
summary: `[Git-Aware Compaction]\n${summary}\n\n[Git Operations Preserved]\n${gitMessages.map(m => m.content).join("\n")}`,
keptMessages: [...gitMessages, ...otherMessages.slice(-5)],
details: { preservedGitOps: gitMessages.length },
};
}
},
};
CompactionEntry 完整欄位
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string; // 摘要文字
firstKeptEntryId: string; // 保留區段起始 Entry ID
tokensBefore: number; // 壓縮前 Token 數
details?: T; // Extension 專用資料(Structured Compaction)
usage?: Usage; // 生成摘要的 LLM Usage
fromHook?: boolean; // true = Extension 產生、false = 內建
}
buildContextEntries 處理 Compaction
// packages/coding-agent/src/core/session-manager.ts
export function buildContextEntries(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map<string, SessionEntry>
): SessionEntry[] {
const path = buildSessionPath(entries, leafId, byId);
let compaction: CompactionEntry | null = null;
// 找最新的 Compaction
for (const entry of path) {
if (entry.type === "compaction") compaction = entry;
}
if (!compaction) return path;
const compactionIdx = path.findIndex(e => e.id === compaction.id);
if (compactionIdx < 0) return path;
// 重構:[Compaction] + [FirstKept 之後] + [Compaction 之後]
const contextEntries: SessionEntry[] = [compaction];
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
if (path[i].id === compaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) contextEntries.push(path[i]);
}
contextEntries.push(...path.slice(compactionIdx + 1));
return contextEntries;
}
Compaction Settings 配置
export interface CompactionSettings {
threshold: number; // Token 佔比閾值 (0-1),預設 0.75
minTurnsToKeep: number; // 最少保留輪次,預設 2
maxSummaryTokens: number; // 摘要最大 Token,預設 4096
summaryModel?: ModelConfig; // 摘要用模型,undefined = 當前模型
// 進階
preserveSystemPrompt: boolean; // 預設 true
preserveTools: boolean; // 預設 true
preserveRecentFiles: number; // 保留最近 N 個檔案操作,預設 5
}
預設值
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
threshold: 0.75,
minTurnsToKeep: 2,
maxSummaryTokens: 4096,
summaryModel: undefined,
preserveSystemPrompt: true,
preserveTools: true,
preserveRecentFiles: 5,
};
參考資料
- GitHub - earendil-works/pi — packages/agent/src/harness/compaction/
- Pi 官方文件:Compaction
- Context Window Management Strategies
- Token Estimation Techniques
下一篇預告
第 13 篇:Agent Harness、Skills、System Prompt 組裝
AgentHarness 類別、System Prompt 組裝流程、Skills 載入與格式化、Prompt Templates、Harness 如何決定 Tool 可用性、Result Handling、Telemetry Schema 註冊、預設 Harness 建構。
Glossary
Loading...