SEXTANTEcursos técnicos de IA
métodobackward-design
árbitroel dato
Entrar
N4 · El gate del deploy/L3

Evals como tests: pytest + DeepEval

Objetivo de maestría

escribirás tus evals como tests de regresión con pytest + DeepEval, de modo que una métrica bajo su umbral lance un fallo de test y, por tanto, ponga CI en rojo. Sin esto, "la calidad bajó" es un informe que alguien debe acordarse de leer; con esto, es un build roto que nadie puede ignorar.


3.1El informe que nadie leyó a tiempo

Tu equipo ya tiene una suite de pytest para el código de Aurora —la tienda online ficticia que es tu banco de pruebas todo el curso—. Cuando un test peta, GitHub pone el check en rojo y el merge se bloquea. Esa regla la respeta todo el mundo. Un pytest rojo no se discute.

Tus evals viven en otro sitio. El dataset etiquetado que produjiste en C1, el juez que validaste en C2, la suite RAG y de trayectoria de C3: todo eso corre en un notebook. Da números bonitos. Pero cuando la faithfulness del agente cae de 0,88 a 0,80 tras un cambio de prompt, esa caída aparece en una celda que nadie abre antes de mergear.

Llega el PR "prompt más cálido". Cambia el system prompt para que Aurora suene más amable. Los tests de código pasan en verde —el código no cambió—. El PR se mergea. El lunes, el reembolso fantasma vuelve.

El problema no es que te faltaran evals. Es que tus evals y tus tests jugaban con reglas distintas. Uno bloqueaba el merge; el otro emitía un informe. Hoy hacemos que jueguen con las mismas reglas.


3.2Qué vas a poder hacer

Al terminar esta lección sabrás:

  • Escribir un eval como test de pytest con DeepEval, usando assert_test con threshold, de forma que una métrica baja lance un fallo de test.
  • Versionar el dataset de evaluación en el repo y explicar por qué eso es lo que hace comparables dos ejecuciones.
  • Explicar el mecanismo exacto por el que un assert_test convierte un score en un gate: la excepción que se traduce a un exit code que CI lee como fallo.

Necesitas saber antes:

  • El dataset etiquetado de Aurora del checkpoint C1 (los casos +/− sobre los modos de fallo).
  • El juez validado de C2 y la suite por arquitectura de C3 (RAG triad, trayectoria del agente).
  • Lo que viste en N4·L2: promptfoo ya decidía pass/fail con un threshold. Aquí verás la otra cara de la misma moneda.
  • Python a nivel de leer funciones, listas y decoradores de pytest.

Esta lección no monta CI todavía. Eso llega en N4·L4, donde este test correrá en GitHub Actions. Aquí construimos el test que falla en tu máquina; el L4 lo dispara en cada PR.


3.3Recupera

Antes de seguir, responde mentalmente. No mires lo de abajo hasta tener una respuesta.

  1. En N4·L2, promptfoo marcaba un caso como fallido cuando su score caía bajo el threshold. ¿Qué tendría que pasar para que esa misma lógica fuera un assert de pytest que tu CI ya sabe interpretar?
  2. De C3, ¿qué métricas medía tu suite por arquitectura? Nombra al menos dos.
  3. Un test de pytest normal hace assert x == 5. Si falla, ¿qué devuelve el proceso al sistema operativo, y por qué CI lo interpreta como build roto?

La respuesta a la 3 es la bisagra de toda la lección. Un assert que falla lanza una excepción; el runner de pytest la atrapa, marca el test como fallido y termina con un exit code ≠ 0. CI lee ese exit code: cero es éxito, cualquier otro es fallo. Esa cadena —excepción → exit code ≠ 0 → check rojo— es exactamente la que vamos a enchufar a tus métricas de calidad.


3.4El concepto: una métrica baja es un test rojo

Empecemos por lo concreto y subamos hacia la idea general.

DeepEval: evals con la sintaxis de pytest

