压缩、修剪与请求前缀

MiMo Code 改写后的压缩流程与失败回滚、按缓存冷热与压力分级的修剪、/context-limit 与触发线的算法,以及为提示词缓存而冻结的请求前缀快照。

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

在 MiMo Code 里,压缩从主角退成了兜底:子智能体溢出时用它;主智能体只有写不出检查点,或者检查点、记忆写入被关掉时才用它;再就是手动 /compact。可 packages/opencode/src/session/compaction.ts 相对上游 v1.14.19 的同名文件几乎重写了一遍,从 526 行变成 742 行。周围还多了三样上游没有的东西:回合结束时按压力分级的修剪 prune.ts(检查点档位也住在这个文件里),允许用户压低的触发线 overflow.ts,以及把系统提示词与工具表冻结下来的请求前缀快照。

怎么用

  • /compact(别名 /summarize):TUI 调用 POST /session/:sessionID/summarize,服务端插入一条压缩边界(默认不算自动压缩),然后运行主循环,由循环生成摘要(packages/opencode/src/server/routes/instance/session.ts:677)。
  • /context-limit:为当前模型选一个比窗口更小的工作预算。选项是“模型默认”,比窗口小的 200K、300K、500K、1M 档,以及自定义(token 数、300K、1M 或 50%)。它只往全局配置里写当前模型那一个键,选“模型默认”写 0;有会话在运行时拒绝保存,因为写配置会销毁实例、取消正在跑的 runner;配置里的 max_context 已经是单值时也拒绝,提示改成按模型配置(packages/opencode/src/cli/cmd/tui/component/dialog-context-limit.tsx:35)。
  • 查看:mimo models <provider> 为每个模型打印窗口、预算与压缩点(packages/opencode/src/cli/cmd/models.ts:53);/status 的 Context 区块给出同样的数字和当前用量(packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx:35)。
  • 配置 compaction(packages/opencode/src/config/config.ts:253):auto 默认开;prune 在代码里默认关,schema 说明写的 “default: true” 与实现不符;reserved 是预留量;max_context 可以是单值,也可以是以 "<providerID>/<modelID>" 为键、支持通配符的映射。tail_turns 与 preserve_recent_tokens 已标为废弃,代码不再读取。
  • 环境变量:MIMOCODE_COMPACTION_MAX_CONTEXT 在配置没写 max_context 时生效;MIMOCODE_COMPACTION_TRIGGER_RATIO 默认 0.9,可写 0.85 或 85%,取值须大于 0 且不超过 1(packages/opencode/src/flag/flag.ts:164);MIMOCODE_DISABLE_AUTOCOMPACT 与 MIMOCODE_DISABLE_PRUNE 在加载配置时把 compaction.auto、compaction.prune 强制设为 false(config.ts:1014)。

触发线:contextWindow

上游 v1.14.19 的 usable 是一个式子:模型有独立的输入上限就用它减去压缩预留,否则用上下文窗口减去最大输出。MiMo 把它拆成三个量(packages/opencode/src/session/overflow.ts:80):

export function contextWindow(input: { cfg: Config.Info; model: Provider.Model }): Window {
  const hard = input.model.limit.context === 0 ? 0 : input.model.limit.input || input.model.limit.context
  if (hard === 0) return { hard: 0, effective: 0, usable: 0, source: "model" }

  const reserved = reserves(input)
  const configured = budget(input, hard, reserved)
  const effective = configured ?? hard
  return {
    hard,
    effective,
    usable: Math.floor(effective * Flag.MIMOCODE_COMPACTION_TRIGGER_RATIO),
    source: configured === undefined ? "model" : "config",
  }
}
  • hard:提供方上限,优先取 limit.input,没有再取 limit.context。limit.context 为 0 表示未知,溢出处理整个关闭。
  • effective:用户预算生效后的工作窗口。budget()(overflow.ts:43)先读 compaction.max_context,没有再读环境变量。映射形式交给 Wildcard.all 匹配 <providerID>/<modelID> 形式的键:模式按长度从短到长排,最后一个命中的生效,也就是最长的模式优先(packages/opencode/src/util/wildcard.ts:21)。值用 Token.parseQuantity 解析,百分比相对 hard(packages/opencode/src/util/token.ts:10)。解析出 0、解析失败、不大于预留量、或不小于 hard,都当作没有预算;其中解析失败与过小会警告一次。
  • usable:effective 乘触发比例后向下取整。它是压缩与重建的触发线,也是检查点档位的分母。

