top of page

Tutorial 7: Build an Eval CI Pipeline

  • Contributor
  • 4 days ago
  • 3 min read

Manual eval = forgotten eval. CI runs it on every PR. Quality gates apply.

Step 1: CLI for Eval (10 min)

Make eval runnable from command line:

# eval.py
import argparse, json

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--prompt-file", required=True)
    parser.add_argument("--eval-set", required=True)
    parser.add_argument("--output", default="results.json")
    parser.add_argument("--threshold", type=float, default=0.85)
    args = parser.parse_args()
    
    prompt = open(args.prompt_file).read()
    eval_set = json.load(open(args.eval_set))
    
    results = run_eval(prompt, eval_set)
    
    json.dump(results, open(args.output, "w"))
    
    if results["pass_rate"] < args.threshold:
        print(f"FAIL: {results['pass_rate']:.1%} < {args.threshold:.1%}")
        exit(1)
    
    print(f"PASS: {results['pass_rate']:.1%}")

if __name__ == "__main__":
    main()

CLI exits non-zero on failure. CI uses exit code.

Step 2: Detect Prompt Changes (5 min)

Only run eval when prompts change:

# .github/workflows/eval.yml
on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'eval/**'
      - 'src/llm/**'

Saves time and API cost.

Step 3: GitHub Actions Workflow (15 min)

name: LLM Eval

on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'eval/**'

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Install deps
        run: pip install -r requirements.txt
      
      - name: Run eval (baseline)
        run: |
          git checkout origin/main -- prompts/answer.txt
          python eval.py --prompt-file prompts/answer.txt --eval-set eval/cases.json --output baseline.json
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      
      - name: Run eval (PR)
        run: |
          git checkout HEAD -- prompts/answer.txt
          python eval.py --prompt-file prompts/answer.txt --eval-set eval/cases.json --output pr.json
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      
      - name: Compare
        run: python compare.py baseline.json pr.json
      
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: eval-results
          path: |
            baseline.json
            pr.json

Baseline vs. PR comparison. Block on regression.

Step 4: Sample-Based Eval (5 min)

Full eval may be slow (100s of cases × multiple seconds each). Sample for speed:

# In CI
python eval.py --prompt-file prompts/answer.txt \
    --eval-set eval/cases.json \
    --sample 20 \
    --output pr.json

20 cases instead of 100. Fast CI, fuller eval nightly.

Step 5: Comment on PR (10 min)

- name: Comment results on PR
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const results = JSON.parse(fs.readFileSync('pr.json'));
      const baseline = JSON.parse(fs.readFileSync('baseline.json'));
      
      const body = `## Eval Results
      
      | Metric | Baseline | PR | Delta |
      |--------|----------|----|----|
      | Pass rate | ${baseline.pass_rate} | ${results.pass_rate} | ${results.pass_rate - baseline.pass_rate} |
      | Avg score | ${baseline.avg_score} | ${results.avg_score} | ${results.avg_score - baseline.avg_score} |
      | Regressions | - | ${results.regressions.length} | - |
      `;
      
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body,
      });

PR shows the eval results inline. Decision-makers see them.

Step 6: Cache LLM Responses (10 min)

Eval = same inputs, same prompts, same model = cacheable.

import hashlib

def cached_call(prompt, input_text):
    key = hashlib.md5(f"{prompt}{input_text}".encode()).hexdigest()
    cache_file = f".eval-cache/{key}.json"
    
    if os.path.exists(cache_file):
        return json.load(open(cache_file))
    
    result = call_llm(prompt, input_text)
    json.dump(result, open(cache_file, "w"))
    return result

Cache + CI artifact for reuse. Saves time and money on re-runs.

Step 7: Nightly Full Eval (5 min)

on:
  schedule:
    - cron: '0 2 * * *'

jobs:
  full-eval:
    # Full 500-case eval on production prompt

Catches drift even without code changes.

Step 8: Eval Set in Repo (10 min)

eval/
├── cases.json
├── categories/
│   ├── happy-path.json
│   ├── edge-cases.json
│   └── failures.json
└── README.md

Versioned with the code. Each case has expected behavior + tags. Easy to maintain.

Step 9: Cost Tracking (5 min)

def track_eval_cost(num_calls, model):
    cost_per_call = COSTS[model]
    total = num_calls * cost_per_call
    print(f"Eval cost: ${total:.2f}")
    
    # Add to GitHub summary
    with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
        f.write(f"\n## Eval Cost\n${total:.2f}\n")

Engineers see cost; helps catch runaway runs.

Step 10: Block Merge on Regression (5 min)

In GitHub: Settings → Branches → Require status check eval to pass.

PR can't merge if eval CI fails. Hard gate.

What You Just Did

Eval CI pipeline. Runs on prompt changes. Compares baseline vs. PR. Comments on PR. Blocks bad merges.

Common Failure Modes

No threshold; every PR shows results but nothing blocks. Theater.

Cost runaway. Full eval on every commit = hundreds of dollars.

Flaky eval. Noisy LLM outputs; runs fail randomly. Use median of N runs.

Eval set drift. Eval doesn't reflect prod traffic anymore.

No cache. Re-running eval on the same prompt costs 10x what it should.

bottom of page