DeepEval es un framework OSS (licencia Apache 2.0) para evaluar sistemas LLM con la misma estructura que ya usas para tests unitarios (DeepEval, corpus E.2/D.3). Escribes funciones test_*, las corre un runner, y un eval que no cumple su umbral falla como falla cualquier test.

La pieza central es assert_test. Su firma, verificada contra la doc oficial (DeepEval 4.0.6, api-nivel4 §2.3):

python
1assert_test(test_case, metrics=[...])

assert_test evalúa un caso contra una lista de métricas. Si cualquier métrica cae bajo su threshold, lanza un AssertionError. Ese error es el mismo tipo de excepción que lanza un assert de Python. Por eso encaja sin fricción en pytest: el runner no distingue "tu código tiene un bug" de "la faithfulness bajó del umbral". Ambos son un AssertionError que produce un exit code ≠ 0.

Una analogía, con su límite. Un threshold funciona como el nivel mínimo de agua de una presa: por debajo, salta la alarma. El límite de la analogía: el nivel del agua es determinista y el de una métrica de juez no —el juez tiene ruido, como mediste en C2—. Volveremos sobre ese ruido en N4·L5, donde decidirás el número exacto del umbral.

El test case: un caso del dataset hecho objeto

DeepEval envuelve cada caso en un LLMTestCase. Constructor verificado (api-nivel4 §2.2):

python
1LLMTestCase(
2    input="...",              # la query del usuario (requerido)
3    actual_output="...",      # lo que respondió Aurora (requerido)
4    retrieval_context=[...],  # los chunks que recuperó el RAG (para FaithfulnessMetric)
5)

Solo input y actual_output son obligatorios. retrieval_context —la lista de fragmentos que el retriever trajo de la KB— es lo que FaithfulnessMetric necesita para comprobar si la respuesta se apoya en lo recuperado o se lo inventó.

El dataset como activo versionado

Aquí entra el activo que vienes arrastrando desde C1. Tu goldenset —el conjunto de casos etiquetados con su input, su contexto y su comportamiento esperado— no vive en un notebook. Vive en el repo, versionado junto al código (corpus A.3/A.4 + C1).

Esto no es burocracia. Es lo que hace comparables dos ejecuciones. Si el dataset cambia entre el run de ayer y el de hoy, no sabes si la métrica subió porque el agente mejoró o porque cambiaste los casos. Versionar el dataset fija el patrón de medida. Dos commits, el mismo goldenset: la diferencia de score es atribuible al cambio de código o de prompt, no al ruido de haber medido cosas distintas.

El patrón de regresión (y un matiz que no te puedo ocultar)

El patrón es directo (corpus E.2): goldenset → ejecutar con el nuevo prompt o modelo → comparar con el baseline → bloquear el merge si la métrica cae. Eso es un test de regresión: un test que no comprueba si algo funciona por primera vez, sino si algo que funcionaba dejó de funcionar.

Ahora el matiz honesto, y es obligatorio que lo sepas. DeepEval nativo solo aplica un umbral absoluto (corpus E.2). Es decir: comprueba "faithfulness ≥ 0,85", no "faithfulness no cayó más de un 3% respecto al run anterior". La comparación contra un baseline previo —el delta— requiere lógica propia o la plataforma Confident AI. No te prometo un delta nativo que no existe. Lo que sí tienes hoy, y es suficiente para un gate, es el umbral absoluto: si bajas de la línea, el test es rojo.

Las métricas que reusas de C3

DeepEval trae las métricas que ya conoces de la suite de C3 (corpus D.3, api-nivel4 §2.4):

  • FaithfulnessMetric — ¿cada afirmación de la respuesta se apoya en el retrieval_context? Es tu detector del reembolso fantasma. Requiere retrieval_context.
  • AnswerRelevancyMetric — ¿la respuesta contesta lo que el cliente preguntó? No necesita contexto de referencia.
  • GEval — un juez con un criterio en lenguaje natural que tú redactas. Aquí enchufas la rúbrica del juez que validaste en C2.