预留量由 reserves()(overflow.ts:34)算出:compaction.reserved,没配时取 33000 与模型最大输出中较小的那个;模型没有独立输入上限时,再加上最大输出与 20000 中较小的那个。在现在的实现里,预留量只用来拒绝过小的预算,不再从触发线里扣除。docs/compose/spec/context-budget-control.md 的 S2.2 节仍写着“上限减去预留”,任务 T5 的验收也以 300K 预算触发于 280K 为例;按代码,300K 预算触发于 270K。

contextPressureLevel(overflow.ts:117)把用量除以 usable 分成四级:不到 50% 为 0,不到 70% 为 1,不到 85% 为 2,其余为 3;compaction.auto 为 false 时 pressureLevel 恒为 0。修剪按它决定下手轻重。

/context-limit 对话框里的换算与比例触发对不上(dialog-context-limit.tsx:122):它把“当前 effective 减 usable”当成预留量,用档位值减去它来显示“约多少时压缩”,保存成功后的提示也这么算。可按比例触发时,这个差随窗口大小变化,不是常数。例如 1M 窗口、还没设预算时差值是 100K,选 300K 档会提示约 200K,保存后的实际触发线却是 300K 乘 0.9,即 270K。

压缩:相对上游改了什么

方面上游 v1.14.19MiMo Code
用途主会话溢出时的唯一手段,也可手动子智能体溢出;主智能体写不出检查点时兜底;手动 /compact
请求构成compaction agent 的身份:compaction.txt 作系统提示词,内置模板作最后一条 user 消息,没有工具父 agent 的身份:冻结的系统提示词与工具表,compaction.txt 作最后一条 user 消息
模型compaction agent 配了模型就用它只在提供方报溢出、且 compaction agent 配了模型时才换
原样保留的尾部压缩前按轮数与 token 预算挑出只保留摘要生成期间新到达的完整轮次
模型看到的What did we do so far? 加 assistant 摘要改写成 user 消息的 <conversation-summary>,外加文件清单
失败处理只处理溢出:记错误并停止溢出、复读、没有摘要都回滚,并删除边界消息
多智能体无按 agentID 切片,session.compacted 事件带 agentID

调用链。压缩仍分两步:SessionCompaction.create(packages/opencode/src/session/compaction.ts:662)只写一条带 compaction 部件的 user 消息;下一轮循环 filterCompacted 在这条边界处截断,取到的最后一条 user 消息就是它,于是转去调用 compaction.process(packages/opencode/src/session/prompt.ts:3839)。自动路径插入边界后立刻 continue,摘要在下一轮生成;prompt.ts:3998 那段注释把退回压缩说成不带 LLM 摘要的裸边界,与这条调用链不符。手动 /rebuild 退回压缩时不进入循环(prompt.ts:5065),用户的下一条消息成了最后一条 user 消息,按代码这条边界不会再被处理:之前的历史直接离开视野,模型只看到部件转换出的一句 Summary of previous conversation:(packages/opencode/src/session/message-v2.ts:891),后面没有摘要。MiMo 还给 create 加了 agentID 参数,让边界落在对应 actor 的切片里,并立即发布 session.compacted 事件。

摘要请求复用父请求前缀(compaction.ts:483):

const summaryRequest = {
  role: "user" as const,
  content: [{ type: "text" as const, text: prompt }],
}
const result = yield* processor.process({
  user: {
    ...requestUser.info,
    system: promptConfig.system,
    systemMode: promptConfig.systemMode,
    harness: promptConfig.harness,
  },
  agent: parentAgent,
  permission: parentSession.permission,
  sessionID: input.sessionID,
  tools: frozen?.tools ? SessionPrefixSnapshot.restoreTools(frozen.tools) : {},
  activeTools: frozen?.tools?.map((item) => item.name),
  toolChoice: "auto",
  system: [],
  prebuiltSystem: frozen?.system,
  messages: [...modelMessages, summaryRequest],
  mergeTurnContextBeforeLastMessage: true,
  model,
})

