TL;DR
Un agente LLM sin evals en CI/CD es un deploy a producción sin tests unitarios: eventualmente se rompe, y nadie sabe qué commit lo rompió. En este artículo armamos un pipeline completo con Promptfoo para evals deterministas de prompts, DeepEval para métricas de RAG (faithfulness, context relevance, answer relevance), Langfuse para trazas y dataset dorado versionado, y GitHub Actions para correr todo en cada PR. Si el score cae más de 5% respecto a main, el merge se bloquea. Incluimos el pipeline YAML completo, los datasets de ejemplo y el script Go que calcula el diff semántico entre corridas.
El problema
Los tests clásicos validan entradas/salidas exactas. Los LLMs son no deterministas — dos ejecuciones con el mismo input pueden diferir sintácticamente sin ser incorrectas. Por eso las tres métricas que importan en CI/CD son:
- Correctness: ¿la respuesta contiene la información correcta? (LLM-as-judge sobre rúbrica).
- Faithfulness: ¿la respuesta es fiel al contexto recuperado? (detección de alucinaciones).
- No-regression: ¿la score media del PR baja respecto al baseline?
Validar una sola no alcanza. Un prompt puede mejorar correctness pero empeorar faithfulness. Por eso corremos las tres en paralelo con umbrales independientes.
Arquitectura
┌─────────────────────────────────────────────────────────┐
│ GitHub Actions (CI) │
│ │
│ PR abierto │
│ │ │
│ ├─► job: promptfoo-eval │
│ │ └─► corre promptfoo run --config promptfoo.yml │
│ │ │
│ ├─► job: deepeval-rag │
│ │ └─► python -m deepeval run │
│ │ │
│ └─► job: regression-guard │
│ └─► descarga baseline de Langfuse │
│ compara scores │
│ ✗ bloquea merge si delta < -5% │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Langfuse (observability + golden dataset) │
│ │
│ datasets/ │
│ ├── golden-support-v3 (120 casos curados) │
│ ├── golden-rag-v2 (80 casos) │
│ └── regression-baseline (snapshots por release) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Qdrant (versionado del dataset dorado) │
│ └─► cada ítem vectorizado permite detectar drift │
└─────────────────────────────────────────────────────────┘
1. Dataset dorado versionado
El dataset vive en dos sitios: texto plano en el repo (evals/datasets/*.yml) para diffs legibles en PR, y vectorizado en Qdrant para detección de drift (si alguien "mejora" un caso alterándolo, queremos saber).
# evals/datasets/golden-support-v3.yml
- id: SUP-001
tags: [refund, mexico, regulated]
input: |
Hola, compré un dispositivo médico hace 3 días y no me sirve.
¿Puedo devolverlo? RFC XAXX010101000, factura F-20456.
expected_facts:
- "período de devolución es de 7 días naturales"
- "requiere factura original"
- "reembolso en 10 días hábiles al método original"
forbidden_phrases:
- "no puedo ayudarte"
- "contacta a un humano"
rubric:
- criterion: "¿Menciona el período correcto (7 días)?"
weight: 0.3
- criterion: "¿Valida RFC y factura antes de confirmar?"
weight: 0.3
- criterion: "¿Tono profesional y empático?"
weight: 0.2
- criterion: "¿No fabrica políticas que no están en el contexto?"
weight: 0.2
2. Promptfoo para evals deterministas
promptfoo.yml:
description: "Agente de soporte — suite de regresión"
prompts:
- file://prompts/support_system.txt
providers:
- id: litellm
config:
apiBaseUrl: https://api.numoru.com/v1
apiKey: ${LITELLM_MASTER_KEY}
model: claude-sonnet
temperature: 0.0
tests:
- vars:
input: "{{case.input}}"
assert:
- type: llm-rubric
value: "{{case.rubric}}"
threshold: 0.75
- type: contains-all
value: "{{case.expected_facts}}"
- type: not-contains-any
value: "{{case.forbidden_phrases}}"
- type: latency
threshold: 6000
datasets:
- file://evals/datasets/golden-support-v3.yml
Promptfoo corre los 120 casos, calcula score por assertion, agrega a nivel de suite, y genera un reporte HTML.
3. DeepEval para métricas RAG
Cuando el agente usa RAG (nuestro mcp-qdrant-rag), los errores no son de prompt sino de retrieval. DeepEval tiene métricas específicas:
# evals/rag/test_rag_quality.py
from deepeval import evaluate
from deepeval.metrics import (
FaithfulnessMetric,
AnswerRelevancyMetric,
ContextualRelevancyMetric,
ContextualRecallMetric,
)
from deepeval.test_case import LLMTestCase
import yaml, httpx
def load_golden(path):
with open(path) as f:
return yaml.safe_load(f)
def call_agent(input_text):
r = httpx.post(
"https://api.numoru.com/v1/agents/support",
json={"input": input_text},
timeout=30,
)
data = r.json()
return data["output"], data["retrieved_contexts"]
def test_rag_suite():
golden = load_golden("evals/datasets/golden-rag-v2.yml")
cases = []
for case in golden:
output, contexts = call_agent(case["input"])
cases.append(LLMTestCase(
input=case["input"],
actual_output=output,
expected_output=case["expected"],
retrieval_context=contexts,
))
metrics = [
FaithfulnessMetric(threshold=0.85),
AnswerRelevancyMetric(threshold=0.80),
ContextualRelevancyMetric(threshold=0.75),
ContextualRecallMetric(threshold=0.70),
]
evaluate(cases, metrics)
Cada métrica tiene su propio umbral. Un PR que rompa uno — típicamente ContextualRecallMetric cuando alguien "optimiza" el chunking — falla CI.
4. Regression guard: comparar contra baseline
El paso más importante. Corremos la suite en main y guardamos el score agregado en Langfuse. Cada PR corre su propia suite y compara.
// cmd/regression-guard/main.go
package main
import (
"context"
"fmt"
"os"
"github.com/numoru-ia/langfuse-go"
)
func main() {
lf := langfuse.New(os.Getenv("LANGFUSE_URL"), os.Getenv("LANGFUSE_KEY"))
ctx := context.Background()
baseline, err := lf.GetDatasetRun(ctx, "golden-support-v3", "baseline")
must(err)
pr, err := lf.GetDatasetRun(ctx, "golden-support-v3", os.Getenv("GITHUB_SHA"))
must(err)
delta := pr.MeanScore - baseline.MeanScore
fmt.Printf("baseline=%.3f pr=%.3f delta=%+.3f\n", baseline.MeanScore, pr.MeanScore, delta)
if delta < -0.05 {
fmt.Fprintln(os.Stderr, "❌ regresión detectada: bloqueando merge")
os.Exit(1)
}
// Lista casos que regresionaron individualmente
for _, c := range pr.Cases {
base := baseline.CaseByID(c.ID)
if base != nil && c.Score < base.Score-0.10 {
fmt.Printf("⚠️ caso %s bajó de %.2f a %.2f\n", c.ID, base.Score, c.Score)
}
}
}
5. El workflow de GitHub Actions
# .github/workflows/agent-evals.yml
name: agent-evals
on:
pull_request:
paths:
- "prompts/**"
- "evals/**"
- "internal/agent/**"
jobs:
promptfoo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm install -g [email protected]
- name: Run promptfoo
env:
LITELLM_MASTER_KEY: ${{ secrets.LITELLM_MASTER_KEY }}
run: promptfoo eval -c promptfoo.yml --output results.json
- uses: actions/upload-artifact@v4
with:
name: promptfoo-results
path: results.json
deepeval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install deepeval==2.3.0 pyyaml httpx
- name: Run DeepEval suite
env:
LITELLM_MASTER_KEY: ${{ secrets.LITELLM_MASTER_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: pytest evals/rag/ -v --junitxml=deepeval-results.xml
regression-guard:
needs: [promptfoo, deepeval]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.23" }
- name: Build guard
run: go build -o guard ./cmd/regression-guard
- name: Compare against baseline
env:
LANGFUSE_URL: ${{ secrets.LANGFUSE_URL }}
LANGFUSE_KEY: ${{ secrets.LANGFUSE_KEY }}
run: ./guard
publish-results:
needs: [regression-guard]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- name: Post PR comment
uses: marocchino/sticky-pull-request-comment@v2
with:
recreate: true
path: promptfoo-results.md
6. Dataset drift: cómo detectarlo
Cuando alguien edita evals/datasets/*.yml, queremos saber si cambió un caso existente o añadió uno nuevo. Hook pre-commit:
#!/bin/bash
# .githooks/pre-commit
python scripts/vectorize_dataset.py \
--input evals/datasets/golden-support-v3.yml \
--qdrant https://qdrant.numoru.com \
--collection golden-support-diff
python scripts/dataset_drift_check.py --threshold 0.05
El script vectoriza cada caso y compara contra la versión en Qdrant. Si un caso cambió semánticamente más de 5% sin cambiar su id, pide confirmación explícita (y abre una issue automáticamente).
7. Métricas en Langfuse
Configuramos evals automáticos en Langfuse sobre producción, no solo en CI. Cada 1000 interacciones reales, un worker las muestrea y corre las mismas rúbricas. El dashboard resultante permite:
- Detectar drift de modelo (cuando el provider actualiza Claude y el comportamiento cambia).
- Detectar drift de datos (nuevos casos que el dataset dorado no cubre).
- Priorizar qué ejemplos reales agregar al dataset dorado.
8. Ejemplo real de falla detectada
Hace dos semanas un PR cambió el prompt del sistema para "hacer respuestas más cortas". En CI:
promptfoo: score 0.812 → 0.807 ✓ ok
deepeval rag: faithfulness 0.88 → 0.76 ❌ bajó
regression-guard: delta -0.12 en caso SUP-047 ❌ bloqueado
La raíz: respuestas más cortas omitían el disclaimer "en base a la documentación disponible...", lo que bajó la faithfulness porque el juez interpretaba que el agente estaba afirmando hechos sin marcar su fuente. Se añadió una regla al prompt y el score recuperó sin perder concisión.
9. Costos reales
| Suite | Casos | Llamadas LLM | Costo/run |
|---|---|---|---|
| promptfoo support | 120 | 120 × 1 = 120 | ~0.40 USD |
| deepeval rag | 80 | 80 × 5 = 400 | ~1.30 USD |
| judge (claude-sonnet) | 200 | 200 | ~0.70 USD |
| Total por PR | ~2.40 USD |
Con ~30 PRs por mes, cuesta 72 USD/mes tener un red de seguridad completa. Un incidente en producción por regresión cuesta (típicamente) un orden de magnitud más.
Comparación de orden de magnitud del costo esperado según dónde se detecta la regresión. Basado en post-mortems del reporte LangChain State of AI Agents 2024 y engagements Numoru.
LangChain State of AI Agents 2024 + logs de incidentes de clientes Numoru.
Impacto de negocio y casos
Vender evals-as-a-service
Toda empresa shipeando agentes en 2026 necesita este pipeline. La mayoría no tiene el músculo para armar Promptfoo + DeepEval + Langfuse + GitHub Actions por sí misma. El patrón de engagement es simple: setup de 2-4 semanas que deja la suite cableada en su CI, más retainer opcional que cura el golden dataset conforme evoluciona el producto.
Quién lo compra
Ticket del pipeline de evals por comprador (Numoru, USD)
Benchmarks públicos
LangChain — State of AI Agents 2024
Promptfoo — casos enterprise
Caso ilustrativo — SaaS AI-native Series B
SaaS AI-native de soporte Series B, 12 ingenieros, 180 clientes pagos
Calculadora ROI — traer evals in-house
SaaS AI-native: sin evals vs pipeline Numoru (12 meses)
| Setup (one-time) | −$16,500 |
| Retainer (12 mo × $600) | −$7,200 |
| Infra CI evals (12 mo × $72) | −$864 |
| Tiempo eng evitado (3.2 × 12 × 12 × $92) | +$42,393 |
| Impacto al cliente evitado (3.2 × 12 × $2,400) | +$92,160 |
| Reducción de churn | +$180,000 |
| Contribución neta año 1 | +$289,989 |
Tiers de pricing Numoru
- Wiring Promptfoo + DeepEval
- Golden dataset (40 casos)
- Pipeline GitHub Actions
- Regression guard threshold 5%
- Warranty 30 días
- Todo lo del Starter
- 3 agentes / 120 casos dorados
- Datasets Langfuse + alertas drift
- Curación semanal de dataset
- Alertas Slack en regresión
- Revisión evals trimestral
- Infra evals compartida multi-equipo
- Dataset curado 300+ casos
- Adendo bias + safety
- Config custom de judge-model
- Artefactos compliance-ready
- CSM dedicado
10. Anti-patrones
- Usar el mismo modelo como judge y como respondedor. Sesgo circular. Judge siempre debe ser un modelo distinto (ideal: otro proveedor).
- Dataset que crece sin curaduría. Más ≠ mejor. 100 casos curados valen más que 1000 autogenerados.
- Umbrales globales. Algunos casos son críticos (compliance, cobros); deben tener umbral mayor individual, no diluirse en el promedio.
- Sin trace-back a casos reales. Cada caso del dataset dorado debe poder vincularse al incidente o feature request que lo originó.
FAQ
¿Puedo correr esto en monorepo con varios agentes?Sí. Cada agente tiene su propio promptfoo.yml y dataset. El regression-guard acepta un flag --agent-id y compara contra la baseline correspondiente.
¿Qué pasa si el LLM del judge tiene un día malo?Por eso la suite corre 3 muestras por caso y usa mediana, no media. Si la varianza del judge supera 0.10 para un caso, se marca como "flaky" y se excluye del cálculo de delta.
¿Cómo migro desde pruebas manuales?Exporta 50 casos recientes desde tu Langfuse de producción. Clasifica manualmente por "respuesta buena/mala" y usa esos labels como seed del dataset dorado. 2-3 días de trabajo, meses de tranquilidad.
¿Funciona con agentes multi-step?Promptfoo sólo evalúa output final. Para evaluar tool calls intermedios, DeepEval tiene ToolCorrectnessMetric. Para evaluar trayectorias completas, usamos deepeval.metrics.ConversationalMetric con el trace completo de Langfuse como input.
¿Detecta prompt injection?Indirectamente: si el adversarial input cambia el comportamiento, los casos golden fallan. Para detección directa, añadir una suite específica con garak o rebuff.
Próximos pasos
El repo github.com/numoru-ia/agent-evals-template tiene el pipeline completo listo para hacer fork. Incluye datasets doradas seed para: soporte al cliente, agente de ventas B2B, RAG documentación técnica y recepcionista IA. Cada una con 50-100 casos curados con casos reales de clientes Numoru (anonimizados).