# 压缩、修剪与请求前缀

> MiMo Code 改写后的压缩流程与失败回滚、按缓存冷热与压力分级的修剪、/context-limit 与触发线的算法，以及为提示词缓存而冻结的请求前缀快照。

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

在 MiMo Code 里，压缩从主角退成了兜底：子智能体溢出时用它；主智能体只有写不出检查点，或者检查点、记忆写入被关掉时才用它；再就是手动 `/compact`。可 `packages/opencode/src/session/compaction.ts` 相对上游 v1.14.19 的同名文件几乎重写了一遍，从 526 行变成 742 行。周围还多了三样上游没有的东西：回合结束时按压力分级的修剪 `prune.ts`（检查点档位也住在这个文件里），允许用户压低的触发线 `overflow.ts`，以及把系统提示词与工具表冻结下来的请求前缀快照。

## 怎么用

- **`/compact`**（别名 `/summarize`）：TUI 调用 `POST /session/:sessionID/summarize`，服务端插入一条压缩边界（默认不算自动压缩），然后运行主循环，由循环生成摘要（`packages/opencode/src/server/routes/instance/session.ts:677`）。
- **`/context-limit`**：为当前模型选一个比窗口更小的工作预算。选项是“模型默认”，比窗口小的 200K、300K、500K、1M 档，以及自定义（token 数、`300K`、`1M` 或 `50%`）。它只往全局配置里写当前模型那一个键，选“模型默认”写 0；有会话在运行时拒绝保存，因为写配置会销毁实例、取消正在跑的 runner；配置里的 `max_context` 已经是单值时也拒绝，提示改成按模型配置（`packages/opencode/src/cli/cmd/tui/component/dialog-context-limit.tsx:35`）。
- **查看**：`mimo models <provider>` 为每个模型打印窗口、预算与压缩点（`packages/opencode/src/cli/cmd/models.ts:53`）；`/status` 的 Context 区块给出同样的数字和当前用量（`packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx:35`）。
- **配置 `compaction`**（`packages/opencode/src/config/config.ts:253`）：`auto` 默认开；`prune` 在代码里默认关，schema 说明写的 “default: true” 与实现不符；`reserved` 是预留量；`max_context` 可以是单值，也可以是以 `"<providerID>/<modelID>"` 为键、支持通配符的映射。`tail_turns` 与 `preserve_recent_tokens` 已标为废弃，代码不再读取。
- **环境变量**：`MIMOCODE_COMPACTION_MAX_CONTEXT` 在配置没写 `max_context` 时生效；`MIMOCODE_COMPACTION_TRIGGER_RATIO` 默认 0.9，可写 `0.85` 或 `85%`，取值须大于 0 且不超过 1（`packages/opencode/src/flag/flag.ts:164`）；`MIMOCODE_DISABLE_AUTOCOMPACT` 与 `MIMOCODE_DISABLE_PRUNE` 在加载配置时把 `compaction.auto`、`compaction.prune` 强制设为 false（`config.ts:1014`）。

## 触发线：contextWindow

上游 v1.14.19 的 `usable` 是一个式子：模型有独立的输入上限就用它减去压缩预留，否则用上下文窗口减去最大输出。MiMo 把它拆成三个量（`packages/opencode/src/session/overflow.ts:80`）：

```ts
export function contextWindow(input: { cfg: Config.Info; model: Provider.Model }): Window {
  const hard = input.model.limit.context === 0 ? 0 : input.model.limit.input || input.model.limit.context
  if (hard === 0) return { hard: 0, effective: 0, usable: 0, source: "model" }

  const reserved = reserves(input)
  const configured = budget(input, hard, reserved)
  const effective = configured ?? hard
  return {
    hard,
    effective,
    usable: Math.floor(effective * Flag.MIMOCODE_COMPACTION_TRIGGER_RATIO),
    source: configured === undefined ? "model" : "config",
  }
}
```

- `hard`：提供方上限，优先取 `limit.input`，没有再取 `limit.context`。`limit.context` 为 0 表示未知，溢出处理整个关闭。
- `effective`：用户预算生效后的工作窗口。`budget()`（`overflow.ts:43`）先读 `compaction.max_context`，没有再读环境变量。映射形式交给 `Wildcard.all` 匹配 `<providerID>/<modelID>` 形式的键：模式按长度从短到长排，最后一个命中的生效，也就是最长的模式优先（`packages/opencode/src/util/wildcard.ts:21`）。值用 `Token.parseQuantity` 解析，百分比相对 `hard`（`packages/opencode/src/util/token.ts:10`）。解析出 0、解析失败、不大于预留量、或不小于 `hard`，都当作没有预算；其中解析失败与过小会警告一次。
- `usable`：`effective` 乘触发比例后向下取整。它是压缩与重建的触发线，也是检查点档位的分母。