工具表与系统提示词取自冻结的前缀快照(compaction.ts:407)。历史照常转成模型消息,非溢出时折叠重建尾部,溢出时剥掉媒体(compaction.ts:444);摘要指令作为最后一条 user 消息追加,turn context 合并到它前面。这样请求的前缀与主循环一致,便于复用提示词缓存。模型只在提供方报溢出、且 compaction agent 配了模型时才换(compaction.ts:427),否则沿用父会话的模型。

摘要指令来自 packages/opencode/src/agent/prompt/compaction.txt,内容整个换了:上游要求总结较早的历史,并说明最近几轮可能原样保留在摘要之外;MiMo 要求写一份续作摘要,分任务概览、当前状态、重要发现、下一步、需要保留的上下文五部分,包在 <summary></summary> 里。

尾部只留压缩期间到达的轮次。上游在压缩前按 tail_turns(默认 2 轮)和 preserve_recent_tokens(usable 的 25%,夹在 2000 到 8000 之间)挑出原样保留的尾部,只摘要前面的部分。MiMo 删掉了这套挑选,摘要覆盖当前窗口里边界之前的全部历史;摘要生成期间若又有消息到达,buildTail(compaction.ts:182)把它们按 API 轮次分组(每条 user 消息开一轮),从新往旧装进 40000 token 的预算,单个工具结果超过 8000 token 就换成占位文本(常量在 compaction.ts:40)。

结果写成投影。成功后,边界上的 compaction 部件多出一个 projection(message-v2.ts:284),记下摘要消息 ID、包装后的摘要、文件清单、触发方式、保留轮次的起止 ID 与被缩掉的工具调用。包装格式(compaction.ts:212):

export function buildSummaryMessage(summary: string, trigger: CompactionTrigger, hasTail: boolean) {
  return [
    `<conversation-summary trigger="${trigger}">`,
    "The earlier conversation has been compacted into the summary below.",
    hasTail
      ? "Complete API rounds that arrived while compaction was running follow this summary."
      : "No additional API rounds arrived while compaction was running.",
    "",
    summary.trim(),
    "</conversation-summary>",
  ].join("\n")
}

文件清单由 buildFileManifest(compaction.ts:82)从被压缩的历史里取最近读写过的 5 个文件,注明读了全文、读了哪几行、写入还是编辑,并提醒模型编辑前先重读。发请求时 compactionProjection(message-v2.ts:638)按投影重排历史,摘要所在的 assistant 消息被改写成一条 user 消息(message-v2.ts:835)。

失败回滚。摘要请求本身溢出、陷入复读、没产出就停下,或者没有任何非空文本时,rollback(compaction.ts:507)给摘要消息记上错误并删掉边界消息,历史恢复原状,本轮循环停止。

compaction.ts 里还留着上游的 prune(compaction.ts:283,加了按 agentID 切片),但生产代码不再调用它,回合结束时调用的是下一节的 SessionPrune.prune。

修剪:prune.ts

上游每次循环结束都在后台跑一遍 compaction.prune。MiMo 换成 packages/opencode/src/session/prune.ts 里的同名函数(prune.ts:430),调用点在 prompt.ts:4854,门槛一共三道:

  1. compaction.prune 必须显式打开。
  2. 缓存必须已经过期:距上一条 assistant 完成超过模型的 cacheTTL,没配就是 300000 毫秒(prune.ts:53)。修剪会改写旧消息,改写之后请求前缀就变了;等缓存自己过期再动手,不会打掉一份仍然有效的缓存。
  3. 压力等级不为 0,即用量至少到了触发线的 50%。

候选的挑法沿用上游:从新往旧跳过最近两轮,遇到摘要消息或已经清除过的输出就停,skill 工具的输出受保护,最近 40000 token 的工具输出不动,更早的才是候选。之后按压力分级处理(prune.ts:480):

