# 周期：逻辑会话与物理窗口

> MiMo Code 怎样把一个会话切成多段上下文窗口：检查点按比例分档在后台写入，用量到达触发线就从检查点重建，写不出检查点才退回压缩。

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

上游 OpenCode 的长会话只有一种收场：用量逼近窗口上限时插入一条压缩请求，由 compaction agent 把旧历史写成摘要，此后模型只看摘要和它之后的消息（见[上下文压缩](https://daiw.org/manual/opencode/compaction)）。摘要在窗口快满时一次写成，写漏的内容，模型之后就看不到了。

MiMo Code 的思路是把“会话”和“窗口”分开。一个会话在数据库里始终是同一个 sessionID、一条不删的消息流，模型每次看到的却只是其中最新的一段。窗口还在增长时，后台的检查点写手就按档位反复更新 `checkpoint.md` 等文件；用量到达触发线，主循环在消息流里插入一条重建边界，下一次请求从检查点、记忆和近期活动重新拼出上下文，新的一段窗口从这里开始。官方博客给的理由是高占用时模型表现会变差，与其在上限处仓促压缩，不如在增长途中分批把状态抽出来；它把以一次重建收尾的这串回合称为一个周期（cycle）。

代码里没有名叫 cycle 的对象，周期由三件事拼出来：`packages/opencode/src/session/prune.ts` 里的检查点档位、`packages/opencode/src/session/overflow.ts` 算出的触发线，以及 `packages/opencode/src/session/prompt.ts` 插入的边界消息。

## 怎么用

默认开启，不需要任何操作。能感知和调整的地方：

- **重建标记**：TUI 在边界处显示一行“⟲ 上下文已重建 较早消息已摘要”（文案在 `packages/opencode/src/cli/cmd/tui/i18n/zh.ts:598`）；重建后第一条回复出来之前，输入框底部的用量读数显示为待定（`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:537`）。
- **`/rebuild`**：立即重建，命令登记在 `packages/opencode/src/command/index.ts:182`。已有检查点就直接重建；还没有就当场拉起写手、最多等 5 分钟再重建；写手失败才退回压缩。它不触发模型回复，结果只在会话状态栏里报告。
- **配置 `checkpoint`**：`thresholds` 自定义档位（`"40%"`、`"100K"`、`"1.5M"` 都行），`reserved` 是档位上限的安全余量，`fork` 决定写手是否分叉父请求前缀（见[检查点写手](https://daiw.org/manual/mimo-code/checkpoint-writer)），`push_caps` 是重建注入的分段预算（见[上下文重建](https://daiw.org/manual/mimo-code/context-rebuild)）。schema 的说明文字与代码不符，写着默认档位 `["40%", "60%", "80%"]`、`reserved` 默认 20000（`packages/opencode/src/config/config.ts:287`、`config.ts:290`），代码里的真实默认值见下一节。
- **关掉**：`memory.disable_write: true` 不再写任何记忆，越过触发线时直接压缩，并在会话里留一条说明；环境变量 `MIMOCODE_DISABLE_CHECKPOINT` 关掉写手、重建，以及系统提示词和工具说明里关于检查点的文字，同样退回压缩（`packages/opencode/src/flag/flag.ts:142`）。
- **触发线**：由 `compaction.max_context`、`/context-limit` 与 `MIMOCODE_COMPACTION_TRIGGER_RATIO` 决定，算法见[压缩、修剪与请求前缀](https://daiw.org/manual/mimo-code/compaction-and-prune)。`compaction.auto: false`（或 `MIMOCODE_DISABLE_AUTOCOMPACT`）只关掉按用量主动重建：档位写手照常触发，提供方报上下文溢出时也仍会重建。

检查点相关文件都在数据目录的 `memory` 子目录下。`<data>` 在 Linux 上默认是 `~/.local/share/mimocode`，设了 `MIMOCODE_HOME` 则是它下面的 `data`。路径函数集中在 `packages/opencode/src/session/checkpoint-paths.ts`：

| 文件 | 路径 | 说明 |
| --- | --- | --- |
| 会话检查点 | `<data>/memory/sessions/<sid>/checkpoint.md` | 每会话一份，写手原地改写 |
| 会话笔记 | `<data>/memory/sessions/<sid>/notes.md` | 主智能体随手记，写手整理后清回模板 |
| 任务进展 | `<data>/memory/sessions/<sid>/tasks/<TID>/progress.md` | 子智能体按任务写 |
| 项目记忆 | `<data>/memory/projects/<pid>/MEMORY.md` | 同一项目跨会话共享 |
| 全局记忆 | `<data>/memory/global/MEMORY.md` | 跨项目，重建时注入 |

水位线存在数据库 `session` 表的 `last_checkpoint_message_id` 列（`packages/opencode/src/session/session.sql.ts:46`），记录最近一次成功的写手覆盖到哪条消息。

## 两条线：保鲜线与触发线

周期里有两条作用不同的线，下图是主循环每轮开头对它们的检查：

```mermaid
flowchart TD
  A["循环开头：取最新一段窗口"] --> B{"越过新的检查点档位"}
  B -- "是" --> C["后台启动检查点写手"]
  B -- "否" --> D{"用量达到触发线"}
  C --> D
  D -- "否" --> E["调用模型"]
  D -- "是" --> F{"主智能体"}
  F -- "否" --> G["按 actor 切片压缩"]
  F -- "是" --> H{"有可用检查点"}
  H -- "有" --> I["插入重建边界，重置档位"]
  H -- "没有" --> J["当场写检查点并等待"]
  J -- "成功" --> I
  J -- "失败" --> K["退回压缩"]
  E --> A
  G --> A
  I --> A
  K --> A
```

**触发线**是 `usable`：有效窗口乘以 `MIMOCODE_COMPACTION_TRIGGER_RATIO`（默认 0.9）后向下取整（`packages/opencode/src/session/overflow.ts:90`）。有效窗口是提供方上限与 `compaction.max_context` 预算中较小的那个。上一条已完成 assistant 的用量（有 `total` 取 `total`，否则是 input、output 与缓存读写之和）达到它，就该换窗口了。

**保鲜线**是检查点档位，分母同样是 `usable`。默认档位随窗口大小变化（`packages/opencode/src/session/prune.ts:44`）：

```ts
export function defaultThresholdsFor(window: number): readonly string[] {
  if (window < 25_000) return []
  if (window <= 200_000) return ["20%", "40%", "60%", "80%"]
  if (window <= 500_000) {
    return ["10%", "20%", "30%", "40%", "50%", "60%", "70%", "80%", "90%"]
  }
  return Array.from({ length: 18 }, (_, i) => `${(i + 1) * 5}%`)
}
```

| `usable` 大小 | 默认档位 | 档数 |
| --- | --- | --- |
| 不到 25K | 无，不写检查点 | 0 |
| 25K 到 200K（含） | 20%、40%、60%、80% | 4 |
| 超过 200K 到 500K（含） | 10% 到 90%，每 10% 一档 | 9 |
| 超过 500K | 5% 到 90%，每 5% 一档 | 18 |

以 200K 窗口、没设预算的模型为例：触发线是 180K，档位落在 36K、72K、108K、144K，折合原始窗口的 18%、36%、54%、72%。1M 窗口的模型触发线是 900K，18 档从 45K 排到 810K。博客写的“约在 20%、45%、70% 处触发”，在代码里没有对应的档位表。

档位字符串经 `resolveThresholds`（`prune.ts:94`）换算成 token 数，上限 `maxAllowed` 是 `usable` 减去 `checkpoint.reserved`（默认 13000，`prune.ts:27`）；第一个超限的档位被夹到 `maxAllowed`，其后超限的丢弃，最后排序去重。`maxAllowed` 不大于 0 时直接报错。

`fireCheckpoints`（`prune.ts:241`）在每轮开头、调用模型之前运行，依次跳过三种情况：执行者不负责检查点（子智能体，以及写手、dream、distill 这些系统智能体，由 `servesCheckpoint` 判断）；检查点被环境变量关闭；本会话已有写手在跑，此时不记账，等写手结束后下一轮补发。然后对每个已越过、尚未触发过的档位调用 `tryStartCheckpointWriter`，记进内存里的 `crossed` 集合。一次越过多档时，第一档启动写手，后面的请求进单槽等待队列，新请求顶掉旧请求，因为新请求要覆盖的范围总包含旧的（`packages/opencode/src/session/checkpoint.ts:673`）。

写手失败不需要专门重试：水位线只在成功时推进，下一档自然会重新覆盖同一段增量。唯一的例外是最后一档，它后面没有下一档；如果它的写手以可重试的错误结束，就在“触发时的用量加一个档距”处设一道恢复闸（不超过 `maxAllowed`），用量涨到那里再补发一次（`prune.ts:397`）。

## 越过触发线：重建还是压缩

两条线在主循环里前后相邻（`packages/opencode/src/session/prompt.ts:3915`），注释写明档位只负责保鲜：

```ts
// Fire background checkpoint writers for any newly-crossed thresholds
// based on the latest completed assistant message's tokens. These
// thresholds only keep the checkpoint fresh; `overflowCheck` below is
// the single trigger for rebuilding the active context.
if (!skipOverflowCheck && !usageRecovered && !isBoundedComputation && lastFinished && lastFinished.tokens) {
  const fireOps = yield* ops()
  yield* prune
    .fireCheckpoints({
      sessionID,
      model,
      tokens: lastFinished.tokens,
      promptOps: fireOps,
      agentID: lastUser.agentID,
    })
    .pipe(Effect.ignore)
}

if (
  !skipOverflowCheck &&
  !usageRecovered &&
  !isBoundedComputation &&
  lastFinished &&
  lastFinished.summary !== true &&
  overflowCheck({ cfg: yield* config.get(), tokens: lastFinished.tokens, model })
) {
```

`docs/compose/spec/context-budget-control.md` 记录了这个取舍：早先最后一档也会触发重建，等于在触发线之前又打一次折扣；现在触发线是唯一的自动切换点。

越线之后先分主次。子智能体（`agentID` 不是 `main`）没有检查点，插入一条按 actor 切片的压缩边界（`prompt.ts:3947`）。主智能体进入 `rebuildEnsuringCheckpoint`（`prompt.ts:668`），这是整个文件里唯一决定“能不能退回压缩”的地方，按用量越线、提供方报溢出（`prompt.ts:4736`）和手动 `/rebuild` 都走它。结果有五种（`prompt.ts:653`）：

| 结果 | 条件 | 自动路径的后续 |
| --- | --- | --- |
| `rebuilt` | 已有可用检查点，或当场写成后插入成功 | 本轮跳过溢出检查，继续循环 |
| `insert-failed` | 检查点在，但重建内容为空等原因没能插入 | 不压缩，照常发请求，由提供方溢出处理兜底 |
| `writer-failed` | 没有检查点，当场拉起写手，等待后仍失败或超时 | 退回压缩 |
| `memory-write-off` | `memory.disable_write` 为真 | 退回压缩，会话里留一条说明 |
| `checkpoint-off` | `MIMOCODE_DISABLE_CHECKPOINT` 开启 | 退回压缩，会话里留一条说明 |

手动 `/rebuild` 拿到同样的结果，只是不进入循环：插入边界或压缩之后就回到空闲，把结论写进状态栏。

“可用检查点”要求文件存在且水位线已设。只看文件不够：写手启动前会先写一份空模板，文件在、水位线却还没有，这是正常的中间状态。当场写检查点时，自动路径最多等 180 秒，手动 `/rebuild` 最多等 300 秒（`prompt.ts:624`）；放弃等待不会取消写手，它仍在后台跑完。退回压缩时插入的是普通压缩边界：自动路径的下一轮循环会据此生成摘要；手动 `/rebuild` 不进入循环，按代码这条边界之后不会再被处理，边界之前的历史直接离开模型视野，见[压缩、修剪与请求前缀](https://daiw.org/manual/mimo-code/compaction-and-prune)。

## 边界消息：一个会话，多段窗口

重建不删任何消息，只写一条新的 user 消息，挂一个 `checkpoint` 部件和几段 synthetic 文本，正文怎么拼见[上下文重建](https://daiw.org/manual/mimo-code/context-rebuild)。关键在它的创建时间（`checkpoint.ts:1673`）：

```ts
const syntheticTime = (input.boundaryCreatedAt ?? Date.now()) + 1
const msg = yield* session.updateMessage({
  id: MessageID.ascending(),
  role: "user" as const,
  model: { providerID: input.model.providerID as ProviderID, modelID: input.model.modelID as ModelID },
  sessionID: input.sessionID,
  agent: input.agent,
  time: { created: syntheticTime },
})
```

`boundaryCreatedAt` 是水位线那条消息的创建时间，在当前窗口里找不到它时才退回当前时间。消息分页按 `time_created`、再按 `id` 倒序排（`packages/opencode/src/session/message-v2.ts:1125`），所以这条边界紧跟在水位线后面，排在水位线之后那些消息的前面。主循环每轮取历史时，`filterCompacted`（`message-v2.ts:1204`）从新往旧走，遇到第一条带 `checkpoint` 或 `compaction` 部件的 user 消息就停：

```ts
export function filterCompacted(msgs: Iterable<WithParts>) {
  const result = [] as WithParts[]
  for (const msg of msgs) {
    result.push(msg)
    if (msg.info.role === "user" && msg.parts.some((p) => p.type === "checkpoint" || p.type === "compaction")) break
  }
  result.reverse()
  return compactionProjection(result)
}
```

于是每轮读到的只是最新一段窗口：边界消息，加上它之后的消息。上游 v1.14.19 的同名函数只在压缩摘要已经成功生成时才截断，并按 `tail_start_id` 往前多留一段；MiMo 改成见到边界就停，摘要与尾部的重排挪进了 `compactionProjection`。

把周期串起来的还有几处细节：

- **水位线与文件同进退**：写手成功才把 `last_checkpoint_message_id` 推到这次的水位线（`checkpoint.ts:1023`），失败或被取消就原地不动；模板只在文件不存在时写入，写手失败不会让上一份检查点被空模板覆盖。
- **档位重置**：边界插入成功后调用 `resetThresholds`（`prompt.ts:604`），清空 `crossed` 与恢复闸（`prune.ts:517`）。新窗口的用量从低处重新涨，档位再一档档触发，下一个周期开始。
- **同一份用量只处理一次**：边界插入后，上一条 assistant 的高用量记录还留在历史里。`usageRecovered`（`prompt.ts:3660`）检查有没有比它更新的边界，有就跳过档位与溢出检查，免得同一条记录触发两次重建。
- **会话仍是一个**：TUI 照常显示全部历史。`SessionRunState` 的 runner 键从 sessionID 改成了 `sessionID:agentID`（`packages/opencode/src/session/run-state.ts:29`），同一会话里的主智能体与各个 actor 各自排队，只有 `main` 的 runner 会改动会话的 busy、idle 状态；写手则干脆跑在子会话里。
- **退出前等写手**：`mimo run` 结束时最多等 120 秒让后台写手收尾（`packages/opencode/src/cli/bootstrap.ts:17`），TUI 的 worker 退出时等 30 秒（`packages/opencode/src/cli/cmd/tui/worker.ts:123`）。

## 与上游压缩的区别

| 方面 | OpenCode v1.14.19 | MiMo Code |
| --- | --- | --- |
| 窗口增长途中 | 只有可选的修剪，默认关闭 | 按档位在后台维护检查点、项目记忆与笔记 |
| 触发 | 用量达到窗口减去输出或压缩预留 | 用量达到有效窗口的 90%（可调） |
| 旧历史的去向 | compaction agent 当场写一份摘要 | 从检查点与记忆按预算重建，写不出检查点才压缩 |
| 状态存放 | 摘要是消息表里的一条 assistant 消息 | 文件在 `<data>/memory` 下，水位线在 `session` 表 |
| 子智能体 | 无此概念 | 仍用压缩，按 `agentID` 切片 |

下一篇：[检查点写手](https://daiw.org/manual/mimo-code/checkpoint-writer)——档位触发之后，谁来改写 `checkpoint.md`，它看到什么、又怎样被限制只写该写的文件。