预留量由 `reserves()`（`overflow.ts:34`）算出：`compaction.reserved`，没配时取 33000 与模型最大输出中较小的那个；模型没有独立输入上限时，再加上最大输出与 20000 中较小的那个。在现在的实现里，预留量只用来拒绝过小的预算，不再从触发线里扣除。`docs/compose/spec/context-budget-control.md` 的 S2.2 节仍写着“上限减去预留”，任务 T5 的验收也以 300K 预算触发于 280K 为例；按代码，300K 预算触发于 270K。

`contextPressureLevel`（`overflow.ts:117`）把用量除以 `usable` 分成四级：不到 50% 为 0，不到 70% 为 1，不到 85% 为 2，其余为 3；`compaction.auto` 为 false 时 `pressureLevel` 恒为 0。修剪按它决定下手轻重。

`/context-limit` 对话框里的换算与比例触发对不上（`dialog-context-limit.tsx:122`）：它把“当前 `effective` 减 `usable`”当成预留量，用档位值减去它来显示“约多少时压缩”，保存成功后的提示也这么算。可按比例触发时，这个差随窗口大小变化，不是常数。例如 1M 窗口、还没设预算时差值是 100K，选 300K 档会提示约 200K，保存后的实际触发线却是 300K 乘 0.9，即 270K。

## 压缩：相对上游改了什么

| 方面 | 上游 v1.14.19 | MiMo Code |
| --- | --- | --- |
| 用途 | 主会话溢出时的唯一手段，也可手动 | 子智能体溢出；主智能体写不出检查点时兜底；手动 `/compact` |
| 请求构成 | compaction agent 的身份：`compaction.txt` 作系统提示词，内置模板作最后一条 user 消息，没有工具 | 父 agent 的身份：冻结的系统提示词与工具表，`compaction.txt` 作最后一条 user 消息 |
| 模型 | compaction agent 配了模型就用它 | 只在提供方报溢出、且 compaction agent 配了模型时才换 |
| 原样保留的尾部 | 压缩前按轮数与 token 预算挑出 | 只保留摘要生成期间新到达的完整轮次 |
| 模型看到的 | `What did we do so far?` 加 assistant 摘要 | 改写成 user 消息的 `<conversation-summary>`，外加文件清单 |
| 失败处理 | 只处理溢出：记错误并停止 | 溢出、复读、没有摘要都回滚，并删除边界消息 |
| 多智能体 | 无 | 按 `agentID` 切片，`session.compacted` 事件带 `agentID` |

**调用链**。压缩仍分两步：`SessionCompaction.create`（`packages/opencode/src/session/compaction.ts:662`）只写一条带 `compaction` 部件的 user 消息；下一轮循环 `filterCompacted` 在这条边界处截断，取到的最后一条 user 消息就是它，于是转去调用 `compaction.process`（`packages/opencode/src/session/prompt.ts:3839`）。自动路径插入边界后立刻 `continue`，摘要在下一轮生成；`prompt.ts:3998` 那段注释把退回压缩说成不带 LLM 摘要的裸边界，与这条调用链不符。手动 `/rebuild` 退回压缩时不进入循环（`prompt.ts:5065`），用户的下一条消息成了最后一条 user 消息，按代码这条边界不会再被处理：之前的历史直接离开视野，模型只看到部件转换出的一句 `Summary of previous conversation:`（`packages/opencode/src/session/message-v2.ts:891`），后面没有摘要。MiMo 还给 `create` 加了 `agentID` 参数，让边界落在对应 actor 的切片里，并立即发布 `session.compacted` 事件。

**摘要请求复用父请求前缀**（`compaction.ts:483`）：

```ts
const summaryRequest = {
  role: "user" as const,
  content: [{ type: "text" as const, text: prompt }],
}
const result = yield* processor.process({
  user: {
    ...requestUser.info,
    system: promptConfig.system,
    systemMode: promptConfig.systemMode,
    harness: promptConfig.harness,
  },
  agent: parentAgent,
  permission: parentSession.permission,
  sessionID: input.sessionID,
  tools: frozen?.tools ? SessionPrefixSnapshot.restoreTools(frozen.tools) : {},
  activeTools: frozen?.tools?.map((item) => item.name),
  toolChoice: "auto",
  system: [],
  prebuiltSystem: frozen?.system,
  messages: [...modelMessages, summaryRequest],
  mergeTurnContextBeforeLastMessage: true,
  model,
})
```