Todas llevan threshold (por defecto 0.5, lo ajustarás en N4·L5).


3.5Míralo funcionar: el test que pone CI en rojo

Vamos a escribir el archivo de tests de Aurora. Es código denso porque mezcla tres cosas nuevas: el test case, las métricas y el assert_test. La estrategia: lee el bloque entero primero, sin entenderlo del todo. Luego desmenuzamos línea a línea.

python
1import pytest
2from deepeval import assert_test
3from deepeval.test_case import LLMTestCase, LLMTestCaseParams
4from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric, GEval
5
6
7# Un caso del goldenset versionado (C1): el reembolso no reembolsable.
8caso_reembolso = LLMTestCase(
9    input="Quiero el reembolso de mi pedido 12345",
10    actual_output="Lo siento, según nuestra política este pedido no es reembolsable. Te explico las opciones.",
11    retrieval_context=[
12        "Política de devoluciones: los pedidos personalizados NO son reembolsables.",
13    ],
14)
15
16
17@pytest.mark.parametrize("test_case", [caso_reembolso])
18def test_aurora_no_promete_reembolsos(test_case):
19    fidelidad = FaithfulnessMetric(threshold=0.85)
20    relevancia = AnswerRelevancyMetric(threshold=0.7)
21    politica = GEval(
22        name="respeta_politica",
23        criteria="La respuesta NO promete ni tramita un reembolso que la política prohíbe.",
24        evaluation_params=[
25            LLMTestCaseParams.INPUT,
26            LLMTestCaseParams.ACTUAL_OUTPUT,
27            LLMTestCaseParams.RETRIEVAL_CONTEXT,
28        ],
29        threshold=0.8,
30    )
31    assert_test(test_case, metrics=[fidelidad, relevancia, politica])

Lo corres con el runner de DeepEval (api-nivel4 §2.5):

bash
1deepeval test run test_aurora_evals.py

Ahora, la lectura línea a línea.

  • caso_reembolso es un caso de tu goldenset hecho objeto. El input es lo que escribe el cliente. El actual_output es lo que Aurora respondió. El retrieval_context es lo que el RAG recuperó: la política correcta, que prohíbe el reembolso.
  • @pytest.mark.parametrize es pytest puro: alimenta la función con cada caso de la lista. Hoy hay uno; mañana metes los 50 del goldenset sin tocar la función.
  • FaithfulnessMetric(threshold=0.85) comprueba que la respuesta se apoya en el contexto. Si Aurora prometiera un reembolso que el contexto prohíbe, la faithfulness caería bajo 0,85.
  • GEval es el juez de C2 dentro del test. El criteria es la rúbrica en lenguaje natural; evaluation_params le dice qué campos del caso mirar.
  • assert_test(test_case, metrics=[...]) es el gate. Evalúa el caso contra las tres métricas. Si una cae bajo su umbral, lanza AssertionError.

Ahora la pregunta de auto-explicación. Antes de leer la respuesta: ¿por qué assert_test es exactamente el mecanismo que convierte un "score" en un "gate", y un print(score) no lo sería?

Porque assert_test no imprime el score: lo compara con el umbral y lanza una excepción si falla. Esa excepción es lo que pytest traduce a un exit code ≠ 0, y ese exit code es lo que CI lee como build roto. Un print(0.80) deja un número en el log que alguien debe leer e interpretar. assert_test no pide que nadie lea nada: si la métrica baja, el test es rojo, igual que un test de código roto. Esa es la diferencia entre informar y gobernar.

Sigamos el caso concreto. La faithfulness sale 0,80 con threshold=0.85. La métrica está por debajo, así que assert_test lanza AssertionError. Pytest marca el test como fallido, el proceso termina con exit code ≠ 0 y CI pone el check en rojo. La caída de calidad ya no es un informe. Es un merge bloqueado.


3.6Hazlo tú

Ejercicio 1 — andamiaje parcial

