上下文重建与预算化注入

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

作者 David更新于 第 7 篇(共 33 篇)

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

怎么用

  • 触发:用量越过触发线、提供方报上下文溢出,或手动 /rebuild,见周期。
  • 界面:边界处出现一行“上下文已重建”标记;重建后第一条回复出来之前,底部用量显示为待定。
  • 预算:checkpoint.push_caps 调整各段上限,schema 在 packages/opencode/src/config/config.ts:296。代码实际读取的键与默认值如下:
键默认管哪一段
tasks_ledger2000任务台账,按字符截断而不是 token
checkpoint11000checkpoint.md
actor_ledger500活跃 actor 清单
recent_user16000用户原话合计,设 0 关闭这一段
recent_user_per_msg2000单条用户原话
memory10000项目 MEMORY.md
global6000全局 MEMORY.md
notes6000notes.md
memory_titles500记忆文件路径索引

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 依次:

  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 checkpointcheckpoint.md11000 token,分节截断
3# Active actors状态为 pending 或 running 的后台 actor500 token,逐行装,装不下即停
4# Recent user input (verbatim)最近 200 条主线消息中的用户原话合计 16000 token,从新往旧装;单条超过 2000 保留头尾
5# Project durable knowledge项目 MEMORY.md10000 token,分节截断
6# Global memory全局 MEMORY.md6000 token,分节截断
7# Session notesnotes.md6000 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 消息则不追加。

合起来,重建后的第一个请求依次是:

  1. 系统提示词与工具表,沿用会话冻结的前缀快照,见压缩、修剪与请求前缀。
  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)。记忆文件本身由谁写、怎样检索,见分层记忆。

与设计文档对照

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

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

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

下一篇:压缩、修剪与请求前缀——写不出检查点时的兜底压缩、平时的修剪,以及为提示词缓存冻结的请求前缀。

本页目录