浅谈 Reasonix 及其 cache-first 工程设计

前言

AI coding agent 方兴未艾,说是八仙过海各显神通一点不为过。笔者在日常工作中经常混合使用各种模型和工具,在两个月前也非常关注现象级的 coding agent —— Reasonix。它号称是 DeepSeek 原生,且有省钱的“黑科技”,这离不开它 cache-first 的工程设计理念。本文来简单聊聊。

当我们烧 token 的时候

想像这样一个场景:一个开发者打开命令行,coding agent 启动,在自己的工作目录里跟 LLM 聊天。第 1 轮他问“我的 Flink 应用为什么报错”,AI 读了一圈代码,给出了一个猜想;第 2 轮他追问“那 network buffer 相关的参数怎么改”,AI 开始改文件;第 3 轮、第 4 轮、第 5 轮…… 一路聊 50 轮。

这个过程里每次发送给模型的请求,prompt 长得惊人——可能开头 6 万 token 是“system prompt + 工具说明 + 项目记忆 + 之前每一轮的代码片段 + 工具返回”。模型拿到这 6 万 token,从头算一遍 context——attention 的 K/V。这就是故事的起点:KV-cache。

下面三个事实会成为后面我们讨论的基础:

  • 第一,大模型推理时,每生成一个新 token 都要把前缀所有 token 的 Key/Value 重算。如果 prefix 不重算,这部分就是“免费的”。
  • 第二,主流推理服务都提供某种形式的 prefix cache:Anthropic 走显式的 cache_control breakpoint,DeepSeek 走全自动 prefix match。匹配上的部分按一定比例的价格收费,没匹配到的按全价。一个 DeepSeek V4 session 跑完,账单上 cache hit 和 cache miss 通常差一个数量级。
  • 第三,只要 prefix 字节不变,cache 就生效。这一点其实是整个 cache-first 设计的核心抓手——理解它之后,我们会发现 Reasonix 一切看似“过度严格”的工程纪律,都是为了让那个 hook 一直紧密地挂着。

又见 prefix cache

Reasonix 项目中的记忆文件 REASONIX.md 中说:

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.

一句话翻译:让该不变的东西永远不变。整个工程都是依照这个原则往下展开。我们先复习一下 prefix cache 的相关知识。

Transformer 自回归生成时,生成第 n+1 个 token 需要前面所有 n 个 token 的 K/V。直接重算这部分的复杂度是 O(n²),而且大量冗余。所以推理引擎会做一件事:把每层、每个 head、每个位置的 K/V 缓存下来,每生成一个新 token,只需算新位置的 K/V,旧的直接取用。这就是 KV-cache。

把所有这些 K/V 按请求的“前缀”分组管理,就成了 prefix cache。同一个前缀在不同请求里出现时,第一次算完之后,后面直接复用。DeepSeek 的 prefix cache 按 64-token block 切分,匹配到哪一段就省哪一段。这是它在各 LLM 中相对比较便宜的根本原因。

API 在 usage block 里把这笔账算给用户看,直接拆成 hit 和 miss 两个数字——prompt_cache_hit_tokensprompt_cache_miss_tokens。相应的,账单也是两档计费。

下表是 DeepSeek V4 的官方计费对照,数据来自项目里的 internal/config/pricing.go

计费项 DeepSeek V4 Flash(¥/M token) DeepSeek V4 Pro(¥/M token)
普通 input 1.00 3.00
Cache hit 0.02 0.025
Cache miss 1.00 3.00
Cache hit 折扣倍数 50× 120×
Output 2.00 6.00

举个直观例子来算笔账:一个 900 token 命中、100 token 未命中、200 token 输出的请求,总成本 900 × 0.02 + 100 × 1 + 200 × 2 = 518 / M token ≈ ¥0.00052。同一个请求如果全部未命中,1200 × 1 + 200 × 2 = 1600 / M token ≈ ¥0.0016—— 贵 3 倍。而这仅仅是在一次请求的尺度上。再考虑到 session 越长 cache 复利越大,50 轮的一个 session,经济差异能从几十倍到上百倍。

Anthropic 和 DeepSeek 在这里走了完全不同的范式:Anthropic 要你显式标 cache_control: { type: "ephemeral" } 才能让一段 prefix 命中,DeepSeek 是完全自动——只要你前 N 个 token 一字不差,就一直命中。项目里 internal/provider/anthropic/anthropic.go 直接写了:

DeepSeek ignores cache_control and manages prefix caching automatically, so keep those fields off its wire entirely.

后面我们会看到,Reasonix 的整套 cache-first 工程,正是建立在“DeepSeek 自动前缀匹配”这个性质之上的。它做的事情非常简单,即不要动 prefix。

cache-first 在 Reasonix 中的设计要点

