压缩、修剪与请求前缀
MiMo Code 改写后的压缩流程与失败回滚、按缓存冷热与压力分级的修剪、/context-limit 与触发线的算法,以及为提示词缓存而冻结的请求前缀快照。
在 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.19 | MiMo 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,门槛一共三道:
compaction.prune必须显式打开。- 缓存必须已经过期:距上一条 assistant 完成超过模型的
cacheTTL,没配就是 300000 毫秒(prune.ts:53)。修剪会改写旧消息,改写之后请求前缀就变了;等缓存自己过期再动手,不会打掉一份仍然有效的缓存。 - 压力等级不为 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 用的是同一个办法。
下一篇:分层记忆——重建时注入的项目记忆、全局记忆与会话笔记各由谁来写,又怎样被检索。