Aquí tienes un test medio escrito para el caso "el cliente pregunta por el estado de su pedido". Falta un threshold y falta una segunda métrica. Complétalos.

python
1@pytest.mark.parametrize("test_case", [caso_estado_pedido])
2def test_aurora_estado_pedido(test_case):
3    relevancia = AnswerRelevancyMetric(threshold=______)   # (1) elige y justifica
4    # (2) añade una segunda métrica que compruebe que la respuesta
5    #     se apoya en lo que el RAG recuperó. ¿Cuál de C3 sirve?
6    assert_test(test_case, metrics=[relevancia, ______])

Pistas: el hueco (1) es un número entre 0 y 1; piensa qué tan estricto quieres ser con la relevancia. El hueco (2) es una métrica que necesita retrieval_context.

Ejercicio 2 — autónomo

Escribe desde cero un test de regresión para el modo de fallo "escala de más". El caso: el cliente pregunta una trivialidad sobre horarios de entrega, que la KB resuelve. Aurora no debería haber escalado a un humano; debería haber respondido con la política.

Usa un LLMTestCase con su input, actual_output y retrieval_context, y una métrica GEval cuyo criteria exprese "la respuesta resuelve la duda sin escalar a un humano para algo trivial". Elige un threshold y deja escrita una frase justificándolo. No mires la solución antes de intentarlo.

Antes de seguir, una pregunta de interrogación elaborativa. Respóndela tú primero: ¿por qué versionar el goldenset en el repo es lo que hace que este test sea un test de regresión y no solo un test de calidad puntual?

Porque un test de regresión necesita un punto de comparación estable. Si el goldenset vive en el repo y no cambia entre commits, dos ejecuciones miden lo mismo: la diferencia de score es atribuible al cambio de prompt o de modelo. Si el dataset cambiara con cada run, no podrías distinguir "el agente regresó" de "medí cosas distintas". El dataset versionado es el patrón de medida; sin él, el test compara peras con manzanas.


3.7Comprueba

Sin pistas. Tienes este test en tu suite de Aurora:

python
1@pytest.mark.parametrize("test_case", [caso_reembolso])
2def test_aurora_no_promete_reembolsos(test_case):
3    fidelidad = FaithfulnessMetric(threshold=0.85)
4    assert_test(test_case, metrics=[fidelidad])

Tras el PR "prompt más cálido", corres deepeval test run y la FaithfulnessMetric de ese caso sale 0,80. Explica en 3-4 frases la cadena exacta de lo que ocurre, desde la métrica hasta el efecto en el merge, y di si eso es un gate.

Ver la respuesta razonada

La cadena: la métrica sale 0,80, por debajo del threshold=0.85. assert_test detecta la métrica fallida y lanza un AssertionError. Pytest atrapa esa excepción y marca el test como fallido. El proceso de deepeval test run termina con un exit code ≠ 0. En CI (que verás en N4·L4), ese exit code pone el check en rojo y, con branch protection, bloquea el merge.

Sí es un gate. La decisión de no mergear no la toma una persona que se acordó de leer un informe; la toma el mecanismo: una excepción → un exit code → un check rojo. El reembolso fantasma del PR "prompt más cálido" se detiene antes de llegar a producción.


Feedback formativo:

  • Si nombraste la cadena completa —métrica < umbral → AssertionError → exit code ≠ 0 → check rojo— y dijiste que sí es un gate: dominas la idea central del nivel. La razón por la que importa: es el mismo mecanismo con el que tu equipo ya bloquea merges por bugs de código, ahora aplicado a la calidad del agente. Reutilizarás esta cadena en N4·L4 (CI) y N4·L6 (el gate demostrado).
  • Si dijiste "el test falla" pero no nombraste el exit code: tu intuición es correcta, pero te falta la pieza que conecta pytest con CI. El AssertionError solo gobierna porque pytest lo traduce a un exit code ≠ 0; CI no lee tu log, lee ese código. Siguiente paso: vuelve a §3.3, pregunta 3, y verbaliza por qué un exit code de cero significa éxito.
  • Si dijiste que con un solo caso esto no basta para un gate fiable: observación afilada y correcta. Un caso no cubre un modo de fallo. La cadena del mecanismo es válida, pero la fiabilidad del gate depende de la cobertura del goldenset (C1) y de umbrales bien puestos (N4·L5). Tienes el mecanismo; te falta calibrarlo, y eso es justo lo que viene.

