← Todo el material
Curso Avanzado 23 de agosto de 2026

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

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:

CasoEntradaQué 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 assertQué haceCosto
contains / not-contains / contains-anyVerificación literal de subcadenasGratis
equals / regexIgualdad exacta o patrónGratis
javascript / pythonFunción propia sobre la salida y el contextoGratis
llm-rubricJuez con una rúbrica en lenguaje naturalUna llamada al LLM
latency / costUmbrales operativos por casoGratis

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 javascript sobre tool_calls funcionen, 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ónpromptfooDeepEval
FormatoYAML declarativoPython estilo pytest
FortalezaComparar N modelos / N prompts lado a ladoLógica arbitraria alrededor del caso
Quién puede editar casosTambién perfiles no-PythonSolo quien escribe Python
Estado previo, mocks, fixturesRequiere proveedor personalizadoNatural: es pytest
ReporteMatriz web caso × modeloReporte de pytest + detalle por métrica
Curva de adopciónHorasHoras si ya usas pytest
Encaja mejor enSelección de modelo, iteración de promptSuite 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ónCosto de adopciónCosto recurrenteQué te daQué te cuesta
Runner propioHorasMantenerlo túControl total, cero fricciónSin UI, métricas las escribes tú
promptfoo1 díaActualizar el YAMLComparación N×N, asserts listosCasos complejos piden proveedor propio
DeepEval1 día si usas pytestSeguir su APIMétricas listas, es PythonOtra API que aprender y que cambia
RAGASHoras—Métricas de RAG estándarSolo RAG
LangSmith1-2 semanasSaaS por trazasTrazas + datasets + UI de anotaciónDatos afuera, costo por volumen
Braintrust1-2 semanasSaaSExperimentos y comparación con UIDatos afuera, SDK propio
Phoenix (Arize)DíasHostearloObservabilidad OTel, self-hostedOperarlo tú
InspectDías—Evaluaciones formales, reproduciblesCeremonia 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-rubric porque 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

EvalspromptfooDeepEvalHerramientasCI

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.