周期:逻辑会话与物理窗口

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

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

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

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 决定写手是否分叉父请求前缀(见检查点写手),push_caps 是重建注入的分段预算(见上下文重建)。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 决定,算法见压缩、修剪与请求前缀。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),记录最近一次成功的写手覆盖到哪条消息。

两条线:保鲜线与触发线

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

图表加载中…

触发线是 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):

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
超过 500K5% 到 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),注释写明档位只负责保鲜:

// 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-offmemory.disable_write 为真退回压缩,会话里留一条说明
checkpoint-offMIMOCODE_DISABLE_CHECKPOINT 开启退回压缩,会话里留一条说明

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

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

边界消息:一个会话,多段窗口

重建不删任何消息,只写一条新的 user 消息,挂一个 checkpoint 部件和几段 synthetic 文本,正文怎么拼见上下文重建。关键在它的创建时间(checkpoint.ts:1673):

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 消息就停:

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

下一篇:检查点写手——档位触发之后,谁来改写 checkpoint.md,它看到什么、又怎样被限制只写该写的文件。

本页目录