工具表与系统提示词取自冻结的前缀快照（`compaction.ts:407`）。历史照常转成模型消息，非溢出时折叠重建尾部，溢出时剥掉媒体（`compaction.ts:444`）；摘要指令作为最后一条 user 消息追加，turn context 合并到它前面。这样请求的前缀与主循环一致，便于复用提示词缓存。模型只在提供方报溢出、且 compaction agent 配了模型时才换（`compaction.ts:427`），否则沿用父会话的模型。

摘要指令来自 `packages/opencode/src/agent/prompt/compaction.txt`，内容整个换了：上游要求总结较早的历史，并说明最近几轮可能原样保留在摘要之外；MiMo 要求写一份续作摘要，分任务概览、当前状态、重要发现、下一步、需要保留的上下文五部分，包在 `<summary></summary>` 里。

**尾部只留压缩期间到达的轮次**。上游在压缩前按 `tail_turns`（默认 2 轮）和 `preserve_recent_tokens`（`usable` 的 25%，夹在 2000 到 8000 之间）挑出原样保留的尾部，只摘要前面的部分。MiMo 删掉了这套挑选，摘要覆盖当前窗口里边界之前的全部历史；摘要生成期间若又有消息到达，`buildTail`（`compaction.ts:182`）把它们按 API 轮次分组（每条 user 消息开一轮），从新往旧装进 40000 token 的预算，单个工具结果超过 8000 token 就换成占位文本（常量在 `compaction.ts:40`）。

**结果写成投影**。成功后，边界上的 `compaction` 部件多出一个 `projection`（`message-v2.ts:284`），记下摘要消息 ID、包装后的摘要、文件清单、触发方式、保留轮次的起止 ID 与被缩掉的工具调用。包装格式（`compaction.ts:212`）：

```ts
export function buildSummaryMessage(summary: string, trigger: CompactionTrigger, hasTail: boolean) {
  return [
    `<conversation-summary trigger="${trigger}">`,
    "The earlier conversation has been compacted into the summary below.",
    hasTail
      ? "Complete API rounds that arrived while compaction was running follow this summary."
      : "No additional API rounds arrived while compaction was running.",
    "",
    summary.trim(),
    "</conversation-summary>",
  ].join("\n")
}
```

文件清单由 `buildFileManifest`（`compaction.ts:82`）从被压缩的历史里取最近读写过的 5 个文件，注明读了全文、读了哪几行、写入还是编辑，并提醒模型编辑前先重读。发请求时 `compactionProjection`（`message-v2.ts:638`）按投影重排历史，摘要所在的 assistant 消息被改写成一条 user 消息（`message-v2.ts:835`）。

**失败回滚**。摘要请求本身溢出、陷入复读、没产出就停下，或者没有任何非空文本时，`rollback`（`compaction.ts:507`）给摘要消息记上错误并删掉边界消息，历史恢复原状，本轮循环停止。

`compaction.ts` 里还留着上游的 `prune`（`compaction.ts:283`，加了按 `agentID` 切片），但生产代码不再调用它，回合结束时调用的是下一节的 `SessionPrune.prune`。

## 修剪：prune.ts

上游每次循环结束都在后台跑一遍 `compaction.prune`。MiMo 换成 `packages/opencode/src/session/prune.ts` 里的同名函数（`prune.ts:430`），调用点在 `prompt.ts:4854`，门槛一共三道：

1. `compaction.prune` 必须显式打开。
2. 缓存必须已经过期：距上一条 assistant 完成超过模型的 `cacheTTL`，没配就是 300000 毫秒（`prune.ts:53`）。修剪会改写旧消息，改写之后请求前缀就变了；等缓存自己过期再动手，不会打掉一份仍然有效的缓存。
3. 压力等级不为 0，即用量至少到了触发线的 50%。

候选的挑法沿用上游：从新往旧跳过最近两轮，遇到摘要消息或已经清除过的输出就停，`skill` 工具的输出受保护，最近 40000 token 的工具输出不动，更早的才是候选。之后按压力分级处理（`prune.ts:480`）：

