上下文重建与预算化注入
越过触发线后,MiMo Code 怎样插入重建边界,按分段预算把检查点、用户原话与各级记忆拼成一条消息,并把水位线之后的尾部折叠成活动清单。
检查点写好只是一半。越过触发线时,rebuildFromCheckpoint(packages/opencode/src/session/prompt.ts:570)要回答的是:下一次请求里放什么。MiMo Code 不删任何消息,只在水位线后面插入一条 user 消息,把任务台账、检查点、用户原话、各级记忆和近期活动按预算拼成它的正文。水位线之前的历史从模型视野里消失;之后的尾部除了用户原话,都被折叠成一份活动清单。
怎么用
- 触发:用量越过触发线、提供方报上下文溢出,或手动
/rebuild,见周期。 - 界面:边界处出现一行“上下文已重建”标记;重建后第一条回复出来之前,底部用量显示为待定。
- 预算:
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):
// 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 依次:
- 调用
renderRebuildContext渲染正文;正文为空就放弃插入,返回 false,上层据此判为insert-failed。 - 新建 user 消息,创建时间取水位线消息的时间加 1 毫秒,让它排在水位线后面。
- 按顺序写入部件:
checkpoint部件(coveredUpTo为水位线,真渲染出近期活动时才带digestUpTo)、检查点目录索引、重建正文、本会话仍在运行的后台 actor 清单,后三者都是 synthetic 文本。 - 微压缩:创建时间晚于水位线的消息里(查询不分 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):
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):
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):
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 消息则不追加。
合起来,重建后的第一个请求依次是:
- 系统提示词与工具表,沿用会话冻结的前缀快照,见压缩、修剪与请求前缀。
- 重建边界消息,文本依次为:
Summary of previous conversation from checkpoint files:(由checkpoint部件在转换时生成,message-v2.ts:885);## Checkpoint索引,给出目录与当前主题;上面拼好的正文;本会话的## Active Actors清单。 - 水位线之后的 user 消息原文。
- 插入之后才产生的消息,原样发送,其中被微压缩的工具结果显示为清除占位。
另外,只要本会话有记忆文件或任务,主循环每轮还会在内存里给最后一条 user 消息追加一段不落库的提醒,给出会话记忆目录,以及 memory、task、actor 工具的检索写法,注释估算每轮约 120 token(prompt.ts:3668)。记忆文件本身由谁写、怎样检索,见分层记忆。
与设计文档对照
docs/compose/spec/context-budget-control.md 里有两条改动决定了重建何时发生,代码与文档一致:
- 最后一个检查点档位不再触发重建,触发线是唯一的自动切换点(文档任务 T12;代码见
prompt.ts:3915的注释与其后唯一的overflowCheck)。 - 同一条 assistant 用量只恢复一次(文档 S2.7):存在比它更新的边界消息,就视为已恢复,跳过档位与溢出检查(
prompt.ts:3660)。文档特别说明这里比较的是消息 ID 而不是时间戳,因为边界的时间戳被回拨到了水位线。
不一致的是触发线本身:文档写的是有效窗口减去各项预留,代码已改成按比例,默认 90%,详见下一篇。
下一篇:压缩、修剪与请求前缀——写不出检查点时的兜底压缩、平时的修剪,以及为提示词缓存冻结的请求前缀。