Profile LLM gateway logs for prefix-cache reuse potential, and export anonymized replayable traces
Project description
llmtrafficlens
Analyze an LLM gateway's request log for prefix-cache reuse, then export the log as an anonymized trace that standard benchmark tools can replay.
Reproduce this in a minute on the public Mooncake conversation trace
(curl -LO https://raw.githubusercontent.com/kvcache-ai/Mooncake/main/FAST25-release/traces/conversation_trace.jsonl):
$ llmtrafficlens profile conversation_trace.jsonl --format mooncake -o report
12,031 requests · unknown
input p50 6,909 tok · output p50 350 tok · stream 0% · 3.4 req/s
hit rate by cache size (steady state: last 50% of requests)
16K tokens 3.6%
64K tokens 4.5%
256K tokens 4.5%
1M tokens 5.6%
4M tokens 18.8%
16M tokens 35.6%
unbounded 39.8% <- ceiling
session identifiers 0.0% of requests (affinity routing cannot reach this reuse)
top prefix by volume 512 tok x 12,031 reqs = 5.3% of reusable volume
top prefix by count 512 tok x 12,031 reqs = 5.3% of reusable volume
wrote report.json, report.html
report.html carries the full leaderboards and distributions;
report.json is the same for scripts. That 39.8% ceiling is what the
official kvcache-simulator reports for this trace too.
Two commands:
profile— how much a prefix cache could save on this traffic, and how much cache memory that takes.export— the same log as a Mooncake- or bailian-format trace, with all text removed.
Background
An engine reads the whole prompt before generating anything, and that work is pure recomputation whenever two requests begin with the same text. A prefix cache keeps the intermediate state around so the next request starting the same way skips it. Three things decide whether that pays off, and they are what this tool reports:
- The traffic. No shared leading text, nothing to reuse. A property of your workload, not your setup.
- Cache memory. KV state is bulky, so a cache holds a bounded number of tokens and evicts the rest — with diminishing returns you can measure instead of guess.
- Routing. With several workers, a request only hits if it reaches the one holding its prefix. That is what prefix-aware routing does; session affinity is the cheaper alternative, and only works when requests carry a session identifier.
Reuse is counted in fixed-size blocks (16 tokens) because engines cache at block granularity, not per character.
Install
pip install llmtrafficlens
No runtime dependencies. Python ≥ 3.10.
profile
llmtrafficlens profile gateway.csv -o report
Input. A CSV with a request_json column holding the OpenAI-format
request body. Two sample inputs ship in examples/: a synthetic CSV for
the parsing path, and 400 requests of real traffic exported to a hashed
trace (lengths and block hashes, no text) for the analysis path. See
examples/README.md. These columns are used when present: response_json,
model_name, status_code, and a timestamp column (timestamp, ts,
created_at, ...; epoch or ISO-8601, or set --ts-column). The model
name selects which chat-template layout to assume; see
What counts as the prompt?. Mooncake and
qwen-bailian traces are accepted as input too
(--format mooncake|bailian).
Output. A summary on stdout (shown above), plus report.html and
report.json containing:
- hit rate at each cache size, ending with the unbounded case — the
ceiling, and split by what could supply each hit: text shared between
unrelated conversations, or an earlier turn of the same one. Measured
over the last half of the log, the first half filling the cache;
--warmup 0measures the whole log from cold instead; - top prefixes by reuse count, and separately by reusable token volume;
- share of requests carrying a session identifier;
- input/output token distributions, streaming ratio, model mix, QPS.
| option | effect |
|---|---|
--warmup F |
leading fraction of requests that fills the cache uncounted (default 0.5) |
--model-family F |
whose chat template decides tool placement (default: per request, from the model name) |
--tools-position P |
place tool definitions explicitly, overriding the family |
--block-size N |
tokens per hash block (default 16) |
--ts-column C |
which CSV column holds timestamps |
--kvcache-sim ID |
also run kvcache-simulator, see below |
How to read it.
| reading | what it decides |
|---|---|
| ceiling | whether to bother at all. It is the share of input tokens servable from cache given unlimited memory and perfect routing, so at a few percent no engineering makes this worthwhile |
| capacity curve | how much memory the ceiling costs. Where it flattens, buying more stops helping |
| session-id share | whether the cheap option suffices. Most requests carrying one means pinning sessions to workers captures the reuse; none carrying one (common for API traffic) leaves prefix matching as the only way |
| the two leaderboards | which prefixes must stay resident. The token-volume ranking is the one that determines savings: a short prefix reused constantly contributes almost nothing, a long one reused a few dozen times can be most of it |
Handing off to kvcache-simulator
Our curve is one LRU replay per capacity. kvcache.ai's simulator does more
on the same input — FIFO, LRU and Belady-optimal side by side, per-model
byte accounting, a C++ core — so profile can run it on the same trace
and fold the result in:
pip install kvcache-simulator
llmtrafficlens profile gateway.csv --kvcache-sim glm-5.2 -o report
kvcache-simulator · glm-5.2 · 93 KiB/token
ceiling 51.3%
16 GiB lru 4.8% (1.1x) optimal 23.9% (1.3x)
128 GiB lru 37.9% (1.6x) optimal 51.3% (2.1x)
512 GiB lru 50.1% (2.0x) optimal 51.3% (2.1x)
Each row is one cache budget, priced for the model you named. lru is the
policy engines actually use; optimal is Belady's clairvoyant replacement,
the best any policy could do at that budget. The percentage is the hit
rate, the multiplier in brackets the prefill speedup it implies.
Reading it: at 16 GiB a perfect policy would hit five times as often as LRU does, and at 128 GiB it already reaches the ceiling that LRU needs 512 GiB to approach. That gap is the part of the shortfall better eviction could recover, as opposed to the part that needs more memory. The step is skipped with a note when the simulator is not installed.
On a production log
The public trace above is chat traffic. A production agent workload — 2,519 requests, tool-calling loops, each turn resending its history — looks different:
| cache size | hit rate | across requests | same conversation |
|---|---|---|---|
| 16K tokens | 0.4% | 0.4% | 0.0% |
| 64K | 1.3% | 1.1% | 0.1% |
| 256K | 5.8% | 4.1% | 1.7% |
| 1M | 27.4% | 9.3% | 18.1% |
| 4M | 49.7% | 14.0% | 35.7% |
| unbounded | 51.3% | 14.9% | 36.4% |
Shared text — system prompts, tool definitions — is touched by hundreds of requests, so even a small cache holds onto it. Conversational history is touched only by that conversation's later turns and there is far more of it, so it needs an order of magnitude more room: a 256K cache here buys the templates and little else, while two thirds of the prize waits past 1M.
This log arrived back to front — nearly every request preceded the
turn it continues — so the tool replays it forwards and says so. Read as
delivered it reports 51.3% as 62.1% with an even split: plausible and
wrong by 11 points. Only --warmup 0 is invariant to replay order.
Two things worth checking in your own log:
- No request carried a session identifier, so nothing says which
conversation a request belongs to. Two thirds of the reuse is
conversational and pinning sessions to workers would capture it; getting
clients to send
prompt_cache_keyis cheaper than any routing change. - Tool sets changed mid-conversation in 11% of conversations. Where a template renders tools near the front, changing them breaks the prefix and discards all the history behind it.
export
llmtrafficlens export gateway.csv --to mooncake -o trace.jsonl # or --to bailian
A benchmark written by hand, with equally popular prompt groups, reports a higher hit rate than production reaches, because every group stays warm. Replaying the real log avoids that, and the export can be shared, because each request is reduced to one line of structure:
{"timestamp": 1753340000123, "input_length": 1994, "output_length": 117,
"hash_ids": [4251731047194047120, 8125214104179782736, ...]}
hash_ids is a salted chained block hash: two requests share their first
N hashes exactly when they share their first N blocks. Replay tools
generate one synthetic block per hash, so the prefix-sharing structure is
preserved while the content is not real.
# replay at the recorded pace
aiperf profile --custom-dataset-type mooncake_trace --input-file trace.jsonl --fixed-schedule ...
# replay at 2x the pace, for rate sweeps
aiperf profile --custom-dataset-type mooncake_trace --input-file trace.jsonl --synthesis-speedup-ratio 2.0 ...
# or SGLang
python -m sglang.bench_serving --dataset-name mooncake --dataset-path trace.jsonl ...
--to bailian adds chat_id, parent_chat_id and turn for AIPerf's
bailian_trace mode; use it when the log carries session identifiers.
Tell the consumer which block size the export used — AIPerf defaults to
512 for mooncake and 16 for bailian (--prompt-input-tokens-block-size).
Privacy
Processing is local. Reports contain aggregates only. An export contains
lengths, timestamps, anonymized session identifiers and salted block
hashes; prompts cannot be reconstructed from it. The hash key is written
to .ltl-salt on first run — keep it unchanged so runs stay comparable,
and treat it as a secret.
FAQ
What counts as the prompt?
Everything a chat template renders before generation: the tool
definitions, then each message with its role, name, tool_call_id,
content and tool_calls.
Where the tool definitions go is model-specific, and it matters — the two
placements read about 7 points apart on one production log. The family is
picked per request from the model name, each entry read from that model's
published chat_template.jinja:
| family | tool definitions | template read |
|---|---|---|
| hy3 | after the system message | 2026-07-27 |
| glm | own block, ahead of the conversation | 2026-07-27 |
| kimi | own block, ahead of the conversation | 2026-07-27 |
An unrecognized model falls back to the leading placement and the report
says so; if that is wrong for your model, set --model-family or
--tools-position. reasoning_content is excluded, since clients echo it
back but engines generally strip it before prefill.
How is the hit rate computed?
Requests are replayed in timestamp order against an LRU block cache — the
default eviction policy in both vLLM and SGLang. Unbounded capacity gives
the "ideal hit rate" of
KVCache in the Wild (ATC'25); bounded
capacities give the Mooncake simulator's capacity
curve. Prefixes
are identified by chained per-block hashing (hash(parent, block), in
16-token blocks, following the
qwen-bailian
convention).
Three assumptions ride along:
- One global cache, i.e. a single worker or perfect routing. With N workers each sees a partition, so read the curve as fleet-level guidance.
- The whole cache is free for reuse. A real KV pool also holds in-flight requests; only the remainder is available.
- Steady state, not cold start. The first half of the log fills the
cache uncounted, so the figure is what a running service settles at.
--warmup 0gives the cold reading: 37.4% against 39.8% on the Mooncake trace. The reference simulator makes the same choice, which is why the two agree.
How do I convert token counts to gigabytes?
Per-token cost follows the architecture. GLM-5.2 caches two things:
| component | derivation | per token |
|---|---|---|
| MLA latent | (512 + 64) x 2 bytes x 78 layers |
87.75 KiB |
| DSA indexer | 128 x 2 bytes x 21 full-indexer layers |
5.25 KiB |
| 93 KiB |
Only 21 of the 78 layers run a full indexer, the rest reuse the previous
one's selection. Both terms match the kvcache-simulator catalog, which
reports 95,232 bytes per token for this model.
Do not scale that figure to another model. Only some architectures carry a
DSA indexer, and the attention term differs as well: Kimi K2.5 is 68.6 KiB
per token, a 32-layer fp16 GQA model 128 KiB. Look yours up with
kvcache-simulator list-models.
Has this been checked against other tools?
Three times, each pinned by a test in tests/test_core.py:
| checked against | result |
|---|---|
NVIDIA AIPerf analyze-trace v0.11.0 |
its per-request definition, recomputed from our block matching, agrees to 5e-15 |
the model's own chat_template.jinja + real tokenizer |
50.8% where we read 51.3%, over 51.7M real tokens versus our 48.8M estimated |
kvcache.ai kvcache-simulator |
39.8% on the Mooncake trace, which our default reproduces exactly; it also reads our own export output and lands within 0.1 points |
The second one matters most, since it skips our approximations entirely: character chunking is not what limits accuracy, tool placement is. AIPerf's headline number differs from ours because it averages per-request ratios while we weight by tokens, which is the right choice for sizing a cache.
What has not been verified?
- Replay round-trip. We have not fed an export through AIPerf or SGLang and confirmed it replays. The formats match their documented schemas, which is not the same thing.
- Exact tokenization. Approximate mode hashes character chunks, so reported structure is a lower bound on what a real tokenizer would match: formatting differences that tokenize identically read as distinct prefixes here.
- Landscape claims. The statement below that no tool goes from a raw gateway log to a hashed trace comes from a survey of vendor documentation in July 2026. That is an absence of evidence, not a vendor denial.
Why not just use AIPerf or the Mooncake simulator?
| tool | takes | gives |
|---|---|---|
| llmtrafficlens | raw gateway log | hashed trace, reuse structure, session-id presence |
| kvcache-simulator | hashed trace | FIFO/LRU/Optimal, per-model bytes, speedup |
AIPerf analyze-trace |
hashed trace | length distributions, cache analysis |
AIPerf profile |
hashed trace, SageMaker capture | replay, rate control, latency |
| vLLM / SGLang benchmarks | synthetic or ShareGPT | engine-side prefix-cache performance |
Use them if you already have a hashed trace — on that input
kvcache-simulator does more than we do. But they all start from a line
like {"timestamp": 0, "input_length": 6909, "hash_ids": [1234, ...]},
and a gateway log is prompts, not that. Turning one into the other is the
step nobody else covers, and it has to happen on a machine you control
because it is the last one that touches plaintext.
Reading the raw log also keeps two things a hashed trace has thrown away: whether requests carry session identifiers, and which prefixes hold the reusable volume.
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file llmtrafficlens-0.13.4.tar.gz.
File metadata
- Download URL: llmtrafficlens-0.13.4.tar.gz
- Upload date:
- Size: 26.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f36e02c626f1bf0e7b8b901ee13b6d7bd746fda81a370f416a88842aede0cfbe
|
|
| MD5 |
dcecc9ca4cd0e7501937638aabb3531f
|
|
| BLAKE2b-256 |
01df5207aa773b4e98ca28ba5a68c9fa334ae4e8004a5aa09d91e74c4b4727ad
|
File details
Details for the file llmtrafficlens-0.13.4-py3-none-any.whl.
File metadata
- Download URL: llmtrafficlens-0.13.4-py3-none-any.whl
- Upload date:
- Size: 32.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1789235e68eb8f8f33052b72aaa1cfcdf8368f25a5d21b5da35ec25e46044087
|
|
| MD5 |
58e5902727d44e637869dead622d1d3f
|
|
| BLAKE2b-256 |
2a6bd62ca0fdaf35bf51962bbf152f4ceeba5f91195779811a5397a98fd585b0
|