DEV Community

Royal Simpson Pinto
Royal Simpson Pinto

Posted on

Making LLM-as-Judge Scoring Reproducible and Trustworthy

I love using a language model to grade another model's output. A judge model can score "is this support reply empathetic and correct?" in a way that a regex never will. But the first time I wired one into CI, my test suite started flaking: the same input scored 0.8 one run and 0.55 the next, and a green build turned red for no reason I could point to. An eval you cannot trust to give the same answer twice is not a gate. It is a coin flip with extra latency.

This is a tutorial on making LLM-as-judge scoring reproducible. I will ground it in the llm-judge scorer from my eval tool, evalgate, but the techniques carry to any stack. There are five moves: a fixed rubric, structured output, temperature 0, a deterministic mock judge for tests, and a guard against judge drift.

1. Freeze the rubric into the prompt

The single biggest source of noise is a vague ask. "Is this a good answer?" invites the judge to invent fresh criteria every call. Instead, pass the exact rubric and pin the response shape. Here is how evalgate builds the judge prompt:

export function buildJudgePrompt(criteria: string, output: string, expected?: string): string {
  return [
    "You are a strict evaluator. Score how well the RESPONSE satisfies the CRITERIA.",
    expected ? `REFERENCE (an ideal answer):\n${expected}` : "",
    `CRITERIA:\n${criteria}`,
    `RESPONSE:\n${output}`,
    'Reply with ONLY compact JSON: {"score": <number between 0 and 1>, "reason": "<short>"}',
  ]
    .filter(Boolean)
    .join("\n\n");
}
Enter fullscreen mode Exit fullscreen mode

Three things are doing the work here. The criteria are supplied by the caller, not improvised by the model, so the standard is constant across cases. An optional reference answer anchors the judge to a concrete target instead of its own taste. And the last line forces the output into a machine-readable contract. A judge that must return prose is a judge you cannot compare run to run.

2. Parse structured output defensively

Even with a clear instruction, models occasionally wrap JSON in commentary or a code fence. So do not trust JSON.parse on the raw reply. Extract the object, parse it, and degrade to a defined failure instead of throwing:

export function parseJudgeReply(text: string): { score: number; reason: string } {
  const match = text.match(/\{[\s\S]*\}/);
  if (match) {
    try {
      const obj = JSON.parse(match[0]);
      const score = Number(obj.score);
      if (!Number.isNaN(score)) {
        return { score, reason: String(obj.reason ?? "judge returned a score") };
      }
    } catch {
      // fall through to the failure path
    }
  }
  return { score: 0, reason: `could not parse judge reply: ${text.slice(0, 80)}` };
}
Enter fullscreen mode Exit fullscreen mode

The point is that an unparseable reply is a scored event, not a crash. It gets a 0 and a reason that tells you exactly what came back. That reason string is worth its weight in gold when you are staring at a failed CI run at midnight.

3. Temperature 0

The judge is not a creative task. You want the most likely token every time, not variety. Set temperature to 0 on the judge call:

const reply = await ctx.provider.complete({
  model: (spec.model as string) || ctx.case.model || "",
  prompt: buildJudgePrompt(criteria, ctx.output, expected),
  temperature: 0,
});
const parsed = parseJudgeReply(reply.output);
Enter fullscreen mode Exit fullscreen mode

Temperature 0 does not make a model perfectly deterministic (batching and hardware can still nudge results), but it removes the sampling variance that was turning my scores into a lottery. It is the cheapest reproducibility win available, and there is no reason to skip it for grading.

4. A deterministic mock judge for tests

Here is the part people forget. If your scorer's own unit tests call a real model, your tests are slow, cost money, and flake for the same reasons your evals did. You cannot test a nondeterministic thing with a nondeterministic thing.

The fix is a mock provider that computes a reproducible score with no network. In evalgate, when the provider is mock, the judge blends word overlap against the reference and the criteria instead of calling out:

if (ctx.provider.name === "mock") {
  const refScore = expected ? overlap(ctx.output, expected) : 0;
  const critScore = overlap(ctx.output, criteria);
  score = expected ? 0.7 * refScore + 0.3 * critScore : critScore;
  reason = `mock judge overlap score ${score.toFixed(3)}`;
}
Enter fullscreen mode Exit fullscreen mode

The overlap here is a plain Jaccard set intersection over lowercased words, which is fully deterministic. Now the tests run offline and give the same answer every time:

