# 上下文重建与预算化注入

> 越过触发线后，MiMo Code 怎样插入重建边界，按分段预算把检查点、用户原话与各级记忆拼成一条消息，并把水位线之后的尾部折叠成活动清单。

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

检查点写好只是一半。越过触发线时，`rebuildFromCheckpoint`（`packages/opencode/src/session/prompt.ts:570`）要回答的是：下一次请求里放什么。MiMo Code 不删任何消息，只在水位线后面插入一条 user 消息，把任务台账、检查点、用户原话、各级记忆和近期活动按预算拼成它的正文。水位线之前的历史从模型视野里消失；之后的尾部除了用户原话，都被折叠成一份活动清单。

## 怎么用

- **触发**：用量越过触发线、提供方报上下文溢出，或手动 `/rebuild`，见[周期](https://daiw.org/manual/mimo-code/cycles)。
- **界面**：边界处出现一行“上下文已重建”标记；重建后第一条回复出来之前，底部用量显示为待定。
- **预算**：`checkpoint.push_caps` 调整各段上限，schema 在 `packages/opencode/src/config/config.ts:296`。代码实际读取的键与默认值如下：

| 键 | 默认 | 管哪一段 |
| --- | --- | --- |
| `tasks_ledger` | 2000 | 任务台账，按字符截断而不是 token |
| `checkpoint` | 11000 | `checkpoint.md` |
| `actor_ledger` | 500 | 活跃 actor 清单 |
| `recent_user` | 16000 | 用户原话合计，设 0 关闭这一段 |
| `recent_user_per_msg` | 2000 | 单条用户原话 |
| `memory` | 10000 | 项目 `MEMORY.md` |
| `global` | 6000 | 全局 `MEMORY.md` |
| `notes` | 6000 | `notes.md` |
| `memory_titles` | 500 | 记忆文件路径索引 |

schema 里另有 `focus_task`、`design_decisions`、`open_notes` 三个键，源码中找不到读取它们的地方。

## 水位线：检查点覆盖到哪

水位线在写手启动时就定下了，重建时直接沿用。`computeBoundary`（`packages/opencode/src/session/checkpoint.ts:295`）从最后一条已完成 assistant 的前一条消息起算尾巴，逐条估算 token（把每个部件 `JSON.stringify` 后按 4 个字符算 1 个 token），然后（`checkpoint.ts:316`）：

```ts
// Natural tail already >= cap: leave it alone (soft ceiling; do NOT pull
// boundary forward — see jsdoc rationale).
if (tailSum >= TAIL_MAX_TOKENS) {
  return msgs[startIdx].info.id
}

// Tail too small — pull boundary earlier (include more history)
// until both floors met, capped at TAIL_MAX_TOKENS.
while (
  startIdx > 0 &&
  tailSum < TAIL_MAX_TOKENS &&
  (tailSum < TAIL_MIN_TOKENS || textBlockCount < TAIL_MIN_TEXT_BLOCK_MESSAGES)
) {
  startIdx -= 1
  tailSum += tokens[startIdx]
  if (hasTextBlocks(msgs[startIdx])) textBlockCount += 1
}
return msgs[startIdx].info.id
```

三个常量是 `TAIL_MIN_TOKENS` 10000、`TAIL_MAX_TOKENS` 20000、`TAIL_MIN_TEXT_BLOCK_MESSAGES` 5（`checkpoint.ts:191`）。尾巴已经有 2 万 token 就不动；否则往前扩，直到尾巴至少 1 万 token、且至少有 5 条带正文或思考的消息，一碰到 2 万就停。2 万只是往前扩的软上限：单轮工具输出本身超过 2 万时尾巴照样更大，因为按消息截断会把工具调用和结果拆开。

之后 `adjustBoundaryForApiInvariants`（`packages/opencode/src/session/boundary.ts:34`）再往前挪，保证 tool_use 与 tool_result 不被拆到两边、同一 message.id 的思考块不被切开。OpenCode 的工具部件把调用和结果放在同一条 assistant 消息里，这一步目前等于空操作，代码注释也这么说（`checkpoint.ts:708`）。

水位线消息本身归检查点覆盖：分叉的写手收到的历史截到水位线并包含它，重建边界则排在它之后。水位线之后这段尾巴写手没看到，重建时靠“近期活动”补上。

## 插入边界

`rebuildFromCheckpoint` 要求检查点文件存在且水位线已设，然后调用 `insertRebuildBoundary`（`checkpoint.ts:1644`）。除了 agent 与模型，交给它的关键参数有三个：水位线 `boundary`，此刻最新一条消息的 ID `digestUpTo`，以及最后一条消息的形态 `lastMessageInfo`。`insertRebuildBoundary` 依次：

1. 调用 `renderRebuildContext` 渲染正文；正文为空就放弃插入，返回 false，上层据此判为 `insert-failed`。
2. 新建 user 消息，创建时间取水位线消息的时间加 1 毫秒，让它排在水位线后面。
3. 按顺序写入部件：`checkpoint` 部件（`coveredUpTo` 为水位线，真渲染出近期活动时才带 `digestUpTo`）、检查点目录索引、重建正文、本会话仍在运行的后台 actor 清单，后三者都是 synthetic 文本。
4. 微压缩：创建时间晚于水位线的消息里（查询不分 agent 切片，同一会话里子智能体的消息也算在内），`read`、`view_image`、`bash`、`grep`、`glob`、`webfetch`、`websearch`、`edit`、`write`、`multiedit`、`apply_patch`、`codesearch` 的已完成结果打上 `time.compacted`（名单在 `checkpoint.ts:222`），发给模型时换成 `[Old tool result content cleared]`（`packages/opencode/src/session/message-v2.ts:979`）。actor、task、question、skill、memory 等工具的结果带着后面还要引用的状态，不清。

## 正文：分段与预算

`renderRebuildContext`（`checkpoint.ts:1252`）只为主智能体渲染，其他 `agentID` 一律返回空（`checkpoint.ts:1270`）。本会话若有写手在跑，它先把状态设为 “Preparing conversation context…” 再等：已有水位线时最多等 30 秒，还没有时最多等 5 分钟（`checkpoint.ts:201`、`checkpoint.ts:209`）。经 `rebuildFromCheckpoint` 进来时水位线必然已设，实际走的是 30 秒那一档；超时就用磁盘上现有的检查点。

读完各数据源后，若任务、检查点、项目记忆、全局记忆、活跃 actor、用户原话全为空，返回空串。否则先写一句总说明（`checkpoint.ts:1420`）：

> The sections below are auto-loaded session context already in this message. File-backed sections list their path on a `File:` line — Grep that path for specific facts; do not Read the whole file again.

再按下表顺序拼接，每段一个一级标题。来自文件的段在标题下多一行 `File:` 加路径，并删掉文件自己的一级标题，让后面的二级标题都挂在这一段下面：

| 顺序 | 标题 | 来源 | 上限与截断 |
| --- | --- | --- | --- |
| 1 | `# Tasks ledger` | 任务注册表，含已结束任务；子任务按父任务汇成一行 | 2000 字符，超出截断 |
| 2 | `# Session checkpoint` | `checkpoint.md` | 11000 token，分节截断 |
| 3 | `# Active actors` | 状态为 pending 或 running 的后台 actor | 500 token，逐行装，装不下即停 |
| 4 | `# Recent user input (verbatim)` | 最近 200 条主线消息中的用户原话 | 合计 16000 token，从新往旧装；单条超过 2000 保留头尾 |
| 5 | `# Project durable knowledge` | 项目 `MEMORY.md` | 10000 token，分节截断 |
| 6 | `# Global memory` | 全局 `MEMORY.md` | 6000 token，分节截断 |
| 7 | `# Session notes` | `notes.md` | 6000 token，按比例截断 |
| 8 | `# Memory keys index` | 记忆全文索引中属于本会话、本项目与全局的文件路径 | 500 token，逐行 |
| 9 | `# Recent activity` | 水位线到 `digestUpTo` 之间的消息 | 最多 200 行 |

几处补充：

- **第 3 段**用的 `listActive()` 只按状态与 background 过滤，查询里没有会话条件（`packages/opencode/src/actor/registry.ts:299`），按代码，同一数据库里其他会话的后台 actor 也会列进来。插入步骤里另附的那份清单才是按本会话筛的。
- **第 4 段**跳过带工具、`checkpoint`、`compaction` 部件的 user 消息，也不收 synthetic 文本，免得把上一次重建的正文再吞进来。单条超过上限时，保留相当于上限 60% 的开头与 30% 的结尾，中间换成一行省略标记，给出 messageID 并提示用 history 工具的 `operation=around` 取全文。
- **第 8 段**先让记忆索引与磁盘对齐，写手在工具之外写下的文件也能列出来；已经注入的项目 `MEMORY.md`、`checkpoint.md`、全局 `MEMORY.md` 不重复列。检查点溢出文件、任务进展文件都以相对记忆根目录的路径出现在这里。

截断由 `packages/opencode/src/session/budgeted-read.ts` 里的两个函数完成。`readBudgeted`（`budgeted-read.ts:9`）用于 notes：超预算就按“预算除以总量再乘 0.95”的比例截到最后一个换行，末尾附一行提示，给出总量与续读用的 offset。`readBudgetedSectionAware`（`budgeted-read.ts:65`）用于检查点与两份 `MEMORY.md`：先保证前言、每个二级标题、斜体说明行和 `- See x.md (N` 形式的索引行都在，再按节的先后往里填正文，填不下的节截掉一部分（剩余预算超过 50 才截）；如果光这副骨架就超预算，只输出骨架。

按 token 计的上限合计是 50000（第 2 到第 8 段），再加第 1 段约 500 token、第 9 段按行数封顶的清单与几句固定文案。博客说重建注入“约 65K token”，代码里没有这个总数，也没有总上限。博客列的注入顺序与代码一致，只是没提第 3 段和第 9 段。

## 尾部：折叠成活动清单

水位线之后、插入时刻之前的消息，在插入时被渲染成第 9 段（`packages/opencode/src/session/tail-digest.ts:83`）。user 消息与旧的边界消息跳过；assistant 的正文写成 `- assistant: …`，截到 240 字符；每个工具调用写成“工具名加参数”，每个参数截到 80 字符，出错的附 `→ error:` 与错误信息，没跑完的附 `→ interrupted`，并在段首提醒需要的话重跑。工具结果一律不进清单。

发请求时，主循环构造前缀打开了 `collapseCheckpointTail`（`prompt.ts:4486`），把已经写进清单的 assistant 消息从请求里拿掉（`tail-digest.ts:115`）：

```ts
export function collapseCheckpointTail(msgs: readonly WithParts[]): WithParts[] {
  const boundary = msgs.findLast((m) => m.info.role === "user" && m.parts.some((p) => p.type === "checkpoint"))
  if (!boundary) return msgs as WithParts[]

  const part = checkpointPart(boundary)
  if (!part?.digestUpTo) return msgs as WithParts[]

  const digestUpTo = part.digestUpTo
  const coveredUpTo = part.coveredUpTo
  const live = msgs.filter(
    (m) => m.info.id <= coveredUpTo || m.info.id > digestUpTo || m.info.role === "user",
  )
  if (live.length === msgs.length) return msgs as WithParts[]
  return live as WithParts[]
}
```

按消息 ID 区间过滤，而不是按边界在数组里的位置，是为了防止与边界同一毫秒生成的消息排到边界前面、逃过折叠。user 消息一律留下：最后一条 user 消息上挂着技能目录等提醒，拿掉了模型就收不到。自动重建发生在工具循环中途时，插入之后新跑的工具轮次原样发送；手动 `/rebuild` 发生在空闲时，整段尾巴都被折叠。检查点写手的分叉前缀不开这个选项，写手看到的仍是完整尾部（`packages/opencode/src/session/llm-request-prefix.ts:25`）。

## 结尾两句与最终请求

正文最后是一句衔接说明（`checkpoint.ts:1566`）：

```ts
lines.push("")
lines.push(
  hasActivity
    ? "This session is continued from a checkpoint. The blocks above cover earlier history and the recent activity list. Resume the last task from that list and any live messages after it. Do not recap this dump."
    : "This session is continued from a checkpoint. The blocks above cover earlier history. Resume the last task from the most recent live messages. Do not recap this dump.",
)
```

然后按尾部最后一条消息的形态追加提醒，形态由 `computeLastMessageInfo` 判断（`packages/opencode/src/session/last-message-info.ts:23`）。最后是 finish 为 `tool-calls` 的 assistant，说明正处在自主循环中间，追加（`checkpoint.ts:125`）：

```ts
function autonomousLoopReminder(): string {
  return [
    "<system-reminder>",
    "You are mid-loop in an autonomous task. Continue your work loop:",
    "respond to the tool results below and proceed to the next iteration.",
    "</system-reminder>",
  ].join("\n")
}
```

最后是以其他方式结束的 assistant，就提醒模型先对照任务的 `progress.md` 核对规格，确实完成了再停；最后是 user 消息则不追加。

合起来，重建后的第一个请求依次是：

1. 系统提示词与工具表，沿用会话冻结的前缀快照，见[压缩、修剪与请求前缀](https://daiw.org/manual/mimo-code/compaction-and-prune)。
2. 重建边界消息，文本依次为：`Summary of previous conversation from checkpoint files:`（由 `checkpoint` 部件在转换时生成，`message-v2.ts:885`）；`## Checkpoint` 索引，给出目录与当前主题；上面拼好的正文；本会话的 `## Active Actors` 清单。
3. 水位线之后的 user 消息原文。
4. 插入之后才产生的消息，原样发送，其中被微压缩的工具结果显示为清除占位。

另外，只要本会话有记忆文件或任务，主循环每轮还会在内存里给最后一条 user 消息追加一段不落库的提醒，给出会话记忆目录，以及 `memory`、`task`、`actor` 工具的检索写法，注释估算每轮约 120 token（`prompt.ts:3668`）。记忆文件本身由谁写、怎样检索，见[分层记忆](https://daiw.org/manual/mimo-code/memory)。

## 与设计文档对照

`docs/compose/spec/context-budget-control.md` 里有两条改动决定了重建何时发生，代码与文档一致：

- 最后一个检查点档位不再触发重建，触发线是唯一的自动切换点（文档任务 T12；代码见 `prompt.ts:3915` 的注释与其后唯一的 `overflowCheck`）。
- 同一条 assistant 用量只恢复一次（文档 S2.7）：存在比它更新的边界消息，就视为已恢复，跳过档位与溢出检查（`prompt.ts:3660`）。文档特别说明这里比较的是消息 ID 而不是时间戳，因为边界的时间戳被回拨到了水位线。

不一致的是触发线本身：文档写的是有效窗口减去各项预留，代码已改成按比例，默认 90%，详见下一篇。

下一篇：[压缩、修剪与请求前缀](https://daiw.org/manual/mimo-code/compaction-and-prune)——写不出检查点时的兜底压缩、平时的修剪，以及为提示词缓存冻结的请求前缀。
