Reproducing agentic KV-cache policy claims on real traces. 68k requests from 393 Claude Code sessions. LRU is harder to beat than the papers suggest.
0
stars
1
commits
Python
primary language
Sep 10, 2026
updated
I replayed 68,266 requests from 393 real Claude Code sessions and 23,608 Mooncake requests through a prefix-cache simulator, tried to beat the production baseline three different ways, and failed. Along the way a widely-cited number came out about half as large on independent data.
Everything here reproduces from a cold checkout with make setup data repro.
Cross-request KV prefix caching is the largest practical lever in agentic LLM serving. It's why your coding agent's fiftieth turn costs a fraction of its first. Every serving stack has one — vLLM's automatic prefix caching, SGLang's RadixAttention, LMCache, Mooncake Store — and all of them evict with LRU.
There's a large, fast-growing literature arguing LRU is the wrong policy for agentic workloads, because agent sessions go idle and LRU can't tell a paused session from a dead one. The argument is intuitive. I believed it, and built a simulator to exploit it.
It didn't work, and why it didn't work turned out to be more interesting than the policy would have been.
A block-granular, discrete-event simulator of a cross-request prefix cache. Three properties that matter, and that quick implementations tend to get wrong:
Hits are prefix-contiguous. A hit is the longest resident prefix of the block chain, not a set intersection. Miss one block at depth 3 and everything after it is unusable even if it's still resident.
The radix structure constrains eviction. A block with resident children isn't evictable. So the baseline is LRU over radix leaves, which is what SGLang and vLLM actually implement. Beating naive flat LRU would be a strawman.
The in-flight chain must be pinned. See finding 5.
Traces are real, not synthetic:
| trace | requests | block size | hash scope | source |
|---|---|---|---|---|
| SemiAnalysis AgentX | 68,266 across 393 Claude Code sessions | 64 tok | session-local | HF (Apache-2.0) |
Mooncake mooncake_trace / toolagent | 23,608 | 512 tok | global | GitHub (Apache-2.0) |
Mooncake conversation | 12,031 | 512 tok | global | same |
Before trusting anything, I reproduced Mooncake's published hit-rate-vs-capacity table on Mooncake's own released trace, with their stated policy.
| cache (blocks) | 1k | 10k | 30k | 50k | 100k | ∞ |
|---|---|---|---|---|---|---|
| published (LRU) | 0.30 | 0.40 | 0.48 | 0.50 | 0.51 | 0.51 |
| measured (radix-leaf LRU) | 0.341 | 0.460 | 0.537 | 0.551 | 0.552 | 0.553 |
| measured (flat block LRU) | 0.340 | 0.460 | 0.537 | 0.551 | 0.552 | 0.553 |
The shape reproduces exactly, including the saturation point they describe in prose ("1,000 to 50,000 blocks boosts the cache hit ratio from 30% to 50%; further capacity increases show minimal improvement").
There is a systematic +4–6pp offset I could not explain. I tested five metric definitions — block denominator, token denominator, dropping the partial tail block, per-request averaging — and none closes it. The infinite-cache case is policy-free, a pure property of the trace, so the discrepancy is definitional or a trace-version mismatch, not a replay bug.
Publishing it unresolved rather than tuning until it matches. If you know why, please open an issue.
Incidental finding: flat block LRU and radix-leaf-restricted LRU differ by 0.02pp on this workload. The leaf restriction both major engines implement buys essentially nothing here.
Reproduce: make validate
sessions=393 requests=68266
session span (h): p50=1.84 p90=28.36 max=254.8
inter-req gap (s): p50=2.1 p90=51.1 p99=3426.3 max=491922 (5.7 days)
gaps > 60s: 9.5%
gaps > 300s: 3.3%
gaps > 3600s: 1.0%
input tokens: p50=88768 p90=204288 max=255808
output tokens: p50=376 p90=1845
requests/session: p50=70 max=3551
DUTY CYCLE (fraction of wall-clock actually executing):
p25=3.4% p50=13.9% p75=33.9%
sessions executing <50% of lifetime: 85.5%
The most-cited characterization of agentic serving reports a 20% median duty cycle and 70% of sessions below 50%. On this independent trace it's 13.9% and 85.5% — the premise is more extreme than published, not less.
Note the shape: gaps are bimodal. A median of 2.1 seconds (tight tool loops) with a heavy tail out to days.
Reproduce: make characterize
This is the finding that changed my mind.
A frequently-cited figure holds that evictions account for 31.5% of aggregate agentic inference cost, driven by a 5-minute provider TTL colliding with 1–10 minute idle gaps. That number motivated my entire approach.
So before optimizing for it, I measured it — policy-independently. Replay the trace, and bucket every request's recomputed tokens by the idle gap that preceded it:
| gap before request | requests | share of all recompute tokens |
|---|---|---|
| <10 s | 10,069 | 33.1% |
| 10–60 s | 912 | 7.0% |
| 1–5 min | 701 | 20.5% |
| 5–30 min | 236 | 8.6% |
| 30–60 min | 50 | 3.0% |
| >1 h | 123 | 5.8% |
Requests arriving after a gap longer than the 5-minute TTL account for 17.5% of recompute. Requests arriving within 10 seconds account for 33.1%.
The dominant source of cache misses in real agentic serving is not sessions idling past a TTL. It's tight two-second tool loops whose 88k-token working sets exceed cache capacity.
That's a capacity problem, not a liveness-prediction problem. And with a p50 gap of 2.1 seconds, almost every session is "about to return" — so a liveness estimator has essentially nothing to discriminate on.
Reproduce: make gap
TTL-300s produced byte-identical results to LRU-leaf in every single run.
Under capacity pressure LRU always evicts before the timer fires. The TTL never binds.
I implemented a policy with three separable, independently ablatable components:
P(session returns) replacing recency. Online Bayesian estimator over
observed inter-turn gaps and continuation rates. No oracle: it only ever sees completed
observations.Hit rate, 40 AgentX sessions, 4,751 requests:
| cache (blocks) | LRU-leaf | TTL-300s | LFU-leaf | +H | +HC | +HCG |
|---|---|---|---|---|---|---|
| 8,000 | 83.48% | 83.48% | 63.61% | 82.89% | 71.77% | 68.63% |
| 20,000 | 93.92% | 93.92% | 69.96% | 93.61% | 84.86% | 78.89% |
| 50,000 | 95.76% | 95.76% | 79.58% | 95.68% | 94.45% | 91.40% |
Effective recompute cost versus LRU-leaf (negative is worse):
| cache | LFU-leaf | +H | +HC | +HCG |
|---|---|---|---|---|
| 8,000 | −129.7% | −3.2% | −38.9% | −81.0% |
| 20,000 | −434.3% | −4.5% | −90.4% | −207.8% |
| 50,000 | −447.1% | −1.0% | −15.4% | −66.8% |
Monotone negative. Every component made it worse, and the one I was most confident in — coherent eviction — was the worst.
Given finding 2, this is exactly what should have happened. I was optimizing for a signal carrying 17.5% of the waste, using a predictor that can't discriminate at a 2.1-second median gap.
Reproduce: make ablation
In my first run, Belady — an offline oracle — lost to LRU. That's not a result, that's a broken harness, and it's worth publishing because I expect it to be common.
The cause: inserting a long chain into a near-full cache lets a policy evict the very prefix it is currently building. LRU is accidentally immune because just-inserted blocks have the newest timestamp. Every non-recency policy cannibalises itself. Real engines prevent this with refcount pins; a from-scratch simulator usually doesn't.
If you build one of these, make your first test "does Belady beat LRU?" If it doesn't, you have this bug, and every policy comparison you run will be silently wrong in LRU's favour.
Two other implementation notes:
touch() need only update that one
block. An O(chain length) walk becomes O(1) — which matters at AgentX's 1,387-block median.The agentic KV-cache problem has been framed as a liveness problem. On this data it's a capacity problem. The interesting question isn't "will this session come back?" — it's "how do I fit 88k-token working sets for N concurrent sessions running tight tool loops?" Those point at different answers: the first at TTL policy and survival prediction, the second at compression, tiering, admission control, and working-set-aware scheduling.
LRU-leaf is a stronger baseline than the literature treats it as. I couldn't beat it with three independent mechanisms on real traces. Meanwhile several published alternatives are evaluated against degraded ports of their competitors — two separate papers benchmark against Continuum with its adaptive TTL replaced by a fixed 2s or 0.3s pin, which disables the thing that makes it work. This null result suggests those margins are softer than they read.
Validate against a published curve before trusting your own numbers. Doing that surfaced a discrepancy I still can't explain, and it's the only reason I trust anything else here.
git clone https://github.com/<you>/agentic-kv-cache && cd agentic-kv-cache
make setup # venv
make data # ~1.1 GB of traces (Apache-2.0), then flattens AgentX to a pickle
make repro # all four experiments, writes results/
Individually:
make validate # Mooncake reproduction -> results/01_validate.txt
make characterize # AgentX duty cycle and gaps -> results/02_characterize.txt
make gap # recompute by idle gap -> results/03_gap.txt
make ablation # policy ablation -> results/04_ablation.txt
The simulator is pure stdlib Python; numpy is only used by helper scripts. Committed outputs
in results/ let you check the tables without downloading anything.
If you can answer any of these, please open an issue — I'd genuinely like to know:
Traces: Mooncake (Moonshot AI, FAST'25) and the AgentX corpus (SemiAnalysis), both Apache-2.0. This work is independent of and unaffiliated with either.
MIT licensed.
1 commits
Python
92.9%
Shell
3.7%
Makefile
3.4%
Reproducing agentic KV-cache policy claims on real traces. 68k requests from 393 Claude Code sessions. LRU is harder to beat than the papers suggest.
0
stars
1
commits
Python
primary language
Sep 10, 2026
updated
I replayed 68,266 requests from 393 real Claude Code sessions and 23,608 Mooncake requests through a prefix-cache simulator, tried to beat the production baseline three different ways, and failed. Along the way a widely-cited number came out about half as large on independent data.
Everything here reproduces from a cold checkout with make setup data repro.
Cross-request KV prefix caching is the largest practical lever in agentic LLM serving. It's why your coding agent's fiftieth turn costs a fraction of its first. Every serving stack has one — vLLM's automatic prefix caching, SGLang's RadixAttention, LMCache, Mooncake Store — and all of them evict with LRU.
There's a large, fast-growing literature arguing LRU is the wrong policy for agentic workloads, because agent sessions go idle and LRU can't tell a paused session from a dead one. The argument is intuitive. I believed it, and built a simulator to exploit it.
It didn't work, and why it didn't work turned out to be more interesting than the policy would have been.
A block-granular, discrete-event simulator of a cross-request prefix cache. Three properties that matter, and that quick implementations tend to get wrong:
Hits are prefix-contiguous. A hit is the longest resident prefix of the block chain, not a set intersection. Miss one block at depth 3 and everything after it is unusable even if it's still resident.
The radix structure constrains eviction. A block with resident children isn't evictable. So the baseline is LRU over radix leaves, which is what SGLang and vLLM actually implement. Beating naive flat LRU would be a strawman.
The in-flight chain must be pinned. See finding 5.
Traces are real, not synthetic:
| trace | requests | block size | hash scope | source |
|---|---|---|---|---|
| SemiAnalysis AgentX | 68,266 across 393 Claude Code sessions | 64 tok | session-local | HF (Apache-2.0) |
Mooncake mooncake_trace / toolagent | 23,608 | 512 tok | global | GitHub (Apache-2.0) |
Mooncake conversation | 12,031 | 512 tok | global | same |
Before trusting anything, I reproduced Mooncake's published hit-rate-vs-capacity table on Mooncake's own released trace, with their stated policy.
| cache (blocks) | 1k | 10k | 30k | 50k | 100k | ∞ |
|---|---|---|---|---|---|---|
| published (LRU) | 0.30 | 0.40 | 0.48 | 0.50 | 0.51 | 0.51 |
| measured (radix-leaf LRU) | 0.341 | 0.460 | 0.537 | 0.551 | 0.552 | 0.553 |
| measured (flat block LRU) | 0.340 | 0.460 | 0.537 | 0.551 | 0.552 | 0.553 |
The shape reproduces exactly, including the saturation point they describe in prose ("1,000 to 50,000 blocks boosts the cache hit ratio from 30% to 50%; further capacity increases show minimal improvement").
There is a systematic +4–6pp offset I could not explain. I tested five metric definitions — block denominator, token denominator, dropping the partial tail block, per-request averaging — and none closes it. The infinite-cache case is policy-free, a pure property of the trace, so the discrepancy is definitional or a trace-version mismatch, not a replay bug.
Publishing it unresolved rather than tuning until it matches. If you know why, please open an issue.
Incidental finding: flat block LRU and radix-leaf-restricted LRU differ by 0.02pp on this workload. The leaf restriction both major engines implement buys essentially nothing here.
Reproduce: make validate
sessions=393 requests=68266
session span (h): p50=1.84 p90=28.36 max=254.8
inter-req gap (s): p50=2.1 p90=51.1 p99=3426.3 max=491922 (5.7 days)
gaps > 60s: 9.5%
gaps > 300s: 3.3%
gaps > 3600s: 1.0%
input tokens: p50=88768 p90=204288 max=255808
output tokens: p50=376 p90=1845
requests/session: p50=70 max=3551
DUTY CYCLE (fraction of wall-clock actually executing):
p25=3.4% p50=13.9% p75=33.9%
sessions executing <50% of lifetime: 85.5%
The most-cited characterization of agentic serving reports a 20% median duty cycle and 70% of sessions below 50%. On this independent trace it's 13.9% and 85.5% — the premise is more extreme than published, not less.
Note the shape: gaps are bimodal. A median of 2.1 seconds (tight tool loops) with a heavy tail out to days.
Reproduce: make characterize
This is the finding that changed my mind.
A frequently-cited figure holds that evictions account for 31.5% of aggregate agentic inference cost, driven by a 5-minute provider TTL colliding with 1–10 minute idle gaps. That number motivated my entire approach.
So before optimizing for it, I measured it — policy-independently. Replay the trace, and bucket every request's recomputed tokens by the idle gap that preceded it:
| gap before request | requests | share of all recompute tokens |
|---|---|---|
| <10 s | 10,069 | 33.1% |
| 10–60 s | 912 | 7.0% |
| 1–5 min | 701 | 20.5% |
| 5–30 min | 236 | 8.6% |
| 30–60 min | 50 | 3.0% |
| >1 h | 123 | 5.8% |
Requests arriving after a gap longer than the 5-minute TTL account for 17.5% of recompute. Requests arriving within 10 seconds account for 33.1%.
The dominant source of cache misses in real agentic serving is not sessions idling past a TTL. It's tight two-second tool loops whose 88k-token working sets exceed cache capacity.
That's a capacity problem, not a liveness-prediction problem. And with a p50 gap of 2.1 seconds, almost every session is "about to return" — so a liveness estimator has essentially nothing to discriminate on.
Reproduce: make gap
TTL-300s produced byte-identical results to LRU-leaf in every single run.
Under capacity pressure LRU always evicts before the timer fires. The TTL never binds.
I implemented a policy with three separable, independently ablatable components:
P(session returns) replacing recency. Online Bayesian estimator over
observed inter-turn gaps and continuation rates. No oracle: it only ever sees completed
observations.Hit rate, 40 AgentX sessions, 4,751 requests:
| cache (blocks) | LRU-leaf | TTL-300s | LFU-leaf | +H | +HC | +HCG |
|---|---|---|---|---|---|---|
| 8,000 | 83.48% | 83.48% | 63.61% | 82.89% | 71.77% | 68.63% |
| 20,000 | 93.92% | 93.92% | 69.96% | 93.61% | 84.86% | 78.89% |
| 50,000 | 95.76% | 95.76% | 79.58% | 95.68% | 94.45% | 91.40% |
Effective recompute cost versus LRU-leaf (negative is worse):
| cache | LFU-leaf | +H | +HC | +HCG |
|---|---|---|---|---|
| 8,000 | −129.7% | −3.2% | −38.9% | −81.0% |
| 20,000 | −434.3% | −4.5% | −90.4% | −207.8% |
| 50,000 | −447.1% | −1.0% | −15.4% | −66.8% |
Monotone negative. Every component made it worse, and the one I was most confident in — coherent eviction — was the worst.
Given finding 2, this is exactly what should have happened. I was optimizing for a signal carrying 17.5% of the waste, using a predictor that can't discriminate at a 2.1-second median gap.
Reproduce: make ablation
In my first run, Belady — an offline oracle — lost to LRU. That's not a result, that's a broken harness, and it's worth publishing because I expect it to be common.
The cause: inserting a long chain into a near-full cache lets a policy evict the very prefix it is currently building. LRU is accidentally immune because just-inserted blocks have the newest timestamp. Every non-recency policy cannibalises itself. Real engines prevent this with refcount pins; a from-scratch simulator usually doesn't.
If you build one of these, make your first test "does Belady beat LRU?" If it doesn't, you have this bug, and every policy comparison you run will be silently wrong in LRU's favour.
Two other implementation notes:
touch() need only update that one
block. An O(chain length) walk becomes O(1) — which matters at AgentX's 1,387-block median.The agentic KV-cache problem has been framed as a liveness problem. On this data it's a capacity problem. The interesting question isn't "will this session come back?" — it's "how do I fit 88k-token working sets for N concurrent sessions running tight tool loops?" Those point at different answers: the first at TTL policy and survival prediction, the second at compression, tiering, admission control, and working-set-aware scheduling.
LRU-leaf is a stronger baseline than the literature treats it as. I couldn't beat it with three independent mechanisms on real traces. Meanwhile several published alternatives are evaluated against degraded ports of their competitors — two separate papers benchmark against Continuum with its adaptive TTL replaced by a fixed 2s or 0.3s pin, which disables the thing that makes it work. This null result suggests those margins are softer than they read.
Validate against a published curve before trusting your own numbers. Doing that surfaced a discrepancy I still can't explain, and it's the only reason I trust anything else here.
git clone https://github.com/<you>/agentic-kv-cache && cd agentic-kv-cache
make setup # venv
make data # ~1.1 GB of traces (Apache-2.0), then flattens AgentX to a pickle
make repro # all four experiments, writes results/
Individually:
make validate # Mooncake reproduction -> results/01_validate.txt
make characterize # AgentX duty cycle and gaps -> results/02_characterize.txt
make gap # recompute by idle gap -> results/03_gap.txt
make ablation # policy ablation -> results/04_ablation.txt
The simulator is pure stdlib Python; numpy is only used by helper scripts. Committed outputs
in results/ let you check the tables without downloading anything.
If you can answer any of these, please open an issue — I'd genuinely like to know:
Traces: Mooncake (Moonshot AI, FAST'25) and the AgentX corpus (SemiAnalysis), both Apache-2.0. This work is independent of and unaffiliated with either.
MIT licensed.
1 commits
Python
92.9%
Shell
3.7%
Makefile
3.4%