fix(deepseek): stop re-billing static prompt sections on every request - #816
Conversation
…ntext prompts Adds build_memory_prompt_parts and build_context_prompt_parts, which return the same content the existing builders produce, split on their volatility seam: memory -> (typed-memory doctrine, ## MEMORY.md body) context -> (workspace+git snapshot, ## Project Instructions) No behavior change on its own. build_memory_prompt and build_context_prompt are now defined as the join of their parts and are byte-for-byte identical to before, verified against the flattened system prompt. The callers that place the halves differently land in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
For DeepSeek, query relocates REQUEST-scope prompt sections out of the system
prompt and re-attaches them AFTER the conversation history, so mid-session
changes cannot perturb the cached prefix. That works, and it has a cost
nobody had priced: the tail sits after the history, so next turn's messages
are inserted before it. The tail is invalidated and recomputed on EVERY
request, which makes its size a permanent per-turn tax.
_cache_scope was tagged at section granularity, so whole blocks of unchanging
prose rode that tail because they happened to contain a few mutable values.
Measured on the wire, the tail was 17,564 chars, of which ~85% was static:
the auto-memory doctrine alone was 13,583 chars (larger than the entire
cached GLOBAL prompt) and is emitted even with an empty memory directory.
Split each offending section and scope the halves independently:
memory -> doctrine SESSION, ## MEMORY.md body REQUEST
context -> CLAWCODEX.md SESSION, workspace+git snapshot REQUEST
non_interactive, tool_restrictions -> SESSION (a module constant and a
list fixed when the session's tool set resolves)
REQUEST-scope drops 13,865 -> 424 chars. mcp_instructions deliberately stays
REQUEST: MCP servers can connect mid-session, and Chapter C2 / PR #650 pinned
that split on purpose.
This is the same trade Reasonix makes (internal/boot/boot.go: project
instructions and the memory index "fold into the system prompt exactly here,
once ... so memory costs nothing per turn"), and it is a pure relocation --
system+tail totals 38,195 chars before and after, every block present exactly
once, so the model sees identical content.
Measured, deepseek-v4-flash:
general multi-turn session, steady-state miss/request 6,625 -> 2,232
terminal-bench 2.1 build-cython-ext 89.97% -> 94.45% hit
git-multibranch 84.25% -> 93.34% hit
build_full_system_prompt is byte-identical, so non-relocating providers are
unaffected. The Anthropic blocks path does move the doctrine out of the
volatile REQUEST group into the cache_control-marked SESSION group -- better
cached, but a real ordering change, documented in the docstring.
Guards added: a budget on the whole REQUEST group, both halves' scopes, the
rejoin identity, content conservation across the relocation, and prefix
stability across a mid-session MEMORY.md write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…local wheel prefix_cache_probe.py records every outgoing chat payload (by patching the OpenAI SDK, so it captures literal wire content) and then diffs consecutive requests, reporting the longest common message prefix and exactly what had to be recomputed. Prefix caches bill from the first changed byte, so the metric that matters is tokens re-sent per request, not an aggregate hit rate -- a harness can look healthy at 90% while re-billing a multi-thousand-token block every turn. That is how the tail tax in the previous commit was located. clawcodex_agent gains a local-artifact install path: a source= that resolves to a file on disk is uploaded into each container and installed from there, so a working-tree build can be benchmarked without pushing a commit first. Resolved once in __init__ so a typo'd path fails fast instead of per trial. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 8m 9s ⏱️ Results for commit 4cdde8b. |
terminal-bench 2.1 subset — final numbersThe 5-task subset finished. Same tasks, same model ( Cache
Miss tokens per request roughly halved. The aggregate lands where the PR body predicted (~94-95%), and the residual is the output-volume gap described there, not cache handling. The two rows missing from the table ( Rewards
3/5 against the baseline's 4/5. Four of five match exactly. The path-tracing regression, unresolvedOne task flipped. I am not going to explain it away, but here is what the evidence says:
That is suggestive, not conclusive, and n=1 cannot separate sampling variance from a real regression. A solo re-run with no contention is in flight; I will post the result here either way. If it fails again solo, that is a real signal and this PR should not merge on my say-so. |
path-tracing solo re-run: 1.0, passedResolved. Re-ran the one task that regressed, alone, on an idle machine:
* steps, not completed requests — the trial was killed before metrics were written. The step counts are the tell. This model's solution path on path-tracing is high variance: 29 steps in the baseline, 47 solo, 61 in the contended run. At 61 steps under CPU contention it ran out of wall clock. At 47 steps on an idle box it finished comfortably and passed. Nothing about the cache change is implicated — it only ever removes tokens from a request. Final reward tally — no regression
5/5 match, including the shared failure. Final cache numbers
Every task measured improved. Aggregate miss tokens per request 6,396 → 3,158. One note against over-reading path-tracing's row: its miss/req moved only 7,739 → 6,922 because the solo run took a different, longer trajectory (47 requests vs 29), so per-request content isn't like-for-like there. The hit rate is the meaningful comparison on that task. This closes the only open item flagged in the PR description. |
What
clawcodex sat at 90.69% DeepSeek prefix-cache hit rate on terminal-bench 2.1 while DeepSeek-Reasonix, same benchmark and comparable score, hit 98.28% — 30.5M vs 4.5M cache-miss tokens. This closes most of that gap.
Why it was happening
Not prefix invalidation, which is where I started looking. Reasonix's own
cacheDiagnosticsreportsprefixChanged: falseon all 3,290 of its requests, and clawcodex had only 14 catastrophic misses in 1,286. The difference was a constant per-request tax:For DeepSeek,
query._split_system_prompt_blocksrelocates_cache_scope == "request"sections out of the system prompt and_append_session_context_tailre-attaches them after the conversation history, so mid-session changes cannot perturb the cached prefix. That works, and it has a cost nobody had priced: the tail sits after the history, so next turn's messages are inserted before it. The tail is invalidated and recomputed on every single request — its size is a permanent per-turn tax.Recorded off the wire, the tail was 17,564 chars. In one steady-state request the genuinely new content was 723 chars; the tail was 24× larger.
_cache_scopewas tagged at section granularity, so entire blocks of unchanging prose rode along because they contained a few mutable values:~85% static. The
# auto memoryblock alone was 13,583 chars — larger than the entire cached GLOBAL prompt (11,856) — and it is emitted even with an empty memory directory, so terminal-bench containers paid it too.The change
Split each offending section on its volatility seam and scope the halves independently:
build_memory_prompt_parts→ doctrine SESSION,## MEMORY.mdbody REQUESTbuild_context_prompt_parts→ CLAWCODEX.md SESSION, workspace+git snapshot REQUESTnon_interactive,tool_restrictions→ SESSION (a module constant, and a list fixed when the session's tool set resolves)REQUEST-scope group: 13,865 → 424 chars. On a real repo the full relocated tail goes ~18,165 → 2,068 chars.
mcp_instructionsdeliberately stays REQUEST — MCP servers can connect mid-session, and Chapter C2 / PR #650 pinned that split on purpose.This is Reasonix's own design, from
internal/boot/boot.go:Reasonix puts the memory index in the prefix too, accepting staleness until the next session. This port keeps MEMORY.md live in the tail — strictly more conservative on freshness.
Does it hurt the model?
No content is dropped, summarised, or reordered relative to itself — only relocated. Proven exactly:
system + tailtotals 38,195 chars before and after, with every block present exactly once. Pinned bytest_relocation_conserves_content_exactly.Measured
General multi-turn session (repo exploration + edits, no benchmark), same task, baseline vs fixed, excluding the cold first request:
The per-request trace is the real evidence — baseline plateaus at ~6,300–7,200 and never drops below 5,248, and that floor is the tail:
Steady-state 6,625 → 2,232 (3×), reaching 1,244–1,718 against Reasonix's 1,295 average.
terminal-bench 2.1 subset, deepseek-v4-flash, effort=max:
Honest limits
non_interactive/tool_restrictionsre-scoping, so its numbers slightly understate the shipped state.Other providers
build_full_system_prompt(the flattened-string path) is byte-identical — verified by diff. Relocation is gated onprovider.is_deepseek, so no other provider's wire changes.One real exception, documented in the docstring rather than glossed: the Anthropic blocks path emits GLOBAL → boundary → SESSION → REQUEST, so the doctrine block does move earlier — out of the volatile REQUEST group into the cache_control-marked SESSION group. Better cached, but an ordering change, not a no-op. Marker count stays at 3, within Anthropic's limit of 4.
Guards
All mutation-tested — reverting the fix fails them with the offending block named and sized.
test_relocated_tail_stays_within_a_token_budget— budgets the whole REQUEST group at 2,000 chars against a real ~400. A tripwire for a misfiled section, not a style rule.test_memory_index_is_request_scoped_and_doctrine_is_nottest_memory_sections_rejoin_to_the_legacy_single_section— the split is a relocation, not a prompt edittest_relocation_conserves_content_exactlytest_prefix_survives_a_mid_session_memory_write— the freshness guarantee the split was carved out oftest_effective_prompt_keeps_project_instructions_out_of_request_scopeTooling
eval/harbor/prefix_cache_probe.py—recordpatches the OpenAI SDK to capture literal wire payloads;analysediffs consecutive requests and reports the longest common message prefix plus exactly what was recomputed. Prefix caches bill from the first changed byte, so tokens-re-sent-per-request is the metric that matters, not aggregate hit rate. This is how the tail was found; reach for it before theorising about cache behaviour.clawcodex_agent.pyalso gains a local-wheelsource=path (uploaded per container) so a working tree can be benchmarked without pushing first.Test plan
origin/main: 9,882 passed, 11 skipped, 0 failures🤖 Generated with Claude Code