上下文压缩
触发与阻塞阈值、HEAD+TAIL 保留策略、摘要与续接提醒、指回事件日志的 Context Recovery,以及压缩请求自身的失败处理。
上下文压缩是长会话 agent 的生死线。v2 的实现分两处:src/agent/fullCompaction/(何时压、怎么调模型,fullCompactionService.ts 955 行)与 src/agent/contextMemory/compactionHandoff.ts(压完的上下文长什么样)。
配置与阈值
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
triggerRatio: 0.85,
blockRatio: 0.85,
reservedContextSize: 50_000,
maxCompactionPerTurn: Infinity,
maxOverflowCompactionAttempts: 3,
maxRecentMessages: 4,
maxRecentUserMessages: Infinity,
maxRecentSizeRatio: 0.2,
minOverflowReductionRatio: 0.05,
};前五项与 v0.29 相同:用到窗口的 85% 触发,预留 5 万 token 给输出,单回合压缩次数不设限,连续溢出压缩最多 3 次。
变化在「可配置」。loop_control 里有三个相关键:compaction_trigger_ratio(0.5–0.99)、reserved_context_size、compaction_max_attempts。RuntimeCompactionStrategy 这样合成实际配置(strategy.ts:91):
private config(model: ProfileModelContext = this.context()): CompactionConfig {
const triggerRatio = model.compactionTriggerRatio ?? DEFAULT_COMPACTION_CONFIG.triggerRatio;
const blockRatio = Math.max(triggerRatio, DEFAULT_COMPACTION_CONFIG.blockRatio);
return {
...DEFAULT_COMPACTION_CONFIG,
triggerRatio,
blockRatio,
reservedContextSize:
model.reservedContextSize ?? DEFAULT_COMPACTION_CONFIG.reservedContextSize,
};
}阻塞比例永远不低于 0.85。默认两者相等,压缩是同步的:到点就停下来压,压完再走。若把触发比例调低,比如 0.7,就有了后台压缩:用量过 70% 即在后台开压(step 开始前查一次,checkAfterStep 为真时 step 结束后再查一次),回合照常推进,到 85% 才停下来等。
后台压缩的竞态由落地前的一道检查兜住(fullCompactionService.ts:875):
function historySafeToCompact(
current: readonly ContextMessage[],
original: readonly ContextMessage[],
): boolean {
if (current.length < original.length) return false;
if (!original.every((message, index) => message === current[index])) return false;
return current.slice(original.length).every(isRealUserInput);
}压缩期间历史只多了用户输入,结果才会应用;多出 assistant 或工具消息,这次压缩就作废。
压缩后的上下文
buildContextCompactionShape() 产出的消息顺序(compactionHandoff.ts:118):
messages: [
...keptMessages,
createCompactionSummaryMessage(contextSummary),
continuationMessage,
],- 保留的用户消息,逐字,受 token 预算约束;
- 一条 user 角色的摘要,以
COMPACTION_SUMMARY_PREFIX开头,origin为compaction_summary; - 一条续接提醒,包在
<system-reminder>里:「Context compaction is complete — continue the work that was in progress when it began.」让模型接着干活,而不是从头再来。
assistant 消息、工具调用与工具结果全部丢弃。用户的原话是意图唯一的权威来源,不能被摘要走样。什么算用户消息看来源:origin 为 user,或由用户以斜杠命令触发的 skill、插件命令,才算真实输入;注入、shell 命令、后台任务、cron、hook 结果、重试一律不留。
HEAD + TAIL
export const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd();
export const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000;
export const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000;
export const COMPACTION_ELISION_VARIANT = 'compaction_elision';
export const COMPACTION_CONTINUATION_VARIANT = 'compaction_continuation';用户消息总量超过 2 万 token 时,保留最早的 2000 token(HEAD,原始任务通常在这里)与最近的 18000 token(TAIL,当前焦点),中间插一条省略说明,告诉模型大约省掉了多少 token、内容已由摘要覆盖。省略说明同样是 injection 来源、<system-reminder> 包裹的 user 消息。
这套规则只有一份:实时压缩(contextMemoryService.ts)与回放时折叠 context.apply_compaction 记录(contextOps.ts)调用的是同一个 buildContextCompactionShape()。否则「模型看到的历史」与「重放出来的历史」会分叉,排障时无从对账。
摘要之外:指回原始日志
摘要必然有损。v2 在摘要后面追加一段 ## Context Recovery(模板 fullCompaction/context-recovery-footer.md),告诉模型被压掉的内容仍在磁盘上:
## Context Recovery
Everything before this note is still on disk in this agent's event log (read-only, append-only):后面列出该 agent 的 wire.jsonl 路径与各个压缩窗口对应的行号区间,并说明怎么 grep、怎么只读一行。需要确切的命令输出或原话时,模型可以回去查,不必凭摘要猜。若有待办列表,摘要末尾还会附上 ## TODO List。
压缩请求自身的失败
压缩本身是一次 LLM 调用,会遇到普通请求的全部失败。相关常量(fullCompactionService.ts:76):
export const MAX_COMPACTION_RETRY_ATTEMPTS = 5;
const DEFAULT_COMPACTION_MAX_COMPLETION_TOKENS = 128 * 1024;
const OVERFLOW_CONTEXT_SAFETY_RATIO = 0.85;
const OVERFLOW_STATUS_RECOVERY_RATIO = 0.5;
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;- 压缩请求自己溢出:只保留最近的消息,依次缩到原 token 量的 70%、50%、35%,最多缩 3 次。
- 摘要被截断或为空:丢掉最旧的一条消息(连同开头的工具结果)再试。
- 可重试错误:退避重试。
- 总尝试次数不超过
compaction_max_attempts,默认 5(v0.43.0 新增的配置项)。
发请求前先用 stripDynamicToolContext() 剥掉动态工具上下文,摘要用不上工具 schema。
micro compaction
v0.29 的 micro.ts 早已禁用,只留着配置当设计记录。v2 里它彻底没有了:没有文件,也没有配置项。
与循环的边界
压缩服务在三处接入循环:
| 接入点 | 做什么 |
|---|---|
onWillBeginStep(full-compaction) | 检查阈值,必要时开压;达到阻塞比例就等压缩完成 |
onDidFinishStep | 清零连续溢出计数;触发与阻塞比例不同时检查是否开压 |
registerLoopErrorHandler | 上下文溢出时压缩,再 retry() |
循环本身不执行压缩——「压缩是宿主层的重试关切,不是停止原因」这条边界在 v2 里依然成立。手动压缩(source: 'manual')要求循环空闲,拿不到 tryAcquireQuiescence() 就报错「Cannot compact while a turn is active…」。
下一篇:会话与存储。