```ts
if (level === 1) {
  for (const part of toPrune) {
    if (part.state.status === "completed") {
      const output = part.state.output
      if (output.length > SOFT_TRIM_THRESHOLD) {
        part.state.output =
          output.slice(0, SOFT_TRIM_KEEP_HEAD) +
          "\n\n[... trimmed — kept first and last 1.5K of " +
          output.length +
          " chars ...]\n\n" +
          output.slice(-SOFT_TRIM_KEEP_TAIL)
        yield* session.updatePart(part)
      }
    }
  }
  log.info("soft-trimmed", { count: toPrune.length })
```

- **1 级**（用量在触发线的 50% 到 70% 之间）：输出超过 4096 字符的候选只保留头尾各 1536 字符，中间换成说明，直接写回数据库。
- **2 级及以上**（70% 起）：候选合计超过 20000 token 才动手，给它们打上 `time.compacted`，发送时换成清除占位；同时 `stripNonEssential`（`prune.ts:188`）把最近三轮以前的媒体附件换成 `[stripped: …]` 形式的文件名，并清空 assistant 的思考文本。

重建时的微压缩与这里无关，它只看工具名单、不看压力，见[上下文重建](https://daiw.org/manual/mimo-code/context-rebuild)。

## 请求前缀：冻结快照

提示词缓存要求请求前缀逐字节相同。上游 v1.14.19 每一步都重新拼系统提示词（环境信息、技能列表、指令文件），其中任何一处变了，前缀就跟着变。MiMo 加了一张 `session_prefix_snapshot` 表，按会话与“画像”冻结前缀。表定义在 `packages/opencode/src/session/session.sql.ts:63`；从迁移目录 `packages/opencode/migration/20260829000000_session_prefix_snapshot` 与 `packages/opencode/migration/20260831000000_session_prefix_tools` 的日期看，它是 2026 年 8 月底才加进来的。画像由这些字段组成（`packages/opencode/src/session/prefix-snapshot.ts:12`）：

```ts
type Profile = {
  providerID: string
  modelID: string
  agent: string
  agentID: string
  harness: string
  systemMode: string
  system: string
  permission: Permission.Ruleset
}
```

字段按键名排序做稳定序列化后取 SHA-256，作为 `profile_key`。换模型、换 agent、改会话提示词或权限，都会落到另一份快照上。

主循环每一步（从 `prompt.ts:4430` 起）先按画像查快照，再决定用哪份系统提示词（`prompt.ts:4493`）：

- 没有快照：照常拼出系统提示词，`pin` 写入快照。
- 有快照，工具表哈希也一致：直接用快照里的系统提示词，不再重新生成环境信息、技能与指令部分。
- 工具表变了：重新拼系统提示词，`rotate` 覆盖快照，修订号加一。
- 这一步成功完成后调用 `advance`，修订号没变才更新快照上的水位线。

快照表里的 `watermark_message_id` 与 `system_hash` 目前只写不读，源码中找不到读取它们的地方。

同一份快照还用在两处：压缩请求直接取它的系统提示词与工具定义（见上一节）；检查点写手分叉时，`prefixCaptureRef` 指向的闭包（`prompt.ts:474`）用同样的画像查快照，保证写手的前缀与父请求一致，分叉的细节见[检查点写手](https://daiw.org/manual/mimo-code/checkpoint-writer)。

`buildLLMRequestPrefix`（`packages/opencode/src/session/llm-request-prefix.ts:35`）是主循环与写手分叉共用的前缀构造函数：输入消息列表、agent、模型，以及系统提示词追加项或冻结的系统提示词，输出系统提示词、schema 形式的工具表与模型消息。两处调用同一个函数，前缀一致就成了结构上的结果，不必另做断言。唯一有意的差异是主循环打开 `collapseCheckpointTail`，写手不开。

`prefixCaptureRef`（`packages/opencode/src/session/prefix-capture-ref.ts:48`）只是一个可变引用。`tryStartCheckpointWriter` 要调用 `buildLLMRequestPrefix`，后者依赖 `LLM.Service` 与 `ToolRegistry.Service`，而 `ToolRegistry` 又依赖 `SessionCheckpoint`，直接声明依赖会让 Layer 成环。于是由已经握有这些服务的 `SessionPrompt.layer` 在初始化时把闭包填进去（`prompt.ts:536`），写手启动时再去取，取不到就不带 `forkContext` 启动。`packages/opencode/src/actor/spawn-ref.ts` 用的是同一个办法。

下一篇：[分层记忆](https://daiw.org/manual/mimo-code/memory)——重建时注入的项目记忆、全局记忆与会话笔记各由谁来写，又怎样被检索。
