第 6 章 · 03 compact / snip / prune 与 Context Engine v2 ★ 本节摘要:本节是全书高潮的收尾,讲 Reasonix 在"不得不动前缀"时如何把代价降到最低,以及更上层的分层记忆检索。三件事:(1)compact 上下文压缩——会话接近上下文窗口时摘要旧对话,它是个"低频受控的 cache-reset 点";(2)snip / prune 旧工具输出,先裁剪再摘要——大段工具输出(如 bash log)先 snip 裁到关键行、再 prune 精简、最后才 compact 摘要,因为直接 compact 大输出会丢失结构且摘要文本本身不稳定;(3)cacheshape 缓存形态诊断与 Context Engine v2
本节摘要:本节是全书高潮的收尾,讲 Reasonix 在"不得不动前缀"时如何把代价降到最低,以及更上层的分层记忆检索。三件事:(1)compact 上下文压缩——会话接近上下文窗口时摘要旧对话,它是个"低频受控的 cache-reset 点";(2)snip / prune 旧工具输出,先裁剪再摘要——大段工具输出(如 bash log)先 snip 裁到关键行、再 prune 精简、最后才 compact 摘要,因为直接 compact 大输出会丢失结构且摘要文本本身不稳定;(3)cache_shape 缓存形态诊断与 Context Engine v2 分层记忆检索(session 即时/project 项目/global 全局,按相关性 BM25 召回)。最后讲 memory 包的 type/scope 正交分类和 skill 技能系统(只有名字+描述进 cache-stable 索引,body 按需加载)。这三层加起来,Reasonix 把"上下文维护"做成了一个分级、可观测、最小破坏缓存的完整体系。
内容来源:原项目源码
internal/agent/compact.go、internal/agent/prune.go、internal/agent/cache_shape.go、internal/memory/(store_v2.go/recall.go)、internal/skill/skill.go、docs/SESSION_MEMORY_RETRIEVAL.md,精读并套用体系化模板。
⚠️ 注意:本节有个反直觉的结论——"直接 compact 摘要大段工具输出"是 Reasonix 刻意避免的。原因有二:摘要会丢失工具输出的结构(行号、字段对齐),且摘要文本每次生成可能略有不同(不稳定),反而更容易破坏缓存。所以策略是"先 snip/prune 局部,实在不行才整体 compact"。理解这个分级,才能看懂 compact.go 和 prune.go 为什么是分开的两个文件。
阅读完本节,你应当能够:
cache_shape.go 的 PrefixShape:它如何用 hash 诊断"前缀哪段变了"。Reasonix 的上下文管理分成两半,prune.go 开头的注释一针见血:
// Tool-result maintenance is the free half of context management: stale tool // results are re-derivable (files can be re-read, commands re-run), so rewriting // them needs no summarizer call and never drops a message. tool_call/result // pairing and assistant content (including signed reasoning) are untouched.
翻译:工具结果维护是上下文管理的"免费半边":过期的工具结果可以重新推导(文件能重读、命令能重跑),所以改写它们不需要调摘要器(summarizer),而且绝不丢消息。tool_call/result 配对、assistant 内容(含已签名的 reasoning)都不碰。
这把上下文管理劈成了代价截然不同的两半:
| 半边 | 操作 | 代价 | 是否丢消息 | 是否调摘要器 |
|---|---|---|---|---|
| 免费半边(snip/prune) | 改写过期工具结果 | 极低(本地字符串操作) | 否(原文归档) | 否 |
| 付费半边(compact) | 摘要旧对话 | 高(要调一次模型做摘要) | 是(原文被摘要替换) | 是 |
理解这个区分是本节的地基:Reasonix 优先用"免费半边"控制总量,把"付费半边"推迟到最后一刻。因为免费半边不动消息结构(只改工具结果的内容),对缓存的破坏相对可控;而付费半边(compact)会改变 provider 看到的消息字节(摘要替换原文),必然触发 cache miss,且摘要本身要花一次模型调用。
prune.go 提供两个函数,对应工具结果维护的两档:
const ( snippedMarker = "[snipped tool result — " prunedMarker = "[elided tool result — " minPruneBytes = 1024 ) type toolResultMaintenanceMode int const ( toolResultSnip toolResultMaintenanceMode = iota toolResultPrune ) // SnipStaleToolResults shortens stale tool-result content older than the // protected recent tail, archiving the originals first. Idempotent; a no-op // when compaction is disabled (no context window). func (a *Agent) SnipStaleToolResults() (PruneStats, error) { return a.maintainStaleToolResults(toolResultSnip) } // PruneStaleToolResults elides stale tool-result content older than the // protected recent tail, archiving the originals first. It can upgrade already // snipped results to a shorter placeholder. func (a *Agent) PruneStaleToolResults() (PruneStats, error) { return a.maintainStaleToolResults(toolResultPrune) }
两档的区别:
[snipped tool result — ...]。它是"温和裁剪"——还留了点内容。[elided tool result — ...]。注释说它能"把已经 snipped 的结果升级成更短的占位符"——所以 prune 是比 snip 更狠的第二档。两个关键不变量。
第一,都"archiving the originals first"(先归档原文)。所以叫"免费半边绝不丢消息"——原文被归档了(可恢复),只是 provider 看到的版本变短了。这和 compact 的"摘要替换原文"不同:snip/prune 保留可恢复性,compact 不保留(摘要不可逆)。
第二,都只动工具结果,不碰 tool_call/result 配对和 assistant 内容。注释:"tool_call/result pairing and assistant content (including signed reasoning) are untouched." 这很重要——工具调用和结果的配对关系不能乱(乱了模型看不懂),assistant 的 reasoning 是带签名的(改了签名失效),所以这些"结构性内容"一概不动,只动工具结果里面那一大段文本。
compact.go 的常量里有 defaultToolResultSnipRatio = 0.6,注释:"rewrite stale tool results cheaply before summary compaction"(在摘要压缩前,廉价地改写过时工具结果)。也就是说,当上下文用到 60% 窗口时,先触发 snip(免费、廉价),把过时工具结果裁短;只有继续涨到 80%(defaultCompactRatio)才触发整体 compact(付费、昂贵)。这就是"先免费后付费"的分级落地。
当免费半边(snip/prune)压不住,上下文涨到 defaultCompactRatio = 0.8(80% 窗口),触发 compact。compact.go 的注释把它的定位讲得很清楚:
// Compaction is a low-frequency cache-reset point: the prompt grows append-only // (high cache hits) until a turn nears compactRatio of the window, then it is // compacted down to a tail budget. The budget is a fixed token count, not a // fraction of the window, so a huge window still compacts rarely while a small // one still lands below the trigger (which is what stops the re-compaction loop).
翻译:Compaction 是一个低频的 cache-reset 点:prompt 以 append-only 方式增长(高缓存命中),直到某 turn 接近 compactRatio 比例的窗口,才被压缩到一个 tail 预算。这个预算是固定 token 数,不是窗口的比例——所以大窗口仍然很少压缩,小窗口仍然能落在触发点之下(这正是阻止"反复压缩循环"的关键)。
五个比率常量构成完整的触发阶梯:
| 常量 | 值 | 含义 |
|---|---|---|
defaultSoftCompactRatio |
0.5 | 50% 时报告上下文在增长,但保持缓存稳定前缀不动 |
defaultToolResultSnipRatio |
0.6 | 60% 时触发 snip(免费裁剪工具结果) |
defaultCompactRatio |
0.8 | 80% 时触发 compact(摘要压缩) |
defaultCompactForceRatio |
0.9 | 90% 时强制压缩,即便低价值也要折 |
defaultCompactTarget |
0.5 | 安全上限:压缩后保留的 tail 不超过 50% 窗口 |
加上 defaultTailTokens = 16384(近期 tail 的逐字保留预算,固定 token 数)。
几个精妙设计。
第一,soft ratio(0.5)只报告不动手。注释:"report growing context here, but keep the cache-stable prefix intact"——50% 时只是提醒"上下文在涨",前缀照旧。这让 UI/日志能提前预警,但不破坏缓存。
第二,tail 预算是固定 token 数,不是比例。这是阻止"反复压缩循环"的关键。想象如果 tail 预算是"窗口的 50%":大窗口(200K)下,压完还剩 100K,下一 turn 很快又涨回 80%,又压……无限循环。改成固定 16384 token 后,大窗口压完只剩 16K 近期 tail,要涨很久才再次触发;小窗口(8K)下 16K tail 占比大,但 0.8 触发点(6.4K)和 0.5 target(4K)保证能压下去。一个常量化解了两端的尴尬。
第三,force ratio(0.9)是高水位强制线。哪怕某些消息被判定为"高价值不该折",到 90% 也强制压——因为再不压就溢出窗口了。这是"保命线"。
第四,摘要用 <compaction-summary> 标签包裹。源码:
const ( summaryTagOpen = "<compaction-summary>" summaryTagClose = "</compaction-summary>" )
注释:"summaryTag wraps the compaction summary so the model can distinguish it from live user input and later strip or skip it when reasoning about the current turn." 摘要用这俩标签包起来,让模型能区分"这是压缩摘要"和"这是真实用户输入",在推理当前 turn 时可以剥离或跳过它。这避免了"模型把摘要当真实指令执行"的混淆。
compact 必然 cache miss,但受控。compact 改变了 provider 看到的消息字节(摘要替换原文),所以触发一次 prefix cache miss,服务端全量重算。但:(1) 它是低频的(只在 80% 触发);(2) 压缩后新前缀(摘要 + 16K 近期 tail)重新稳定,后续 turn 又能命中;(3) 因为 snip/prune 已经在 60% 时把过时工具结果压过一遍,到 80% 时真正需要摘要的"大块"已经少了——这就是为什么"先免费后付费"能真的降低 compact 的代价。
compact 和 snip/prune 都会改变消息字节,那 Reasonix 怎么知道"这次操作到底让前缀哪段变了、缓存会不会 miss"?答案是 internal/agent/cache_shape.go。它的核心结构 PrefixShape:
// PrefixShape hashes the portions of the request prefix that influence // provider-side prompt-cache reuse. Comparing snapshots across turns // lets us explain *why* a cache miss happened. type PrefixShape struct { SystemHash string ToolsHash string PrefixHash string LogRewriteVersion int ToolSchemaTokens int }
字段含义:SystemHash(系统提示的短 hash)、ToolsHash(工具 schema 的短 hash)、PrefixHash(系统提示 + 工具 schema 合在一起的 hash,代表整个前缀)、LogRewriteVersion(第 5 章 01 节讲的重写计数器)、ToolSchemaTokens(工具 schema 估算 token 数)。
CaptureShape 给当前前缀拍快照:
func CaptureShape(systemPrompt string, schemas []provider.ToolSchema, rewriteVersion int) PrefixShape { normalizedSchemas := normalizeToolSchemas(schemas) toolsJSON, _ := json.Marshal(normalizedSchemas) return PrefixShape{ SystemHash: shortHash(systemPrompt), ToolsHash: shortHash(string(toolsJSON)), PrefixHash: shortHash(map[string]interface{}{ "system": systemPrompt, "tools": string(toolsJSON), }), LogRewriteVersion: rewriteVersion, ToolSchemaTokens: estimateTokens(string(toolsJSON)), } }
注意 normalizeToolSchemas:它把工具 schema 按 Name/Description 排序后再序列化。这是个关键的"确定性化"步骤——如果 schema 数组顺序不稳定(比如工具注册顺序受 map 影响),hash 就会乱跳。先排序再 hash,保证"相同工具集"总是产生相同 hash,不受顺序干扰。这和第 6 章 01 节 golden test 的"两次 Build 字节一致"是同一精神的延伸。
CompareShape 把前后两个 PrefixShape 对比,产出 CacheDiagnostics:告诉调用方"system 变了没、tools 变了没、整体 prefix 变了没、是不是因为 rewrite"。第 5 章 01 节讲的 pendingContentReasons(每次重写记一个原因)就是喂给 CompareShape 的——它能把"这次 miss 是因为 compact / prune / snip / rewind / guardian merge 哪个操作"诊断出来。
所以 cache_shape 不是"维护缓存"本身,而是"缓存形态的诊断仪":它告诉你前缀当前长什么样(指纹)、和上次比哪段变了、为什么变。这对可观测性至关重要——没有它, Reasonix 就没法回答"为什么这一 turn 缓存没命中"这个排查长会话性能的首要问题。
讲完"上下文压缩"(compact/snip/prune),进入更上层的"上下文检索"——Context Engine v2。SESSION_MEMORY_RETRIEVAL.md 开宗明义:
Context Engine v2 gives Reasonix two durable context layers with different authority:
- Standing instructions define how the agent must work.
- Background memory stores facts that may help later but can become stale.
Keeping those layers separate is the central design rule. A fact should not silently become a command, and a long-lived rule should not depend on retrieval finding it at the right moment.
翻译:Context Engine v2 给 Reasonix 两个权威性不同的持久上下文层——standing instructions(常驻指令,定义 agent 必须怎么工作)和 background memory(背景记忆,存"以后可能有用但可能过期"的事实)。把这两层分开是核心设计规则:一个事实不该"悄悄变成命令",一个长期规则不该"依赖检索在恰当时候找到它"。
这条规则是整个 v2 设计的灵魂。它解决一个常见陷阱:如果指令和事实混在一起,模型可能把一条过时的事实当成必须遵守的命令(危险),或者把一条必须遵守的规则当成"看检索运气"(更危险)。分开后,standing instructions 永远在场(进前缀),background memory 按需召回(进 tail),各司其职。
两层的归属:
| 放进 | 用途 | 示例 |
|---|---|---|
| AGENTS.md / REASONIX.md / CLAUDE.md | 必须在每个相关 turn 都在场的规则 | 必跑的测试命令、仓库边界、review 约定 |
| 项目记忆(project memory) | 只在本工作区有效的持久事实 | release 分支、非显然的服务约束、项目工单 URL |
| 全局记忆(global memory) | 在每个工作区都该可用的事实 | 用户显式选为 global 的偏好 |
| 会话历史(session history) | 原始措辞、工具输出、还没成稳定事实的决策 | 昨天的错误、被放弃的方案 |
standing instructions(第一行)是第 5 章 03 节讲的分层记忆(进 cache-stable 前缀);background memory(中间两行)是本节接下来讲的 auto-recall(进 turn tail)。第四行(会话历史)就是 Session.Messages 本身,由 compact/snip/prune 维护。
background memory 怎么进入上下文?通过 auto-recall(自动召回)。第 6 章 02 节讲 Compose 时提过 c.memory.recall(source)——在每个真实用户 turn 之前,Reasonix 用用户的原始消息搜索活跃事实,把选中的事实作为"有界、低权威后缀"追加到该 turn 的 tail。SESSION_MEMORY_RETRIEVAL.md 列了召回的保守策略:
<local-home>。这套策略的关键词是"保守"。Reasonix 不追求"尽可能多召回",而追求"只在真有用时召回,且召回的内容明确标注'可能出错、不能覆盖当前请求或 standing instructions'"。这和"两层分离"一脉相承:召回的事实权威性最低,只能作为参考,绝不能盖过 standing instructions 或用户当前请求。
新鲜度按 type 分:
| type | 新鲜 | 当前 | 过期后 |
|---|---|---|---|
| reference | 14 天 | 45 天 | 45 天 |
| project | 30 天 | 180 天 | 180 天 |
| user / feedback | 90 天 | 365 天 | 365 天 |
注意:新鲜度是"警告和排序信号,不是真实性判断"。召回的文本会明确告诉模型它可能错。这让模型不会盲目信任一条可能过时的 reference(比如一个 14 天前的 URL 可能已经失效)。
第 5 章 03 节提过 remember 工具事实模型的 type 和 scope,这里系统化。internal/memory/store_v2.go 是 v2 的存储实现。每个事实是带 frontmatter 的 Markdown 文件,字段:id(不可变)、revision(单调)、created_at/updated_at、name/title/description、独立的 type 和 scope、body。
type 分类内容(四值):user(用户身份/偏好)、feedback(怎么工作及为什么)、project(代码看不出的项目目标/约束)、reference(外部资源如 URL/工单号)。
scope 控制范围(两值):project(安全默认,只在本工作区)、global(必须显式选,在每个工作区都生效)。
type 不蕴含 scope——两者正交。一条 project feedback 仍是 project 局部;一条 global reference 仍只是 reference。这避免了"因为 type 是 reference 就自动 global"这种隐式提升。
project-over-global 优先级:当等价的 project 和 global 事实都存在时,自动召回用 project 的那条。但两者在 Context Center 和 /memory 里都保持可见,override 关系会解释而不是删除/隐藏任何一个源。这种"显式解释 override"比"静默删一个"更可信——用户能看清"为什么这条被那条盖了"。
memory 写入有个"零配置自动创建"路径,但条件极严:SESSION_MEMORY_RETRIEVAL.md 列了 7 个全部满足才能自动创建——拥有当前项目 store 的 controller(交互式或顶层 headless,绝不能是子代理)、type 显式是 project/reference、scope 是 project 或省略、操作是 create-only 不是 update、body 在自动写预算内、没检测到凭据/密钥/私钥/邮箱、不存在同名/同 title/同 description 的事实。grant 是一次性的,存储层强制 create-only 语义。
其他一切都还要显式确认:global 事实、user 偏好和 feedback、对已有 id/revision 的更新、可能的重复、敏感或超大内容、每一次 forget 操作。Auto 和 Yolo 都不能绕过这些确认;Guardian 和 permission 钩子不能替用户批准它们。这是一道很硬的安全线——记忆写入是高权限操作,默认拒绝,只对最窄的一类"低风险新建"放行。
最后讲 skill 技能系统。internal/skill/skill.go 包注释:
// Package skill loads invokable playbooks ("skills") from Markdown files. A skill // is a named, described prompt body the model can invoke via the run_skill tool // (or the user via a slash name): an "inline" skill folds its body into the turn as // a tool result, a "subagent" skill runs in an isolated child loop and returns // only its final answer. Project scope wins over global; only names+descriptions // enter the cache-stable system-prompt index (see index.go) — bodies load on // demand. ...
skill 是"可调用的剧本"——从 Markdown 文件加载,有个名字和描述,模型通过 run_skill 工具调用(或用户用 slash 名)。两种执行模式:
最关键的缓存设计:只有名字+描述进 cache-stable 系统提示索引,body 按需加载。为什么?因为如果把所有 skill 的完整 body 都塞进系统提示,前缀会非常长(每个 turn 都要为所有 skill 的 body 付 token),而且每加/改一个 skill 都会让前缀字节变化(破坏缓存)。所以 Reasonix 只把 skill 的名字和描述放进前缀(让模型知道"有这些技能可用"),body 只在模型实际调用 run_skill 时才加载进 turn tail。这完美呼应了 Cache-first 契约:前缀保持精简稳定,重内容按需进 tail。
skill 的发现扫描多个约定目录(.reasonix / .agents / .agent / .claude,在项目根和 home 目录下),所以为别的 agent 工具写的 skill 能原样迁移进来。目录式 skill 用 <name>/SKILL.md,扁平的 <name>.md(从 Claude 根来的)只有带 skill frontmatter 才加载。发现过程跟随符号链接,所以链接进来的 skill 像真实 skill 一样被识别。
Project scope wins over global——和 memory 一样,项目级 skill 压过全局级。这和分层记忆的优先级链一致。
把本章三节放一起,Reasonix 的上下文维护是个三级体系,每级代价不同、触发时机不同,共同把 prefix cache 的命中率推到最高:
外加 cache_shape(诊断前缀形态)、Context Engine v2(standing instructions 进前缀 + background memory 按 BM25 召回进 tail)、skill(名字进前缀 body 按需进 tail),构成完整的"前缀稳定 + 按需召回"体系。
这就是 Reasonix 把"LLM 推理优化"做成"系统工程"的全貌:不是某个 trick,而是一套从宪法到代码、从免费到付费、从压缩到检索的分级、可观测、最小破坏缓存的工程。理解了这套体系,你才算真正读懂了 Reasonix。
[snipped tool result — ]),prune 几乎完全省略([elided tool result — ]),prune 可把已 snip 的升级成更短占位符;都只动工具结果内容,不碰 tool_call/result 配对和 assistant reasoning。<compaction-summary> 标签让模型可区分。下一章,我们离开 agent 内核,看 Reasonix 的两层扩展体系:MCP 插件客户端(运行时外部插件,stdio JSON-RPC)与 Extension Protocol v1 sidecar(更强的拦截器/Provider/UI)。注意:插件贡献的工具会改变 tool schema,进而影响 cache-stable 前缀——这是本章 Cache-first 契约在扩展体系的直接延伸。