greedy_nondet.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. #!/usr/bin/env python3
  2. """Identical greedy requests over a long prompt return different completions.
  3. Standard library only. Point it at a running vLLM OpenAI server:
  4. python greedy_nondet.py --url http://127.0.0.1:8000 --model glm53
  5. Sends the same ~42k-token prompt N times at temperature 0 with a fixed seed and
  6. groups the completions. More than one group means the forward pass is not
  7. reproducible.
  8. """
  9. import argparse
  10. import collections
  11. import hashlib
  12. import json
  13. import queue
  14. import threading
  15. import urllib.request
  16. # Repeated verbatim to reach the target length. Content does not matter: any
  17. # filler of the same size behaves the same.
  18. PARAGRAPH = """The job runner reads a JSON manifest, then runs each job in order.
  19. Every job carries a name, a command, a retry count and an environment overlay.
  20. The overlay is applied on top of the inherited environment on each attempt, so a
  21. key set to null must remove the inherited value rather than set it to the empty
  22. string. Failures are retried up to the configured count, and the runner returns
  23. the worst exit code it saw. The exporter reads the same manifest and publishes
  24. one gauge per job plus a counter for retries.
  25. """
  26. QUESTION = "\nIn one sentence: what does the runner return when every job fails?"
  27. def post(url, payload, timeout=1800):
  28. req = urllib.request.Request(
  29. url, data=json.dumps(payload).encode(), headers={"content-type": "application/json"}
  30. )
  31. with urllib.request.urlopen(req, timeout=timeout) as r:
  32. return json.loads(r.read())
  33. def build_prompt(base, model, target):
  34. """Repeat PARAGRAPH until the tokenizer reports at least `target` tokens."""
  35. low, high = 1, 16
  36. while True:
  37. text = PARAGRAPH * high + QUESTION
  38. if count_tokens(base, model, text) >= target:
  39. break
  40. low, high = high, high * 2
  41. while low < high:
  42. mid = (low + high) // 2
  43. if count_tokens(base, model, PARAGRAPH * mid + QUESTION) >= target:
  44. high = mid
  45. else:
  46. low = mid + 1
  47. text = PARAGRAPH * low + QUESTION
  48. return text, count_tokens(base, model, text)
  49. def count_tokens(base, model, text):
  50. return post(base + "/tokenize", {"model": model, "prompt": text}, timeout=300)["count"]
  51. def complete(base, model, prompt, max_tokens):
  52. body = {
  53. "model": model,
  54. "messages": [{"role": "user", "content": prompt}],
  55. "temperature": 0,
  56. "seed": 1234,
  57. "max_tokens": max_tokens,
  58. }
  59. choice = post(base + "/v1/chat/completions", body)["choices"][0]
  60. message = choice["message"]
  61. # Compare the reasoning too, under either spelling: `reasoning_content` from
  62. # SGLang, `reasoning` from vLLM. A thinking model whose trace does not close
  63. # inside max_tokens returns empty content, and hashing content alone then
  64. # groups every run together and reports perfect agreement -- the divergence
  65. # this script exists to find happens in the trace as readily as in the answer.
  66. text = "".join(
  67. message.get(key) or ""
  68. for key in ("reasoning_content", "reasoning", "content")
  69. )
  70. if not text:
  71. raise SystemExit(
  72. f"empty completion and empty reasoning ({choice.get('finish_reason')}, "
  73. f"{max_tokens} max_tokens): nothing to compare. Raise --max-tokens so "
  74. "the trace can close, or send reasoning_effort=none."
  75. )
  76. return text
  77. def main():
  78. ap = argparse.ArgumentParser()
  79. ap.add_argument("--url", default="http://127.0.0.1:8000")
  80. ap.add_argument("--model", required=True)
  81. ap.add_argument("--prompt-tokens", type=int, default=42000)
  82. ap.add_argument("--max-tokens", type=int, default=48)
  83. ap.add_argument("--runs", type=int, default=16)
  84. ap.add_argument("--concurrency", type=int, default=3)
  85. a = ap.parse_args()
  86. base = a.url.rstrip("/")
  87. prompt, n_tokens = build_prompt(base, a.model, a.prompt_tokens)
  88. print(f"prompt: {n_tokens} tokens, {a.runs} runs at temperature 0, seed 1234")
  89. results, lock = [], threading.Lock()
  90. work = queue.Queue()
  91. for i in range(a.runs):
  92. work.put(i)
  93. def worker():
  94. while True:
  95. try:
  96. work.get_nowait()
  97. except queue.Empty:
  98. return
  99. try:
  100. out = complete(base, a.model, prompt, a.max_tokens)
  101. except Exception as exc:
  102. out = f"<error {exc!r}>"
  103. with lock:
  104. results.append(out)
  105. print(f" {len(results):>3}/{a.runs} {hashlib.sha1(out.encode()).hexdigest()[:8]}",
  106. flush=True)
  107. threads = [threading.Thread(target=worker) for _ in range(a.concurrency)]
  108. for t in threads:
  109. t.start()
  110. for t in threads:
  111. t.join()
  112. groups = collections.Counter(results)
  113. print(f"\n{len(groups)} distinct completion(s) from {a.runs} identical requests")
  114. for text, n in groups.most_common():
  115. print(f"\n--- {n} run(s) ---\n{text}")
  116. raise SystemExit(0 if len(groups) == 1 else 1)
  117. if __name__ == "__main__":
  118. main()