llm-cachecheck 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env python3
2
+ """cachecheck: how often does the prompt cache change your server's answers?
3
+
4
+ Sends short two-turn conversations to an OpenAI-compatible server with
5
+ greedy decoding and compares the second answer on two paths:
6
+
7
+ cache path turn 2 reuses the KV state the server wrote while it
8
+ generated turn 1 (a cache hit)
9
+ recompute path turn 2 is processed from scratch (no cache reuse)
10
+
11
+ Each path runs twice. A probe counts only when both paths repeat, the
12
+ server's own telemetry confirms that the cache hit covered the turn-1
13
+ answer, and the recompute path reused nothing. The method adapts the
14
+ paired design of Patodiya, "Same Request, Different Answer: Quantization
15
+ Amplifies Cache-Induced Divergence in LLM Serving" (IEEE Access, 2026,
16
+ doi:10.1109/ACCESS.2026.3749922; arXiv:2609.04748).
17
+
18
+ Two modes:
19
+
20
+ --mode llamacpp one llama.cpp server; the cache is switched per
21
+ request with "cache_prompt" (start it with -np 1)
22
+ --mode two-servers vLLM, SGLang or another OpenAI-compatible stack:
23
+ --base-url serves with prefix caching on, and
24
+ --nocache-url serves the same model with it off
25
+ (tested against a simulated server only)
26
+
27
+ Standard library only. Python 3.8 or newer.
28
+
29
+ Usage (from PyPI: pip install llm-cachecheck, then run llm-cachecheck
30
+ in place of python cachecheck.py):
31
+ python cachecheck.py --base-url http://127.0.0.1:8080
32
+ python cachecheck.py --mode two-servers --base-url http://127.0.0.1:8000 \
33
+ --nocache-url http://127.0.0.1:8001 --model Qwen/Qwen2.5-7B-Instruct
34
+
35
+ Exit status: 0 no difference and at least 80% of probes valid, 1 at
36
+ least one valid probe differs, 2 inconclusive.
37
+ """
38
+
39
+ import argparse
40
+ import binascii
41
+ import json
42
+ import os
43
+ import sys
44
+ import urllib.error
45
+ import urllib.request
46
+
47
+ __version__ = "0.2.1"
48
+
49
+ CITATION = (
50
+ "Patodiya, A. Same Request, Different Answer: Quantization Amplifies "
51
+ "Cache-Induced Divergence in LLM Serving. IEEE Access, 2026. "
52
+ "doi:10.1109/ACCESS.2026.3749922"
53
+ )
54
+
55
+ MIN_VALID_SHARE = 0.8
56
+
57
+ # Two-turn probes. Turn 1 asks for a worked answer of a few sentences, so
58
+ # the server writes a useful amount of KV state while generating it.
59
+ # Turn 2 builds on that answer, so it is processed on top of that state.
60
+ PROBES = [
61
+ ("A train leaves at 9:40 and arrives at 13:15. How long is the trip? "
62
+ "Show your steps.",
63
+ "If the train had left 25 minutes later and travelled at the same "
64
+ "speed, when would it arrive? Explain briefly."),
65
+ ("List four prime numbers between 50 and 80 and say how you checked "
66
+ "each one.",
67
+ "Add the four numbers and say whether the sum is prime."),
68
+ ("Explain in a short paragraph what a hash table is and why lookups "
69
+ "are usually fast.",
70
+ "Now describe one situation where a hash table lookup becomes slow."),
71
+ ("Write a Python function that returns the second largest number in "
72
+ "a list. Add a short comment on each step.",
73
+ "Rewrite the function so it also handles lists with repeated values "
74
+ "and lists shorter than two."),
75
+ ("A shop sells pens at 3 for 2 dollars. How much do 15 pens cost? "
76
+ "Show the steps.",
77
+ "The shop now offers 10 percent off orders above 8 dollars. What do "
78
+ "15 pens cost now?"),
79
+ ("Summarize the water cycle in four or five sentences.",
80
+ "Which step of the cycle you described is most affected by higher "
81
+ "temperatures, and why?"),
82
+ ("Give three tips for writing clear error messages in software, with "
83
+ "one example each.",
84
+ "Pick the most important tip and rewrite this message with it: "
85
+ "'Error 17: operation failed.'"),
86
+ ("Convert 72 degrees Fahrenheit to Celsius and show the formula.",
87
+ "Now convert your answer to Kelvin and explain the step."),
88
+ ("Describe how binary search works on a sorted list of 100 numbers. "
89
+ "How many steps does it need at most?",
90
+ "What changes if the list has 1000 numbers? Give the new maximum."),
91
+ ("Write a SQL query that returns the five customers with the highest "
92
+ "total order value from tables customers(id, name) and "
93
+ "orders(id, customer_id, amount).",
94
+ "Change the query so it only counts orders from the year 2025, given "
95
+ "a column orders.created_at."),
96
+ ("A rectangle has a perimeter of 36 cm and its length is twice its "
97
+ "width. Find its area. Show the steps.",
98
+ "If both sides grow by 2 cm, by how much does the area grow?"),
99
+ ("Explain the difference between a process and a thread in a few "
100
+ "sentences.",
101
+ "Give one bug that can happen with threads but not with separate "
102
+ "processes, and how to avoid it."),
103
+ ("Plan a three-step morning routine for someone who wants to read "
104
+ "more. Keep each step to one sentence.",
105
+ "Adapt the routine for someone who has only 15 minutes."),
106
+ ("What is 17 times 24? Work it out in two different ways.",
107
+ "Use your result to compute 17 times 25 without multiplying again."),
108
+ ("Write a regular expression that matches dates like 2026-11-01 and "
109
+ "explain each part.",
110
+ "Extend it so it rejects months above 12. Explain the change."),
111
+ ("Explain what a p-value is to someone who has never studied "
112
+ "statistics, in four sentences.",
113
+ "Give one common misreading of a p-value and correct it."),
114
+ ("A recipe for 4 people needs 300 g of rice and 2 onions. Scale it "
115
+ "for 10 people and show the steps.",
116
+ "Round the amounts to what you would actually buy, and say why."),
117
+ ("Describe what a git rebase does, in a short paragraph.",
118
+ "When would you choose merge instead of rebase? Give one reason."),
119
+ ("A loan of 1000 dollars grows by 5 percent each year. What is it "
120
+ "after 3 years? Show each year.",
121
+ "How many whole years until it first exceeds 1500 dollars?"),
122
+ ("List the steps to make a cup of tea, numbered.",
123
+ "Rewrite the steps for someone using a microwave instead of a "
124
+ "kettle."),
125
+ ]
126
+
127
+
128
+ class RequestError(Exception):
129
+ pass
130
+
131
+
132
+ def http_json(url, body=None, api_key=None, timeout=300):
133
+ """POST body (or GET when body is None) and return the decoded JSON."""
134
+ headers = {"Content-Type": "application/json"}
135
+ if api_key:
136
+ headers["Authorization"] = "Bearer " + api_key
137
+ data = None if body is None else json.dumps(body).encode()
138
+ req = urllib.request.Request(url, data=data, headers=headers)
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
141
+ return json.loads(resp.read().decode())
142
+ except urllib.error.HTTPError as exc:
143
+ detail = exc.read()[:300].decode(errors="replace")
144
+ raise RequestError("HTTP %d: %s" % (exc.code, detail))
145
+ except (urllib.error.URLError, OSError, ValueError) as exc:
146
+ raise RequestError(str(exc))
147
+
148
+
149
+ def post_chat(url, messages, args, cache_prompt=None, cache_salt=None):
150
+ """One greedy chat completion, returned as a small dict."""
151
+ body = {"messages": messages, "temperature": 0,
152
+ "max_tokens": args.max_tokens, "seed": args.seed}
153
+ if args.model:
154
+ body["model"] = args.model
155
+ if cache_prompt is not None:
156
+ body["cache_prompt"] = cache_prompt
157
+ if cache_salt is not None:
158
+ body["cache_salt"] = cache_salt
159
+ data = http_json(url.rstrip("/") + "/v1/chat/completions", body,
160
+ args.api_key, args.timeout)
161
+ try:
162
+ choice = data["choices"][0]
163
+ msg = choice["message"]
164
+ except (KeyError, IndexError, TypeError):
165
+ raise RequestError("unexpected response: " + json.dumps(data)[:300])
166
+ usage = data.get("usage") or {}
167
+ return {
168
+ "content": msg.get("content") or "",
169
+ "reasoning": (msg.get("reasoning_content") or msg.get("reasoning")
170
+ or ""),
171
+ "finish_reason": choice.get("finish_reason"),
172
+ "prompt_tokens": usage.get("prompt_tokens"),
173
+ "completion_tokens": usage.get("completion_tokens"),
174
+ "cached_tokens": cached_tokens(data),
175
+ "system_fingerprint": data.get("system_fingerprint"),
176
+ }
177
+
178
+
179
+ def cached_tokens(data):
180
+ """Prompt tokens served from the cache, if the server reports it."""
181
+ details = (data.get("usage") or {}).get("prompt_tokens_details") or {}
182
+ if isinstance(details, dict) and details.get("cached_tokens") is not None:
183
+ return details["cached_tokens"]
184
+ timings = data.get("timings") or {}
185
+ if timings.get("cache_n") is not None:
186
+ return timings["cache_n"]
187
+ return None
188
+
189
+
190
+ def first_difference(a, b):
191
+ """Character index where two strings first differ, or None."""
192
+ for i, (x, y) in enumerate(zip(a, b)):
193
+ if x != y:
194
+ return i
195
+ return None if len(a) == len(b) else min(len(a), len(b))
196
+
197
+
198
+ def same_answer(x, y):
199
+ return x["content"] == y["content"] and x["reasoning"] == y["reasoning"]
200
+
201
+
202
+ def hit_check(turn1, turn2, tolerance):
203
+ """Did the cache-path turn 2 reuse the KV of turn 1 and its answer?
204
+
205
+ Returns (ok, note). ok is None when the server gave no telemetry.
206
+ """
207
+ cached = turn2["cached_tokens"]
208
+ p1, c1 = turn1["prompt_tokens"], turn1["completion_tokens"]
209
+ if cached is None or p1 is None or c1 is None:
210
+ return None, "server did not report cached tokens"
211
+ # The last sampled token never had its KV computed, hence the - 1.
212
+ needed = p1 + c1 - 1 - tolerance
213
+ if cached >= needed:
214
+ return True, "cached %d of %d" % (cached, p1 + c1 - 1)
215
+ return False, ("cache hit covered %d tokens, turn 1 and its answer "
216
+ "need about %d" % (cached, p1 + c1 - 1))
217
+
218
+
219
+ def run_probe(probe, index, args, nonce):
220
+ """Run one probe on both paths, each twice. Returns a result dict."""
221
+ q1, q2 = probe
222
+ turn1 = [{"role": "user", "content": q1}]
223
+ llama = args.mode == "llamacpp"
224
+ nocache_url = args.base_url if llama else args.nocache_url
225
+
226
+ # Cache path, twice. Turn 1 is computed fresh each time, so the KV of
227
+ # its answer is written during generation; turn 2 then reuses it. On
228
+ # llama.cpp cache_prompt=false forces the fresh turn 1. On other
229
+ # stacks each iteration gets its own cache_salt, shared by its two
230
+ # turns, so iteration 2 cannot reuse what iteration 1 left behind.
231
+ first1, cache_runs = None, []
232
+ for it in range(2):
233
+ salt = None if llama else "cachecheck-%s-%d-%d" % (nonce, index, it)
234
+ t1 = post_chat(args.base_url, turn1, args,
235
+ False if llama else None, salt)
236
+ if first1 is None:
237
+ first1 = t1
238
+ turn2 = turn1 + [{"role": "assistant", "content": first1["content"]},
239
+ {"role": "user", "content": q2}]
240
+ t2 = post_chat(args.base_url, turn2, args,
241
+ True if llama else None, salt)
242
+ cache_runs.append((t1, t2))
243
+
244
+ turn2 = turn1 + [{"role": "assistant", "content": first1["content"]},
245
+ {"role": "user", "content": q2}]
246
+ nocache_runs = [post_chat(nocache_url, turn2, args,
247
+ False if llama else None)
248
+ for _ in range(2)]
249
+
250
+ (t1a, w1), (t1b, w2) = cache_runs
251
+ c1, c2 = nocache_runs
252
+ problems = []
253
+ if not same_answer(t1a, t1b):
254
+ problems.append("turn 1 did not repeat")
255
+ if not same_answer(w1, w2):
256
+ problems.append("cache path did not repeat")
257
+ if not same_answer(c1, c2):
258
+ problems.append("recompute path did not repeat")
259
+ if not (t1a["content"] and w1["content"] and c1["content"]):
260
+ problems.append("empty answer (a thinking model? turn thinking off)")
261
+
262
+ hits = [hit_check(t1, t2, args.hit_tolerance) for t1, t2 in cache_runs]
263
+ leaks = [c["cached_tokens"] for c in nocache_runs]
264
+ if not args.skip_hit_check:
265
+ for ok, note in hits:
266
+ if ok is False:
267
+ problems.append(note)
268
+ if any(ok is None for ok, _ in hits):
269
+ problems.append("cache hit not confirmed: server did not "
270
+ "report cached tokens")
271
+ if any(n for n in leaks if n is not None):
272
+ problems.append("recompute path reused %d cached tokens"
273
+ % max(n for n in leaks if n is not None))
274
+ elif any(n is None for n in leaks):
275
+ problems.append("recompute path not confirmed: server did not "
276
+ "report cached tokens")
277
+ problems = list(dict.fromkeys(problems))
278
+
279
+ differs = not same_answer(w1, c1)
280
+ return {
281
+ "probe": index,
282
+ "valid": not problems,
283
+ "problems": problems,
284
+ "paths_differ": differs,
285
+ "reasoning_differs": w1["reasoning"] != c1["reasoning"],
286
+ "first_difference_char": first_difference(w1["content"],
287
+ c1["content"]),
288
+ "cache_hit": [note for _, note in hits],
289
+ "recompute_cached_tokens": leaks,
290
+ "turn1": [t1a, t1b],
291
+ "cache_path": [w1, w2],
292
+ "recompute_path": [c1, c2],
293
+ }
294
+
295
+
296
+ def server_props(args):
297
+ """llama.cpp /props: slot count and build, or {} if unavailable."""
298
+ try:
299
+ props = http_json(args.base_url.rstrip("/") + "/props", None,
300
+ args.api_key, 30)
301
+ except RequestError:
302
+ return {}
303
+ return {"total_slots": props.get("total_slots"),
304
+ "build_info": props.get("build_info"),
305
+ "model_file": os.path.basename(props.get("model_path") or "")}
306
+
307
+
308
+ def status_line(r):
309
+ if not r["valid"]:
310
+ return "inconclusive (%s)" % "; ".join(r["problems"])
311
+ if not r["paths_differ"]:
312
+ return "same"
313
+ if r["first_difference_char"] is None:
314
+ return "DIFFERENT (in the reasoning text)"
315
+ return "DIFFERENT (from character %d)" % r["first_difference_char"]
316
+
317
+
318
+ def main(argv=None):
319
+ ap = argparse.ArgumentParser(
320
+ description="Measure how often prompt caching changes greedy answers.")
321
+ ap.add_argument("--base-url", required=True,
322
+ help="server root, e.g. http://127.0.0.1:8080")
323
+ ap.add_argument("--mode", choices=["llamacpp", "two-servers"],
324
+ default="llamacpp",
325
+ help="llamacpp (default): one llama.cpp server. "
326
+ "two-servers: vLLM, SGLang or another OpenAI-"
327
+ "compatible stack; tested against a simulated "
328
+ "server only, not yet against a real one")
329
+ ap.add_argument("--nocache-url",
330
+ help="two-servers mode: same model, prefix caching off")
331
+ ap.add_argument("--model", default=None,
332
+ help="model name to send (needed by vLLM and SGLang)")
333
+ ap.add_argument("--n", type=int, default=len(PROBES),
334
+ help="number of probes, 1 to %d" % len(PROBES))
335
+ ap.add_argument("--max-tokens", type=int, default=256)
336
+ ap.add_argument("--seed", type=int, default=42)
337
+ ap.add_argument("--timeout", type=float, default=300)
338
+ ap.add_argument("--hit-tolerance", type=int, default=None,
339
+ help="tokens the cache hit may fall short by (default "
340
+ "1 for llamacpp, 32 for two-servers, which covers "
341
+ "block-sized caches)")
342
+ ap.add_argument("--skip-hit-check", action="store_true",
343
+ help="for servers that report no cached tokens; the "
344
+ "cache hit is then assumed, not confirmed")
345
+ ap.add_argument("--api-key", default=os.environ.get("CACHECHECK_API_KEY"))
346
+ ap.add_argument("--out", default="cachecheck_report.json")
347
+ ap.add_argument("--version", action="version", version=__version__)
348
+ args = ap.parse_args(argv)
349
+ if args.mode == "two-servers" and not args.nocache_url:
350
+ ap.error("--mode two-servers needs --nocache-url")
351
+ if not 1 <= args.n <= len(PROBES):
352
+ ap.error("--n must be between 1 and %d" % len(PROBES))
353
+ if args.hit_tolerance is None:
354
+ args.hit_tolerance = 1 if args.mode == "llamacpp" else 32
355
+
356
+ props = server_props(args) if args.mode == "llamacpp" else {}
357
+ slots = props.get("total_slots")
358
+ if slots not in (None, 1):
359
+ print("Warning: the server has %d slots. Restart it with -np 1 so "
360
+ "other requests cannot share or evict its cache." % slots)
361
+
362
+ nonce = binascii.hexlify(os.urandom(4)).decode()
363
+ probes = PROBES[:args.n]
364
+ results, failures = [], 0
365
+ for i, probe in enumerate(probes, 1):
366
+ try:
367
+ r = run_probe(probe, i, args, nonce)
368
+ except RequestError as exc:
369
+ failures += 1
370
+ print("probe %2d/%d: request failed: %s" % (i, len(probes), exc))
371
+ continue
372
+ results.append(r)
373
+ print("probe %2d/%d: %s" % (i, len(probes), status_line(r)))
374
+
375
+ valid = [r for r in results if r["valid"]]
376
+ diverged = [r for r in valid if r["paths_differ"]]
377
+ reasons = {}
378
+ for r in results:
379
+ for p in r["problems"]:
380
+ key = p.split(":")[0] if "cache hit covered" not in p \
381
+ else "cache hit too short"
382
+ reasons[key] = reasons.get(key, 0) + 1
383
+ summary = {"probes_planned": len(probes), "probes_failed": failures,
384
+ "probes_valid": len(valid), "probes_diverged": len(diverged),
385
+ "inconclusive_reasons": reasons,
386
+ "hit_check": "skipped" if args.skip_hit_check else "on"}
387
+ report = {"tool": "llm-cachecheck " + __version__, "mode": args.mode,
388
+ "base_url": args.base_url, "nocache_url": args.nocache_url,
389
+ "model": args.model, "max_tokens": args.max_tokens,
390
+ "seed": args.seed, "server_props": props,
391
+ "summary": summary, "probes": results, "method": CITATION}
392
+ out_dir = os.path.dirname(args.out)
393
+ if out_dir:
394
+ os.makedirs(out_dir, exist_ok=True)
395
+ with open(args.out, "w") as fh:
396
+ json.dump(report, fh, indent=1)
397
+
398
+ print()
399
+ print("%d of %d valid probes gave a different turn-2 answer on the cache "
400
+ "path." % (len(diverged), len(valid)))
401
+ if len(valid) < len(probes):
402
+ print("%d of %d probes were not valid:" % (len(probes) - len(valid),
403
+ len(probes)))
404
+ if failures:
405
+ print(" request failed: %d" % failures)
406
+ for key, n in sorted(reasons.items()):
407
+ print(" %s: %d" % (key, n))
408
+ if args.mode == "llamacpp":
409
+ print("For a clean check, run llama-server with -np 1 and "
410
+ "--cache-ram 0, and send it no other traffic.")
411
+ else:
412
+ print("Make sure both servers serve the same model with the same "
413
+ "settings, report cached tokens, and get no other traffic.")
414
+ if args.skip_hit_check:
415
+ print("Cache hits were assumed, not confirmed (--skip-hit-check).")
416
+ print("Report written to %s" % args.out)
417
+ print("Method: " + CITATION)
418
+
419
+ if diverged:
420
+ return 1
421
+ if failures or len(valid) < MIN_VALID_SHARE * len(probes):
422
+ return 2
423
+ return 0
424
+
425
+
426
+ if __name__ == "__main__":
427
+ sys.exit(main())
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: llm-cachecheck
3
+ Version: 0.2.1
4
+ Summary: Measure how often the prompt cache changes greedy answers on your LLM server (llama.cpp, plus an experimental two-server mode for vLLM and SGLang)
5
+ Project-URL: Homepage, https://github.com/aditi-p31/cache-divergence-study/tree/main/cachecheck
6
+ Project-URL: Source, https://github.com/aditi-p31/cache-divergence-study
7
+ Project-URL: Paper, https://doi.org/10.1109/ACCESS.2026.3749922
8
+ Project-URL: Preprint, https://arxiv.org/abs/2609.04748
9
+ Project-URL: Issues, https://github.com/aditi-p31/cache-divergence-study/issues
10
+ Author: Aditi Patodiya
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: inference,kv cache,llama.cpp,llm,nondeterminism,prefix caching,prompt cache,reproducibility,sglang,vllm
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+
25
+ # llm-cachecheck
26
+
27
+ On PyPI this tool is `llm-cachecheck`. The PyPI package named `cachecheck` is
28
+ a different project.
29
+
30
+ How often does the prompt cache change your server's answers?
31
+
32
+ Serving stacks reuse the cached key-value state of a shared prompt prefix. A
33
+ cache hit and a recomputation run different operations, so with greedy
34
+ decoding a small numeric change can flip a token. After that the answer goes
35
+ its own way. This is known behavior: the llama.cpp server README says that
36
+ `cache_prompt` "can cause nondeterministic results". What varies is how often
37
+ it happens on your model, weights and build. `cachecheck` measures that.
38
+
39
+ It sends short two-turn conversations with greedy decoding and compares the
40
+ second answer on two paths:
41
+
42
+ - **cache path:** turn 2 reuses the state the server wrote while it generated
43
+ turn 1
44
+ - **recompute path:** turn 2 is processed from scratch
45
+
46
+ Each path runs twice. A probe counts as valid only when all of these hold:
47
+
48
+ - both paths repeat on their own
49
+ - the server's own counts show that the cache hit covered the turn-1 answer
50
+ - the server's own counts show that the recompute path reused nothing
51
+
52
+ ## Run it
53
+
54
+ Python 3.8 or newer, no dependencies. Install it from PyPI:
55
+
56
+ ```bash
57
+ pip install llm-cachecheck
58
+ llm-cachecheck --help
59
+ ```
60
+
61
+ Or download the single file
62
+ [`cachecheck.py`](https://github.com/aditi-p31/cache-divergence-study/blob/main/cachecheck/cachecheck.py)
63
+ and run `python cachecheck.py` in place of `llm-cachecheck`. Both take the
64
+ same options. A full run
65
+ sends 120 requests.
66
+
67
+ **llama.cpp.** The cache is switched per request. Start the server with one
68
+ slot, because the default is several slots that share a cache. Turn the
69
+ host-memory prompt cache off to match the study's controlled runs:
70
+
71
+ ```bash
72
+ llama-server -m model.gguf -np 1 --cache-ram 0 --port 8080
73
+ llm-cachecheck --base-url http://127.0.0.1:8080
74
+ ```
75
+
76
+ **vLLM, SGLang or another OpenAI-compatible server.** The cache is a server
77
+ setting, so serve the same model twice, once with prefix caching on and once
78
+ with it off. Both servers must report cached tokens. For vLLM on one GPU:
79
+
80
+ ```bash
81
+ vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000 --gpu-memory-utilization 0.45 \
82
+ --generation-config vllm --enable-prompt-tokens-details
83
+ vllm serve Qwen/Qwen2.5-7B-Instruct --port 8001 --gpu-memory-utilization 0.45 \
84
+ --generation-config vllm --enable-prompt-tokens-details --no-enable-prefix-caching
85
+ llm-cachecheck --mode two-servers --base-url http://127.0.0.1:8000 \
86
+ --nocache-url http://127.0.0.1:8001 --model Qwen/Qwen2.5-7B-Instruct
87
+ ```
88
+
89
+ For SGLang, add `--enable-cache-report` to both servers and
90
+ `--disable-radix-cache` to the second one.
91
+
92
+ The two-server mode is covered by the unit tests against a simulated server.
93
+ It has not yet been run against a real vLLM or SGLang server. If you run it,
94
+ an [issue](https://github.com/aditi-p31/cache-divergence-study/issues) with what you saw is welcome.
95
+
96
+ Two server processes can also differ for other reasons. To check that, start
97
+ both with prefix caching off and run with `--skip-hit-check`. Every probe
98
+ should come out the same.
99
+
100
+ Options: `--n` sets the number of probes (default 20), `--max-tokens` the
101
+ answer length (default 256), `--out` the JSON report path.
102
+
103
+ Models that think before answering need thinking turned off for this check.
104
+ Their templates often drop past thinking from the history, which shortens the
105
+ cache hit, and the tool marks those probes as inconclusive.
106
+
107
+ ## Reading the result
108
+
109
+ A real run on Apple Silicon (Metal) with Qwen2.5-3B-Instruct at Q4_K_M,
110
+ llama.cpp b12138, `-np 1 --cache-ram 0`, default options:
111
+
112
+ ```
113
+ probe 1/20: same
114
+ ...
115
+ probe 10/20: DIFFERENT (from character 578)
116
+ ...
117
+ probe 13/20: DIFFERENT (from character 235)
118
+ ...
119
+ probe 15/20: DIFFERENT (from character 376)
120
+ ...
121
+ 3 of 20 valid probes gave a different turn-2 answer on the cache path.
122
+ ```
123
+
124
+ All 20 probes were valid. On every probe the server reported that the cache
125
+ hit covered all of turn 1 and its answer, and that the recompute path reused
126
+ nothing. The full report (made with a pre-release build of 0.2.0) is in
127
+ [`examples/qwen2.5-3b-q4km_metal_b12138.json`](https://github.com/aditi-p31/cache-divergence-study/blob/main/cachecheck/examples/qwen2.5-3b-q4km_metal_b12138.json).
128
+ It took about 15 minutes on a laptop.
129
+
130
+ | Exit status | Meaning |
131
+ |---|---|
132
+ | 0 | No valid probe differed, and at least 80% of probes were valid |
133
+ | 1 | At least one valid probe differed |
134
+ | 2 | Inconclusive: too few valid probes, or a request failed |
135
+
136
+ The JSON report keeps every answer, the finish reasons, token counts, cached
137
+ token counts and the server build.
138
+
139
+ The rate is specific to these short probes. It is not comparable to the
140
+ episode rates below, where one agent episode chains about ten requests.
141
+
142
+ ## What to do about a difference
143
+
144
+ For runs that must repeat, turn prefix reuse off and record that setting with
145
+ your results:
146
+
147
+ - llama.cpp: `"cache_prompt": false` per request, or `--no-cache-prompt` for
148
+ the server
149
+ - vLLM: `--no-enable-prefix-caching`
150
+ - SGLang: `--disable-radix-cache`
151
+
152
+ A difference is not a bug in your model. It shows that the answer depends on
153
+ the server's cache state as well as on the request.
154
+
155
+ ## Background and citation
156
+
157
+ `cachecheck` adapts the paired cache-on and cache-off design of the study in
158
+ [cache-divergence-study repository](https://github.com/aditi-p31/cache-divergence-study).
159
+ The study used Qwen2.5-7B-Instruct on llama.cpp with greedy
160
+ decoding, 80 BFCL multi-turn agent episodes and the host-memory prompt cache
161
+ off. The share of episodes whose trajectory changed between a cache hit and a
162
+ recomputation was 37.4% with F16 weights and 82.5% with Q4_K_M weights, as a
163
+ mean over ten server histories.
164
+
165
+ If `cachecheck` helps your work, please cite the paper:
166
+
167
+ ```bibtex
168
+ @article{patodiya2026samerequest,
169
+ title = {Same Request, Different Answer: Quantization Amplifies
170
+ Cache-Induced Divergence in LLM Serving},
171
+ author = {Patodiya, Aditi},
172
+ journal = {IEEE Access},
173
+ year = {2026},
174
+ doi = {10.1109/ACCESS.2026.3749922},
175
+ note = {Early Access},
176
+ url = {https://doi.org/10.1109/ACCESS.2026.3749922}
177
+ }
178
+ ```
179
+
180
+ MIT License. See `LICENSE`.
@@ -0,0 +1,6 @@
1
+ llm_cachecheck/__init__.py,sha256=ZkAjtzsRj_oD-99fzk19E7ggYGtwWc1Fb1GQDmrR7xU,18990
2
+ llm_cachecheck-0.2.1.dist-info/METADATA,sha256=Kw-O4k9p9MMIpduyxhZ1S0eyh6xvZhfPufiDjUO8Tm0,7366
3
+ llm_cachecheck-0.2.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ llm_cachecheck-0.2.1.dist-info/entry_points.txt,sha256=BWQsFnLXEal_Cfh7Pfo8CQijUsCkciuQETKj6jLe4-c,55
5
+ llm_cachecheck-0.2.1.dist-info/licenses/LICENSE,sha256=UsD2__IZIqw3nYxnzS737MXsmYD8N05rjAUc8QcfKG8,1389
6
+ llm_cachecheck-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ llm-cachecheck = llm_cachecheck:main
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aditi Patodiya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Scope note: the measurement dataset in this repository (the results/,
24
+ results-repair/ and results-revision/ directories, revision/orders/,
25
+ findings.json, repair_findings.json, and the JSON result files in analysis/) is
26
+ licensed separately under the Creative Commons Attribution 4.0
27
+ International License (CC BY 4.0).