毫不夸张地说,Reasonix 是把“前缀缓存”作为产品 KPI 来贯彻的。看 CONTRIBUTING.md 里的这段话:

Reasonix treats high prompt-cache hit rate as product behavior. Changes that touch provider-visible system prompt construction, memory prefix, output styles, skill index behavior, default tool surfaces, tool schemas, provider request serialization, compaction, or MCP/tool registration need explicit cache review.

为了贯彻这个原则,PR 流程被强行加了几条“cache 守门员”规则——每个触及“prefix”的 PR 都必须填 Cache-impactnone|low|medium|high 分级加原因)、Cache-guard(“填守卫测试或解释为什么不需要”)、在 system prompt 有变化时还要填 System-prompt-review。CI 强制这三条元信息,不填是不允许合并的。Reasonix 开发者不只是说说“我们关心 cache”而已,是把这件事内化成了一个 review 框架。

下面我们把 cache-first 工程中最重要的 4 条设计要点展开解释。这 4 条共同构成了 Reasonix 能够宣称其 cache hit ratio 持续维持在 90% 以上的原因,缺一不可。

P1:会话历史 append-only

这条乍看简单,但执行起来非常严。每次模型发请求时,Reasonix 把当前 session 的 messages 复制一份(注意是复制,不修改原 slice),清掉 UI 用的 CreatedAt,送给 provider。internal/agent/agent.go 里这段代码是这一原则的物理体现:

requestMessages := append([]provider.Message(nil), provider.ModelMessages(a.session.Messages)...)
for i := range requestMessages {
    requestMessages[i].CreatedAt = 0
}

第一个 append 保证只是浅拷贝——append-only 的物理前提,任何“修正历史”的尝试都不会影响源数据。第二个 for 把 UI metadata 从传输拷贝里剥离,避免在 turn 之间差 1 秒就让 prefix 失效。注意源码里紧跟着的注释:

CreatedAt is durable UI metadata, not model input. Strip it from the transport copy so wall-clock differences never invalidate the provider's prompt-cache prefix (and custom providers cannot accidentally send it).

它背后的思考是:任何一个看似无关的字段变化(wall clock 时间戳、UI 用的 ID、用户在本地时区和服务器时区下写入的同一时间)都足以让 cache 失效,所以必须手动剥离。在测试用例 desktop/session_prompt_bytes_test.go 中也有非常严格的测试,它的 assertion 是两次同样输入必须逐字节相等——任何会改写序列化结果的行为都被这个测试挡在合并之前。这一类 byte-stability 测试贯穿 Reasonix 整个工程。

P2:system prompt 不变性

system prompt 在请求里位于最顶部,是 prefix 的最大头。但它又必须能塞很多内容:基础指令、tool schema、项目记忆、环境快照。Reasonix 的处理非常极端——所有这些内容只在启动时合成一次,从此不再改变。

internal/memory/doc.go 中说道:

All of it folds into the durable system-prompt prefix exactly once at boot (see Compose)... Mid-session changes never mutate that prefix; they take effect through the controller's transient tail-injection and fold into the prefix on the next session.

问题是:如果用户中途说“#记住我偏好用 tabs”这类导致记忆新增的输入,Reasonix 会怎么办?看似合理的做法是改 system prompt,但 Reasonix 把这件事改了一种编排方式,即:写入 internal/control/memory.godrainPending 队列,下一轮请求时由 Compose 拼到 user turn 的尾部,模型能立刻看到,且这一轮前 1 字节的 prefix 不动。下次会话重启时,这块内容自然进入 prefix。

这是个非常漂亮的解法:从用户体验上讲是立即可见,但 cache 零损失。类似地,plan mode 的开关、MCP schema 变更等都走 turn-tail,甚至直接不允许中途变更(下文会提)。

P3:强制 tool schema 稳定

tool schema 通常也是 system prompt 的重要组成部分,任何微小的改动——把工具 A、B、C 的描述调整、调整参数顺序、给某个工具增加 description——都会让 schema hash 整体变化,前面的 prefix 全部失效。Reasonix 在三个层面做了防御。

第一是注册顺序固定,直接看 internal/boot/boot.go

    addTaskTool := func() string {
        if taskToolAdded {
            return "task tool is already enabled."
        }
        taskToolAdded = true
        if taskTool == nil {
            taskTool = newTaskTool()
        }
        // Fixed registration order for prompt-cache stability: task →
        // parallel_tasks → fleet. Profile names never enter tool schemas.
        reg.Add(taskTool)
        reg.Add(agent.NewParallelTasksTool(taskTool, reg))
        reg.Add(agent.NewFleetTool(taskTool))
        return "enabled task."
    }

第二是 Profile 名不写入 schema,internal/agent/profile_spec.go 写道:

