LLM Eval Runner with Caching and a Regression Gate
Build an eval harness that runs a model over a test set, survives flaky APIs, avoids paying twice for the same call, and decides whether a candidate model may ship relative to a baseline.
1Eval runner
run(cases) calls call(model, prompt) per case and grades output against expected (exact match by default), returning one Result per case in input order.
2Parallel with retries
Run cases on a thread pool. Retry transient errors (TimeoutError, ConnectionError) with exponential backoff via an injected sleep; record permanent errors immediately without retrying.
3Result caching
Cache successful outputs by SHA-256 of (model, prompt) so reruns skip the API; failures are never cached. Results report whether they came from cache.
4Regression gate
regression_gate(baseline, candidate, margin, z) pairs results by case id and computes a confidence interval on the per-case pass-rate difference. It passes only if the lower bound is at least -margin (non-inferiority).
Two design calls matter. Cache on a hash of everything that determines the output (model and prompt; add temperature and system prompt in practice) and never cache errors. And gate on a paired interval, not two raw pass rates: both models see the same cases, so per-case differences remove case difficulty from the variance and a small eval set cannot wave through a real regression.
Try it first.
tests.py · 15 passing
import threading as _t
cases = [Case(f"c{i}", f"q{i}", f"a{i}") for i in range(20)]
fails: dict[str, int] = {"q3": 2, "q7": 5} # transient failures before success
seen: dict[str, int] = {}
lock = _t.Lock()
def model_call(model: str, prompt: str) -> str:
with lock:
seen[prompt] = seen.get(prompt, 0) + 1
n = seen[prompt]
if prompt == "q5":
raise ValueError("content filter")
if n <= fails.get(prompt, 0):
raise TimeoutError("slow")
return "a" + prompt[1:] if prompt != "q9" else "wrong"
sleeps: list[float] = []
r = EvalRunner("m1", model_call, max_workers=8, max_attempts=3, sleep=sleeps.append)
res = r.run(cases)
assert [x.case_id for x in res] == [c.id for c in cases] # order preserved
by = {x.case_id: x for x in res}
assert by["c3"].passed and by["c3"].attempts == 3
assert not by["c7"].passed and by["c7"].error == "TimeoutError: slow" and by["c7"].attempts == 3
assert by["c5"].attempts == 1 and by["c5"].error.startswith("ValueError")
assert not by["c9"].passed and by["c9"].output == "wrong"
assert sum(x.passed for x in res) == 17
assert r.calls == 20 + 2 + 2 and sorted(sleeps) == [0.5, 0.5, 1.0, 1.0]
# cache: rerun only re-calls the cases that errored
res2 = r.run(cases)
assert r.calls == 24 + 1 + 3 and sum(x.cached for x in res2) == 18
assert by["c7"].passed is False and {x.case_id: x for x in res2}["c7"].passed # q7 succeeds on call 6
assert cache_key("p", "m1") != cache_key("p", "m2") != cache_key("m1", "p")
# regression gate
def mk(passes: list[bool]) -> list[Result]:
return [Result(f"c{i}", "", p, 1, False) for i, p in enumerate(passes)]
base = mk([True] * 100)
g = regression_gate(base, mk([True] * 100))
assert g.passed and g.delta == 0 and g.lo == 0
g = regression_gate(base, mk([False] + [True] * 99))
assert abs(g.delta + 0.01) < 1e-12 and abs(g.lo + 0.0296) < 1e-9 and not g.passed
assert regression_gate(base, mk([False] + [True] * 99), margin=0.05).passed
assert not regression_gate(base, mk([False] * 20 + [True] * 80), margin=0.05).passed
assert regression_gate(mk([False] * 10 + [True] * 90), mk([True] * 100)).passedHow would you run this in CI for real? Persist the cache keyed by a full request fingerprint, set temperature 0 or sample k times per case, grade with an LLM judge whose own agreement is measured, and size the eval set so the CI half-width is below the margin you care about (about 1.96 * sqrt(var / n)).