needle.py 3.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. #!/usr/bin/env python3
  2. """Long-context retrieval: can the model find one fact buried in a long prompt?
  3. Standard library only.
  4. python needle.py --url http://127.0.0.1:8000 --model glm53
  5. Places a distinctive sentence at several depths in filler of several lengths and
  6. asks for it back. Reports one line per (length, depth) with the exact-match
  7. verdict, so a partial credit answer still reads as a miss.
  8. """
  9. import argparse
  10. import json
  11. import time
  12. import urllib.request
  13. NEEDLE = "The access code for the Vermilion vault is 7741-KESTREL."
  14. ANSWER = "7741-KESTREL"
  15. QUESTION = ("\n\nQuestion: what is the access code for the Vermilion vault? "
  16. "Reply with the code alone.")
  17. # Numbered so the filler is not a degenerate repeat of one block.
  18. FILLER = ("Maintenance note {n}: the west pump was serviced and the intake "
  19. "filter replaced; readings held within tolerance for the shift.\n")
  20. def post(url, payload, timeout=3600):
  21. request = urllib.request.Request(
  22. url, data=json.dumps(payload).encode(), headers={"content-type": "application/json"})
  23. with urllib.request.urlopen(request, timeout=timeout) as response:
  24. return json.loads(response.read())
  25. def count(base, model, text):
  26. return post(base + "/tokenize", {"model": model, "prompt": text}, timeout=600)["count"]
  27. def build(base, model, target, depth):
  28. """Filler of ~target tokens with the needle at `depth` through it."""
  29. per = count(base, model, FILLER.format(n=1))
  30. lines = max(1, target // max(per, 1))
  31. body = [FILLER.format(n=i) for i in range(lines)]
  32. at = min(len(body), max(0, int(len(body) * depth)))
  33. body.insert(at, NEEDLE + "\n")
  34. text = "".join(body)
  35. return text + QUESTION
  36. def ask(base, model, prompt, max_tokens):
  37. body = {"model": model, "messages": [{"role": "user", "content": prompt}],
  38. "max_tokens": max_tokens, "temperature": 0,
  39. "chat_template_kwargs": {"enable_thinking": False}}
  40. started = time.time()
  41. result = post(base + "/v1/chat/completions", body)
  42. message = result["choices"][0]["message"]
  43. text = (message.get("content") or "") + " " + (message.get("reasoning") or "")
  44. return text.strip(), result.get("usage", {}), time.time() - started
  45. def main():
  46. ap = argparse.ArgumentParser()
  47. ap.add_argument("--url", default="http://127.0.0.1:8000")
  48. ap.add_argument("--model", required=True)
  49. ap.add_argument("--lengths", type=int, nargs="+", default=[32000, 128000, 256000])
  50. ap.add_argument("--depths", type=float, nargs="+", default=[0.1, 0.5, 0.9])
  51. ap.add_argument("--max-tokens", type=int, default=200)
  52. a = ap.parse_args()
  53. base = a.url.rstrip("/")
  54. passed = total = 0
  55. for length in a.lengths:
  56. for depth in a.depths:
  57. prompt = build(base, a.model, length, depth)
  58. try:
  59. text, usage, secs = ask(base, a.model, prompt, a.max_tokens)
  60. except Exception as exc:
  61. print(f" {length:>7} tok depth {depth:>4.0%} ERROR {exc!r}", flush=True)
  62. total += 1
  63. continue
  64. hit = ANSWER in text
  65. passed += hit
  66. total += 1
  67. finish = usage.get("completion_tokens")
  68. capped = finish is not None and finish >= a.max_tokens
  69. note = " (hit max_tokens)" if capped and not hit else ""
  70. print(f" {usage.get('prompt_tokens', '?'):>7} tok depth {depth:>4.0%} "
  71. f"{'FOUND' if hit else 'MISS ':5} {secs:6.1f}s{note} {text[:60]!r}",
  72. flush=True)
  73. print(f"\n retrieval: {passed}/{total}")
  74. raise SystemExit(0 if passed == total else 1)
  75. if __name__ == "__main__":
  76. main()