Evals — Módulo 10: montar el stack de evals de verdad
Décimo módulo del Curso de Evals: dejamos la teoría y montamos la suite completa de un agente con dos tools, dos veces — con promptfoo (YAML declarativo, asserts, comparación de modelos lado a lado) y con DeepEval (estilo pytest). Archivos de configuración reales, salida real, y el criterio honesto para elegir entre runner propio, framework y plataforma.
☰ Índice del contenido
- La tesis del módulo
- El caso: un agente de soporte con dos tools
- Montaje 1 — promptfoo
- El archivo de configuración
- Correr y leer la salida
- Montaje 2 — DeepEval
- promptfoo o DeepEval: cuál para qué
- La pregunta honesta: ¿necesitas un framework?
- El montaje final: la suite en CI
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 9 cerramos la última familia de cosas que hay que medir. Llevas nueve módulos escribiendo runners a mano — deterministas, jueces, trayectorias, tool calls, suites adversariales — y eso fue completamente a propósito: ahora sabes exactamente qué hace un framework de evals por debajo, y por lo tanto puedes juzgarlo en vez de adoptarlo por moda. Este módulo es el montaje. Tomamos un caso concreto, le armamos la suite completa dos veces con dos herramientas distintas, y después respondemos la pregunta que nadie responde con honestidad: ¿de verdad necesitas un framework?
Antes de empezar, una aclaración de alcance. La tabla comparativa del ecosistema — RAGAS, DeepEval, TruLens, LangSmith, Phoenix, Braintrust, con su foco y su integración — ya está escrita en evaluación de RAG y no la vamos a repetir. Acá no hay tabla de features: hay archivos que corren.
La tesis del módulo
El framework no es la decisión importante; el golden dataset lo es. Un framework se cambia en dos días, y todos hacen aproximadamente lo mismo: correr casos, aplicar asserts, reportar. Lo que no se cambia en dos días es el dataset y la disciplina de mirarlo. Elige la herramienta más barata de adoptar que resuelva tu problema de hoy, y guarda el esfuerzo para lo que sí es tuyo.
El caso: un agente de soporte con dos tools
Para que la comparación sea justa, las dos herramientas evalúan exactamente el mismo sistema. Es el agente más común del mundo real: soporte de una tienda, con dos tools.
EL SISTEMA BAJO EVALUACIÓN
usuario ──► ┌──────────────────────────────┐
│ AGENTE │
│ system prompt + 2 tools │
└───┬──────────────────────┬───┘
│ │
buscar_ │ │ estado_
producto │ │ pedido
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ catálogo (SKU, │ │ pedidos (id, │
│ precio, stock) │ │ estado, fecha) │
└─────────┬─────────┘ └─────────┬─────────┘
│ │
└──────────┬───────────┘
▼
┌────────────────────┐
│ respuesta al │
│ usuario │
└────────────────────┘
Tres cosas a evaluar, y ninguna se mide igual:
1. ¿eligió la tool correcta? ──► determinista
2. ¿los argumentos son correctos? ──► determinista
3. ¿la respuesta sirve? ──► juez
Los tres casos de prueba que vamos a usar en las dos herramientas:
| Caso | Entrada | Qué debe pasar |
|---|---|---|
| A | ”¿Tienen la zapatilla Runner X en talla 42?” | Llama buscar_producto, responde con stock |
| B | ”¿Dónde va mi pedido 88421?” | Llama estado_pedido con pedido_id=88421 |
| C | ”¿Cuál es el precio del dólar hoy?” | No llama ninguna tool; declina fuera de alcance |
El caso C es el más importante de los tres y el que casi nadie escribe: mide que el agente no haga cosas. Un agente que llama tools para todo es tan malo como uno que no las llama nunca, y solo se detecta con casos negativos.
Montaje 1 — promptfoo
promptfoo es una herramienta de evals con configuración declarativa: escribes un YAML con tus prompts, tus proveedores (modelos) y tus casos con sus asserts, corres un comando y obtienes una matriz de resultados. Su gracia principal es que comparar dos modelos o dos prompts sobre el mismo dataset es cambiar una línea del YAML, y el reporte sale lado a lado. Se instala con Node: npm install -g promptfoo.
El archivo de configuración
# promptfooconfig.yaml
description: "Agente de soporte — suite de evals"
# Los proveedores son los modelos a evaluar. Poner dos (o cinco) es
# lo que produce la comparación lado a lado: promptfoo corre TODOS
# los casos contra TODOS los proveedores.
providers:
- id: openai:gpt-4o-mini
config:
temperature: 0
- id: openai:gpt-4o
config:
temperature: 0
# El prompt bajo prueba. Puede ser inline o un archivo externo
# (file://prompts/soporte.txt), que es lo recomendable para versionarlo.
prompts:
- file://prompts/soporte_v3.txt
# defaultTest aplica sus asserts a TODOS los casos. Acá van las
# invariantes: cosas que deben cumplirse siempre, sin excepción.
defaultTest:
assert:
- type: not-contains
value: "system prompt"
- type: latency
threshold: 8000 # milisegundos
- type: llm-rubric
value: "La respuesta está en español y tiene tono profesional."
tests:
- description: "A — consulta de stock, debe usar buscar_producto"
vars:
consulta: "¿Tienen la zapatilla Runner X en talla 42?"
assert:
# javascript permite assertear sobre la estructura, no solo el texto.
# Es la puerta de escape para todo lo que el DSL no cubre.
- type: javascript
value: |
const calls = JSON.parse(context.vars.tool_calls || "[]");
return calls.some(c => c.name === "buscar_producto");
- type: contains-any
value: ["stock", "disponible", "agotado", "unidades"]
- description: "B — estado de pedido con el id correcto"
vars:
consulta: "¿Dónde va mi pedido 88421?"
assert:
- type: javascript
value: |
const calls = JSON.parse(context.vars.tool_calls || "[]");
const c = calls.find(x => x.name === "estado_pedido");
return c ? String(c.args.pedido_id) === "88421"
: { pass: false, reason: "no llamó estado_pedido" };
- type: contains
value: "88421"
- description: "C — fuera de alcance, NO debe llamar tools"
vars:
consulta: "¿Cuál es el precio del dólar hoy?"
assert:
- type: javascript
value: |
return JSON.parse(context.vars.tool_calls || "[]").length === 0;
- type: llm-rubric
value: >
La respuesta indica amablemente que el tema está fuera del
alcance del asistente de soporte de la tienda. NO inventa
un valor del dólar.
Cuatro tipos de assert cubren casi todo lo que necesitas, y conviene tenerlos claros:
| Tipo de assert | Qué hace | Costo |
|---|---|---|
contains / not-contains / contains-any | Verificación literal de subcadenas | Gratis |
equals / regex | Igualdad exacta o patrón | Gratis |
javascript / python | Función propia sobre la salida y el contexto | Gratis |
llm-rubric | Juez con una rúbrica en lenguaje natural | Una llamada al LLM |
latency / cost | Umbrales operativos por caso | Gratis |
La estrategia que rinde: todo lo que se pueda assertear con javascript no se assertea con llm-rubric. El assert determinista es gratis, instantáneo y no tiene varianza. El juez se reserva para lo que de verdad requiere criterio.
Correr y leer la salida
# Corre todos los casos contra todos los proveedores
npx promptfoo eval
# Abre la vista web con la matriz caso x modelo, para inspeccionar
# los fallos uno por uno con el output completo
npx promptfoo view
# Exporta el resultado para procesarlo o guardarlo como artifact
npx promptfoo eval --output resultados.json
La salida de consola es una tabla: filas los casos, columnas los proveedores.
┌──────────────────────────┬────────────────┬────────────────┐
│ description │ gpt-4o-mini │ gpt-4o │
├──────────────────────────┼────────────────┼────────────────┤
│ A — consulta de stock │ [PASS] │ [PASS] │
│ B — estado de pedido │ [PASS] │ [PASS] │
│ C — fuera de alcance │ [FAIL] │ [PASS] │
│ │ llamó tool │ │
│ │ buscar_produc. │ │
└──────────────────────────┴────────────────┴────────────────┘
Successes: 5 Failures: 1 Pass rate: 83.33%
Token usage: 4,120 Est. cost: $0.0143
Esa tabla es la razón para usar promptfoo. La pregunta “¿me alcanza con el modelo barato?” es exactamente la que un equipo discute en reuniones de una hora, y acá se responde con una corrida de treinta segundos: el modelo chico ahorra plata pero llama tools cuando no debe. Ahora la discusión es sobre un dato, no sobre intuiciones.
Ojo: para que los asserts de
javascriptsobretool_callsfuncionen, tu integración tiene que exponer la traza de tools a promptfoo. Con un proveedor personalizado (un archivo JS o Python que promptfoo invoca) devuelves{ output, tokenUsage, ... }y metes la traza donde puedas leerla. Es el mismo requisito del módulo 9: si el harness no captura las tool calls, ninguna herramienta te va a salvar.
Montaje 2 — DeepEval
DeepEval ataca el mismo problema desde el otro extremo: en vez de un DSL declarativo, te da un framework de evaluación con estilo pytest. Los casos son objetos de Python, las métricas son clases con umbral, y la suite corre con deepeval test run o directamente con pytest. Se instala con pip install deepeval.
La unidad básica es el LLMTestCase, que tiene campos con nombres fijos: input, actual_output, expected_output, retrieval_context y tools_called. Las métricas se aplican sobre esos campos.
"""tests/test_agente_soporte.py — suite con DeepEval, estilo pytest.
Cada métrica lleva su propio threshold. assert_test corre las métricas
sobre el caso y falla el test si alguna queda bajo el umbral, así que
la integración con pytest y con CI es la de siempre: exit code != 0.
"""
import pytest
from deepeval import assert_test
from deepeval.test_case import LLMTestCase, ToolCall
from deepeval.metrics import (
AnswerRelevancyMetric,
GEval,
ToolCorrectnessMetric,
)
from deepeval.test_case import LLMTestCaseParams
from mi_agente import responder # devuelve (texto, tool_calls)
# --- Métricas ---------------------------------------------------------
# ToolCorrectness es determinista: compara las tools que se llamaron
# contra las esperadas. No consume tokens, no tiene varianza.
tool_correcta = ToolCorrectnessMetric(threshold=1.0)
# AnswerRelevancy usa un juez. Se le fija el modelo explícitamente:
# NUNCA el mismo modelo que genera (sesgo de auto-preferencia).
relevancia = AnswerRelevancyMetric(threshold=0.8, model="gpt-4o")
# GEval permite escribir una rúbrica propia en lenguaje natural y
# acotar sobre qué campos evalúa. Es el equivalente al llm-rubric
# de promptfoo, pero con criterios enumerados.
sin_invencion = GEval(
name="SinInvencion",
criteria=(
"Penaliza si la respuesta afirma datos concretos (precios, "
"fechas, stock) que no provienen del resultado de una tool. "
"Declinar por falta de información NO se penaliza."
),
evaluation_params=[
LLMTestCaseParams.INPUT,
LLMTestCaseParams.ACTUAL_OUTPUT,
],
threshold=0.7,
model="gpt-4o",
)
# --- Casos ------------------------------------------------------------
def test_caso_a_consulta_de_stock():
entrada = "¿Tienen la zapatilla Runner X en talla 42?"
salida, tools = responder(entrada)
caso = LLMTestCase(
input=entrada,
actual_output=salida,
tools_called=[ToolCall(name=t["nombre"]) for t in tools],
expected_tools=[ToolCall(name="buscar_producto")],
)
assert_test(caso, [tool_correcta, relevancia])
def test_caso_b_estado_de_pedido():
entrada = "¿Dónde va mi pedido 88421?"
salida, tools = responder(entrada)
caso = LLMTestCase(
input=entrada,
actual_output=salida,
tools_called=[ToolCall(name=t["nombre"]) for t in tools],
expected_tools=[ToolCall(name="estado_pedido")],
)
assert_test(caso, [tool_correcta, relevancia])
# El argumento exacto es un assert propio y determinista: la métrica
# de tools verifica CUÁL se llamó, no CON QUÉ. Esto lo cubres tú.
llamada = next(t for t in tools if t["nombre"] == "estado_pedido")
assert str(llamada["args"]["pedido_id"]) == "88421"
def test_caso_c_fuera_de_alcance():
entrada = "¿Cuál es el precio del dólar hoy?"
salida, tools = responder(entrada)
# El caso negativo: la mejor trayectoria es la trayectoria vacía.
assert tools == [], f"llamó tools innecesarias: {tools}"
caso = LLMTestCase(input=entrada, actual_output=salida)
assert_test(caso, [sin_invencion])
# --- Suite completa sobre el golden dataset --------------------------
@pytest.mark.parametrize("caso_json", cargar_golden("evals/golden.jsonl"))
def test_golden_dataset(caso_json):
"""parametrize convierte cada línea del golden dataset en un test
independiente, con su propio PASS/FAIL en el reporte. Es la forma
de que un fallo apunte a UN caso y no a 'la suite'."""
salida, tools = responder(caso_json["input"])
caso = LLMTestCase(
input=caso_json["input"],
actual_output=salida,
expected_output=caso_json.get("respuesta_ideal"),
)
assert_test(caso, [relevancia])
# El runner propio de DeepEval: agrega caché, paralelismo y un reporte
# con el detalle de cada métrica (score, threshold, razón del juez).
deepeval test run tests/test_agente_soporte.py
# -n para correr en paralelo (las llamadas al juez son I/O, escala bien)
deepeval test run tests/test_agente_soporte.py -n 4
# También corre con pytest a secas, si prefieres un solo runner en CI
pytest tests/test_agente_soporte.py -v
Lo que DeepEval te da y un YAML no: lógica arbitraria de Python alrededor del caso. El test_caso_b de arriba lo muestra — la métrica de tools verifica cuál se llamó, y el argumento exacto lo verificas con un assert común. Cuando tu evaluación necesita montar un estado previo, mockear una tool, o comparar contra una base de datos, estás escribiendo Python igual, y un framework que ya es Python te ahorra la fricción.
Cuidado con el modelo juez implícito. Las métricas de DeepEval que usan juez tienen un modelo por defecto. Fíjalo explícitamente con
model=y elígelo de una familia distinta a la que genera, o vas a estar midiendo auto-preferencia. Y cuenta ese costo aparte: una suite de 200 casos con tres métricas de juez son 600 llamadas por corrida.
promptfoo o DeepEval: cuál para qué
Las dos herramientas hacen lo mismo con filosofías opuestas, y eso decide para qué sirve cada una.
| Dimensión | promptfoo | DeepEval |
|---|---|---|
| Formato | YAML declarativo | Python estilo pytest |
| Fortaleza | Comparar N modelos / N prompts lado a lado | Lógica arbitraria alrededor del caso |
| Quién puede editar casos | También perfiles no-Python | Solo quien escribe Python |
| Estado previo, mocks, fixtures | Requiere proveedor personalizado | Natural: es pytest |
| Reporte | Matriz web caso × modelo | Reporte de pytest + detalle por métrica |
| Curva de adopción | Horas | Horas si ya usas pytest |
| Encaja mejor en | Selección de modelo, iteración de prompt | Suite de regresión en CI |
La respuesta en la práctica no es “una u otra”: muchos equipos usan promptfoo para decidir (¿qué modelo? ¿qué versión del prompt?) y DeepEval o pytest para proteger (la suite de regresión que corre en cada PR). Son momentos distintos del ciclo y no compiten.
Mención aparte para Inspect, el framework de evaluaciones del AI Security Institute del Reino Unido (inspect-ai; hasta febrero de 2025 se llamaba AI Safety Institute). Está pensado para evaluaciones formales y reproducibles — modela explícitamente datasets, solvers (cómo se resuelve la tarea) y scorers (cómo se puntúa), y trae un visor propio de trazas. Si lo tuyo es una evaluación que alguien externo debe poder auditar y reproducir — un informe de capacidades, una evaluación de seguridad con metodología publicable — vale la pena mirarlo. Para la suite de regresión de un producto es más ceremonia de la que necesitas.
La pregunta honesta: ¿necesitas un framework?
Acá viene la parte que las páginas de las herramientas no escriben.
¿QUÉ NECESITAS DE VERDAD?
START ──► ┌────────────────────────────┐
│ ¿Cuántos casos y quién los │
│ va a mirar? │
└─────────────┬──────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
< 100 casos 100-500 casos > 500 casos
solo el equipo equipo + CI varios equipos
técnico + negocio
│ │ │
▼ ▼ ▼
┌───────────┐ ┌─────────────┐ ┌─────────────┐
│ RUNNER │ │ FRAMEWORK │ │ PLATAFORMA │
│ PROPIO │ │ promptfoo / │ │ LangSmith / │
│ ~100 líne.│ │ DeepEval │ │ Braintrust │
└─────┬─────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ ▼
0 dependencias 1 dependencia SaaS + datos
0 costo adopción 1-2 días afuera, 1-2 sem.
Cuándo basta un runner propio de cien líneas — que es la mayoría de los casos. Si tu suite son 60 casos, la corren tres personas del mismo equipo, los asserts son deterministas más un juez, y el reporte que necesitas es “pasó/no pasó más los diez peores”: no necesitas nada. El runner del módulo 3 más el juez del módulo 4 ya son eso. Cero dependencias, cero versiones que se rompen, cero API que cambia entre releases, y control total sobre el formato del dataset. Muchos equipos que adoptan un framework en la semana dos terminan peleando con el framework en vez de escribir casos.
Cuándo conviene un framework. Cuando aparece alguna de estas tres necesidades concretas: comparar varios modelos o prompts de forma sistemática (promptfoo lo regala), tener métricas de juez ya implementadas y calibradas en vez de escribirlas (DeepEval, RAGAS), o que la suite se integre con el runner de tests que ya usas. Nota que ninguna de las tres es “porque es lo que se usa”.
Cuándo hace falta una plataforma con interfaz. Cuando el cuello de botella deja de ser técnico y pasa a ser humano: gente de negocio que debe revisar y anotar casos sin tocar git, historial de corridas comparable en el tiempo, o la necesidad de convertir trazas de producción en casos con un par de clics — que es el tema del módulo 11.
| Opción | Costo de adopción | Costo recurrente | Qué te da | Qué te cuesta |
|---|---|---|---|---|
| Runner propio | Horas | Mantenerlo tú | Control total, cero fricción | Sin UI, métricas las escribes tú |
| promptfoo | 1 día | Actualizar el YAML | Comparación N×N, asserts listos | Casos complejos piden proveedor propio |
| DeepEval | 1 día si usas pytest | Seguir su API | Métricas listas, es Python | Otra API que aprender y que cambia |
| RAGAS | Horas | — | Métricas de RAG estándar | Solo RAG |
| LangSmith | 1-2 semanas | SaaS por trazas | Trazas + datasets + UI de anotación | Datos afuera, costo por volumen |
| Braintrust | 1-2 semanas | SaaS | Experimentos y comparación con UI | Datos afuera, SDK propio |
| Phoenix (Arize) | Días | Hostearlo | Observabilidad OTel, self-hosted | Operarlo tú |
| Inspect | Días | — | Evaluaciones formales, reproducibles | Ceremonia alta para un producto |
Ojo con el costo escondido de las plataformas SaaS. El precio de lista no es el costo real. El costo real es que tus prompts, tus trazas y — si no filtras — datos de tus usuarios se van a un tercero, con lo que eso implica de revisión legal, acuerdo de tratamiento de datos y retención. En dominios regulados esa conversación toma más tiempo que montar la herramienta. Empezar por Phoenix self-hosted evita esa negociación mientras aprendes qué necesitas de verdad.
El montaje final: la suite en CI
El eval que corre cuando alguien se acuerda no protege nada — la mecánica del gate con umbral y exit code ya está en evals como tests y el pipeline completo en CI con GitHub Actions. Lo único que agrega este módulo es cómo se conectan las dos herramientas a ese pipeline, y una decisión de diseño.
# .github/workflows/evals.yml (fragmento)
jobs:
evals-rapidos:
# En cada PR: solo lo determinista. Segundos, cero pesos.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Evals deterministas (tools, formato, seguridad)
run: pytest tests/evals -m "not juez" -q
evals-con-juez:
# Solo si el PR toca prompts o el agente: acá se gasta plata.
needs: evals-rapidos
if: contains(github.event.pull_request.labels.*.name, 'prompt')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- name: Suite con juez (DeepEval)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: deepeval test run tests/evals -m juez
- name: Comparación de modelos (promptfoo)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: npx promptfoo eval --output promptfoo.json
# El artifact es lo que permite abrir la matriz de resultados
# desde la corrida, sin volver a gastar tokens.
- uses: actions/upload-artifact@v4
with:
name: resultados-evals
path: |
promptfoo.json
La decisión de diseño está en el if:. Los evals con juez no corren en todos los PRs, porque un cambio en el README no necesita 600 llamadas al modelo. Corren cuando el PR toca lo que puede degradar la calidad — el prompt, el agente, las tools — y de noche sobre todo el dataset. El eval determinista, en cambio, corre siempre: es gratis y atrapa la mayoría de las regresiones estructurales.
Errores comunes
- Adoptar un framework antes de tener un dataset. El orden correcto es dataset primero, runner después, framework cuando duela. Un framework con doce casos de juguete es configuración sin señal.
- Meter todo en
llm-rubricporque es más fácil de escribir. Cada rúbrica es una llamada al LLM con su costo y su varianza. Todo lo que se puede assertear con una función determinista debe assertearse con una función determinista. - No exponer las tool calls al framework. Sin la traza, ninguna herramienta puede evaluar comportamiento — solo texto. Es la limitación del harness, no del framework, y hay que arreglarla antes.
- Dejar el modelo juez por defecto. Si el juez es de la misma familia que el generador, hay sesgo de auto-preferencia documentado. Fíjalo explícitamente y de otra familia.
- Correr la suite completa con juez en cada push. Se vuelve carísima, la gente empieza a saltarse el check y el gate muere de inanición. Determinista siempre, juez con criterio.
- Olvidar los casos negativos. El caso C — “no debe llamar ninguna tool” — es el que detecta al agente que hace cosas de más. Sin casos negativos, un agente hiperactivo saca 100%.
- Usar dos frameworks para lo mismo. Un solo dataset, uno o dos runners con roles claros. Tres herramientas midiendo lo mismo con números distintos es peor que ninguna: nadie sabe cuál creer.
La frase para llevarse
“El framework se cambia en dos días; el golden dataset son semanas de trabajo experto. Gasta el esfuerzo donde no se puede reemplazar.”
Qué viene
Módulo 11: evaluar en producción, con tráfico real. Todo lo que llevamos hecho es offline: un dataset fijo, con respuestas ideales, corriendo antes de publicar. En producción se cae el supuesto central — no hay respuesta ideal — y aparece uno nuevo: el volumen es enorme y no puedes juzgarlo todo. Vamos a ver cómo muestrear el tráfico, qué señales sirven cuando no hay ground truth (y por qué las implícitas son más honestas que el pulgar arriba), cómo montar el circuito producción → muestreo → juicio → dataset que alimenta de vuelta tus gates offline, y qué se puede bloquear en línea versus qué solo se puede revisar después.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.