Score with an LLM judge¶
Not every answer can be checked by comparing rows or normalizing SQL ASTs. Does the SQL read
clearly? Does it follow your team's style? Do two queries return the same thing when you have no
warehouse to run them on? LlmJudge answers questions like these: it asks a grader model to
score the case against criteria you write, and turns that score into a pass/fail verdict.
What the judge does¶
You give the judge a standard to grade against, written as plain text in criteria. It builds a
prompt from that standard and the case, asks the grader model to reason about how well the case
meets it and give a score from 0.0 to 1.0, then compares that score to a threshold to
decide the verdict:
from evaldata import LlmJudge
judge = LlmJudge(
model="openai/gpt-4o-mini",
criteria="The SQL answers the question, reads clearly, and uses no SELECT *.",
threshold=0.7,
)
The grader's score is in result.score and its rationale is in result.explanation. The result
has basis="judged", which marks it as a probabilistic judgment rather than a proof.
Pass it to assert_eval like any other scorer:
Inconclusive, not a guess¶
A judge can fail to reach a verdict if the provider call errors or the response doesn't parse. The
result is then inconclusive (verdict="inconclusive") and carries the failure in its
explanation. Inconclusive means the judge couldn't decide; it doesn't mean the answer is wrong.
Configure the judge¶
criteria alone is often enough. Four optional arguments control the grading prompt and pass
threshold:
steps: an ordered checklist the grader works through before scoring, rendered as a numbered block.rubric: score bands that describe each range.examples: few-shot anchors, each a graded output with its score and reason.threshold: the minimum score (inclusive) for a pass. Defaults to0.5.
from evaldata import JudgeExample, LlmJudge, RubricBand
judge = LlmJudge(
model="openai/gpt-4o-mini",
criteria="The SQL answers the question and is idiomatic for an analytics team.",
steps=[
"Check the query returns the columns the question asks for.",
"Check it filters and aggregates correctly.",
"Judge readability: aliases, no SELECT *, sensible formatting.",
],
rubric=[
RubricBand(min_score=0.0, max_score=0.4, description="Wrong result or unreadable."),
RubricBand(min_score=0.4, max_score=0.8, description="Correct result, rough style."),
RubricBand(min_score=0.8, max_score=1.0, description="Correct and idiomatic."),
],
examples=[
JudgeExample(
actual_output="SELECT name FROM customers WHERE country = 'US'",
score=1.0,
reason="Correct filter, named column, clear.",
),
],
threshold=0.8,
)
Unset rubric and examples values are not included in the prompt.
Choose what the grader sees¶
By default, the judge shows the grader these case fields when they are available:
input: the case's question.actual_output: the SQL the solver produced.expected_output: the gold query's SQL, when the case has aGoldQueryexpected.
Use show to hide fields that would bias or distract the grader. For example, grade the output
without revealing the reference answer:
judge = LlmJudge(
model="openai/gpt-4o-mini",
criteria="The SQL answers the question.",
show=["input", "actual_output"], # withhold the expected query
)
Choosing a grader model¶
The grader is separate from the solver. model takes any litellm identifier (or an Llm to use
directly), so the grader can run on a different provider from the model being evaluated.
The grader must support structured output: the judge requests a JSON {score, reason} response
and treats a malformed one as inconclusive. Grading defaults to temperature=0.0 for repeatable
scores; pass temperature=None to leave the provider default, or a timeout to bound the call.
Use fixed judge responses in CI¶
A judged eval calls a model, which costs money and can vary between runs. Add a conftest.py next
to your test that returns the structured {score, reason} response the judge expects:
"""Mocked grader replies for the LLM-judge example, so it runs without a key or network."""
import json
from typing import Any
import litellm
import pytest
_EQUIVALENT = json.dumps({"reason": "the CTE wraps the same filter", "score": 1.0})
_NOT_EQUIVALENT = json.dumps({"reason": "country = 'GB' selects different customers", "score": 0.0})
def _mock_reply(messages: list[dict[str, Any]]) -> str:
"""Pick the grader reply for a request by matching the actual SQL under judgement.
Args:
messages: The chat messages of the request, whose prompt carries the actual SQL.
Returns:
A `JudgeReply`-shaped `{"reason", "score"}` JSON string.
"""
prompt = " ".join(m.get("content", "") for m in messages)
return _NOT_EQUIVALENT if "country = 'GB'" in prompt else _EQUIVALENT
@pytest.fixture(autouse=True)
def _mock_completion(monkeypatch: pytest.MonkeyPatch) -> None:
"""Patch grader calls to return a deterministic structured reply per case, with no network."""
real_completion = litellm.completion
def fake(**kwargs: Any) -> Any:
return real_completion(**kwargs, mock_response=_mock_reply(kwargs["messages"]))
monkeypatch.setattr("litellm.completion", fake)
monkeypatch.setattr("litellm.supports_response_schema", lambda **_: True)
conftest.py makes the eval use fixed judge responses:
"""LLM-judge example evals: `judged_equivalence` has an LLM grade the SQL the syntax check can't confirm."""
import os
import tempfile
from collections.abc import Iterator
from pathlib import Path
import duckdb
import pytest
from evaldata import CallableSolver, EvalCase, eval_case, judged_equivalence
from evaldata.platforms import duckdb_platform, resolve
from evaldata.scorers import QueryRunner, ScoreContext, Scorer
from evaldata.types import ScoreResult, SolverSuccess
_DB_PATH = Path(tempfile.mkdtemp(prefix="evaldata_ex05_")) / "shop.duckdb"
_PLATFORM = duckdb_platform(name="examples-llm-judge", path=str(_DB_PATH))
_MODEL = os.getenv("EVALDATA_HOSTED_MODEL", "openai/gpt-4o-mini")
@pytest.fixture(scope="module", autouse=True)
def _seed_db() -> Iterator[None]:
con = duckdb.connect(str(_DB_PATH))
con.execute("CREATE TABLE customers (id INTEGER, name VARCHAR, country VARCHAR)")
con.execute("INSERT INTO customers VALUES (1, 'Ada', 'GB'), (2, 'Bo', 'US'), (3, 'Cy', 'US')")
con.close()
yield
def _score(case: EvalCase, model_sql: str, scorer: Scorer) -> ScoreResult:
"""Score `model_sql` against the case's gold query with `scorer`.
Args:
case: The eval case, carrying the gold query and platform.
model_sql: The model's SQL to judge against the gold query.
scorer: The scorer to run.
Returns:
The `ScoreResult`.
"""
solver = CallableSolver(lambda _case: model_sql)
output = solver.solve(case)
assert isinstance(output, SolverSuccess)
sql = output.output
dialect = case.platform.dialect or case.platform.kind
runner = QueryRunner(resolve(case.platform), sql, dialect, None)
context = ScoreContext(queries=runner)
return scorer.score(case, output, runner.run(sql), context=context)
def _trail(result: ScoreResult) -> list[tuple[str, bool]]:
"""The `(scorer, passed)` of each member that ran, in order.
Args:
result: A `judged_equivalence` score result.
Returns:
One `(scorer, passed)` pair per member that ran.
"""
return [(entry["scorer"], entry["passed"]) for entry in result.metadata["first_decisive"]]
@eval_case(
input="Name the US customers.",
expected={"kind": "gold_query", "sql": "SELECT name FROM customers WHERE country = 'US'"},
platform=_PLATFORM,
)
def test_judge_confirms_when_ast_is_inconclusive(case: EvalCase) -> None:
"""A CTE the syntax check can't match; the judge confirms it without running either query."""
result = _score(
case,
"WITH us AS (SELECT * FROM customers WHERE country = 'US') SELECT name FROM us",
judged_equivalence(_MODEL),
)
assert result.passed
assert _trail(result) == [("semantic_equivalence", False), ("llm_judge", True)]
@eval_case(
input="Name the US customers.",
expected={"kind": "gold_query", "sql": "SELECT name FROM customers WHERE country = 'US'"},
platform=_PLATFORM,
)
def test_judge_refutes_wrong_filter(case: EvalCase) -> None:
"""A wrong filter the syntax check can't match; the judge refutes it without running either query."""
result = _score(
case,
"WITH gb AS (SELECT * FROM customers WHERE country = 'GB') SELECT name FROM gb",
judged_equivalence(_MODEL),
)
assert not result.passed
assert _trail(result) == [("semantic_equivalence", False), ("llm_judge", False)]
Remove conftest.py to grade with the model.
Run it from a clone
This is the bundled examples/05_llm_judge/ example. If you've cloned the repo, run it
with uv run pytest examples/05_llm_judge. It includes fixed judge responses.
A ready-made judge: SQL equivalence¶
sql_equivalence_judge(model) is an LlmJudge pre-loaded with criteria and examples for one
common question: do two SQL queries return the same rows? It grades actual_output against
expected_output (the gold query), forgiving differences that never change the result and
penalising those that do.
Most often you don't reach for it directly. judged_equivalence(model) first tries to confirm
equivalence by normalizing and comparing SQL ASTs, and only falls back to this judge when that
check is inconclusive. Use it when you have no warehouse to run the queries against. See
Check semantic equivalence for that cascade.
Next steps¶
- Check semantic equivalence: confirm two queries match by AST normalization, with the judge as the fallback.
- Scorers reference:
LlmJudge,JudgeExample,RubricBand. - Concepts: scorers, solvers, and expected types in depth.