# 上下文压缩

> microcompact 与自动压缩的阈值公式和具体数字，rapid refill 保护的规则，压缩时保留什么、丢掉什么，摘要请求怎样构造与重试，图片与文档怎样降级，手动 /compact 的入口，压缩后补回哪些提醒，以及摘要怎样落库、冷恢复时怎样重建。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/compaction
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

对话长了总会撑满上下文窗口。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](https://daiw.org/manual/zcode/zcode-protocol)。

<Callout type="warn">
  配置里有 `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` 配置只能由嵌入方直接传入。
</Callout>

## 什么时候自动压缩

回合循环每一步请求前先跑 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`）：

```ts
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`）：

```ts
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`：

```ts
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](https://daiw.org/manual/zcode/turn-loop)。

## 留什么、丢什么

历史先切成前缀和对话两段，对话按“从 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`）：

```ts
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`）：

```ts
          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`），细节见[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)。provider 仍以媒体过大拒绝时，重试一次，把所有图片、视频与文档块替换成 `[image]`、`[video]`、`[document]` 三种占位文字（`apps/zcode-cli/packages/core/src/runtime/helpers/compact-media.ts:84`）。换成占位符的只是这次摘要请求；保留下来的最近一轮若带着图片，仍以原样留在新历史里。

## 落库与冷恢复

```mermaid
flowchart TD
  A["每步请求前"] --> MC["microcompact<br/>默认关闭"]
  MC --> T{"估算 token 达到阈值？"}
  T -- 否 --> REQ["发出模型请求"]
  T -- 是 --> RR{"连续第 3 次 rapid refill？"}
  RR -- 是 --> ERR["抛 ModelContextExceeded，回合结束"]
  RR -- 否 --> SEL["选区：前缀、待摘要轮次、保留轮次"]
  MAN["手动 /compact"] --> SEL
  SEL --> SUM["摘要请求<br/>待摘要条目加压缩提示词"]
  SUM -- 超窗 --> MORE["多保留几轮，或丢最旧的轮次"]
  MORE --> SUM
  SUM -- 媒体过大 --> PH["媒体换成占位文字"]
  PH --> SUM
  SUM -- 成功 --> NEW["新历史：前缀、摘要、保留轮次、补回的提醒"]
  NEW --> DB["落库：摘要消息、CompactBoundary、提醒消息"]
  DB --> REQ
  REQ -- provider 报超窗 --> RC["反应式压缩，每步最多一次"]
  RC --> SEL
```

摘要落库时写成一条对界面隐藏、对模型可见的 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 会话库](https://daiw.org/manual/zcode/session-store)。

两处容易看错的地方：`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`），三者都没有产生它们的地方。

下一篇：[项目记忆](https://daiw.org/manual/zcode/memory)——摘要只管一个会话，跨会话的事实写进按项目分目录的 Markdown 文件，由后台 Agent 在每个回合后抽取。
