Eval Gates in CI
Fail the build when a golden set regresses. Thresholds, flake budgets, and why an LLM judge in CI needs pinning and retries of its own.
- evals
- ci
- llmops
A prompt pull request that drops refund-id accuracy by eight points should be as blocked as a red unit test. If the only review is “I tried it in the playground,” you are shipping folklore. Eval gates in CI mean a golden set runs on the change, a threshold fails the job, and the merge is not a vibe.
Judges flake. Models move. CI minutes are not free. Put deterministic checks first, pin versions, give the judge a small retry budget, and quarantine cases that disagree with themselves.
Key takeaways
- Gate on assertions you can replay: schema, exact ids, allowlists, retrieval hit rate.
- LLM-as-judge is a second job with its own pin, timeout, and flake budget.
- Fail closed on product-critical slices. Do not average them away in a single 1–5 score.
- Pin prompt version, model snapshot, and dataset git sha in the report.
- Quarantine flaky cases. A noisy gate that people skip is worse than no gate.
What belongs in an eval gates CI job
Not every quality idea belongs on pull_request. CI needs a short, stable, labeled set. Long-tail online evals live after merge.
| Layer | Example | Flake risk | Put in PR gate? |
|---|---|---|---|
| Schema / types | Zod parse, required keys | Low | Yes |
| Exact / set match | orderId, enum labels |
Low | Yes |
| Retrieval | Gold chunk in top-k | Low if index is fixture | Yes |
| Programmatic rules | Totals sum, URL allowlist | Low | Yes |
| Rubric judge | “Helpful, grounded” | High | Sample, or nightly |
| Human | Brand voice | n/a | Not CI |
If you cannot write the assertion without a paragraph of taste, it is not a gate yet. It is a dashboard.
The dataset is a versioned artifact: inputs, expected fields, and tags (refund, empty-retrieval, injection). How you collect it is golden datasets. How you think about scorers is LLM evals. This page is the part that fails the build.
A workflow that fails closed
Keep the runner boring. A Node script, a fixture index, secrets from the environment. GitHub Actions is fine.
# .github/workflows/llm-eval.yml
name: llm-eval
on:
pull_request:
paths:
- 'prompts/**'
- 'evals/**'
- 'src/lib/llm/**'
- '.github/workflows/llm-eval.yml'
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm run eval:gate
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
EVAL_MODEL: gpt-4.1-mini-2025-04-14
The script should exit non-zero. Do not parse a markdown summary with a human in the loop and then click merge.
import { z } from 'zod';
const Extract = z.object({
orderId: z.string(),
refundable: z.boolean(),
citationIds: z.array(z.string()),
});
type Case = {
id: string;
tags: string[];
input: { question: string; orderId: string };
expect: { orderId: string; refundable: boolean; mustCite?: string };
};
export async function runGate(cases: Case[], run: (c: Case) => Promise<unknown>) {
const failures: string[] = [];
for (const c of cases) {
let parsed: z.infer<typeof Extract>;
try {
parsed = Extract.parse(await run(c));
} catch {
failures.push(`${c.id}: schema`);
continue;
}
if (parsed.orderId !== c.expect.orderId) failures.push(`${c.id}: orderId`);
if (parsed.refundable !== c.expect.refundable) failures.push(`${c.id}: refundable`);
if (c.expect.mustCite && !parsed.citationIds.includes(c.expect.mustCite)) {
failures.push(`${c.id}: citation`);
}
}
const bySlice = (tag: string) => {
const slice = cases.filter((c) => c.tags.includes(tag));
const failed = failures.filter((f) => slice.some((c) => f.startsWith(c.id))).length;
return slice.length === 0 ? 1 : 1 - failed / slice.length;
};
const overall = 1 - failures.length / cases.length;
const refund = bySlice('refund');
if (refund < 0.95) throw new Error(`refund slice ${refund.toFixed(3)} < 0.95`);
if (overall < 0.9) throw new Error(`overall ${overall.toFixed(3)} < 0.90`);
console.log(JSON.stringify({ overall, refund, failures }, null, 2));
}
Slice thresholds matter. A 99% overall score that hides a 70% refund slice is how you merge a polite, wrong bot.
Flakes, judges, and pinned models
Deterministic checks should not need retries. Network 503s on the candidate model can retry with the same policy as production (retries, timeouts, fallbacks), inside the job timeout.
Judges are different:
- Pin the judge snapshot.
gpt-latestin CI is how Friday’s pass becomes Monday’s fail with no prompt change. - Pin the candidate. Evaluating a moving alias is not a regression test.
- Blind the order if you pairwise-compare. Position bias is real.
- Retry the judge, not the product. If two judge calls disagree, mark the case
flakyand do not fail the whole gate on one coin flip unless the slice is critical and both calls failed the rubric. - Budget. A 400-case judge on every docs typo PR will be disabled by the first person who pays the bill. Keep PR gates small; run the long set on
mainor nightly.
Quarantine is a file, not a Slack message:
type Quarantine = { id: string; reason: string; added: string };
export function loadCases(all: Case[], q: Quarantine[]) {
const skip = new Set(q.map((x) => x.id));
const skipped = all.filter((c) => skip.has(c.id));
if (skipped.length / all.length > 0.05) {
throw new Error('quarantine exceeds 5%; fix cases or the judge');
}
return all.filter((c) => !skip.has(c.id));
}
A quarantine that grows forever is a skipped test suite. Cap it, and make adding an id a reviewed line in git.
Cost, secrets, and the rest of LLMOps
CI uses a real key. That key is a secret, not an org-wide playground token with production quota. Scope it, cap spend, and do not print prompts that contain customer fixtures into GitHub logs.
Cache embeddings for a frozen retrieval fixture so you are not re-embedding the handbook on every PR. Do not cache the candidate prompt result across commits: that is the thing you are testing.
When the gate is green, still canary in production. CI is the cheap filter. LLMOps is the rest: traces, online sampling, dataset refresh. A gate that never sees production tickets will rot.
Provider outages will fail your PRs. That is painful and correct if the product cannot ship without that provider. If you must merge docs-only changes during an outage, path-filter the workflow so README edits do not call the model.
Interview answer (out loud)
“An eval gate is a CI job that runs a versioned golden set and fails the build on slice thresholds, not a playground screenshot. I put schema and exact-match assertions on the PR path, pin both the candidate and the judge, and keep the LLM rubric small enough to afford. Flaky judge cases get quarantined in git with a cap. Refund-like slices have their own threshold so a high average cannot hide them. The job prints a report with prompt version, model snapshot, and dataset sha.”
Further reading (primary sources)
Related on this site
Related guides
- Golden Datasets for LLM AppsA golden set is labeled inputs and acceptable outputs you own. How to collect it from production, keep it from rotting, and stop cheating on it.
- LLM as JudgeA judge model scores outputs against a rubric. It is biased, cheap, and useful if you calibrate it against humans and keep the rubric tight.
- LLM Cost OptimizationCost is tokens × price × retries, plus the tools you invoked. Cache, route, shrink context, and stop looping before you negotiate a contract.
- LLM EvalsAn eval is a scored dataset you run on every prompt change. Offline, sampled online, and why "we tried it and it looked good" is not a release bar.
- LLMOps LifecycleLLMOps is how an LLM feature is versioned, evaluated, deployed, and watched. Prompts are artifacts. Traces are tests waiting to happen.