Cómo armar un pipeline de evaluación de LLMs en CI/CD
Aprendé a armar un pipeline de evaluación de LLMs en CI/CD con aserciones deterministas, LLM-as-a-judge calibrado y control de costos.
Tocás una palabra en el system prompt para arreglar una alucinación y, sin avisar, se te rompe la llamada a una herramienta en el flujo principal. Si no tenés evaluación automatizada en tu CI/CD, tu sistema no está verificado: estás esperando que un usuario encuentre el bug en producción.
En este post te explico cómo armar un pipeline de evaluación en CI/CD con tres capas, cómo mitigar los sesgos del LLM juez y qué estrategia usar para no fundirte en costos de API.
Tomemos un caso concreto que vamos a seguir en todo el post: un agente de soporte financiero que consulta el estado de un reintegro bancario llamando a una herramienta interna.
La jerarquía de evals: determinismo antes que semántica
El error más común es evaluar todo con otro LLM. Los evaluadores con modelos son lentos, caros y suman varianza.
Mi regla operativa es simple: nunca evalúes con un LLM lo que podés verificar con aserciones de código estándar.
flowchart TD
A["Output del Agente y Trazas"] --> B{"1. Aserciones Deterministas"}
B -->|"Falla de Schema o Tool Call"| C["Fail Inmediato (0 ms / Costo $0)"]
B -->|"Pasa"| D{"2. Verificación de Estado en Sandbox"}
D -->|"Mutación de Estado Incorrecta"| C
D -->|"Pasa"| E{"3. LLM-as-a-Judge Calibrado"}
E -->|"Score < Umbral de Regresión"| F["Flag de Regresión en CI"]
E -->|"Score >= Umbral de Regresión"| G["Pass"]
Un stack ordenado trabaja en tres niveles:
- Aserciones deterministas (Fast-Fail): validás schemas JSON, tipos de retorno, latencia y conteo de tokens. Si el agente emite un payload roto al consultar el reintegro o inventa una función, el test corta en milisegundos con costo cero. Esto se conecta directo con lo que vimos sobre cómo controlar a tus agentes de IA con un sandbox operativo.
- Task Success en Sandbox: acá importa el efecto colateral en el sistema, no el texto. Si la tarea es cancelar una suscripción, revisás una base de datos efímera de pruebas y chequeás que
subscription.status == 'canceled'. - Evaluación semántica calibrada: la dejás sólo para respuestas abiertas, tono y fidelidad de los hechos. Corre únicamente si los dos niveles anteriores pasaron.
| Capa | Qué valida | Latencia / Costo | Estrategia de Ejecución |
|---|---|---|---|
| Determinista | Schemas, status codes, tool calls, regex | <10ms / $0 | En cada commit / PR |
| Task Success | Mutación de estado (DB mocks, sandboxes) | ~100ms / $0 | Pre-merge en CI |
| Semántica (Juez) | Fidelidad factual, completitud, relevancia | >1.5s / $$ | Muestreo en PR; suite completa en staging |
Implementación real en la evaluación de LLMs: pytest, Structured Outputs y Pass@k
Los LLMs no garantizan determinismo bit a bit ni con temperature=0. La causa técnica es la falta de invarianza al tamaño de batch: el motor de serving junta pedidos dinámicamente y altera el orden de reducción del kernel, lo que choca con la no-asociatividad del punto flotante.
Por eso conviene usar métricas como Pass@k (la probabilidad de que al menos una de k pasadas sea correcta) o correr N=3 repeticiones sobre casos críticos.
Para la parte semántica, tu evaluador jamás debe devolver texto libre porque se vuelve imposible de parsear. Lo mejor es seguir la guía de Structured Outputs de OpenAI y exigir un schema rígido donde el modelo justifique su razonamiento paso a paso antes de dar el puntaje.
import pytest
from pydantic import BaseModel, Field
from openai import OpenAI
client = OpenAI()
class EvaluationJudgement(BaseModel):
claims_extracted: list[str] = Field(description="Hechos individuales presentes en la respuesta")
unsupported_claims: list[str] = Field(description="Afirmaciones no respaldadas por el contexto")
reasoning: str = Field(min_length=20, description="Justificación técnica paso a paso")
faithfulness_score: float = Field(ge=0.0, le=1.0, description="1.0 si no hay alucinaciones, 0.0 si todo es inventado")
def run_judge_eval(input_query: str, context: str, agent_output: str) -> EvaluationJudgement:
"""Invoca un LLM juez forzando Structured Outputs para evitar desvíos de formato."""
prompt = f"""
Sos un juez estricto de CI/CD. Evaluá la fidelidad factual (faithfulness) del output del agente.
Contexto provisto: {context}
Pregunta del usuario: {input_query}
Respuesta del agente: {agent_output}
Regla: Extraé todas las afirmaciones fácticas y marcá cuáles NO tienen sustento en el contexto.
"""
response = client.beta.chat.completions.parse(
model="vendor-frontier-judge", # Usar tier de frontera distinto al agente
messages=[{"role": "user", "content": prompt}],
response_format=EvaluationJudgement,
temperature=0.0,
)
return response.choices[0].message.parsed
@pytest.mark.parametrize("test_case", [
{
"input": "Quiero saber el estado de mi reintegro #441",
"context": "El reintegro #441 fue emitido el 18 de septiembre por $12.500 ARS.",
"expected_tool": "get_refund_status",
"min_faithfulness": 0.90
}
])
def test_agent_pipeline(test_case):
# 1. Ejecución del agente bajo prueba
raw_response = run_agent(test_case["input"])
# 2. Gate Determinista: Tool Calling y Schema
assert raw_response.tool_call.name == test_case["expected_tool"]
assert raw_response.tool_call.arguments.get("refund_id") == 441
# 3. Gate Semántico: Verificación contra el Juez
verdict = run_judge_eval(
input_query=test_case["input"],
context=test_case["context"],
agent_output=raw_response.final_message
)
assert len(verdict.unsupported_claims) == 0, f"Alucinaciones detectadas: {verdict.unsupported_claims}"
assert verdict.faithfulness_score >= test_case["min_faithfulness"], f"Score bajo: {verdict.reasoning}"
Calibración del juez y mitigación de sesgos
Un LLM juez sin calibrar sólo agrega ruido a tu build. Zheng et al. describen tres sesgos típicos en su paper sobre LLM-as-a-Judge:
- Position Bias: en comparaciones de a pares, suele ganar la primera opción. Lo mitigás corriendo el test dos veces invirtiendo el orden (
[A, B]y[B, A]). Si el resultado difiere, descartás el caso. - Verbosity Bias: el evaluador califica mejor las respuestas largas y adornadas. Lo resolvés extrayendo afirmaciones atómicas y calculando el ratio de claims respaldadas sobre claims totales.
- Self-Enhancement Bias: los modelos favorecen textos generados por su misma familia técnica. Lo solucionás usando para el juez un proveedor distinto al del agente evaluado.
Para calibrar el sistema, tomás una muestra validada a mano por tu equipo y medís la concordancia con el coeficiente de correlación de Spearman o Cohen’s Kappa. Si el acuerdo es bajo, tenés que ajustar la rúbrica antes de mandar el eval a CI.
+-------------------------------------------------------------------------+
| PROMPT DEL JUEZ CON RÚBRICA ATÓMICA |
+-------------------------------------------------------------------------+
| Contexto: {context} |
| Output del agente: {output} |
| |
| Tarea paso a paso: |
| 1. Descomponer el output en una lista de proposiciones atómicas (P1..Pn)|
| 2. Para cada Pn, determinar si se infiere lógicamente del contexto. |
| 3. faithfulness_score = count(Pn válidas) / count(Pn totales) |
| |
| REGLA: No juzgues estilo, tono ni cortesía. Limitá el análisis a lógica |
| proposicional estricta. |
+-------------------------------------------------------------------------+
Tradeoffs: estrategia CI/CD y feedback loop online-offline
Correr un dataset de prueba completo duplicando llamadas para evitar el sesgo de posición es carísimo y vuelve lentos tus PRs.
Para cuidar tiempo y plata, te conviene separar las suites:
- En Pull Requests (Branch CI): corrés el 100% de las pruebas deterministas y un subconjunto chico de casos semánticos críticos.
- En Main / Staging (Suite Nocturna): ejecutás el dataset de referencia completo con evaluación estadística (N=3).
flowchart LR
A["Tráfico Productivo"] -->|"Sampleo 5%"| B["Evaluación Asíncrona Online"]
B -->|"Triage de Fallas"| C{"Human-in-the-Loop"}
C -->|"Curación y Ground Truth"| D["Nuevo Caso en Golden Dataset"]
D --> E["Regression Suite (CI Offline)"]
E -->|"Validación de Cambios"| A
Deriva de datos y triage productivo
Las evaluaciones online analizan una muestra asíncrona del tráfico real (por ejemplo, el 5%). Como vimos al revisar distributed tracing con OpenTelemetry en agentes, necesitas trazas claras para detectar anomalías.
La deriva en producción suele verse en tres problemas:
- Esquemas rotos por cambios en respuestas de APIs externas.
- Comportamientos nuevos del modelo por actualizaciones del proveedor.
- Formas de redacción imprevistas por parte de los usuarios.
Si una traza productiva falla por baja fidelidad o cancelación del usuario, entra a una cola de triage. El costo acá es el tiempo del equipo. Necesitás reservar horas semanales para revisar esas fallas, etiquetar la respuesta correcta y sumarlas al dataset del CI para no repetir el error.
Plan de acción técnico
- Gate determinista inmediato: frená el CI si el agente devuelve un JSON que Pydantic no puede parsear o si los argumentos del tool call no cumplen el schema.
- Dataset de referencia mínimo: guardá en el repo un archivo JSON o YAML con casos reales que incluyan input, contexto, llamada esperada y estado final en base de datos.
- Pipeline del juez estructurado: configurá un LLM evaluador de otro proveedor usando Structured Outputs para extraer afirmaciones atómicas y razonar antes de puntuar.
- Calibración inicial: calculá el Cohen’s Kappa entre el juez y tu equipo hasta lograr un acuerdo confiable en las evaluaciones.
- Estratificación del CI: armá una suite corta para PRs con tests deterministas más un muestreo semántico, y mandá la suite completa a una corrida nocturna.
La estabilidad de tus agentes en producción no pasa por el modelo que elijas, sino por el rigor de las pruebas con las que validás sus pasos antes de llegar a tus usuarios.