Pre-step batch places the user prompt before injected context, forfeiting cross-session prefix cache (measured: 42% vs 99.8%) #4749
Replies: 2 comments
|
I checked this against the rc.2 source at What the source proves
messages: context === undefined ? claimed : [...claimed, context]The final
So the first-turn order you measured is consistent with the implementation, and a variable claimed prompt before a byte-stable baseline necessarily prevents that later baseline from belonging to a cross-session common prefix. Why
|
|
Follow-up with results — I implemented the reordering as a local plugin and measured it end to end. Setup: local oMLX server on Apple silicon, Before (
After (
Same machine, same model, same workspace, same context on both sides — the only variable is where the injected context sits relative to the prompt. 7 seconds off every session start, from a pure reordering that adds, drops, and rewrites nothing. This is the local-inference argument from the original post in concrete terms: on a hosted API this shows up as a discounted line item, but locally it is the difference between a session that feels instant and one that visibly stalls before the first token. Nothing regressed. The runtime snapshot's supersede-by-append still works as designed, and putting the prompt last is a mild bonus for instruction recency, since it no longer sits ~2k tokens upstream of the generation point. Still happy to share session exports with the raw |
Uh oh!
There was an error while loading. Please reload this page.
Summary
preStep()inpackages/core/agent-loopassembles each step's batch as[...claimed, context]— the claimed user prompt first, then every injected context message appended behind it. Plugins that inject context (agent-instructions,tool-skill's catalog,system-prompt's runtime snapshot, third-party plugins) all hook theagent/pre-stepwaterfall and append with[...decision.messages, theirs].Because prefix caching is strictly positional, this places ~2k tokens of byte-stable context behind a token that is unique to every session. That context can never participate in a cross-session cache hit.
Observed on
@deepseek-ai/dsh@0.1.1-rc.2, a batch lands as:Messages 2–5 are identical on every session start in that workspace. They are re-prefilled every time.
Evidence — DeepSeek official API
Three fresh sessions, same workspace, same provider/model/config. I diffed the request headers:
system(10,083 chars) and all 39 tool schemas (39,282 chars) are byte-identical across them. The only variable was the first prompt.test(cold)test 2test(repeat of A)Session C recovers almost the entire prefix because the prompt text matches byte-for-byte. Session B changes two characters and forfeits 7,586 tokens of prefill against an otherwise identical prompt. The delta is not the tool schemas — those are provably unchanged. It is the ordering.
Why this matters disproportionately for local models (oMLX and similar)
On a hosted API a cache miss costs money at a discount ratio — cache-hit tokens are roughly 10x cheaper, so a miss is a real but bounded expense. On local inference the currency is different: a miss costs wall-clock time to first token, on hardware the user is simultaneously using for everything else.
Measurements from the same harness pointed at a local oMLX server (
DeepSeek-V4-Flash-8bit, Apple silicon), first turn of a fresh session:37% of the first request was re-prefilled, and the bulk of it was context that had not changed since the previous session. The four injected messages in that workspace came to ~2,080 tokens.
Several things compound here:
cacheReadTokensvalue observed was a multiple of 2048. The context group is ~1,870–2,080 tokens, i.e. almost exactly one whole block that is reusable in principle and never reused in practice.The irony is that the harness already does the hard part correctly:
systemand the tool schemas are stable and cache beautifully. The context messages are equally stable — they are just on the wrong side of one variable token.Proposed change
Order the entering batch as
[...context, ...prompts]instead of[...claimed, context].Concretely, in
preStep()'s innermost waterfall fallback, and ideally as a final normalization after the waterfall resolves so plugin-injected context is included:source.kind === "user".agent-instructions → runtime → plugins → skills → promptis retained.This is a pure reordering — no message is added, dropped, merged, or rewritten.
It does not conflict with supersede-by-append
The runtime snapshot's
"This snapshot supersedes earlier runtime-context snapshots"design is deliberate and good: re-sending policy changes as an appended message, rather than mutatingsystem, avoids invalidating the whole session's KV cache mid-run. That mechanism is untouched. Superseding works by appending later in the stream regardless of where the initial snapshot sat. Only the first-turn ordering changes.Secondary benefit
Placing the user's actual request last also improves instruction recency — the prompt currently sits ~2k tokens upstream of the generation point, behind a wall of reference material.
Workaround
This is fixable downstream today.
agent/pre-stepis a waterfall, cordis documents listeners as running outermost-first, and{ prepend: true }unshifts to the front of the chain. So a plugin can delegate vianext(), let the built-in fallback and every other plugin assemble the complete batch, then reorder what comes back.Two caveats make it an awkward thing to leave to userland:
time-contextandtmux-contextalready prepend, so correctness depends on plugin load order — last to prepend wins. That is fragile to reason about and fragile to document.agent-loopdirectly is not viable for anyone running vianpx, since the checkout lives in the npm cache and is wiped on upgrade.Every user who wants cross-session cache hits has to independently discover this and get the load-order dance right. It seems better handled in the loop.
Happy to share session exports with the raw
usageevents if that would help.All reactions