// ProfileDefinition is the resolved, runtime-facing shape of a runAs=subagent
// skill used by task/fleet/run_skill. Profile names are resolved at call time
// from the Skill store and must never be written into tool schemas or the
// parent system prompt (prompt-cache stability).
type ProfileDefinition struct {
    Name         string
    Body         string
    AllowedTools []string
    Model        string
    Effort       string
    ReadOnly     bool
    Invocation string
    NamedBuiltin bool
}

第三是 MCP cache-hit placeholder,简单来讲,就是在调用 MCP 时如果是 cache-hit 的,就直接把磁盘里缓存的 MCP schema 拿来用,彻底避免此次会话中 MCP server 更新导致的缓存失效风险。当然,如果是 cache-miss 的话,就会发送正常的 MCP 请求。

P4:多级精细化处理 compaction

由于 prefix 不动,随着 session 运行时长的增加, prompt 就会越长,直到触顶。所以必然存在某种“主动压缩历史”的机制,即compaction(这个词对数据人而言已经见怪不怪了),当然这也是唯一的 cache-reset 触发点。

internal/agent/compact.go 的开头注释写明了 cache 与 compaction 之间的工程权衡:

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).

上述代码中的 maybeCompact 方法实际上是一个状态机,有五档触发:

触发阈值(窗口占比) 动作 cache 影响
50% (soft) 发 notice 提示,不动 prompt 0
60% (snip) 剪掉旧 tool result 的多余内容 0
80% (trigger) 调 LLM 摘要中部 → 替换成 summary message 1 次 cache miss
90% (force) 强制compact,绕开经济性 skip 1 次 cache miss
连续 2 次都撞线 暂停自动compact,提示用户窗口太小 0

需要特别提示的是,budget 是固定 token(默认 16384),而不是比例。这是一个反直觉的工程选择:比例式折中很容易陷入“窗口越来越小 → 频繁折中 → cache 命中归零”的死亡循环。固定 token 保证了大窗口(> 1M token)几乎不compact,小窗口也能稳定停在 trigger 之下,完全杜绝了上述循环。

这个状态机里有三个非常细节的设计值得一提。

第一是前置 prune。即在 80% 触发 compact 前,先尝试只剪掉过期的 tool output,如果光这一招就能把 prompt 长度拉回阈值以下,就避免了调用 LLM 进行摘要这种昂贵的动作。

第二是小 user turn(如果 ≤1500 token 或 ≤窗口 15%)与历史摘要永远不删除,避免关键用户事实发生漂移。

第三,如果调用 LLM 进行摘要失败,会有一层兜底——已经 archive 的原文存盘,告诉模型摘要暂时拿不到,可以再次询问用户,保证 compaction 永远不会处在半完成态。

综上所述,Reasonix 的 compaction 是一种谨慎乐观的设计:默认 cache hit 攒得好,所以只在必要时compact;做compact时尽量保留;真正不得不断裂历史时,一次性、有兜底地做完。

为什么其他 agent 没有这样做?

说了这么多,看官可能会问:cache-first 听起来有如此大的收益,为什么 Claude Code、Cursor 等更耳熟能详的 agent 不这么做?要把这件事说清楚,关键在理解“是否做得到”与“是否值得做”之间的区别。

先说是否值得做。不同 agent 的产品形态、用户场景、模型优先级都不同:

  • Claude Code 是 Anthropic 自家工具。它的 cache 哲学是 Anthropic Messages API 风格的“显式 cache_control breakpoint”——要在一个 JSON 里精确标注哪些块建立缓存。这与 DeepSeek 的“自动 prefix 匹配”是两套完全不同的 cache 范式。同时它对项目记忆采用“运行时再合并”的做法,这种灵活性与 byte-stable cache 是冲突的。把 cache-first 推到首位,意味着牺牲“项目个性化”的产品体验。
  • Cursor、Continue 的形态是 IDE 嵌入型,典型的会话都比较短。如果一个 session 平均只有 2~3 轮交互,cache hit 和 cache miss 的经济差异可能只有几厘钱——不值得为此牺牲 prompt 灵活性。
  • Roo Code / Zoo Code 走模式化路线(Architect / Code / Debug / Ask等),每种模式自有一套 tool set 和 system prompt,本身就放弃了“单一前缀”假设。

再说是否做得到。

通过上一节的解析可以知道,要把 cache-first 推到 Reasonix 这个程度,需要的不只是几个优化技巧,而是 agent 架构的重新设计。仅仅是拒绝启动后修改 system prompt 这个决策,对很多工程师而言是“过分严格”。更进一步将,越是“通用”的 agent,越难严格 cache-first;越是“专精一种模型 + 一种形态”的 agent,越容易做到这种优化。Reasonix 选了后者,它的收益也是这种选择下的自然产物,是一种路径与目标的对齐。

The End

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容