Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./July 7, 2026/12 min

Your LLM Judge Is the Flakiest Test in Your Suite

Filed underai-testing/playwright/llm/framework-design
Your LLM Judge Is the Flakiest Test in Your Suite

Table of Contents
  • Why is an LLM judge flaky when temperature is zero?
  • What does the naive Playwright judge get wrong?
  • How do you make the judge reproducible?
  • Force a schema’d verdict
  • Pin the version and anchor the rubric
  • How do you kill position and self-preference bias?
  • Why order-sensitive verdicts get thrown out
  • How do you make reruns deterministic?
  • How do you know the judge itself is correct?
  • Keep the judge out of the hot path
  • The one thing to do today

On this page

  • Why is an LLM judge flaky when temperature is zero?
  • What does the naive Playwright judge get wrong?
  • How do you make the judge reproducible?
  • Force a schema’d verdict
  • Pin the version and anchor the rubric
  • How do you kill position and self-preference bias?
  • Why order-sensitive verdicts get thrown out
  • How do you make reruns deterministic?
  • How do you know the judge itself is correct?
  • Keep the judge out of the hot path
  • The one thing to do today

You stopped asserting exact LLM text — good. You replaced it with an LLM-as-judge that scores whether the chatbot’s answer is “helpful and correct” — and now your suite is flaky in a way that’s even harder to debug, because the thing deciding pass/fail is itself a non-deterministic, biased model. You didn’t remove the coin flip. You added a second coin and multiplied them together.

That’s the trap I want to take apart. LLM-as-judge is the right tool for the meaning layer — there’s no regex that tells you a summary is faithful. But the moment you bolt a raw judge call into a Playwright expect, you’ve built an assertion whose verdict depends on option order, output length, and which model family wrote the answer. A senior SDET’s job isn’t to avoid the judge. It’s to turn it from a mood ring into an instrument: version-pinned, structurally constrained, bias-corrected, cached, and — this is the part everyone skips — calibrated against a human-labeled set before you trust a single green check.

Why is an LLM judge flaky when temperature is zero?

Because the judge inherits every non-determinism and bias of the model underneath it, and “temperature 0” fixes neither. The same numerical non-determinism that makes the system under test drift between runs applies to the model you’re using as a grader. But the judge adds failure modes the SUT doesn’t have: it’s sensitive to which answer you show first, how long the answer is, and whether the answer “sounds like” something the judge model would write.

These aren’t anecdotes. The foundational MT-Bench study found that strong judges like GPT-4 reach over 80% agreement with human evaluators — impressive, until you read the next number: the same GPT-4 judge was only about 65% consistent when the two answers were swapped in order, and weaker, cheaper judges flip far more often. A 2025 systematic study of position bias in LLM-as-a-judge confirms the effect is structural across pairwise and list-wise settings, not a quirk of one model.

§ The judge's hidden variance

80%+

Human agreement

GPT-4, best case

~65%

Swap consistency

Same judge, order flipped

12

Bias types

Quantified in the literature

The “Justice or Prejudice” work catalogs twelve distinct judge biases — position, verbosity, self-enhancement, and bandwagon among them. And self-preference bias means a judge systematically rewards outputs with lower perplexity, i.e. answers that look like its own writing. If your product’s answer and your judge come from the same model family, you’ve baked a thumb onto the scale and called it ground truth.

[ WARNING ]

A test where the verdict changes when you swap two arguments isn’t a strict test — it’s a survey of one respondent who hasn’t had coffee. If you can’t reproduce the judge’s score on a rerun with identical inputs, you cannot use that score as a merge gate.

What does the naive Playwright judge get wrong?

It treats a free-text model call as if it returned a boolean. Here’s the pattern I keep finding in real suites — it looks reasonable, and it’s the source of the flake.

tests/ai/answer.spec.ts (the trap)
test('chatbot answers the refund question', async ({ page }) => {
await page.getByRole('textbox').fill('How do I get a refund?');
await page.getByRole('button', { name: 'Send' }).click();
const answer = await page.getByTestId('assistant-message').innerText();
// One ungrounded judge call, parsed by vibes
const verdict = await judge(`Is this answer good? "${answer}"`);
expect(verdict.toLowerCase()).toContain('yes'); // 🙈
});

