Skip to content
AI Product Engineer Wiki

    ↑ ↓ to move between results, Enter to open, Esc to close

    Menu

    Regression eval suites in CI

    Run evals in CI so prompt and model changes can't silently make an AI feature worse, with thresholds, flakiness handling, cost control and reporting.

    Level: Advanced 11 min read Updated
    On this page

    Why this matters

    AI features regress quietly. A prompt change that fixes one kind of request breaks another. A new model version is better on average and worse on your most important category. A teammate tightens an instruction for length and the answers start missing a key detail. None of these show up in code review, because the code looks fine.

    Running your eval suite in CI catches these changes before users do, the same way unit tests catch code regressions. This article covers what to run on every change, how to deal with outputs that vary from run to run, a working runner and GitHub Actions workflow, and how to keep the cost predictable.

    What to run on every change

    Running every eval case on every commit is slow and expensive. Split the suite in two:

    • A smoke set on every pull request that touches prompts, model settings or AI code: 20 to 50 cases covering every category and every must-never-fail case. Fast enough to wait for.
    • The full suite nightly, and before releases or model switches: every case, including LLM-judge criteria. Slower, more thorough, and the source of your trend line.
    Evals in the development workflow. Fast checks block merges; the full suite tracks trends and catches what the smoke set misses.
    Show text descriptionHide text description

    A sequence of 5 steps.

    1. Pull request : Changes a prompt, model setting or AI code
    2. Smoke evals : Code checks on a small set covering every category
    3. Compare to baseline : Block on regressions beyond tolerance, or any must-never-fail failure
    4. Merge : Baseline updated deliberately when a change improves scores
    5. Nightly full suite : All cases and judge criteria; results tracked over time

    Trigger the smoke set only when relevant files change, so ordinary UI work doesn’t wait for evals or spend money on them.

    Thresholds and flakiness

    Model outputs vary between runs, so eval scores do too. A suite that fails randomly gets ignored, then disabled. Handle variation deliberately:

    • Compare against a baseline, with a tolerance. Instead of a fixed number, compare each category’s pass rate with the last accepted run, and allow a small margin for noise, for example three percentage points. Larger drops block the merge.
    • No tolerance for must-never-fail cases. Any failure there blocks, every time.
    • Make cases less flaky. Many flaky cases have vague criteria. Tighten the criterion before reaching for statistics.
    • Re-run genuinely variable cases. For a few cases where variation is inherent, run them three times and use the majority verdict.
    • Grow the set. With more cases, one random failure moves the score less. Twenty cases per category is a reasonable floor.

    Update the baseline deliberately, in the same pull request that improves the scores, so the history shows why it moved.

    A CI workflow

    The runner below executes the suite with a concurrency limit, computes the pass rate per category, compares it with a stored baseline, and exits with an error if any category regressed beyond the tolerance or any must-never-fail case failed. --smoke runs only the smoke cases; --update-baseline stores the results as the new baseline.

    evals/run-suite.ts
    import Anthropic from '@anthropic-ai/sdk';
    import { readFile, writeFile } from 'node:fs/promises';
    
    const client = new Anthropic();
    
    const SMOKE = process.argv.includes('--smoke');
    const UPDATE_BASELINE = process.argv.includes('--update-baseline');
    const CONCURRENCY = 4; // Stay well inside your rate limits.
    const TOLERANCE = 0.03; // Allow three points of run-to-run noise before calling it a regression.
    
    interface Case {
      id: string;
      category: string;
      input: string;
      mustInclude: string[];
      smoke?: boolean;
    }
    type Scores = Record<string, number>;
    
    /** The feature under test. Returns null if the response didn't finish normally, which fails the case. */
    async function feature(input: string): Promise<string | null> {
      const response = await client.messages.create({
        model: 'claude-opus-5-5',
        max_tokens: 4096,
        output_config: { effort: 'low' },
        system: 'Summarize the email thread in at most five short bullet points. Name the decision that is needed, if any.',
        messages: [{ role: 'user', content: input }],
      });
      if (response.stop_reason !== 'end_turn') return null;
      return response.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
    }
    
    const passes = (output: string | null, c: Case) =>
      output !== null && c.mustInclude.every((fact) => output.toLowerCase().includes(fact.toLowerCase()));
    
    /** Runs tasks with at most `limit` in flight. */
    async function mapLimited<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
      const results: R[] = [];
      let next = 0;
      const worker = async () => {
        while (next < items.length) {
          const i = next++;
          results[i] = await fn(items[i] as T);
        }
      };
      await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
      return results;
    }
    
    const all: Case[] = JSON.parse(await readFile('evals/cases.json', 'utf8'));
    const cases = SMOKE ? all.filter((c) => c.smoke) : all;
    
    const results = await mapLimited(cases, CONCURRENCY, async (c) => {
      try {
        return { c, passed: passes(await feature(c.input), c) };
      } catch {
        // An API error fails the case rather than crashing the whole run.
        return { c, passed: false };
      }
    });
    
    const scores: Scores = {};
    for (const category of new Set(cases.map((c) => c.category))) {
      const inCategory = results.filter((r) => r.c.category === category);
      scores[category] = inCategory.filter((r) => r.passed).length / inCategory.length;
    }
    
    const baseline: Scores = JSON.parse(await readFile('evals/baseline.json', 'utf8').catch(() => '{}'));
    const problems: string[] = [];
    
    for (const [category, score] of Object.entries(scores)) {
      const before = baseline[category];
      const line = `${category.padEnd(20)} ${(score * 100).toFixed(0)}%${before === undefined ? '' : ` (baseline ${(before * 100).toFixed(0)}%)`}`;
      console.log(line);
      if (category === 'must-never-fail' && score < 1) problems.push(`must-never-fail: ${(score * 100).toFixed(0)}%, expected 100%`);
      else if (before !== undefined && score < before - TOLERANCE) problems.push(`${category} dropped from ${(before * 100).toFixed(0)}% to ${(score * 100).toFixed(0)}%`);
    }
    
    await writeFile('evals/latest.json', JSON.stringify(scores, null, 2));
    if (UPDATE_BASELINE) await writeFile('evals/baseline.json', JSON.stringify(scores, null, 2));
    
    if (problems.length > 0) {
      console.error(`\nRegressions:\n- ${problems.join('\n- ')}`);
      process.exitCode = 1;
    }

    And a GitHub Actions workflow that runs the smoke set on relevant pull requests and the full suite every night:

    .github/workflows/evals.yml
    name: Evals
    
    on:
      pull_request:
        paths: ['prompts/**', 'lib/ai/**', 'evals/**']
      schedule:
        - cron: '0 3 * * *' # Nightly full suite at 03:00 UTC
    
    jobs:
      evals:
        runs-on: ubuntu-latest
        timeout-minutes: 30
        steps:
          - uses: actions/checkout@v4
          - uses: actions/setup-node@v4
            with:
              node-version: 22
              cache: npm
          - run: npm ci
          - name: Smoke evals
            if: github.event_name == 'pull_request'
            run: npx tsx evals/run-suite.ts --smoke
            env:
              ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          - name: Full eval suite
            if: github.event_name == 'schedule'
            run: npx tsx evals/run-suite.ts
            env:
              ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          - name: Keep results
            if: always()
            uses: actions/upload-artifact@v4
            with:
              name: eval-results
              path: evals/latest.json

    A few practical notes:

    • Store the API key as a repository secret, never in the workflow file. Pull requests from forks don’t receive secrets by default, which is the safe behavior; run evals for external contributions manually.
    • The paths filter means only changes to prompts, AI code or evals trigger the smoke run.
    • Keep the results artifact so you can compare runs and investigate failures later.
    • Add judge criteria to the nightly run using the calibrated judge, and keep smoke runs to fast code checks.

    Keeping costs predictable

    Evals call the model, so they cost money on every run. Keep it under control:

    • Smoke sets small, full suites nightly, not on every commit.
    • Trigger on relevant paths only.
    • Use prompt caching for long shared system prompts or documents across cases; see estimating cost per task.
    • Run the full suite through the Batches API (opens in a new tab) when results don’t need to be immediate, at a discount.
    • Set a spending limit for the key CI uses, separate from production.
    • Estimate per run: cases × average cost per case. A smoke set of 30 cases at a few cents each costs about as much as a coffee per day of active development, a bargain compared with shipping a regression.

    Common mistakes

    • Running the full suite on every commit. Slow and costly. Use a smoke set on relevant pull requests and the full suite nightly.
    • Fixed thresholds with no tolerance. Normal run-to-run variation then fails builds randomly and people stop trusting the check.
    • Tolerance on must-never-fail cases. These should block on any failure.
    • Moving the baseline silently. Update it in the pull request that improved scores, so the history explains every change.
    • API keys in workflow files. Use repository secrets and a separate key with its own spending limit.

    Try it

    Exercise

    About 90 min

    Add an eval gate to a repository

    1. Put 20 to 30 cases in evals/cases.json, marking about half as smoke, with at least two in a must-never-fail category.
    2. Adapt run-suite.ts to call your feature, and run it locally with --update-baseline to create the first baseline.
    3. Add the workflow, with your API key as a repository secret.
    4. Open a pull request that deliberately makes the prompt worse, and confirm the check fails. Then revert it.
    Show a hint

    A quick way to make the prompt worse on purpose: remove the instruction that covers your must-never-fail case. The gate should block it.

    Show a worked solution

    A working setup looks like this:

    • evals/cases.json with categories and smoke flags; evals/baseline.json committed.
    • Pull requests touching prompts/ run about 15 smoke cases in under two minutes.
    • The deliberately degraded prompt fails with a message like “must-never-fail: 50%, expected 100%”, and the pull request can’t merge.
    • A nightly run uploads latest.json, giving you a daily record of quality.

    Check your understanding

    Question 1 of 4: Why compare each category against a baseline with a tolerance, instead of using a fixed threshold?

    Show answer

    B. Model outputs vary between runs; a tolerance absorbs normal noise while still catching real regressions

    Without a tolerance, random variation fails builds and people stop trusting the gate.

    Question 2 of 4: Which cases should have zero tolerance?

    Show answer

    B. Must-never-fail cases

    A single failure on a must-never-fail case blocks the merge, every time.

    Question 3 of 4: What should run on every relevant pull request?

    Show answer

    B. A small, fast smoke set covering every category and every must-never-fail case

    Fast feedback on every change, with the slower full suite running nightly.

    Question 4 of 4: In the runner, what happens when a case throws an API error?

    Show answer

    B. The case is counted as failed and the run continues

    One failing request should not crash the suite, and it should not quietly disappear from the score either.

    Key takeaways

    • Run a smoke set on relevant pull requests and the full suite nightly.
    • Compare categories with a baseline plus tolerance; block on any must-never-fail failure.
    • Reduce flakiness with tighter criteria, more cases and majority votes for inherently variable cases.
    • Keep cost predictable with path filters, caching, batching and a separate key with a spending limit.

    Progress is saved in this browser and lights up the roadmap.