07 - Eval em CI-CD

TL;DR

Eval em CI/CD é o que transforma EDD (01 - Eval-driven development — a disciplina) de boa intenção em disciplina forçada. Padrão canônico: eval gate em PR que bloqueia merge se regressão > threshold X. Cada PR de prompt/modelo dispara golden set; merge em main dispara full eval + atualização de baseline. Decisões de design: tamanho de sample por PR (subset top-N pra velocidade vs full pra rigor), threshold por categoria (safety = 0, calibration = 5-10%), quarentena de evals flaky (não-determinísticos precisam tolerância), failing loudly (Slack/PR comment) vs graciosamente (warn-only em early stage). Custo realista de eval por PR: 5. Pivot quando custo cresce: amostragem estratificada.

O contrato eval-em-CI

A garantia que CI/CD com eval entrega:

"Nenhum prompt change vai pra main sem ter sido medido."
"Toda regressão > threshold bloqueia merge."
"Toda métrica fica versionada em git."

Sem isso, EDD vira ritual — todo mundo concorda que evals importam, ninguém roda.

Padrão de pipeline

Developer push prompt change → PR aberto
                ↓
        CI dispara automaticamente
                ↓
    ┌───────────────────────────────┐
    │ 1. Run eval em subset crítico │  ← fast feedback (~2-5min)
    │ 2. Compara com baseline       │
    │ 3. Reporta no PR              │
    └───────────────────────────────┘
                ↓
       Regressão > threshold?
       /                  \
    SIM                    NÃO
     ↓                      ↓
  Bloqueia              Permite merge
  merge                       ↓
                       Merge em main
                              ↓
                    Roda FULL eval
                    (golden set inteiro)
                              ↓
                  Atualiza baseline em main

Duas razões pra split fast / full:

  • Velocidade em PR — feedback em 2-5min, não 30min
  • Rigor em main — última verificação antes de produção

Eval gate em PR — anatomia

# .github/workflows/llm-eval.yml
name: LLM Eval
 
on:
  pull_request:
    paths:
      - "prompts/**"
      - "src/llm/**"
      - "config/models.yaml"
 
jobs:
  eval:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
 
      - name: Install promptfoo
        run: npm install -g promptfoo@latest
 
      - name: Run eval — fast subset
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          promptfoo eval \
            --config promptfooconfig.yaml \
            --filter-tags "critical,safety" \
            --output eval-results.json \
            --no-write
 
      - name: Check thresholds
        run: |
          node scripts/check-thresholds.js \
            --results eval-results.json \
            --thresholds-config eval-thresholds.yaml
 
      - name: Comment results in PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('eval-results.json'));
            const body = formatResultsMarkdown(results);
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body,
            });

Companion eval-thresholds.yaml:

thresholds:
  correctness:
    regression_max: 0      # Qualquer regressão bloqueia
    avg_min: 0.85
 
  format:
    regression_max: 0      # Quebra de shape = blocker
    pass_rate_min: 1.0
 
  safety:
    regression_max: 0      # Safety nunca regride
    pass_rate_min: 1.0
    automatic_failure_max: 0
 
  calibration:
    regression_max: 0.1    # 10% tolerância
    avg_min: 0.75
 
  latency_p99:
    regression_max: 0.15   # 15% tolerância
    max_ms: 3000
 
  cost_per_item:
    regression_max: 0.20   # 20% tolerância (modelos novos podem custar mais)
    max_usd: 0.05

Estratégias de sampling

Rodar full golden set em todo PR não escala. Em produto com golden set de 500+ itens, full eval custa $5-20 e leva 5-15min. Solução: sampling estratificado.

EstratégiaComoQuando usar
FullTodos os itensMerge em main, release
Subset críticoItens marcados tags: [critical, safety]PR rápido (default)
Stratified randomN de cada categoriaPR com mais cobertura
Diff-targetedItens cujo prompt afetado pelo PRPR com mudança específica
Smoke test5-10 itens canônicosQuick sanity check
# promptfooconfig.yaml — sampling
tests:
  - vars: { input: "..." }
    metadata:
      tags: ["critical", "format"]   # sempre roda
      priority: "high"
 
  - vars: { input: "..." }
    metadata:
      tags: ["edge-case", "japanese"]  # roda em full eval
      priority: "medium"
# Fast em PR
promptfoo eval --filter-metadata 'priority=high'
 
# Full em merge
promptfoo eval  # tudo

Custo realista

Cálculo direto pra time típico:

Golden set: 200 itens
Modelo: Claude Sonnet 4.6
Tokens médios: 800 in + 400 out por item
Custo: ~$0.005/item

Fast subset (top-30 critical): $0.15 por run
Full set: $1 por run

PRs/dia: 5-10
Merges em main/dia: 3-5

Custo diário:
  PR fast: 10 × $0.15 = $1.50
  Main full: 5 × $1 = $5
  Total: ~$6.50/dia = ~$200/mês

Pra time enterprise com golden set de 2000+ e judges Opus 4:

Full eval com Opus 4 judge: $15-40 por run
10 merges/dia: $150-400/dia
Mensal: $4500-12000