Three things are broken here. The prompt asks “is this good?” — an unanchored question that invites the judge’s verbosity and self-preference biases to drive the answer. The output is free text parsed with toContain('yes'), so “Yes, but…” and “No, yet it’s not bad” both pass. And there’s no record of why it passed, so when it flips next Tuesday you have nothing to diff. This is exactly the failure mode I warned about when arguing you should let AI triage failures, not decide them — the same discipline applies when AI decides pass/fail.

How do you make the judge reproducible?

Constrain it the way you’d constrain any API contract: pin the version, force structured output against a schema, and anchor the rubric so “good” has a definition instead of a vibe. The judge should return data you can validate, not prose you have to interpret.

Force a schema’d verdict

tests/ai/judge.ts
import { z } from 'zod';
const Verdict = z.object({
faithful: z.boolean(), // grounded in the provided context only
resolves: z.boolean(), // actually answers the user's question
score: z.number().int().min(1).max(5),
evidence: z.string().max(280), // quote that justifies the score
});
// Pin everything that moves: model, version, temperature, seed.
const JUDGE = { model: 'gpt-4.1-2025-04-14', temperature: 0, seed: 42 };

Pin the version and anchor the rubric

Pinning the model version string matters more than people expect: providers ship silent quality updates behind floating aliases, and a judge that drifts is a test environment that drifts. The anchored rubric — faithful, resolves, a 1–5 score, and a required evidence quote — does the real work. Forcing the judge to cite the span that justifies its score suppresses verbosity bias and gives you a diffable artifact when a verdict changes.

How do you kill position and self-preference bias?

Run the judge double-blind and take a quorum. For any pairwise or reference-based comparison, evaluate both orderings and only trust a verdict that survives the swap; for single-answer scoring, sample the judge a few times and require agreement. Practitioners converge on not trusting a judge until it clears roughly 75–80% agreement with a human-labeled set — treat anything below that as an unreliable instrument.

tests/ai/judge.ts (swap + quorum)
export async function gradeFairly(answer: string, context: string) {
// Double-blind: score in both orderings, discard order-sensitive verdicts.
const [a, b] = await Promise.all([
score({ first: answer, second: context, ...JUDGE }),
score({ first: context, second: answer, ...JUDGE }),
]);
if (a.faithful !== b.faithful) {
return { stable: false, faithful: false, reason: 'order-sensitive' };
}
// Quorum on the numeric score: median of N samples, not a single roll.
const scores = await sampleScores(answer, context, JUDGE, 3);
return { stable: true, faithful: a.faithful, score: median(scores) };
}

Why order-sensitive verdicts get thrown out

A verdict that disagrees with itself when you swap argument order is, by definition, not measuring the answer — it’s measuring position. Dropping those is how you stop importing the judge’s ~35% inconsistency into your suite. To blunt self-preference bias, use a judge from a different model family than the system under test; never let a model grade its own homework.

How do you make reruns deterministic?

Cache the verdict by a hash of its inputs, so an identical answer and rubric never gets re-judged. This is the single highest-leverage move for CI stability: a retried job, a rerun on a flake, or a no-op rebuild should return the same verdict from cache, not a fresh dice roll. It also slashes the token bill — relevant if you remember that naive AI integrations quietly cost six figures a year in CI.

tests/ai/judge-cache.ts
import { createHash } from 'node:crypto';
function verdictKey(answer: string, context: string, rubricVersion: string) {
return createHash('sha256')
.update(`${rubricVersion}\n${context}\n${answer}`)
.digest('hex');
}
// Cache hit => identical verdict on every rerun. Cache miss => grade once, store.
[ TIP ]

Key the cache on a rubricVersion you bump by hand. When you change the rubric, you want every cached verdict invalidated — a silent rubric edit that re-grades nothing is how a “passing” suite hides a quality regression.

How do you know the judge itself is correct?

You write a test for the judge. Keep a small, version-controlled calibration set of answers you’ve labeled by hand — clearly good, clearly bad, and a few deliberate near-misses — and run the judge against it in CI. If the judge’s agreement with your labels drops below threshold, you fail the judge, not the product. This is the step that separates a measured instrument from a guess.

