Módulo 7: Evaluación de Prompts
5. Regression Testing para Prompts
Descripción
Detectar cuando un cambio de prompt rompe casos que antes funcionaban. Implementación de test suites con pytest. CI/CD integration con GitHub Actions. Manejo de baseline scores y alertas de regresión. Estrategias para reducir costos de testing.
¿Qué es Regression Testing para Prompts?
En software tradicional, los regression tests verifican que un bug arreglado no vuelve a aparecer. Para prompts, la regresión es diferente:
Problema típico sin regression testing:
1. Tienes un prompt de clasificación con accuracy 94%
2. Encuentras un edge case nuevo, modificas el prompt
3. El edge case ahora funciona (95% accuracy en ese caso)
4. Pero sin saberlo, rompiste 3 casos que antes funcionaban
5. Accuracy real: 91%
6. Lo descubres cuando los usuarios reportan problemas
Con regression testing:
1. Al modificar el prompt, ejecutas el golden set automáticamente
2. Sistema detecta: accuracy bajó de 94% a 91%
3. Muestra exactamente cuáles casos rompiste
4. No deployar hasta corregir la regresión
Diferencia con Testing de Software Tradicional
| Aspecto | Software Tradicional | Prompts LLM |
|---|---|---|
| Determinismo | 100% — mismo input = mismo output | No siempre — mismo input puede variar |
| Tolerancia | 0% — test pasa o falla | 2-5% — pequeñas variaciones son normales |
| Costo de ejecutar | Milisegundos, sin costo variable | Segundos, costo por token |
| Causa de regresión | Bug en código | Cambio de prompt, modelo, temperatura |
| Profundidad | Test unitario exacto | Evaluación estadística |
Implementación Base
El Motor de Regression Testing
import json
import time
from pathlib import Path
from dataclasses import dataclass
from typing import Callable
from openai import OpenAI
client = OpenAI()
@dataclass
class TestResult:
ejemplo_id: str
input: str
expected: str
actual: str
passed: bool
score: float
latencia_ms: float
tokens_usados: int
def run_prompt(prompt_template: str, input_text: str) -> tuple[str, dict]:
"""
Ejecuta el prompt y retorna (output, metadata).
metadata incluye latencia y tokens.
"""
inicio = time.time()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": prompt_template.format(input=input_text)}
],
temperature=0
)
latencia_ms = (time.time() - inicio) * 1000
output = response.choices[0].message.content.strip()
metadata = {
"latencia_ms": latencia_ms,
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens
}
return output, metadata
def evaluar_ejemplo(
prompt_template: str,
ejemplo: dict,
evaluador: Callable | None = None
) -> TestResult:
"""
Evalúa un solo ejemplo del golden set.
evaluador: función custom. Si no se provee, usa exact match normalizado.
"""
output, metadata = run_prompt(prompt_template, ejemplo["input"])
if evaluador:
passed, score = evaluador(ejemplo["expected_output"], output)
else:
# Default: exact match normalizado
expected_norm = str(ejemplo["expected_output"]).strip().lower()
output_norm = output.strip().lower()
passed = expected_norm == output_norm
score = 1.0 if passed else 0.0
return TestResult(
ejemplo_id=ejemplo["id"],
input=ejemplo["input"],
expected=str(ejemplo["expected_output"]),
actual=output,
passed=passed,
score=score,
latencia_ms=metadata["latencia_ms"],
tokens_usados=metadata["total_tokens"]
)
Baseline Management
El baseline es el conjunto de métricas del prompt actual, contra el que se comparan las nuevas versiones.
class BaselineManager:
"""Gestiona los baselines de evaluación para comparación de regresiones."""
def __init__(self, baseline_path: str = "baseline.json"):
self.baseline_path = Path(baseline_path)
self._baselines: dict = {}
if self.baseline_path.exists():
self._cargar()
def _cargar(self) -> None:
with open(self.baseline_path) as f:
self._baselines = json.load(f)
print(f"Baseline cargado: {len(self._baselines)} prompts")
def guardar(self) -> None:
with open(self.baseline_path, "w") as f:
json.dump(self._baselines, f, indent=2)
print(f"Baseline guardado en {self.baseline_path}")
def registrar(self, prompt_name: str, metricas: dict, version: str) -> None:
"""Registra las métricas actuales como nuevo baseline."""
self._baselines[prompt_name] = {
"version": version,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"metricas": metricas
}
self.guardar()
print(f"Nuevo baseline registrado para '{prompt_name}' ({version})")
def obtener(self, prompt_name: str) -> dict | None:
"""Obtiene el baseline de un prompt. None si no existe."""
return self._baselines.get(prompt_name)
def comparar(
self,
prompt_name: str,
metricas_nuevas: dict,
tolerancia: float = 0.02
) -> dict:
"""
Compara métricas nuevas contra el baseline.
tolerancia: Variación máxima permitida antes de reportar regresión.
0.02 = 2% de margen — small variations son normales.
"""
baseline = self.obtener(prompt_name)
if baseline is None:
return {
"status": "NO_BASELINE",
"mensaje": f"No hay baseline para '{prompt_name}'. Registrar con baseline_manager.registrar()",
"regressions": [],
"mejoras": []
}
metricas_baseline = baseline["metricas"]
regressions = []
mejoras = []
sin_cambio = []
for metrica, valor_nuevo in metricas_nuevas.items():
if metrica not in metricas_baseline:
continue
valor_anterior = metricas_baseline[metrica]
delta = valor_nuevo - valor_anterior
if delta < -tolerancia:
regressions.append({
"metrica": metrica,
"anterior": valor_anterior,
"nuevo": valor_nuevo,
"delta": delta,
"severidad": "CRITICA" if delta < -0.05 else "MENOR"
})
elif delta > tolerancia:
mejoras.append({
"metrica": metrica,
"anterior": valor_anterior,
"nuevo": valor_nuevo,
"delta": delta
})
else:
sin_cambio.append(metrica)
return {
"status": "FAIL" if regressions else "PASS",
"regressions": regressions,
"mejoras": mejoras,
"sin_cambio": sin_cambio,
"baseline_version": baseline["version"],
"baseline_fecha": baseline["timestamp"]
}
# Uso:
baseline_mgr = BaselineManager()
# Primera vez: registrar baseline del prompt actual
metricas_actuales = {"accuracy": 0.94, "faithfulness": 0.88, "format": 1.0}
baseline_mgr.registrar("clasificador_sentimiento", metricas_actuales, "v1.0")
# Al cambiar el prompt: comparar
metricas_nuevas = {"accuracy": 0.91, "faithfulness": 0.89, "format": 1.0}
resultado = baseline_mgr.comparar("clasificador_sentimiento", metricas_nuevas)
if resultado["status"] == "FAIL":
print("❌ REGRESIÓN DETECTADA:")
for r in resultado["regressions"]:
print(f" {r['metrica']}: {r['anterior']:.2%} → {r['nuevo']:.2%} ({r['delta']:+.2%}) [{r['severidad']}]")
else:
print("✅ Sin regresiones")
for m in resultado["mejoras"]:
print(f" 📈 {m['metrica']}: {m['anterior']:.2%} → {m['nuevo']:.2%} ({m['delta']:+.2%})")
Suite de Tests con pytest
Para integrar con CI/CD, organiza los tests con pytest:
# tests/test_clasificador.py
import pytest
import json
from pathlib import Path
from openai import OpenAI
client = OpenAI()
# ===== FIXTURES =====
@pytest.fixture(scope="session")
def golden_set():
"""Carga el golden set una sola vez por sesión."""
path = Path("datasets/clasificador_sentimiento.json")
with open(path) as f:
return json.load(f)
@pytest.fixture(scope="session")
def baseline():
"""Carga el baseline de métricas."""
path = Path("baseline.json")
if not path.exists():
return {}
with open(path) as f:
return json.load(f)
@pytest.fixture(scope="module")
def prompt_actual():
"""Carga el prompt actual desde archivo."""
with open("prompts/clasificador_v1.txt") as f:
return f.read()
# ===== TESTS INDIVIDUALES =====
class TestClasificadorSentimiento:
@pytest.mark.parametrize("ejemplo_id,esperado", [
("001", "POSITIVO"),
("002", "NEGATIVO"),
("003", "NEUTRO"),
])
def test_casos_criticos(self, prompt_actual, ejemplo_id, esperado, golden_set):
"""
Tests que NUNCA deben fallar — los casos más básicos.
Si estos fallan, hay un problema grave.
"""
ejemplo = next(e for e in golden_set if e["id"] == ejemplo_id)
output, _ = run_prompt(prompt_actual, ejemplo["input"])
assert output.strip().upper() == esperado, \
f"Caso crítico {ejemplo_id} falló: esperado={esperado}, obtenido={output}"
def test_accuracy_minima(self, prompt_actual, golden_set):
"""La accuracy sobre el golden set completo no puede bajar del 85%."""
outputs = []
for ej in golden_set:
output, _ = run_prompt(prompt_actual, ej["input"])
correcto = output.strip().lower() == str(ej["expected_output"]).strip().lower()
outputs.append(correcto)
accuracy = sum(outputs) / len(outputs)
assert accuracy >= 0.85, \
f"Accuracy {accuracy:.2%} por debajo del mínimo aceptable (85%)"
def test_sin_regresion_vs_baseline(self, prompt_actual, golden_set, baseline):
"""Accuracy no puede caer más de 2% respecto al baseline."""
if "clasificador_sentimiento" not in baseline:
pytest.skip("No hay baseline registrado — skipping regression test")
baseline_accuracy = baseline["clasificador_sentimiento"]["metricas"]["accuracy"]
outputs = []
for ej in golden_set:
output, _ = run_prompt(prompt_actual, ej["input"])
outputs.append(output.strip().lower() == str(ej["expected_output"]).strip().lower())
accuracy_actual = sum(outputs) / len(outputs)
assert accuracy_actual >= baseline_accuracy - 0.02, \
f"REGRESIÓN: accuracy bajó de {baseline_accuracy:.2%} a {accuracy_actual:.2%} ({accuracy_actual - baseline_accuracy:+.2%})"
def test_format_compliance(self, prompt_actual, golden_set):
"""El output debe ser siempre: POSITIVO, NEGATIVO, o NEUTRO."""
outputs_invalidos = []
for ej in golden_set[:20]: # Solo primeros 20 para rapidez
output, _ = run_prompt(prompt_actual, ej["input"])
if output.strip().upper() not in ["POSITIVO", "NEGATIVO", "NEUTRO"]:
outputs_invalidos.append({"id": ej["id"], "output": output})
assert len(outputs_invalidos) == 0, \
f"Outputs con formato inválido: {outputs_invalidos}"
def test_edge_cases(self, prompt_actual, golden_set):
"""Los edge cases deben tener accuracy >= 70%."""
edge_cases = [e for e in golden_set if e.get("dificultad") in ["dificil", "muy_dificil"]]
if not edge_cases:
pytest.skip("No hay edge cases en el golden set")
outputs = []
for ej in edge_cases:
output, _ = run_prompt(prompt_actual, ej["input"])
outputs.append(output.strip().lower() == str(ej["expected_output"]).strip().lower())
accuracy = sum(outputs) / len(outputs)
assert accuracy >= 0.70, \
f"Accuracy en edge cases ({accuracy:.2%}) por debajo de 70%"
def test_latencia_aceptable(self, prompt_actual):
"""La latencia promedio debe ser < 3 segundos."""
import time
test_input = "Este es un producto excelente"
latencias = []
for _ in range(3):
inicio = time.time()
run_prompt(prompt_actual, test_input)
latencias.append(time.time() - inicio)
latencia_promedio = sum(latencias) / len(latencias)
assert latencia_promedio < 3.0, \
f"Latencia promedio {latencia_promedio:.1f}s supera el límite de 3s"
Integración con CI/CD (GitHub Actions)
# .github/workflows/prompt-regression-tests.yml
name: Prompt Regression Tests
on:
pull_request:
paths:
- 'prompts/**' # Solo cuando cambian los prompts
- 'datasets/**' # O cuando cambia el golden set
push:
branches: [main]
schedule:
- cron: '0 2 * * *' # Daily a las 2am (para detectar drift del modelo)
jobs:
regression-tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache dependencies
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run critical tests (fast)
run: pytest tests/test_clasificador.py::TestClasificadorSentimiento::test_casos_criticos -v
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run regression tests
run: |
pytest tests/test_clasificador.py -v \
--tb=short \
--junitxml=test-results.xml \
-k "not test_latencia" # Excluir tests de latencia en CI (inestables)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: test-results.xml
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: test-results.xml
Estrategias para Reducir Costos de Testing
El costo de ejecutar regression tests en CI puede ser significativo. Estrategias para reducirlo:
Estrategia 1: Tiers de Tests
# conftest.py — Configuración de pytest
import pytest
def pytest_addoption(parser):
parser.addoption("--test-tier", action="store", default="smoke",
help="Test tier: smoke | standard | full")
@pytest.fixture(scope="session")
def test_tier(request):
return request.config.getoption("--test-tier")
# En tests:
@pytest.fixture
def golden_set_por_tier(test_tier):
"""Retorna subset del golden set según el tier."""
with open("datasets/golden_set.json") as f:
todos = json.load(f)
if test_tier == "smoke":
# Solo casos críticos: rápido y barato
return [e for e in todos if e.get("critico", False)][:10]
elif test_tier == "standard":
# Happy path + edge cases: balance costo/cobertura
return [e for e in todos if e.get("dificultad") in ["facil", "dificil"]][:50]
else: # full
return todos
# En CI (PRs): solo smoke tests
pytest tests/ --test-tier=smoke
# En CI (merge to main): tests estándar
pytest tests/ --test-tier=standard
# Overnight: suite completa
pytest tests/ --test-tier=full
Estrategia 2: Caché de Resultados
import hashlib
import json
from pathlib import Path
class TestCache:
"""Caché de resultados de tests para evitar re-ejecutar tests sin cambios."""
def __init__(self, cache_dir: str = ".test_cache"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
def cache_key(self, prompt: str, input_text: str, model: str = "gpt-4o-mini") -> str:
content = f"{prompt}|{input_text}|{model}"
return hashlib.sha256(content.encode()).hexdigest()[:16]
def get(self, prompt: str, input_text: str) -> str | None:
key = self.cache_key(prompt, input_text)
path = self.cache_dir / f"{key}.json"
if path.exists():
with open(path) as f:
return json.load(f)["output"]
return None
def set(self, prompt: str, input_text: str, output: str) -> None:
key = self.cache_key(prompt, input_text)
path = self.cache_dir / f"{key}.json"
with open(path, "w") as f:
json.dump({"output": output, "timestamp": time.time()}, f)
def stats(self) -> dict:
files = list(self.cache_dir.glob("*.json"))
return {"cached_results": len(files), "cache_dir": str(self.cache_dir)}
cache = TestCache()
def run_prompt_cached(prompt: str, input_text: str) -> str:
"""Versión cacheada de run_prompt para tests."""
cached = cache.get(prompt, input_text)
if cached:
return cached
output, _ = run_prompt(prompt, input_text)
cache.set(prompt, input_text, output)
return output
Estrategia 3: Paralelización
# pytest-xdist para paralelizar tests
# pip install pytest-xdist
# En CI:
# pytest tests/ -n 4 # 4 workers en paralelo
# En el código de tests, usar asyncio para paralelizar las llamadas API:
import asyncio
from openai import AsyncOpenAI
async_client = AsyncOpenAI()
async def run_prompt_async(prompt_template: str, input_text: str) -> tuple[str, dict]:
"""Versión async de run_prompt."""
response = await async_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt_template.format(input=input_text)}],
temperature=0
)
return response.choices[0].message.content.strip(), {}
async def evaluar_golden_set_async(
prompt_template: str,
golden_set: list[dict],
max_concurrent: int = 10
) -> list[dict]:
"""Evalúa el golden set en paralelo con límite de concurrencia."""
semaphore = asyncio.Semaphore(max_concurrent)
async def evaluar_con_limite(ejemplo):
async with semaphore:
output, _ = await run_prompt_async(prompt_template, ejemplo["input"])
return {
"id": ejemplo["id"],
"expected": str(ejemplo["expected_output"]),
"actual": output,
"passed": output.strip().lower() == str(ejemplo["expected_output"]).strip().lower()
}
tasks = [evaluar_con_limite(ej) for ej in golden_set]
return await asyncio.gather(*tasks)
# Ejecutar:
# resultados = asyncio.run(evaluar_golden_set_async(prompt, golden_set))
Reporte de Regresión
def generar_reporte_regresion(
prompt_name: str,
resultados: list[dict],
baseline_metricas: dict | None = None
) -> str:
"""
Genera un reporte markdown de los resultados del regression test.
Útil para comentar en PRs o enviar por Slack.
"""
pasados = sum(1 for r in resultados if r["passed"])
fallidos = len(resultados) - pasados
accuracy = pasados / len(resultados)
lineas = [
f"# Regression Test: {prompt_name}",
f"",
f"**Fecha:** {time.strftime('%Y-%m-%d %H:%M:%S')}",
f"",
f"## Resumen",
f"| Métrica | Valor |",
f"|---------|-------|",
f"| Total ejemplos | {len(resultados)} |",
f"| Pasados | {pasados} ✅ |",
f"| Fallidos | {fallidos} {'❌' if fallidos > 0 else '✅'} |",
f"| Accuracy | {accuracy:.2%} |",
]
# Comparación con baseline
if baseline_metricas and "accuracy" in baseline_metricas:
baseline_acc = baseline_metricas["accuracy"]
delta = accuracy - baseline_acc
estado = "✅ PASS" if delta >= -0.02 else "❌ REGRESSION"
lineas.extend([
f"| Baseline accuracy | {baseline_acc:.2%} |",
f"| Delta vs baseline | {delta:+.2%} |",
f"| Estado | {estado} |",
])
# Casos fallidos
if fallidos > 0:
lineas.extend([
f"",
f"## Casos Fallidos ({fallidos})",
f"",
])
for r in resultados:
if not r["passed"]:
input_corto = str(r.get("input", ""))[:60]
lineas.extend([
f"### ❌ ID: {r['id']}",
f"- **Input:** `{input_corto}...`",
f"- **Esperado:** `{r['expected']}`",
f"- **Obtenido:** `{r['actual']}`",
f"",
])
return "\n".join(lineas)
Troubleshooting
Problema 1: Falsos positivos en regression tests
Síntoma: Los tests fallan aunque el prompt no cambió.
Causa: Variabilidad natural del modelo, incluso con temperature=0.
Solución:
# Aumentar la tolerancia del baseline comparison
resultado = baseline_mgr.comparar(
"clasificador",
metricas_nuevas,
tolerancia=0.03 # 3% en lugar de 2%
)
# O ejecutar múltiples veces y promediar
def accuracy_promedio(prompt, golden_set, n_runs=3):
accuracies = []
for _ in range(n_runs):
outputs = [run_prompt(prompt, e["input"])[0] for e in golden_set]
acc = sum(o.strip().lower() == str(e["expected_output"]).strip().lower()
for o, e in zip(outputs, golden_set)) / len(golden_set)
accuracies.append(acc)
return sum(accuracies) / len(accuracies)
Problema 2: Golden set demasiado pequeño
Síntoma: Con 20 ejemplos, un solo fallo cambia la accuracy en 5%.
Causa: 20 ejemplos = demasiada varianza para detectar cambios reales vs ruido.
Solución:
# Calcular el tamaño mínimo necesario
def margen_error_actual(n: int, accuracy: float = 0.9, confianza: float = 0.95) -> float:
"""Calcula el margen de error con n ejemplos."""
from scipy.stats import norm
z = norm.ppf((1 + confianza) / 2)
return z * (accuracy * (1 - accuracy) / n) ** 0.5
# n=20: margen ≈ ±13% — demasiado amplio
# n=100: margen ≈ ±6% — aceptable
# n=400: margen ≈ ±3% — bueno
print(f"n=20: ±{margen_error_actual(20):.0%}")
print(f"n=100: ±{margen_error_actual(100):.0%}")
print(f"n=400: ±{margen_error_actual(400):.0%}")
Problema 3: Costo excesivo en CI
Síntoma: Cada PR cuesta $5-10 en llamadas API.
Solución:
# 1. Usar smoke tests pequeños en PRs (10-20 ejemplos críticos)
# 2. Full suite solo en merges a main
# 3. Caché de resultados para prompts que no cambiaron
# 4. Rate limiting para reducir costo
import time
def run_con_rate_limit(
prompt_template: str,
golden_set: list[dict],
requests_per_minute: int = 30
) -> list[dict]:
"""Ejecuta el golden set con rate limiting."""
delay = 60.0 / requests_per_minute # segundos entre requests
resultados = []
for i, ejemplo in enumerate(golden_set):
if i > 0:
time.sleep(delay)
output, metadata = run_prompt(prompt_template, ejemplo["input"])
resultados.append({
"id": ejemplo["id"],
"output": output,
"tokens": metadata["total_tokens"]
})
return resultados
Ejercicios
Ejercicio 1: Escribir tu primer regression test
Escribe un test que verifique que el siguiente prompt de clasificación mantiene accuracy >= 88%:
PROMPT_CLASIFICADOR = """Clasifica el siguiente texto como POSITIVO, NEGATIVO, o NEUTRO.
Responde solo con la categoría.
Texto: {input}
Categoría:"""
Ver solución
import pytest
import json
from openai import OpenAI
client = OpenAI()
PROMPT_CLASIFICADOR = """Clasifica el siguiente texto como POSITIVO, NEGATIVO, o NEUTRO.
Responde solo con la categoría.
Texto: {input}
Categoría:"""
@pytest.fixture
def golden_set_sentimiento():
return [
{"id": "001", "input": "Excelente producto, muy recomendado", "expected_output": "POSITIVO"},
{"id": "002", "input": "Terrible, jamás compraría de nuevo", "expected_output": "NEGATIVO"},
{"id": "003", "input": "El paquete llegó a tiempo", "expected_output": "NEUTRO"},
{"id": "004", "input": "Increíble calidad, superó mis expectativas", "expected_output": "POSITIVO"},
{"id": "005", "input": "Muy mala experiencia de compra", "expected_output": "NEGATIVO"},
{"id": "006", "input": "El producto viene en caja azul", "expected_output": "NEUTRO"},
{"id": "007", "input": "¡Me encanta!", "expected_output": "POSITIVO"},
{"id": "008", "input": "Fraude total, no sirve para nada", "expected_output": "NEGATIVO"},
]
def test_accuracy_minima(golden_set_sentimiento):
"""Accuracy no puede bajar del 88%."""
correctos = 0
for ejemplo in golden_set_sentimiento:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_CLASIFICADOR.format(input=ejemplo["input"])}],
temperature=0
)
output = response.choices[0].message.content.strip().upper()
if output == ejemplo["expected_output"].upper():
correctos += 1
accuracy = correctos / len(golden_set_sentimiento)
assert accuracy >= 0.88, f"Accuracy {accuracy:.2%} por debajo del mínimo"
print(f"✅ Accuracy: {accuracy:.2%}")
def test_formato_valido(golden_set_sentimiento):
"""El output debe ser siempre POSITIVO, NEGATIVO, o NEUTRO."""
categorias_validas = {"POSITIVO", "NEGATIVO", "NEUTRO"}
invalidos = []
for ejemplo in golden_set_sentimiento[:5]: # Solo 5 para rapidez
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": PROMPT_CLASIFICADOR.format(input=ejemplo["input"])}],
temperature=0
)
output = response.choices[0].message.content.strip().upper()
if output not in categorias_validas:
invalidos.append({"id": ejemplo["id"], "output": output})
assert len(invalidos) == 0, f"Outputs con formato inválido: {invalidos}"
Ejercicio 2: Implementar baseline comparison
Crea una función que compare métricas actuales contra un baseline y retorne si hay regresión:
Ver solución
def detectar_regresion(
metricas_nuevas: dict[str, float],
metricas_baseline: dict[str, float],
tolerancia: float = 0.02
) -> dict:
"""
Detecta regresiones comparando métricas actuales contra baseline.
Retorna: {"hay_regresion": bool, "regressions": list, "mejoras": list}
"""
regressions = []
mejoras = []
for metrica, valor_nuevo in metricas_nuevas.items():
if metrica not in metricas_baseline:
continue
valor_anterior = metricas_baseline[metrica]
delta = valor_nuevo - valor_anterior
if delta < -tolerancia:
regressions.append({
"metrica": metrica,
"anterior": f"{valor_anterior:.2%}",
"nuevo": f"{valor_nuevo:.2%}",
"delta": f"{delta:+.2%}",
"severidad": "CRITICA" if delta < -0.05 else "MENOR"
})
elif delta > tolerancia:
mejoras.append({
"metrica": metrica,
"delta": f"{delta:+.2%}"
})
return {
"hay_regresion": len(regressions) > 0,
"regressions": regressions,
"mejoras": mejoras,
"resumen": f"{'❌ REGRESIÓN' if regressions else '✅ OK'}: {len(regressions)} regressions, {len(mejoras)} mejoras"
}
# Test
baseline = {"accuracy": 0.94, "format": 1.0, "faithfulness": 0.88}
nuevas = {"accuracy": 0.91, "format": 1.0, "faithfulness": 0.91}
resultado = detectar_regresion(nuevas, baseline)
print(resultado["resumen"])
# ❌ REGRESIÓN: 1 regressions, 1 mejoras
Resumen
- Regression testing: Detectar cuando un cambio de prompt rompe casos que antes funcionaban
- Baseline: Guardar métricas del prompt actual; comparar después de cada cambio
- pytest: Framework natural para organizar tests de prompts; con fixtures y parametrize
- CI/CD: GitHub Actions ejecuta tests en cada PR o push
- Tolerancia: 2-3% de margen para variación natural del modelo
- Tiers: Smoke (barato, rápido) → Standard → Full (caro, completo)
- Caché: Reutilizar resultados de tests cuando el prompt no cambió
- Paralelización: Async para acelerar la ejecución del golden set
Recursos adicionales
- pytest Documentation — Framework de testing
- GitHub Actions — CI/CD
- pytest-xdist — Paralelización de tests
- OpenAI Rate Limits — Límites de API para planificar tests
- LangSmith Regression Testing — Alternativa managed