it("scores high when output matches the reference", async () => {
  const r = await run(
    llmJudgeScorer,
    { type: "llm-judge", criteria: "should be correct", expected: "paris is the capital of france", threshold: 0.5 },
    ctx("paris is the capital of france"),
  );
  expect(r.passed).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

The mock is not pretending to be smart. It is a stand-in that exercises the same code path (criteria required, reference fallback, threshold comparison) so you can prove the plumbing works without a live model in the loop.

5. Guard against judge drift

The last risk is slow: the provider silently ships a new model version and your grading standard shifts underneath you. Two habits keep this honest. First, pin the judge model explicitly rather than defaulting to "latest," so an upgrade is a deliberate diff you can review. Second, keep a small set of golden cases with known-correct scores and re-run them whenever you change the judge model or the rubric. If a case that used to score 0.9 now scores 0.6, that is drift, and you want to catch it in a review, not in production.

The honest caveat

An LLM judge is still an LLM. It can be biased toward longer answers, toward outputs that echo its own phrasing, or toward a particular position in a comparison. Temperature 0 and a fixed rubric make it reproducible, not correct. Reproducible means it gives the same wrong answer twice, which is exactly what lets you notice and fix the wrongness. Treat judge scores as a strong signal that needs periodic human spot-checks, never as ground truth. Calibrate the threshold against cases you have graded by hand.

If you want to see all of this wired together, including the mock provider and the scorer registry, the code is at github.com/AgentPostmortem/evalgate. Borrow the pattern, or just steal the deterministic mock idea for your own suite. Either way, your CI stops flipping coins.

Top comments (5)

Collapse
 
pm25coder profile image
pm25coder •

Point 1 is the one I'd push on, from a measurement rather than a preference.

I checked this shape on scoring rules that judge implementations instead of text: 6 tasks, 6 rules each, every rule scored first against a single reference and then against a panel of 5 valid + 5 invalid candidates.

Scored against one reference: 20 of 22 rules pass (the 22 whose buggy baseline actually fails).
Scored against the panel: 1 of 22.

Nothing about the rules got worse. A reference only asks "does this agree with my answer"; a panel asks "does this accept what works and reject what doesn't". Of the 20 rules that passed against the reference, 8 failed only by rejecting a valid implementation, 6 only by accepting a broken one, and 5 failed both ways. A single reference cannot tell those two defects apart - it just prints a pass, which is the same failure you describe with the flaking judge, one layer up.

So I would keep expected for calibration and add a handful of known-good and known-bad responses to score against. One reference buys agreement; a panel buys discrimination, and discrimination is what you actually want a gate to have.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

Temperature 0 lowers the variance of a judge, it does not make it deterministic. Hosted APIs can return a different completion for the same prompt, so one call per case gives you one sample of the score. Run each case several times and gate CI on the spread, not on one value that happened to land well.

Collapse
 
pm25coder profile image
pm25coder •

Checked this on my own endpoint rather than assuming it. Same prompt, temperature 0, eight calls: eight distinct completions, and still eight after normalising case and punctuation.

A sharper case: same suite, same spec, "score 0-100, integer only", temperature 0, six calls gave 70, 60, 70, 60, 60, 70 - a 10-point spread on identical input. Completion tokens for one prompt ranged 154-855, so even the reasoning trace varies when the answer is short. A gate on one value is a gate on one draw from that.

One trap on the way: my first run came back with ten empty strings, because the model spent its whole budget on reasoning and emitted no content. Ten empty strings are trivially identical and read as 10/10 determinism. Worth confirming the instrument is not empty before believing a zero spread.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

@pm25coder Six calls, a 10-point spread on one input: one judge score is one draw from a distribution. Measure the width first, then set the threshold above it. Check the empty-output case too, as you did: ten empty strings look like perfect determinism.

Collapse
 
pm25coder profile image
pm25coder •

That's the rule I've adopted, and two things had to be added to it.

  1. Prove the sample is non-empty before reading any spread. My first run at max_tokens 120 returned ten empty completions, and distinct = 10/10 by the naive count -- which reads as perfect determinism. I now print len(content) and the token counts before I print a width.

  2. Make the threshold a margin, not a point. On a 0-100 scale with max-min = 10 across six draws, a candidate within 10 points of the bar is undecidable, so it is recorded as undecidable instead of being rounded to pass or fail. The same width applies to A/B: a delta smaller than one prompt's own swing is not evidence for anything.

So the number worth reporting is the width, not the score -- and it costs six calls per prompt to get it.