Label a golden set

Hand-grade 20–40 real answers: faithful/unfaithful, resolves/doesn’t. Include adversarial near-misses (right facts, wrong conclusion) — these are what catch a lazy judge.

Gate on agreement

In CI, run the judge over the golden set and assert agreement ≥ 80%. Below that, the judge is miscalibrated and no product verdict it produces is trustworthy.

Re-run on every judge change

Bumping the judge model, prompt, or rubric is a code change. It must re-pass calibration before it grades a single product answer.

tests/ai/judge-calibration.spec.ts
test('judge agrees with human labels', async () => {
const labeled = loadGoldenSet(); // committed, reviewed in PRs
const results = await Promise.all(labeled.map(g => gradeFairly(g.answer, g.context)));
const agreement = results.filter((r, i) => r.faithful === labeled[i].faithful).length / labeled.length;
expect(agreement).toBeGreaterThanOrEqual(0.8); // fail the JUDGE, not the product
});

Keep the judge out of the hot path

One architectural note that saves teams from themselves: don’t put the judge inline in your blocking E2E suite. Structure and invariant checks — schema, required fields, no-hallucinated-links — are deterministic and belong in the fast suite that gates merges. Run the LLM-judge meaning checks as a separate graded job with its own threshold and its own dashboard, the same way you’d treat a green check that might be hiding a real bug with suspicion. A probabilistic grader gating every PR turns your merge queue into a slot machine; a probabilistic grader tracked as a quality metric tells you something true over time.

§ Delta · LLM meaning assertion Reproducible verdict

Before

Single raw judge call, parsed with toContain('yes')

→ ↓

After

Pinned + schema'd + swap-checked + quorum + cached + calibrated

The one thing to do today

Take your flakiest AI-feature test, and add one assertion that has nothing to do with the judge: run the judge twice on the same answer and assert the two verdicts match. If they don’t, you’ve just proven the test was never measuring your product — and you now have the reproduction case to fix it with the rubric, swap, and cache pattern above. Pin the judge, calibrate it against a human-labeled set, and move the meaning checks out of your merge gate. A judge you’ve measured is an asset. A judge you haven’t is just a more expensive flake.

§ Frequently Asked FAQ
+ Does temperature 0 make an LLM judge deterministic?

No. Temperature 0 only fixes the token-sampling step; numerical non-determinism in batched GPU inference and provider-side load balancing still cause variation, and the judge’s position, verbosity, and self-preference biases are untouched by temperature. If you need byte-stable reruns, cache the verdict by a hash of its inputs so an identical answer is never re-judged.

+ Can I use the same model to generate answers and judge them?

Avoid it. Self-preference bias means a judge systematically rewards outputs that resemble its own writing, so a model grading its own family inflates scores. Use a judge from a different model family than the system under test, and calibrate it against human labels before trusting it.

+ How many human-labeled examples do I need to calibrate a judge?

Start with 20–40 carefully chosen examples spanning clear-good, clear-bad, and adversarial near-misses (right facts, wrong conclusion). This is a regression gate, not a statistics exercise. Require roughly 80% agreement with your labels before the judge grades product answers in CI.

+ Should the LLM judge gate every pull request?

No. Keep deterministic structure and invariant checks in the blocking suite that gates merges, and run probabilistic meaning checks as a separate graded job with its own threshold and dashboard. A non-deterministic grader on the critical path turns your merge queue into a slot machine.

§ Further Reading 03 of 03
01AI Testing

Your Agent's Confirm Dialog Is Safety Theater

Your Playwright test checks the confirm dialog renders — not that the irreversible action was blocked. Test the human-in-the-loop approval gate properly.

Read →
02AI Testing

Your Agent Acts on Half-Streamed Tool-Call JSON

Streamed tool-call arguments are invalid JSON until the final chunk — here's the Playwright pattern that stops your AI agent from acting on half-parsed args.

Read →
03AI Testing

Test What Your AI Agent Does, Not What It Says

Your AI agent test is green because you asserted on its reply. Assert on the tool calls it makes in Playwright instead — the pattern for agentic features.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.