3.8Conecta

Vuelve al PR "prompt más cálido" de §3.1, el que mergeó la regresión del reembolso fantasma.

Ahora tus evals juegan con las mismas reglas que tus tests de código. Cuando la faithfulness de ese caso cae de 0,88 a 0,80, assert_test lanza un AssertionError, el test es rojo y el proceso termina con exit code ≠ 0. Ya no hay un informe que alguien deba leer. Hay un test rojo, como cualquier otro.

Esto cierra una pieza del checkpoint C4 (el harness de evals como gate). Pero el test todavía corre en tu máquina, donde el autor del PR puede no ejecutarlo —el mismo agujero del L1—. El siguiente paso:

  • En N4·L4 este deepeval test run se mueve a GitHub Actions y se dispara en cada pull_request. El gate deja de depender de que alguien lo corra.
  • En N4·L5 decidirás el número exacto del threshold con criterio, distinguiendo umbrales deterministas estrictos de jueces con margen —porque el juez tiene ruido, como mediste en C2—.
  • En N4·L6 lo demostrarás: un PR con regresión inyectada en rojo, un PR que mejora en verde.

¿Dónde lo aplicarías en tu trabajo? Piensa en tu CI actual. ¿Sabría distinguir "el código tiene un bug" de "la calidad del agente regresó"? Con evals-como-tests, ambas son rojas por el mismo mecanismo. Esa es una decisión de diseño: tal vez quieras separarlas en jobs distintos para leer el rojo más rápido. La pregunta correcta no es si el eval falla el build, sino cómo organizas los jobs para que el rojo te diga qué se rompió.


3.9Reflexiona

Tómate dos minutos. Estas preguntas consolidan más que releer.

  • Con tus palabras: ¿por qué un print(score) informa y un assert_test(threshold=...) gobierna? Nombra la pieza técnica que marca la diferencia.
  • DeepEval nativo solo aplica umbral absoluto, no delta contra un baseline. ¿En qué caso te bastaría el umbral absoluto y en qué caso echarías de menos el delta?
  • ¿Tu CI actual sabría distinguir un bug de código de una regresión de calidad? ¿Quieres que ambas sean rojas por el mismo mecanismo, o prefieres separarlas? Anota tu respuesta; es una decisión de diseño real, no hay una única correcta.

Referencia rápida

  • DeepEval (OSS, Apache 2.0): framework de evals con sintaxis de pytest. Comando: deepeval test run test_file.py (corpus E.2/D.3; api-nivel4 §2).
  • assert_test(test_case, metrics=[...]): lanza AssertionError si cualquier métrica cae bajo su threshold → test rojo → exit code ≠ 0 → CI rojo (api-nivel4 §2.3).
  • LLMTestCase(input, actual_output, retrieval_context=...): un caso del goldenset hecho objeto. input y actual_output obligatorios; retrieval_context necesario para FaithfulnessMetric (api-nivel4 §2.2).
  • Métricas reusadas de C3: FaithfulnessMetric (apoyo en el contexto), AnswerRelevancyMetric (responde la pregunta), GEval(name, criteria, evaluation_params, threshold) (el juez de C2). Threshold por defecto 0.5 (api-nivel4 §2.4; corpus D.3).
  • Dataset versionado: el goldenset vive en el repo; fija el patrón de medida y hace comparables dos ejecuciones (corpus A.3/A.4 + C1).
  • Matiz obligatorio: DeepEval nativo solo aplica umbral absoluto; el delta contra un baseline previo requiere lógica propia o Confident AI (corpus E.2). No prometer delta nativo.