Don't let a model grade its own homework
My bedtime-story app writes a whole story from a short form: age, duration, characters, a theme, a tone. One LLM call does the heavy lifting, and the single biggest quality lever in the entire product is which model makes that call. Everything else is plumbing around one creative decision.
So: which model?
The lazy answers are “whatever tops the leaderboard” and “whatever felt good in the three prompts I tried.” I didn’t want the app my kid uses running on vibes, so I built a small, repeatable bake-off and ran it twice. Round one picked the live model. Round two, a few days later, replaced it. Both rounds taught me something about the ranking. But the thing that nearly wrecked both, and the reason I’m writing this, is that my judge had a thumb on the scale, and I almost didn’t notice.
Why not just trust the leaderboard?
Two failure modes I wanted to design around.
Public benchmarks measure the wrong thing. MMLU and its cousins don’t tell you whether a model writes a soothing, on-length, age-4-appropriate story that stays on the requested theme without turning preachy. My product scores stories on nine quality axes. None of them appears on any leaderboard.
And vibes don’t survive contact with the matrix. “It felt great” usually means “it nailed the one prompt I happened to type.” A five-minute sleepy story for a four-year-old and a fifteen-minute adventure for a twelve-year-old are genuinely different jobs. A model can be lovely at one and mediocre at the other, and you’ll never see it if you only test the corner you like.
The fix is boring and it works: fix an input matrix, score every output on the same rubric, and hold everything constant except the model.
The harness, briefly
I already had the harness for this. It’s an offline eval (npm run eval, scripts/eval-stories.ts) that I built to gate prompt changes, and I’ve written about it before. Three moving parts: a fixed matrix of {age, duration, themes, characters, mode} cells covering the corners, a generator (the model under test) that writes one story per cell, and an LLM judge that scores each story 1 to 5 on every axis and emits per-axis means plus an overall.
Same matrix, same rubric, same prompt across a round. Only the generator changes. Every run writes a dated markdown report, so the numbers below are artifacts I can point at, not things I remember.
At least, only the generator is supposed to change. The judge changes too, and that turned out to be the whole story.
The trap I walked into
Here’s the thing that will quietly ruin your eval if you let it.
An LLM judge is not neutral. When a model grades text written by its own model family, it scores it higher. In my runs, same-family judging looked optimistic, likely because the judge preferred familiar style. This isn’t a hunch: my harness prints a warning right in the report header when it happens.
⚠️ Generator and judge are the same provider family.
Scores carry a same-family optimism bias.
That warning fired on two of my round-one runs, because I’d casually reached for gpt-4o as the judge. Round one was gpt-4.1 vs claude-haiku-4-5 vs gpt-4.1-mini: two GPT contestants and one from Anthropic. A GPT judge is same-family for the two GPT generators and a rival to the one Claude generator.
That’s not an eval. That’s a home game with a hometown referee. The GPT judge has a thumb on the scale for the GPT contestants, and the lone Anthropic contestant gets graded by the other team. Any ranking I drew from a GPT-only judge would be structurally tilted toward GPT, and I’d have no way to tell a real lead from a flattered one.
Think of it like a chili cook-off where one of the cooks is also the person tasting. Not because they’d cheat on purpose, but because their own palate is calibrated to their own recipe. The dish that tastes “right” to them is the one that tastes like theirs.
The fix: judge cross-family, and average
Two rules came out of that warning.
The wrong way, the one I started with:
// One judge, same family as most of the field.
const judge = "gpt-4o";
const overall = scoreField(field, judge); // structurally tilted toward GPT
The cost of this is invisible and that’s exactly what makes it dangerous. The number looks fine. It’s a clean 4-point-something on a 5-point scale. It just happens to be the wrong number, and nothing in the output tells you so unless you wrote a tool that shouts.
The right way:
// Two judges from different families; average the aggregates.
const gptScore = scoreField(field, "gpt-4o");
const claudeScore = scoreField(field, "claude-sonnet-4-6");
const overall = (gptScore + claudeScore) / 2; // home-team biases partly cancel
Rule one: never let a family be the only judge of itself. If GPT wrote it, don’t let only GPT grade it. Rule two: average across judges from different families, so each judge’s home-team bias partially cancels the other’s. A single judge is a data point. The average is the verdict.
This is cheap insurance. Judging costs a fraction of generation (the reports break out judge $ per story, and it’s pennies), and that debiasing accounts for the entire gap between a real ranking and a flattering one.
Round one: gpt-4.1 wins, and mini can’t hit length
Debiased aggregate, averaged across the GPT and Claude judges, on the 5-point scale:
| Model | Debiased overall |
|---|---|
| gpt-4.1 | ≈ 4.57 |
| claude-haiku-4-5 | ≈ 4.47 |
| gpt-4.1-mini | ≈ 4.17 |
gpt-4.1 came out on top, with claude-haiku-4-5 a close and genuinely respectable second, about 0.1 behind. That gap matters: it was well inside the range a single biased judge could have flipped. If I’d trusted the GPT-only run, I’d have “confirmed” a GPT win that the averaging shows was never a safe call. gpt-4.1-mini trailed by a clear 0.4.
But the aggregate isn’t the interesting part. The per-axis breakdown is, because it told me why mini lost, and it wasn’t the prose. Here’s the single-judge run detail:
| Axis | gpt-4.1 | Haiku 4.5 | gpt-4.1-mini |
|---|---|---|---|
| safety | 5.00 | 5.00 | 5.00 |
| constraint_adherence | 4.67 | 4.67 | 4.33 |
| length_compliance | 3.17 | 3.00 | 2.17 |
| bedtime_suitability | 5.00 | 5.00 | 5.00 |
| mode_adherence | 5.00 | 4.83 | 5.00 |
| age_appropriateness | 5.00 | 5.00 | 5.00 |
| narrative_quality | 4.33 | 4.33 | 4.00 |
| engagement | 4.33 | 4.50 | 4.00 |
| not_preachy | 5.00 | 4.83 | 5.00 |
Mini’s prose axes are barely behind the big models. The gap is almost entirely length_compliance at 2.17: it chronically undershot the target word count. The per-story detail is brutal. Asked for a 1950-word story it produced 436 words. Asked for 1300, it produced 600. It writes fine. It just writes a fraction of what you ask for, which is disqualifying for an app that promises “a 15-minute story” and then hands you a three-minute one.
So round one did two useful things at once. It picked gpt-4.1 as the live generator, and it turned “mini feels a bit thin” into a number (2.17) with a concrete cause (undershoot). That number fed straight into a later prompt fix: the Length: instruction now aims at the upper end of the word band, precisely to fight this tendency.
Round two: a new family, judged for creativity
A few days later the gpt-5.4 family landed. By then the app was pinned at 5.00 on safety, suitability, and mode-adherence, so the frontier had moved. The stories were competent but sometimes samey. I added an originality axis to the rubric and re-ran the bake-off, this time judged by claude-sonnet-4-6: cross-family for every GPT contestant, no home-team thumb on the scale. Note that this round used a single cross-family judge, not the full round-one debiasing (there’s no averaging across families here), so it satisfies rule one but not rule two.
Three generators, same 8-cell matrix, same Claude judge:
| Axis | gpt-4.1 | gpt-5.4-mini | gpt-5.4 |
|---|---|---|---|
| safety | 5.00 | 5.00 | 5.00 |
| constraint_adherence | 5.00 | 4.75 | 4.63 |
| length_compliance | 3.38 | 1.88 | 1.25 |
| bedtime_suitability | 5.00 | 5.00 | 5.00 |
| mode_adherence | 5.00 | 5.00 | 5.00 |
| age_appropriateness | 5.00 | 5.00 | 4.63 |
| narrative_quality | 4.75 | 5.00 | 5.00 |
| engagement | 4.50 | 4.63 | 4.63 |
| not_preachy | 4.50 | 5.00 | 5.00 |
| originality | 4.13 | 5.00 | 5.00 |
| overall | 4.59 | 4.53 | 4.39 |
Read the creativity axes. gpt-5.4-mini scored a perfect 5.00 on narrative_quality, not_preachy, and originality, sweeping the axes that actually separate a good bedtime story from a serviceable one. The reigning champ, gpt-4.1, sat at 4.13 on originality; the judge literally described its stories as “charming but predictable.”
One thing to keep straight: overall is a separately-judged holistic score, not the arithmetic mean of the axes above it. The judge scores it in its own right, so it won’t match an average you compute from the rows.
Now notice the paradox. gpt-4.1 has the highest overall at 4.59, and it lost. That’s because overall is dragged around by length_compliance, where the gpt-5.4 family craters: both gpt-5.4-mini (1.88) and gpt-5.4 (1.25) massively over-write. Asked for 1950 words, gpt-5.4-mini gave 2639; gpt-5.4 gave over 3000.
So I made a deliberate call: over-length is a fixable prompt-and-param problem, but a boring story isn’t. You clamp length by aiming the Length: band and, if needed, tightening max_completion_tokens. In this harness, the originality gap looked harder to close than response length. So I picked the model that won the axis I can’t fix and paid down the axis I can.
Why gpt-5.4-mini and not the full gpt-5.4? They tie on every creativity axis at 5.00, but full gpt-5.4 over-writes even harder (1.25 length), started drifting mature (4.63 age-appropriateness), and cost about 2.6 times more: the round-two total was $0.52 for gpt-5.4 against $0.20 for gpt-5.4-mini. Same creative ceiling, worse discipline, higher bill. The live default moved from gpt-4.1 to gpt-5.4-mini.
(One operational footnote, since it bit me: gpt-5.4-mini is a reasoning model, so the provider had to switch from max_tokens to max_completion_tokens and lift the output cap high enough to cover the reasoning tokens plus the story. Set it too low and long generations silently starve.)
What I’d tell you to take from this
Model choice is a measurement, not a taste. Fix a matrix, fix a rubric, change only the model, and the whole thing runs offline for cents.
LLM judges have a home-team bias. A model scores its own family higher, so a single same-family judge is measuring self-recognition, not quality. Judge cross-family and average across judges. In round one the top two were 0.1 apart, and that averaging is the only reason I trust which of them actually won.
Read the per-axis breakdown, not the aggregate. It hid the real story both times. Mini lost on length, not prose. And gpt-4.1 had the top overall in round two and still lost, because overall was masking a creativity gap.
Optimize for the axis you can’t fix and pay down the one you can. Over-length is a dial. Originality is a ceiling.
And “mini” is a size, not a verdict. gpt-4.1-mini lost round one, then gpt-5.4-mini won round two outright over its full-size sibling: same creative ceiling, better length discipline, cheaper. Re-test the small model every generation.
That said, the bias isn’t a reason to distrust LLM judges. They’re the only tool I have that can score “is this a good bedtime story” at all, and a same-family judge is still a real signal, just a tilted one. The point isn’t that a single judge is useless. It’s that the moment your ranking is close, the tilt is bigger than the gap, and the cheapest fix in the world is a second judge from the other team.
I’d rather my kid’s stories be picked by a fair fight than a flattering one.