第 6 章 · 01 Cache-first 契约与 boot 系统提示装配 ★ 本节摘要:本章是全书高潮(★),深潜 Reasonix 的灵魂——prefix-cache 友好的上下文维护。本节先讲清两件地基:DeepSeek prefix cache 的工作原理(相同前缀复用 KV 缓存大幅降低计算成本),以及 REASONIX.md 里那条"宪法级"Cache-first 契约原文——系统提示前缀(base prompt + tools + memory)必须字节稳定,永不 mid-session 修改,要加东西就 ride the turn tail。然后精读 (约 13.
本节摘要:本章是全书高潮(★),深潜 Reasonix 的灵魂——prefix-cache 友好的上下文维护。本节先讲清两件地基:DeepSeek prefix cache 的工作原理(相同前缀复用 KV 缓存大幅降低计算成本),以及 REASONIX.md 里那条"宪法级"Cache-first 契约原文——系统提示前缀(base prompt + tools + memory)必须字节稳定,永不 mid-session 修改,要加东西就 ride the turn tail。然后精读
internal/boot(约 13.2K 行)如何装配系统提示:base prompt → outputstyle → UserDecisionPolicy/LanguagePolicy → workspace 行 → token 经济/交付 prompt → tools schema → memory 索引,全部拼成那个"缓存稳定前缀"。最后讲清"为什么前缀必须字节稳定"(任何一字节变化 → DeepSeek prefix cache miss → 全量重算 KV → 成本飙升)和 Reasonix 用什么工程手段守住这条线:golden baseline 确定性测试(两次 Build 必须字节一致)+ cache-impact PR 元数据规范(scripts/check-cache-impact.sh检查的敏感路径)。这是把"LLM 推理优化"做成"系统工程"的极佳案例。
内容来源:原项目源码
internal/boot/(boot.go、resolver.go、runtime.go、golden_baseline_test.go)、REASONIX.md、scripts/check-cache-impact.sh,精读并套用体系化模板。
⚠️ 注意:本章是全书高潮,概念密度高。最关键的一句话:prefix cache 的命中条件是"前缀字节稳定",不是"前缀语义相同"——改一个标点、加一个空格、调整字段顺序,语义没变但字节变了,缓存照样 miss。理解这一点,后面所有的设计(reride the turn tail / golden test / cache-impact 规范)才说得通。
阅读完本节,你应当能够:
internal/boot 装配系统提示前缀的拼装顺序(base / outputstyle / policy / workspace / token / tools schema / memory 索引)。scripts/check-cache-impact.sh 检查的 cache-sensitive 路径,以及 PR 必须带的 Cache-impact / Cache-guard / System-prompt-review 三行元数据。前五章讲的(Session、harness loop、permission、checkpoint、工具失败自愈)都是"agent 怎么把任务跑完"。但 Reasonix 真正压倒同类产品的,是它把一个底层推理优化——prefix cache——做成了贯穿全栈的系统工程。这不是某个包的技巧,而是一条从宪法(REASONIX.md)到代码(boot/control/agent)到 CI(check-cache-impact.sh)到 PR 规范(Cache-impact 元数据)全员遵守的契约。
绝大多数 AI coding agent 的做法是"把所有上下文塞给模型,让模型自己处理"。Reasonix 的做法是:我保证前缀字节稳定,让 DeepSeek 的 prefix cache 每个 turn 都命中,只重算尾部新 token。在一个动辄几十轮工具调用、上下文几万 token 的长会话里,这个差别是"每 turn 全量重算" vs "每 turn 只算增量"——成本和延迟可能差一个数量级。
要理解这套机制,得先从 DeepSeek prefix cache 的原理讲起。
Transformer 推理的瓶颈不在"算模型权重",而在"算 attention"——而 attention 的开销随序列长度平方增长。为了不每一步都重算所有历史 token 的 attention 中间表示,推理引擎会把每个 token 经过每层 attention 算出的 Key/Value 向量缓存下来,叫 KV cache。下一个 token 来时,直接复用缓存的 KV,只算新 token 的 KV 追加进去。
但 KV cache 是按请求维护的:同一个会话里,如果两次请求的"前缀"(system + 历史 messages)完全一样,第二次就不用重算前缀的 KV,直接复用——这就是 prefix cache。DeepSeek(以及多数现代推理服务)在服务端维护一个 prefix cache 池:当一个新请求的前缀能在池里命中,就跳过那部分 KV 计算,只算新增尾部。
这里有个铁律:命中条件是前缀字节稳定,不是"语义相似"。原因很技术——KV cache 是按 token 序列索引的,token 序列由分词器(tokenizer)对字节流切分得到。前缀哪怕只差一个字节(比如多一个空格、字段顺序换了、改了个标点),分词后的 token 序列就可能从某个位置开始全错位,那之后所有 token 的 KV 都对不上,缓存全部失效。所以:
prefix cache 命中 ⟺ 请求的前缀字节序列与缓存中的完全一致(逐字节相同)
这不是 Reasonix 的发明,是 DeepSeek(及同类服务)的物理特性。Reasonix 的贡献是:把这条物理特性当成产品宪法来遵守。
REASONIX.md 的 "Conventions" 节第三条,是整个项目的宪法级约定,我建议你逐字读:
Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never mutate it mid-session — ride the turn tail instead (see
control.Compose).
翻译:Cache 优先:系统提示前缀(base prompt + tools + memory)必须在 turn 之间保持字节稳定,这样 DeepSeek 的自动 prefix cache 才能保持温热。永远不要在会话中途修改它——改成"骑在 turn 尾部"(ride the turn tail),见 control.Compose。
拆解四个要点。
第一,"system-prompt prefix"包括三块:base prompt(系统提示正文)+ tools(工具 schema)+ memory(记忆索引)。这三块拼起来构成前缀。注意:用户对话历史不算前缀的一部分(它是"前缀之后"的内容),但前缀本身的字节稳定性是命脉。
第二,"byte-stable across turns":每个 turn 之间,这三块的字节必须完全一致。不是"差不多",不是"语义相同",是逐字节相同。
第三,"so DeepSeek's automatic prefix cache stays warm":"warm"是缓存术语,指缓存仍然有效、能命中。这条直接呼应上一节讲的命中条件。
第四,"Never mutate it mid-session — ride the turn tail instead":这是最关键的可操作指令——会话中途绝不要改前缀;要加新信息,改加到"turn tail"(本轮尾部)。"ride the turn tail"是个生动的说法:新东西(记忆更新、后台任务通知、召回的事实)不要去动前缀,而是"骑"在每轮消息的尾巴上,作为本轮用户消息的一部分发给模型。这样前缀字节不变,缓存照常命中;新信息虽然本轮要算 token,但下一轮它就成了"历史的一部分"(在 turn tail 之后),前缀依然稳定。下一节会用 control.Compose 的源码证明这是怎么做到的。
这条契约不是文档里写写而已——它被代码、测试、CI、PR 规范全链路守护。本节后半段讲 boot 怎么装配前缀、golden test 怎么守住确定性;下一节讲 Compose 怎么 ride the turn tail;第三节讲 compact/snip/prune 怎么在"不得不动前缀"时把代价降到最低。
internal/boot 是 Reasonix 的"装配车间"——它把"用户配置了什么"变成"一个前端能驱动的 Controller"。包注释:
// Package boot assembles a ready-to-drive control.Controller from configuration: // it loads config, resolves the model(s), builds the tool registry (built-ins + // plugins), wires the permission gate, and constructs the executor — optionally // wrapping it in a two-model Coordinator. It is the one place that turns "what the // user configured" into "a Controller a frontend can drive", so every frontend — // the terminal TUI, the HTTP/SSE server, the desktop webview — shares the exact // same assembly instead of each re-deriving it.
为什么 boot 这么重要?因为系统提示前缀就是在 boot 阶段装配的。boot 是"前缀字节稳定性"的第一道、也是最重要的一道关口——如果 boot 每次装配出的前缀都不一样(比如依赖某个随机值、或字段顺序不稳定),那再怎么"ride the turn tail"也没用,缓存永远 miss。
boot.go 里 Build 函数装配系统提示的关键片段(精简):
sysPrompt, err := cfg.ResolveSystemPromptForRoot(root) if err != nil { if !config.IsMissingSystemPromptFile(err) { return nil, err } // A stale missing prompt file must not block startup: warn and fall back // to the inline (or built-in default) system prompt. ... sysPrompt = cfg.InlineSystemPrompt() } // Output style: fold the selected persona/tone block into the base prompt // // before language/memory/skills append, so a "replace" style (keep-coding // // false) still keeps those. Applied once, into the cache-stable prefix. if st, ok := outputstyle.Resolve(cfg.Agent.OutputStyle, outputstyle.Dirs()); ok { sysPrompt = outputstyle.Apply(sysPrompt, st) } sysPrompt += "\n\n" + config.UserDecisionPolicy sysPrompt += "\n\n" + config.LanguagePolicy if workspaceLine := currentWorkspacePromptLine(root); workspaceLine != "" { sysPrompt += "\n\n" + workspaceLine } if tokenEconomy { sysPrompt += "\n\n" + tokenEconomyPrompt } else if tokenDelivery { sysPrompt += "\n\n" + tokenDeliveryPrompt }
这段拼装顺序大有讲究。用图表示:
几个关键设计。
第一,base prompt 缺失时降级而不阻塞启动。 注释:"A stale missing prompt file must not block startup: warn and fall back to the inline (or built-in default) system prompt." 但紧接着:"Other read failures stay fatal so Reasonix never runs without explicitly configured policy."——其他读取错误仍然致命,Reasonix 绝不在"没有显式配置策略"的情况下运行。这是个精细的平衡:文件没了可以兜底,但文件坏了/读不了不能糊弄。
第二,outputstyle 必须在 memory/skills 之前折叠进 base。 注释里那句"before language/memory/skills append, so a 'replace' style (keep-coding false) still keeps those"是关键:有些输出风格是"replace"型(替换 base 的人格/语气,keep-coding=false)。如果先追加 memory 再 apply outputstyle,replace 型风格会把 memory 也一起替掉——那是灾难。所以顺序是"先 apply outputstyle 到 base,再追加 memory/skills",这样 replace 型只影响 base 那段,memory/skills 安全。"Applied once, into the cache-stable prefix"——只应用一次,且应用进缓存稳定前缀(不是每 turn 重新 apply)。
第三,token 经济 vs 交付 prompt 二选一。 tokenEconomy 和 tokenDelivery 是互斥的两种运行时模式,对应不同的额外 prompt 块。这俩是协作模式(collaboration mode)在前缀里的体现——切换协作模式会改变前缀字节,所以切换协作模式必然导致一次 cache miss(这是 Reasonix 接受的代价,因为切换是低频操作)。
第四,workspace 行依赖 root。 currentWorkspacePromptLine(root) 把当前工作区根写进前缀。这意味着切换工作区也会改变前缀字节——但每个工作区内部,前缀是稳定的。这是合理的:不同项目本来就该有不同的前缀。
前缀不只是系统提示文本,还有两块"非文本"内容:
internal/tool 注册表序列化出来的。boot 把 built-in 工具 + 插件工具拼成 registry,再序列化成 schema 追加进前缀。schema 里字段顺序、描述文字任何一个字节变了,前缀就变。 这就是为什么第 7 章会强调"插件贡献的工具会改变 tool schema,进而影响缓存"。至此,前缀的三块(base prompt + tools schema + memory 索引)都凑齐了。它们的字节稳定性就是 prefix cache 命中的全部条件。
💡 契约要点(本节核心):boot 装配出的前缀 = base prompt(含 outputstyle/policy/workspace/token 块) + tools schema + memory 索引。这三块的字节必须在 turn 之间、甚至在"相同配置的两次 Build"之间完全一致。boot.go 里那段拼装顺序不是随便写的——每一步的先后都有理由(outputstyle 必须先于 memory,replace 型才不会吃掉 memory;token 经济/交付互斥,避免叠加)。改 boot 装配逻辑前,先想清楚"这一改会不会让前缀字节不稳定"。
把"前缀字节变化"的后果讲透,你才会理解 Reasonix 为什么花这么大力气守这条线。代价链如下:
具体有多贵?在一个 50 轮工具调用、前缀 1 万 token 的会话里,如果每个 turn 前缀都变(比如某个字段顺序不稳定、或每 turn 重新 apply outputstyle),那每个 turn 都要全量重算 1 万 token 的 KV——相当于每次都从冷启动算起。而如果前缀稳定,只有第一个 turn 全量算,后面 49 个 turn 都只算新增的几百 token。这是数量级的差别。
更阴险的是"间歇性 miss":如果前缀大部分时候稳定、偶尔因为某个非确定性操作(比如 map 遍历顺序)变一下,缓存会周期性失效又重建,表现为"会话时不时变慢",极难排查。这就是为什么 Reasonix 不满足于"大多数时候稳定",而是追求"确定性稳定"——下一节的 golden test 就是干这个的。
Reasonix 怎么保证 boot 装配的前缀真的字节稳定?靠一个叫 golden baseline 的测试。internal/boot/golden_baseline_test.go:
// goldenBaseline is one deterministic capture of the provider-visible // runtime surface with zero extensions installed. type goldenBaseline struct { SystemPrompt string ToolSchemas []byte ProviderReq []byte PrefixShape agent.PrefixShape } // ... 捕获第一次 Build 的表面 first := captureGoldenBaseline(t) // In-run determinism: an identical second Build must capture the exact // same surface before we bother comparing against the committed golden. second := captureGoldenBaseline(t) if first.SystemPrompt != second.SystemPrompt { t.Fatalf("system prompt is not deterministic across identical Builds, first diff: %q", firstDivergence(first.SystemPrompt, second.SystemPrompt)) } if string(first.ToolSchemas) != string(second.ToolSchemas) { t.Fatal("tool schemas are not deterministic across identical Builds") } if string(first.ProviderReq) != string(second.ProviderReq) { t.Fatal("provider request serialization is not deterministic across identical Builds") }
这个测试干两件事。
第一,运行内确定性。同一份配置,连续 Build 两次,捕获的 SystemPrompt / ToolSchemas / ProviderReq 必须逐字节相同。这一步先排除"代码里有非确定性"(比如 map 遍历顺序、时间戳、随机数)。如果两次 Build 结果不一样,直接 t.Fatalf 报出第一个分歧点。这是最关键的防线——只要这一步过不了,说明代码本身就产不出稳定前缀,别谈别的。
第二,与提交的 golden 对比。把第一次 Build 的结果写成几个 golden 文件(system_prompt.txt / tool_schemas.json / provider_request.json / prefix_shape.json),提交进仓库。以后每次 CI 跑测试,都拿新 Build 的结果跟 golden 文件比——任何字节差异都失败。这样,任何一次让前缀字节变化的代码改动,都必须同时更新 golden 文件;而更新 golden 文件是个显眼的 diff,reviewer 一眼就能看到"这个 PR 改了系统提示",从而触发 cache-impact 评估。
PrefixShape 是上一章提到的 internal/agent/cache_shape.go 的产物——它把前缀的 system/tools/prefix 三段各算一个短 hash,加上 LogRewriteVersion 和 ToolSchemaTokens。golden test 比的是完整字节,PrefixShape 给人看的是"哪段变了"的诊断摘要。
这套测试的精妙在于:把"字节稳定性"这个本来不可见的运行时属性,变成了一次会失败的 CI 测试 + 一个会显眼的 git diff。开发者再也无法"不知不觉地"改了前缀——他要么让测试过(前缀没变),要么显式更新 golden(承认改了前缀,走 cache-impact 流程)。
代码层面守住了(golden test),还要守"流程层面"——开发者写了个 PR 改了 internal/boot 或 internal/tool,reviewer 怎么知道这会影响缓存?REASONIX.md 的 "Cache-impact PR metadata" 节规定:
When PR changes touch files under
internal/boot/,internal/tool/,internal/provider/, or other cache-sensitive paths (listed inscripts/check-cache-impact.sh), the PR body MUST include these lines at the end:Cache-impact: <none|low|medium|high> — <reason> Cache-guard: <focused guard test/command or existing guard rationale>If the PR also touches files under
internal/config/,internal/memory/,internal/outputstyle/,internal/skill/, orinternal/boot/, add:System-prompt-review: <reviewer/approval note>Values
n/a,none,todo,tbdare rejected — use a descriptive reason instead.
三行元数据各自的意思。
internal/config/、internal/memory/、internal/outputstyle/、internal/skill/、internal/boot/(这些直接影响系统提示内容),要加一行"系统提示 review 说明"——因为这类改动不止影响缓存命中,还影响模型看到的内容本身,需要人脑 review。scripts/check-cache-impact.sh 是 CI 脚本,它列出"cache-sensitive 路径",PR 改了这些路径就强制要求上面的元数据。从脚本看到的敏感路径(部分):
internal/agent/agent.go | internal/agent/ask.go | internal/agent/cache* | internal/agent/compact* | internal/agent/parallel_tasks.go | internal/agent/prune* | internal/agent/subagent_registry* | internal/agent/task.go | internal/boot/* | internal/command/slashtool.go | internal/config/config.go | internal/config/system_prompt* | internal/environment/* | internal/history/tool.go | internal/installsource/* | internal/lsp/tool.go | internal/memory/* | internal/outputstyle/* | internal/plugin/* | internal/provider/* | internal/skill/* | internal/tool/*
这份清单几乎覆盖了"所有能让前缀字节变化"的包:boot(装配)、config/system_prompt(配置)、memory/outputstyle/skill(前缀内容来源)、provider/tool/plugin(工具 schema 来源)、agent 的 cache/compact/prune 子模块(压缩逻辑)。任何一个 PR 碰了这些路径,CI 就会检查 PR body 有没有那三行元数据;没有就失败。
这就是 Reasonix 的全链路守护:契约(REASONIX.md)→ 代码(boot 装配 + golden test)→ CI(check-cache-impact.sh)→ PR 规范(三行元数据)。一条物理特性(prefix cache 命中要字节稳定),被四层工程手段层层包裹,变成"开发者想破坏都难"的硬约束。
Cache-impact: 等级 — 理由 / Cache-guard: 守卫测试 / System-prompt-review: review 说明;scripts/check-cache-impact.sh 列敏感路径(boot/config/memory/outputstyle/skill/provider/tool/plugin + agent 的 cache/compact/prune),PR 碰这些路径强制要求元数据。下一节,我们看 Cache-first 契约的执行者:
control.Compose。它用"ride the turn tail"的手法,把记忆更新、后台任务通知、召回事实等所有"新信息"都追加到本轮消息尾部,绝不碰前缀。这是让前缀在长会话里"始终温热"的关键技巧——也是 Reasonix 把推理优化做成系统工程的第二块拼图。