Mitigação: judge mais barato (Sonnet) ou sampling estratégico

Custo é problema real depois de certo volume. Estratégia: judge barato em PR, judge forte em main + 1x/semana.

Quarentena de evals flaky

LLM é estocástico. Mesmo com temperature=0, há variação cross-run (modelo mudou, infra cache, etc.). Alguns itens vão ser flaky — passam 80%, falham 20%.

Como detectar:

# Roda o mesmo item N vezes
for item in golden_set:
    results = [run_eval(item) for _ in range(5)]
    if not all_equal(results):
        item.metadata.flaky = True
        item.metadata.tolerance = compute_variance(results)

Como tratar:

SituaçãoAção
Flakiness baixa (1-5% das runs falham)Tolerance maior, mantém em CI
Flakiness média (10-20%)Mover pra quarentena (roda mas não bloqueia merge)
Flakiness alta (>30%)Excluir do golden set ou refazer expected

Quarentena formal:

quarantine:
  - id: edge_042
    reason: "Flaky em 25% dos runs — modelo varia tom"
    quarantined_at: "2026-04-12"
    review_by: "2026-06-12"  # revisita em 60 dias

Sem quarentena, evals flaky destroem confiança no CI. Times começam a ignorar “regressão” porque “sempre falha aleatório”. A partir desse ponto, CI vira ruído.

Failing gracefully vs failing loudly

ModoQuando
Block mergeTime maduro, eval estabilizado, threshold calibrado
Warn-onlyEarly stage, eval ainda calibrando, OR mudança de modelo
Slack notifySempre (canal llm-evals dedicado)
PR commentSempre (visibilidade pra reviewer)

Pattern recomendado em adoção gradual:

Mês 1-2: warn-only, comentário em PR, ninguém bloqueado
Mês 3: block em safety regression (0 tolerance)
Mês 4: block em correctness > 5% regression
Mês 6+: thresholds tightening progressivo

Forçar block desde o dia 1 = pushback do time. Adoção gradual = cultura.

A/B em prod como complemento

CI mede pre-deploy. Não substitui B em prod com métricas de negócio. Pattern recomendado:

1. CI eval → métrica subiu → merge permitido
2. Deploy em 5% via feature flag
3. Métricas de negócio em prod (conversão, NPS, resolution rate)
4. Subiu? Ramp pra 100%. Caiu? Rollback.

Eval em CI sozinho diz que o sistema é tecnicamente melhor. A/B diz que o usuário foi impactado positivamente. Os dois importam.

Github Actions — exemplo completo

# .github/workflows/llm-eval.yml
name: LLM Eval Pipeline
 
on:
  pull_request:
    paths:
      - "prompts/**"
      - "src/llm/**"
  push:
    branches: [main]
 
env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
 
jobs:
  fast-eval:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
 
      - run: npm ci
 
      - name: Run fast eval
        run: |
          npx promptfoo eval \
            --filter-metadata "priority=high" \
            --output results.json
 
      - name: Check regression thresholds
        run: node scripts/check-regression.js results.json
 
      - name: Comment PR
        if: always()
        run: node scripts/post-pr-comment.js results.json
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
  full-eval:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
 
      - run: npm ci
 
      - name: Run full eval
        run: |
          npx promptfoo eval --output results-full.json
 
      - name: Update baseline
        run: |
          cp results-full.json baselines/main-$(date +%Y%m%d).json
          git config user.email "ci@example.com"
          git config user.name "CI Bot"
          git add baselines/
          git commit -m "ci: update baseline $(date +%Y-%m-%d)" || true
          git push
 
      - name: Notify Slack
        run: node scripts/notify-slack.js results-full.json
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}

Observações:

  1. PR roda fast eval — feedback rápido.
  2. Merge em main roda full e atualiza baseline em git (versionado).
  3. Slack notify só em main (PRs já têm comment).
  4. Timeout: 10min em PR, 30min em main.

Anti-patterns

  • Block merge desde o dia 1 — sem calibração, vira friction; time desativa
  • Threshold uniforme — safety com mesma tolerância de calibration
  • Sem quarentena de flaky — CI vira ruído, ninguém confia mais
  • Eval só em PR, não em main — baseline nunca atualizado; comparação fica obsoleta
  • Eval ignorando custo — full eval com Opus 4 em todo PR custa $100/dia
  • Sem PR comment — reviewer não vê resultado, eval invisível
  • Block sem possibilidade de override — não há mecanismo emergencial (e.g. [skip-eval] em commit message para hotfix raríssimo)
  • Baseline não-versionado — não dá pra fazer regression cross-time

Maturidade

NívelSinal
0Nenhum eval em CI; só “tested locally”
1Eval roda em CI mas warn-only
2Eval bloqueia merge em safety regression
3Eval bloqueia em safety + correctness; baseline versionado
4Fast/full split; sampling estratificado; quarentena de flaky
5+ A/B em prod; baseline auto-update; dashboard ops

Meta pra 2026: nível 3 como padrão; nível 5 em produtos críticos.

Veja também

Fontes