跪拜 Guibai
← Back to the summary

How Reasonix Keeps DeepSeek Prefix Cache Hits at 99%

Hello everyone, I'm HLAIA Photon.

My own project, OpenMMV, uses DeepSeek as its default AI. Recently, something has been bothering me: DeepSeek's cache hit rate is particularly low, sometimes only 20% to 30%.

DeepSeek's prefix caching makes the hit portion very cheap. But OpenMMV's cache hit rate is very low, so the API costs end up being much higher.

On forums like L and the AI sections of Bilibili, I often see people discussing an agent harness called Reasonix. It's an agent specifically adapted for DeepSeek, and when running with DeepSeek, its cache hit rate generally reaches 99%.

Also using DeepSeek, why is my project's hit rate so bad? So I studied Reasonix carefully.

DeepSeek's Official Caching Mechanism

DeepSeek API's prompt caching is automatic on the server side. Unlike using Anthropic, you don't need to explicitly set cache_control markers. As long as the prompt prefix is completely identical across multiple requests, the hit portion is billed at the cache price.

The matching rule is: starting from the first token of the prompt, it compares token by token. If any token in the middle is different, everything from that position onward is a miss.

So it's not hard to imagine that to get a high hit rate, you need to keep the prefix part of the prompt unchanged across every round of requests, and put any changing parts at the end of the prompt.

Memory Constraint

Reasonix has a constraint written in its project memory file REASONIX.md:

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.

This is Reasonix's engineering constraint for DeepSeek cache hits.

Then look at Reasonix's boot phase, the Build() function in internal/boot/boot.go, and how the prefix assembly order is designed:

// boot.go
sysPrompt, err := cfg.ResolveSystemPromptForRoot(root)
// output style folded into base prompt, assembled only once, enters cache-stable prefix
if st, ok := outputstyle.Resolve(cfg.Agent.OutputStyle, outputstyle.Dirs()); ok {
    sysPrompt = outputstyle.Apply(sysPrompt, st)
}
sysPrompt += "\n\n" + config.LanguagePolicy

// persistent memory folded into system prompt here, once
mem := memory.Load(memory.Options{CWD: root, UserDir: config.MemoryUserDir()})
sysPrompt = memory.Compose(sysPrompt, mem)

// skills only put index (name+description), body loaded on demand, not in prefix
skills := skillStore.List()
if !tokenEconomy {
    sysPrompt = skill.ApplyIndex(sysPrompt, skills)
}

The base prompt goes first, which normally doesn't change. Even if memory changes across sessions, the base prompt remains a valid cache prefix.

Skills only put names and descriptions into the prefix; the body doesn't enter the prefix and is loaded only when needed. This should be common sense.

After this sysPrompt is assembled, it's passed to agent.NewSession and becomes Session.Messages[0]. After that, it won't change within this session.

The tool schema is the same: registered into *tool.Registry at startup and unchanged within the session. Even functional switches like plan mode don't change the tool list.

// agent.go
// planMode, when true, refuses any tool call whose ReadOnly() is false.
// The cache-friendly bits — system prompt, tools schema, message history —
// are left untouched, so the toggle costs nothing in cache hits.

That is, entering plan mode doesn't delete write tools from the schema, but intercepts them during execution, letting the model see the error and adapt on its own.

This is attention to detail.

In the spirit of open source, I decided to just copy this solution directly into OpenMMV

XML Concatenation

If a user changes plan mode or temporarily adds a piece of memory mid-session, the model must know about these state changes.

You definitely can't write them directly into the system prompt, otherwise the prefix changes and the cache hit is gone.

Reasonix's solution is: all mid-session changes are concatenated as XML blocks to the very front of the current user message.

The source code implementation is in Compose() in internal/control/input.go

// input.go —— Compose
func (c *Controller) Compose(text string) string {
    plan := c.planMode
    notes := c.pendingMemory
    goal, goalStatus, goalResearchMode := c.goals.snapshot()

    // active goal block, prepended to user turn
    if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
        text = activeGoalBlock(goal, goalResearchMode) + "\n\n" + text
    }
    //plan mode marker, prepended to user turn
    if plan {
        text = PlanModeMarker + "\n\n" + text
    }
    //reasoning language block, transient user-turn context
    text = agent.WithReasoningLanguage(text, reasoningLanguage)

    //mid-session memory additions, piggybacked on the turn
    if len(notes) > 0 {
        var b strings.Builder
        b.WriteString("<memory-update>\n")
        b.WriteString("The following project-memory changes were just made and apply from now on:\n")
        for _, n := range notes {
            b.WriteString("- " + n + "\n")
        }
        b.WriteString("</memory-update>\n\n")
        text = b.String() + text
    }
    //background job completion notification, piggybacked on the turn
    if c.jobs != nil {
        if note := c.jobs.DrainCompletedNoteForSession(c.parentSessionID()); note != "" {
            text = "<background-jobs>\n" + note + "\n</background-jobs>\n\n" + text
        }
    }
    return text
}

These <active-goal>, PlanModeMarker, <reasoning-language>, <memory-update>, <background-jobs> blocks are all prepended to the latest user message, at the tail of the prompt.