if (level === 1) {
  for (const part of toPrune) {
    if (part.state.status === "completed") {
      const output = part.state.output
      if (output.length > SOFT_TRIM_THRESHOLD) {
        part.state.output =
          output.slice(0, SOFT_TRIM_KEEP_HEAD) +
          "\n\n[... trimmed — kept first and last 1.5K of " +
          output.length +
          " chars ...]\n\n" +
          output.slice(-SOFT_TRIM_KEEP_TAIL)
        yield* session.updatePart(part)
      }
    }
  }
  log.info("soft-trimmed", { count: toPrune.length })
  • 1 级(用量在触发线的 50% 到 70% 之间):输出超过 4096 字符的候选只保留头尾各 1536 字符,中间换成说明,直接写回数据库。
  • 2 级及以上(70% 起):候选合计超过 20000 token 才动手,给它们打上 time.compacted,发送时换成清除占位;同时 stripNonEssential(prune.ts:188)把最近三轮以前的媒体附件换成 [stripped: …] 形式的文件名,并清空 assistant 的思考文本。

重建时的微压缩与这里无关,它只看工具名单、不看压力,见上下文重建。

请求前缀:冻结快照

提示词缓存要求请求前缀逐字节相同。上游 v1.14.19 每一步都重新拼系统提示词(环境信息、技能列表、指令文件),其中任何一处变了,前缀就跟着变。MiMo 加了一张 session_prefix_snapshot 表,按会话与“画像”冻结前缀。表定义在 packages/opencode/src/session/session.sql.ts:63;从迁移目录 packages/opencode/migration/20260829000000_session_prefix_snapshot 与 packages/opencode/migration/20260831000000_session_prefix_tools 的日期看,它是 2026 年 8 月底才加进来的。画像由这些字段组成(packages/opencode/src/session/prefix-snapshot.ts:12):

type Profile = {
  providerID: string
  modelID: string
  agent: string
  agentID: string
  harness: string
  systemMode: string
  system: string
  permission: Permission.Ruleset
}

字段按键名排序做稳定序列化后取 SHA-256,作为 profile_key。换模型、换 agent、改会话提示词或权限,都会落到另一份快照上。

主循环每一步(从 prompt.ts:4430 起)先按画像查快照,再决定用哪份系统提示词(prompt.ts:4493):

  • 没有快照:照常拼出系统提示词,pin 写入快照。
  • 有快照,工具表哈希也一致:直接用快照里的系统提示词,不再重新生成环境信息、技能与指令部分。
  • 工具表变了:重新拼系统提示词,rotate 覆盖快照,修订号加一。
  • 这一步成功完成后调用 advance,修订号没变才更新快照上的水位线。

快照表里的 watermark_message_id 与 system_hash 目前只写不读,源码中找不到读取它们的地方。

同一份快照还用在两处:压缩请求直接取它的系统提示词与工具定义(见上一节);检查点写手分叉时,prefixCaptureRef 指向的闭包(prompt.ts:474)用同样的画像查快照,保证写手的前缀与父请求一致,分叉的细节见检查点写手。

buildLLMRequestPrefix(packages/opencode/src/session/llm-request-prefix.ts:35)是主循环与写手分叉共用的前缀构造函数:输入消息列表、agent、模型,以及系统提示词追加项或冻结的系统提示词,输出系统提示词、schema 形式的工具表与模型消息。两处调用同一个函数,前缀一致就成了结构上的结果,不必另做断言。唯一有意的差异是主循环打开 collapseCheckpointTail,写手不开。

prefixCaptureRef(packages/opencode/src/session/prefix-capture-ref.ts:48)只是一个可变引用。tryStartCheckpointWriter 要调用 buildLLMRequestPrefix,后者依赖 LLM.Service 与 ToolRegistry.Service,而 ToolRegistry 又依赖 SessionCheckpoint,直接声明依赖会让 Layer 成环。于是由已经握有这些服务的 SessionPrompt.layer 在初始化时把闭包填进去(prompt.ts:536),写手启动时再去取,取不到就不带 forkContext 启动。packages/opencode/src/actor/spawn-ref.ts 用的是同一个办法。

下一篇:分层记忆——重建时注入的项目记忆、全局记忆与会话笔记各由谁来写,又怎样被检索。

本页目录