上下文压缩
microcompact 与自动压缩的阈值公式和具体数字,rapid refill 保护的规则,压缩时保留什么、丢掉什么,摘要请求怎样构造与重试,图片与文档怎样降级,手动 /compact 的入口,压缩后补回哪些提醒,以及摘要怎样落库、冷恢复时怎样重建。
对话长了总会撑满上下文窗口。ZCode 的对策有四种:请求前的 microcompact,把旧工具结果换成一句占位文字;请求前的自动压缩,超过阈值就让模型把旧对话写成摘要;请求被 provider 以超窗拒绝后的反应式压缩;以及用户手动 /compact。后三种共用 compactActiveConversation,结果都是“前缀 + 一条摘要 + 可能保留的最近一轮 + 几条补充提醒”。上一篇讲过的前缀(系统提示词、AGENTS.md、技能清单)不参与摘要,原样保留。
纯策略与提示词在 apps/zcode-cli/packages/core/src/compact(阈值、microcompact、按轮分组、压缩提示词),运行时实现在同包的 runtime/methods/compact*.ts(触发、执行、持久化)与 runtime/helpers/compact*.ts(选区、媒体、压缩后提醒),事件与载荷的类型在 apps/zcode-cli/packages/contracts/src/compact。
| 机制 | 时机 | 做什么 | 默认 |
|---|---|---|---|
| microcompact | 每步请求前 | 旧工具结果换成占位文字,不调模型 | 关闭 |
| 自动压缩 | 每步请求前,估算用量达到阈值 | 模型写摘要,保留最近一轮 | 开启 |
| 反应式压缩 | provider 报超窗 | 同上,按超出量多保留几轮,然后重发这一步 | 开启 |
手动 /compact | 用户命令,单独成一个回合 | 模型写摘要,不保留最近轮次 | — |
怎么用
- 命令:TUI 与命令行里输入
/compact [instructions](帮助文本见apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:58)。运行时只认/compact本身或/compact加一段说明(apps/zcode-cli/packages/core/src/runtime/helpers/commands.ts:5),它单独成一个回合,不走普通回合循环(apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:240);这个回合的输入标为model-only,界面上不会出现一条用户气泡(apps/zcode-cli/packages/core/src/runtime/methods/compact.ts:69)。 - 无需压缩:历史不足两轮或没有 assistant 回复时直接跳过,返回 “Context is up to date; no compression needed”(
apps/zcode-cli/packages/core/src/runtime/methods/compact-active.ts:217),TUI 显示“上下文已是最新,无需压缩”。 - 进度:TUI 在对话里画一条分隔线,依次显示“正在压缩上下文”“正在重试压缩上下文(2/3)”“上下文已压缩”;失败或中断时附上
Ctrl-R 重试 /compact(apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:289),按Ctrl+R即重发同一条命令(apps/zcode-cli/packages/tui/src/app-keyboard.ts:235)。 - 桌面端:经 ZCode Protocol V4 发起,载荷为空,不带附加说明;会话忙时压缩意图排进队列,已有压缩在跑或排队则拒绝(
apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/goal-compact.ts:49)。协议层细节见ZCode Protocol V4。
配置里有 features.compact 开关,默认 true(apps/zcode-cli/packages/adapters/src/config/schema.ts:34、apps/zcode-cli/packages/contracts/src/config/index.ts:309),但 bootstrap 组装运行时配置时没有把它映射到运行时的 compact.enabled。从代码看,把它设成 false 并不能关掉自动压缩;运行时的 compact 配置只能由嵌入方直接传入。
什么时候自动压缩
回合循环每一步请求前先跑 microcompact,再判断自动压缩(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:67);第一步记为 pre_request 阶段,之后记为 mid_turn。阈值只看 token 数,不是固定比例(apps/zcode-cli/packages/core/src/compact/policy.ts:67):
export function getEffectiveContextWindowSize(config: AutoCompactPolicyConfig = {}): number {
const contextWindow = positiveInt(config.contextWindow) ?? DEFAULT_COMPACT_CONTEXT_WINDOW;
// provider 的 context window 是 input + output 共享窗口;自动压缩只能让出输入侧,
// 因此阈值分母必须先扣掉当前模型允许的 output token,而不是继续吃完整 contextWindow。
const reserve = Math.min(getAutoCompactOutputReserveTokens(config), contextWindow);
return Math.max(0, contextWindow - reserve);
}
export function getAutoCompactOutputReserveTokens(config: AutoCompactPolicyConfig = {}): number {
const maxOutputTokens = positiveInt(config.maxOutputTokens);
// 旧 legacy 分支为完整模型输出预留窗口,既过早压缩又要求远端选择;现在统一保留至多 21K。
return Math.min(
maxOutputTokens ?? DEFAULT_AUTOCOMPACT_OUTPUT_RESERVE_TOKENS,
PREFLIGHT_AUTOCOMPACT_OUTPUT_RESERVE_TOKENS,
);
}
export function getAutoCompactThreshold(config: AutoCompactPolicyConfig = {}): number {
const effectiveContextWindow = getEffectiveContextWindowSize(config);
const buffer = positiveInt(config.bufferTokens) ?? AUTOCOMPACT_BUFFER_TOKENS;
return Math.max(0, effectiveContextWindow - buffer);
}也就是:阈值 = 上下文窗口 − 输出预留 − 13000,输出预留取模型输出上限与 21000 中较小的那个。模型没声明窗口时按 200000 算,没声明输出上限时按 32000 算(policy.ts:6、policy.ts:9、policy.ts:12;输出上限取值见 apps/zcode-cli/packages/core/src/runtime/methods/model-token-limits.ts:7)。几个例子:
| 上下文窗口 | 模型输出上限 | 预留 | 触发阈值 | 约占窗口 |
|---|---|---|---|---|
| 1000000 | 32000 | 21000 | 966000 | 96.6% |
| 200000 | 32000 | 21000 | 166000 | 83% |
| 131072 | 32000 | 21000 | 97072 | 74% |
| 65536 | 8192 | 8192 | 44344 | 68% |
比较的是“当前请求大概有多少 token”。如果历史里最近一条已提交的 assistant 消息带着 provider 报告的用量,就以它为基数,再加上其后消息的本地估算;否则整段本地估算(methods/compact.ts:313)。本地估算是字符数除以 3,工具调用的入参与推理内容也算进去(apps/zcode-cli/packages/core/src/compact/manual.ts:102)。注意它和上一篇 estimateTokens 不同,不给中文加权。
三种情况不压:历史里可摘要的部分不足两轮或没有 assistant 消息(manual.ts:69);连续失败已达 3 次,熔断(policy.ts:14、policy.ts:136),计数在压缩成功或回退消息后清零(methods/compact.ts:282、apps/zcode-cli/packages/core/src/runtime/methods/rewind-message.ts:735);运行时配置显式关闭。AutoCompactPolicyConfig 里的 thresholdPercentOverride 与 summaryReserveTokens 声明了却没人读,DEFAULT_AUTOCOMPACT_THRESHOLD_PERCENT = 100 也只写进日志(policy.ts:13、policy.ts:21)。
自动压缩失败只记一次失败、不中断回合,这一步照常带着完整历史去请求。真被 provider 以超窗拒绝,就进反应式压缩:每个模型步骤最多一次,成功后重发这一步(apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:429、turn-model-step.ts:742)。反应式压缩不看阈值,也不看熔断计数。
microcompact:默认关闭
microcompact 的开关判定写得很直白(apps/zcode-cli/packages/core/src/runtime/methods/microcompact.ts:105):
function resolveLocalMicrocompactConfig(
config: AutoCompactPolicyConfig,
): LocalMicrocompactPolicyConfig {
const fullCompactThreshold = getAutoCompactThreshold(config);
return {
...config.microcompact,
enabled: config.microcompact?.enabled === true,
thresholdTokens:
config.microcompact?.thresholdTokens ??
buildDefaultMicrocompactThreshold(fullCompactThreshold),
};
}只有 compact.microcompact.enabled 显式为 true 才启用,而仓库里没有任何地方设置它,所以生产路径上 microcompact 从不生效。它的完整逻辑在 apps/zcode-cli/packages/core/src/compact/microcompact.ts,启用后是这样:
- 触发:距上一条 assistant 消息完成已超过 60 分钟(按时间),或估算 token 达到阈值(按压力)(
compact/microcompact.ts:171)。默认阈值取“自动压缩阈值的 90%”与“自动压缩阈值减 2000”中较小的那个(compact/microcompact.ts:77),200000 窗口下是 149400。 - 清理对象:Read、Bash、Grep、Glob、WebFetch、WebSearch、Edit、Write、ApplyPatch 的结果(
compact/microcompact.ts:19),按 assistant 的工具调用批次分组,保留最近 5 批(compact/microcompact.ts:14、compact/microcompact.ts:197)。出错的结果默认不清,含图片、视频或文件块的结果一律不清(compact/microcompact.ts:225、compact/microcompact.ts:249)。 - 效果:内容换成
[Old tool result content cleared](compact/microcompact.ts:13);省下不足 256 token 就放弃,原样返回(compact/microcompact.ts:16、compact/microcompact.ts:148)。成功时发一个MicrocompactBoundary事件(apps/zcode-cli/packages/core/src/runtime/methods/microcompact.ts:87)。
rapid refill 保护
压完没几步又满了,往往说明某个文件或工具输出本身就太大,再压也是白费请求。回合循环为此记两个数:上次压缩后完成了几批工具调用,以及连续几次“刚压完又要压”。阈值都是 3(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:21),判定在 turn-loop-state.ts:154:
export function evaluateRapidRefill(
tracking: CompactLoopTracking | undefined,
): RapidRefillDecision {
const toolTurnsSinceCompact = tracking?.toolTurnsSinceCompact ?? 0;
const consecutiveRapidRefills =
tracking && toolTurnsSinceCompact < RAPID_REFILL_TOOL_TURN_THRESHOLD
? tracking.consecutiveRapidRefills + 1
: 0;
return {
consecutiveRapidRefills,
shouldBlock: consecutiveRapidRefills >= MAX_CONSECUTIVE_RAPID_REFILLS,
toolTurnsSinceCompact,
};
}记账只在一个用户回合之内:本回合第一次压缩前没有记录,不算 rapid;压缩成功后工具批次计数归零,每完成一批加一(turn-loop-state.ts:170、turn-loop-state.ts:180)。之后若在不到 3 批工具调用内又需要压缩,就记一次 rapid refill。第 1、2 次照压,连续第 3 次不再压缩,直接抛出 ModelContextExceeded 错误结束回合(turn-loop.ts:91;反应式压缩前同样检查,turn-model-step.ts:753),错误标记为可恢复、可重试,文案如下,两个 3 由上述常量代入(apps/zcode-cli/packages/core/src/runtime/helpers/model-errors.ts:52):
Autocompact stopped because the context refilled within fewer than 3 tool turns after compaction 3 times in a row. A file or tool output may be too large. Read it in smaller chunks, or start a new session.
中间只要有一次压缩前已经过了 3 批以上工具调用,连续计数就清零。它看的是两次压缩之间隔了几批工具调用,不是工具调用总数;回合本身的停止条件见回合循环与 TurnMachine。
留什么、丢什么
历史先切成前缀和对话两段,对话按“从 assistant 消息开始新一轮”分组(apps/zcode-cli/packages/core/src/compact/rounds.ts:1)。自动与反应式压缩保留最近一轮原文,其余交给模型摘要;手动 /compact 一轮也不留(apps/zcode-cli/packages/core/src/runtime/helpers/compact-selection.ts:36、compact-selection.ts:225)。摘要写完后,新的历史这样拼(apps/zcode-cli/packages/core/src/runtime/helpers/compact.ts:72):
export function buildPostCompactRuntimeEntries(
activeEntries: readonly RuntimeMessageEntry[],
summaryEntry: RuntimeMessageEntry,
options: {
postCompactReminderEntries?: readonly RuntimeMessageEntry[];
preservedEntries?: readonly RuntimeMessageEntry[];
} = {},
): RuntimeMessageEntry[] {
// compact 后只保留 metadata 标记的 prefix,避免用户 literal <system-reminder> 被文本规则误留。
const prefixCount = countContextPrefixMessages(activeEntries);
return [
...activeEntries.slice(0, prefixCount).map(cloneRuntimeEntry),
cloneRuntimeEntry(summaryEntry),
...(options.preservedEntries ?? []).map(cloneCompactPreservedRuntimeEntry),
...(options.postCompactReminderEntries ?? []).map(cloneRuntimeEntry),
];
}- 摘要消息是一条 user 消息,开头一句 “This session is being continued from a previous conversation that ran out of context.”,结尾附一段要求:直接接着做,不要复述、不要确认摘要(
apps/zcode-cli/packages/core/src/compact/prompt.ts:133;压缩时总带上这一段,compact-active.ts:518)。 - 计划文件:Plan 模式批准过的计划存在工作区的
.zcode/plans/plan-<sessionId>.md,存在且非空就作为plan_file_reference补回,最多读 81024 字节,即计划上限 20000 字符的 4 倍加 1024(apps/zcode-cli/packages/core/src/runtime/helpers/plan-file-continuity.ts:16、plan-file-continuity.ts:18)。 - 读过的文件:按最近读取时间取至多 5 个,单个不超过约 5000 token、合计约 50000 token,以一次 Read 调用及结果的样子补回;超限的只留一句“读过但太大,需要时再读”;
.git下的文件和保留轮次里已经读过的文件跳过(apps/zcode-cli/packages/core/src/runtime/helpers/compact-post-reminders.ts:20、compact-post-reminders.ts:57)。 - 丢掉的:其余对话连同全部工具结果。文件读取状态整体清空(
compact-active.ts:625),所以即使某个文件的内容刚被补回,编辑前也得重新 Read,否则 Edit 报 “File has not been read yet.”(apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:62)。保留轮次里 assistant 消息的用量也被作废(apps/zcode-cli/packages/core/src/runtime/helpers/compact.ts:157),下一次阈值判断先用本地估算,等新的回复带来真实用量。 - 不补的:Todo 清单与目标状态不在补回之列,它们存在会话库里,之后照常由 Todo 提醒与目标机制处理。技能清单和 AGENTS.md 在前缀里,天然保留。
摘要请求
压缩提示词首尾都在强调只许输出文本、不许调用工具(prompt.ts:1、prompt.ts:10),正文第一句是(prompt.ts:14):
Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
它要求先在 <analysis> 里按时间顺序过一遍对话,再在 <summary> 里分九节写:主要请求与意图、关键技术概念、文件与代码片段、错误与修复、问题解决、全部用户消息、待办任务、当前工作、可选的下一步(prompt.ts:35);用户提过的安全约束(敏感文件、禁止的操作、凭据处理)要逐字保留(prompt.ts:30)。/compact 后面的说明以 “Additional Instructions:” 接在正文后(prompt.ts:111);模型输出里的 <analysis> 被删掉,<summary> 换成 “Summary:”(prompt.ts:119)。
请求的组成是“待摘要的条目(含前缀)+ 压缩提示词作为最后一条 user 消息”(apps/zcode-cli/packages/core/src/runtime/methods/compact-active-helpers.ts:80)。缓存标记不打在提示词上,而是前移到它前面那条真实的上下文消息(apps/zcode-cli/packages/core/src/runtime/helpers/provider-request-messages.ts:301)。其他参数:
- 模型用当前回合的模型,手动压缩则按会话当前选择建一个(
compact-active.ts:174);输出上限取模型上限与 20000 中较小的(compact-active.ts:716、policy.ts:11)。 - 工具目录原样带上,超过 100 个才不带(
compact-active.ts:251);模型真的调了工具,就报 “Tool use is not allowed during compaction”,不可重试(compact-active-helpers.ts:93)。 - 走流式,内容块提交之前出错就退回非流式重发一次(
apps/zcode-cli/packages/core/src/runtime/methods/compact-summary-model-request.ts:258)。
出错时按类型分别处理(compact-active.ts:432):
if (isModelMediaTooLargeError(error) && !stripMediaForSummary) {
stripMediaForSummary = true;
this.logger?.info(
"Compact summary hit media-size error; retrying with stripped media",
{
...traceContextToLogContext(modelTraceContext),
errorMessage: error instanceof Error ? error.message : String(error),
event: "compact.request.media_too_large.retry",
module: "core.runtime",
},
);
continue;
}
if (isModelContextExceededError(error)) {
if (reselectEntriesAfterPromptTooLong(error)) continue;
if (truncateEntriesAfterPromptTooLong(error)) continue;
throw createCompactPromptTooLongError({
attempt: compactPromptTooLongAttempts,
cause: error,
preCompactTokenCount,
});
}
throw error;- 摘要请求本身超窗:自动与反应式压缩把更多最近轮次挪进“保留”一侧,挪几轮由错误信息里 “N tokens > M” 的差额决定,解析不出就挪一轮(
compact-selection.ts:59、compact-selection.ts:400);手动压缩从最旧的轮次开始丢,丢到覆盖差额为止,没有差额信息就丢两成,最多重试 3 次,截断处插一行[earlier conversation truncated for compaction retry](compact-selection.ts:279、compact-selection.ts:311、manual.ts:55)。GLM 系端点有时以length加空文本收尾,也按超窗处理(compact-active-helpers.ts:61)。都救不回来时报 “Conversation too long to compact automatically.”,并标为不可重试,免得外层重试把 3 次放大成 3×3(compact-active-helpers.ts:26)。 - 反应式压缩一开始就按触发它的超窗错误估算差额,预先多保留几轮(
compact-selection.ts:103)。 - 整体重试:自动压缩整个过程最多尝试 3 次,遇到可重试错误就从头再来,时间线显示“重试中”(
compact-active.ts:72、compact-active.ts:633);反应式与手动只试一次。
图片与文档怎样降级
摘要请求和普通请求走同一套模型媒体策略:先按模型声明的输入能力去掉不支持的媒体,再套一个默认 40 MiB 的整请求媒体预算(compact-active.ts:323、apps/zcode-cli/packages/core/src/runtime/helpers/media-budget.ts:28、media-budget.ts:51),细节见读、写、改、搜。provider 仍以媒体过大拒绝时,重试一次,把所有图片、视频与文档块替换成 [image]、[video]、[document] 三种占位文字(apps/zcode-cli/packages/core/src/runtime/helpers/compact-media.ts:84)。换成占位符的只是这次摘要请求;保留下来的最近一轮若带着图片,仍以原样留在新历史里。
落库与冷恢复
摘要落库时写成一条对界面隐藏、对模型可见的 user 消息,语义标为 compact_summary(apps/zcode-cli/packages/core/src/runtime/methods/compact-persistence.ts:324),带两个 part:一段文本,即上面那条摘要消息;一个 compaction part,里面是 CompactBoundary(compact-persistence.ts:351)。边界载荷记着触发方式、阶段、原因、压缩前后的 token 数、被摘要的消息数、保留区间,以及压缩后的大小是否仍超阈值、下一回合还会再压(willRetriggerNextTurn,compact-active.ts:549)。保留区间不照搬内存里的条目,而是在会话库的活跃消息上按同样的“assistant 开新轮”规则重新分组,取最后几组的首尾消息 ID(apps/zcode-cli/packages/core/src/runtime/helpers/compact-preservation.ts:13)。补回的提醒各存成一条 model-only 的合成消息(compact-persistence.ts:384)。这几步属于同一次历史替换,任何一步失败都会把已写入的消息删掉回滚(compact-persistence.ts:306、compact-persistence.ts:378)。
另有一条时间线 part 给界面画分隔线,状态经历 started、retrying、completed、skipped、failed、interrupted(compact-persistence.ts:85、apps/zcode-cli/packages/contracts/src/compact/index.ts:60),并伴随 CompactStarted、CompactCompleted、CompactFailed、CompactBoundary 事件。内存里的历史在全部落库之后才被替换(compact-active.ts:617)。
冷恢复时,历史从最近一个压缩边界开始重建,保留区间里的消息从原位置取回,插在摘要消息之后(apps/zcode-cli/packages/core/src/agent/compact-session.ts:4)。取回时跳过对模型隐藏的消息、压缩相关的消息和出错的 assistant 消息(compact-session.ts:69)。进程在压缩中途退出、时间线停在 started 或 retrying 的,恢复时按有无边界收敛为 completed 或 interrupted(compact-persistence.ts:200)。会话库与恢复流程见SQLite 会话库。
两处容易看错的地方:context-usage-log-compact.ts 名字里有 compact,做的却是给上下文用量日志瘦身,只留每类前 5 个贡献者(apps/zcode-cli/packages/core/src/runtime/methods/context-usage-log-compact.ts:1),与上下文压缩无关;契约里还有 partial、session_memory 两种触发方式和 model_downshift 原因(apps/zcode-cli/packages/contracts/src/compact/index.ts:9、apps/zcode-cli/packages/contracts/src/compact/index.ts:41),当前代码只在默认值映射里提到前两者(apps/zcode-cli/packages/core/src/runtime/helpers/compact.ts:38),三者都没有产生它们的地方。
下一篇:项目记忆——摘要只管一个会话,跨会话的事实写进按项目分目录的 Markdown 文件,由后台 Agent 在每个回合后抽取。