The prefix part is stable and can hit the cache; the tail is what changes.

The comments describe the strategy:

Memory added mid-session rides the turn (never the cached system prefix), so it takes effect now without invalidating the prompt cache. It folds into the system prefix on the next session, where it costs nothing per turn.

It can be seen that Reasonix isolates session state from the cache prefix. The latest state changes are concatenated into XML and are not directly written into the system prompt, so the prefix is not broken.

Protocol Layer Cache Miss Prevention

The above are all harness-level designs; at the provider adaptation layer, Reasonix also has several direct handling mechanisms for DeepSeek to avoid cache invalidation and request failures.

DeepSeek's thinking mode has a place that easily causes cache misses. When you want to resend a historical AI message "with tool calls" to DeepSeek, you must also send back the original chain of thought reasoning_content for that message, otherwise DeepSeek directly returns a 400 error.

Reasonix handles this specifically in buildRequest.

// openai.go —— buildRequest
// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was
// dropped on a cache-miss replay, so round it back — but only on the turn
// that carries the tool calls.
if c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {
    cm.ReasoningContent = m.ReasoningContent
}

Note that it only sends it back on the turn that carries tool_calls. This avoids the 400 error without stuffing all reasoning back in, because resending reasoning content incurs extra cost.

These seemingly edge-case logic checks are actually details of the cache hit strategy. Only by filling in all these potential pitfalls can the cache hit rate become high.

Engineering Discipline

To achieve a hit rate of 99.x%, just writing good code isn't enough. You also need to ensure subsequent changes don't cause degradation. This layer is missing in many projects, and Reasonix has established several engineering rules for this.

First, cache diagnostics. In the agent's run loop, every round takes a prefix snapshot, compares it with the previous round, and attaches the result to the Usage event. The diagnostic info includes PrefixChanged and PrefixChangeReasons, telling you whether this round's miss was because the system changed, tools changed, or logs were compressed and rewritten.

This way, Reasonix lets you see why this round missed, because you can't manage what you can't see.

Moreover, it uses a session-level cumulative hit rate, not a single-round ratio, and the cumulative value is not reset during compression, so compression won't cause the displayed hit rate to suddenly plummet.

Second, cache-guard in CI. It has two scripts. One runs the TestReleaseCacheHitGuard test, which is a detection script for "cache hit rate must not degrade before release." The other script's role is: when a PR changes cache-sensitive files, such as internal/boot, internal/tool, internal/provider, it forces the PR body to explicitly state the Cache-impact: and Cache-guard: lines. Using perfunctory words like n/a, none, todo as values will be directly rejected.

This eliminates many low-quality PRs.

Third, PR rules written into project memory. REASONIX.md solidifies this set of norms as a constraint for all contributors.

That is, "Will this change affect the DeepSeek cache hit rate?" is a question every PR must explicitly answer.

Organizational engineering discipline like this, I think, is one of the moats that allows Reasonix's cache hit rate to be maintained at 99.x% long-term.

Independent Session History

Reasonix supports dual-model collaboration: a planner plans and an executor executes. This is a scenario where caching can easily break down. If you switch models within the same conversation, the system, tools, and history formats all change, and the cache is cleared to zero.

Reasonix's approach is to make the planner and executor Sessions independent, not sharing history. The planner uses its own session to produce a plan, the plan is handed to the executor as structured text, and the executor executes in its own independent session.

The sessions never mix, so neither model's prefix is disturbed by the other's turns.

Without shared history, the cache prefixes for the two sessions grow stably.

Request Structure

Stringing together the previous layers, the prompt for a single request sent to DeepSeek roughly has this structure:

Prompt structure of a Reasonix request: the upper half is the cache hit zone, the lower half is the miss zone

The upper half is the cache hit zone, byte-stable and unchanged for the entire session: base prompt, output style, language, memory, skill index, plus all tool schemas, plus append-only growing history.

The lower half is the cache miss zone, newly added each round, just a small tail section: those <active-goal>, <reasoning-language>, <memory-update>, <background-jobs> blocks, plus the Plan mode marker, and finally the user's actual input text.

DeepSeek matches from the beginning. The entire upper block prefix is the same as the previous round, so it hits a large area; only the newly concatenated tail at the very bottom is a cache miss.

Final Thoughts

Sometimes I wonder why OpenMMV's cache hit rate is so low, and then I analyze it: because OpenMMV's AI messages have very few tokens, the prefix itself is very small. Coupled with the fact that the proportion of newly added messages per round is relatively large, the cache hit rate naturally won't be high.

The larger the prefix proportion, the closer the hit rate approaches 100%. In Reasonix's long sessions, the system plus tools plus history might have tens of thousands or even hundreds of thousands of tokens, while the tail section is only a few hundred to a few thousand tokens, so the cache hit rate is relatively large.

A high cache hit rate is not a trick from a single line of code, but an engineering discipline that runs consistently from project memory to CI scripts.

If you found this article helpful, please like and follow, give it a thumbs up~

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

用户706534315506 2 likes

DeepSeek Reasonix hits the cache with precision — the author's research is deep.