Evals — Módulo 3: el primer eval, sin gastar un token
Tercer módulo del Curso de Evals: los evals deterministas que corren en segundos y no cuestan nada. Validación de esquema, restricciones, exact match con normalización, clasificación con precision/recall/F1, exactitud de enrutamiento, y por qué BLEU y ROUGE no sirven para texto libre. Con un runner de evals completo en 60 líneas de Python.
☰ Índice del contenido
- La tesis del módulo
- Por qué se salta esta capa
- Las seis familias deterministas
- 1. Validación de esquema
- 2. Cumplimiento de restricciones
- 3. Exact match y normalización
- 4. Clasificación: precision, recall y F1
- 5. Exactitud de enrutamiento
- 6. Verificación de efectos
- Qué NO usar, y por qué
- El runner mínimo: sesenta líneas y ninguna magia
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 2 armamos la ficha de métricas con umbrales. Mírala de nuevo y fíjate en algo: de las nueve filas, cuatro se pueden verificar sin llamar a ningún LLM. La derivación correcta se lee del evento del sistema. La tasa de error sale de los logs. El costo y la latencia salen de la traza. Y esa proporción no es casualidad del ejemplo: en la mayoría de los sistemas reales, una parte enorme de las fallas que llegan a producción son deterministas — JSON que no parsea, campo obligatorio faltante, idioma equivocado, respuesta de 900 palabras en un canal que aguanta 40, ruta mal elegida. Nada de eso necesita un juez. Este módulo cubre esa capa, que es la que hay que construir primero porque corre en segundos, cuesta cero y atrapa la mayoría de los bugs.
La tesis del módulo
Lo determinista primero, siempre. Un eval que corre en tres segundos, no cuesta un peso y da el mismo resultado dos veces es infinitamente más útil que uno caro y ruidoso — y cubre mucho más de lo que la gente supone. El juez LLM viene después, para lo que de verdad no se puede verificar con código.
Por qué se salta esta capa
El eval determinista tiene un problema de marketing: es poco glamoroso. Nadie escribe un hilo entusiasta sobre validar un esquema JSON. La consecuencia es que muchos equipos parten directo por el juez LLM, gastan tokens evaluando cosas que un if resolvía, y quedan con un eval lento y caro que corren una vez a la semana en vez de en cada commit.
Las tres ventajas que hacen que esta capa venga primero:
Corre en cada push. Segundos, sin red, sin API key. Puede vivir en el pre-commit y en el CI sin que nadie proteste por el tiempo ni por la factura.
No tiene varianza. El mismo input da exactamente el mismo resultado. Cuando el número cambia, cambió tu sistema — no el clima estadístico. Eso hace que las regresiones se atribuyan de inmediato.
Es auditable. Un F1 se le explica a un comité de riesgo. “El juez le puso 8” es mucho más difícil de defender.
LA PIRÁMIDE DE EVALS
┌───────────┐
│ HUMANO │ caro, lento
│ patrón │ no escala
│ oro │ decenas de casos
└───────────┘
┌───────────────────┐
│ JUEZ LLM │ caro, con ruido
│ calidad, tono, │ cientos de casos
│ fidelidad │
└───────────────────┘
┌─────────────────────────────┐
│ DETERMINISTA │ gratis, exacto
│ esquema, constraints, │ miles de casos
│ F1, routing, exact match │ corre en cada push
└─────────────────────────────┘
Se construye de abajo hacia arriba.
Cada capa filtra lo que la de arriba no
debería tener que revisar.
La lógica de la pirámide es la de siempre en testing: la capa de abajo es barata y ancha, filtra lo grueso, y las capas caras solo ven lo que sobrevivió.
Las seis familias deterministas
1. Validación de esquema
Si tu sistema devuelve estructura — JSON, una llamada a tool, un objeto tipado — la primera pregunta no es si la respuesta es buena, es si es procesable. Un JSON malformado o con un campo faltante rompe el sistema aguas abajo sin importar qué tan brillante era el contenido.
"""Validación de esquema con Pydantic v2.
Pydantic convierte una clase Python en un validador: si la salida del
modelo no calza con los tipos y restricciones declarados, levanta
ValidationError con el detalle de qué campo falló.
"""
import json
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class RespuestaSoporte(BaseModel):
"""Contrato de salida del agente de soporte."""
intencion: Literal["consulta_pedido", "devolucion",
"stock", "otro"]
respuesta: str = Field(min_length=1, max_length=600)
requiere_humano: bool
# Field(default=None) hace el campo opcional sin romper el contrato
numero_pedido: str | None = Field(default=None, pattern=r"^P\d{8}$")
def validar_esquema(salida_cruda: str) -> tuple[bool, str]:
"""Devuelve (pasa, motivo). Cero llamadas a LLM, cero costo."""
try:
datos = json.loads(salida_cruda)
except json.JSONDecodeError as e:
return False, f"json_invalido: {e.msg}"
try:
RespuestaSoporte.model_validate(datos)
except ValidationError as e:
# errors() da la lista de campos que fallaron y por qué
campos = ", ".join(str(err["loc"][0]) for err in e.errors())
return False, f"esquema_invalido: {campos}"
return True, "ok"
# Casos reales de falla que esto atrapa gratis:
assert validar_esquema('{"intencion": "stock"}')[0] is False # faltan campos
assert validar_esquema('```json\n{"a":1}\n```')[0] is False # con fences
assert validar_esquema('{"intencion": "devolucion", "respuesta": "ok",'
' "requiere_humano": false,'
' "numero_pedido": "12345"}')[0] is False # patrón malo
Ese último caso es el que más rinde: un campo que existe, tiene el tipo correcto y el formato equivocado. El sistema no revienta, guarda basura, y el bug aparece tres semanas después en otro lugar.
Ojo: el bug de esquema más frecuente en la práctica no es un campo faltante, es el modelo envolviendo el JSON en un bloque de código con backticks. Vale la pena tener una función de extracción que limpie eso antes de parsear, pero medir la tasa de salidas que necesitaron limpieza es en sí una métrica útil: si sube, tu prompt de formato se degradó.
2. Cumplimiento de restricciones
Las constraints (restricciones) son reglas duras sobre la forma de la salida, independientes de su contenido. Cada una es una función de una línea y juntas cubren una cantidad sorprendente de quejas reales de usuarios.
"""Verificadores de restricciones. Cada uno devuelve bool.
Se combinan por caso: un caso puede exigir varias a la vez.
"""
import re
import unicodedata
def cumple_largo(texto: str, maximo_palabras: int) -> bool:
"""Canal con límite (WhatsApp, voz, SMS): el largo es requisito."""
return len(texto.split()) <= maximo_palabras
def cumple_idioma_es(texto: str) -> bool:
"""Heurística barata y sorprendentemente efectiva: buscar
palabras funcionales del español. Para algo serio, langdetect."""
marcadores = {" el ", " la ", " de ", " que ", " y ", " en "}
t = f" {texto.lower()} "
return sum(m in t for m in marcadores) >= 2
def sin_frases_prohibidas(texto: str, prohibidas: list[str]) -> bool:
"""Los 'nunca' del negocio: promesas, descuentos, consejo médico."""
t = texto.lower()
return not any(p.lower() in t for p in prohibidas)
def contiene_todas(texto: str, requeridas: list[str]) -> bool:
"""Elementos obligatorios: el disclaimer, el número de ticket."""
t = texto.lower()
return all(r.lower() in t for r in requeridas)
def es_json_puro(texto: str) -> bool:
"""Sin prosa ni fences alrededor del JSON."""
return texto.strip().startswith(("{", "["))
def sin_pii_evidente(texto: str) -> bool:
"""Detección gruesa de datos personales que no deben salir."""
patrones = [
r"\b[\w.+-]+@[\w-]+\.[\w.]+\b", # email
r"\b\d{1,2}\.\d{3}\.\d{3}-[\dkK]\b", # RUT chileno
]
return not any(re.search(p, texto) for p in patrones)
Una regla de negocio como “nunca prometer una fecha de despacho” se implementa como una lista de frases prohibidas y se verifica sobre los 40 casos trampa del dataset. Cuesta cero y es exactamente el guardrail #3 de la ficha del módulo anterior — el que era bloqueante.
3. Exact match y normalización
Exact match es comparar la salida con la respuesta esperada carácter por carácter. Suena inútil para lenguaje y no lo es: sirve perfecto cuando la respuesta correcta es un valor cerrado — una etiqueta, un código, un número, un SKU, una fecha, un sí/no.
El truco está en la normalización: la comparación no se hace sobre el texto crudo sino sobre una forma canónica.
"""Exact match con normalización.
Sin normalizar, 'Santiago.' != 'santiago' y el eval reporta un fallo
que no existe. Normalizar es lo que hace útil al exact match.
"""
import re
import unicodedata
ARTICULOS = {"el", "la", "los", "las", "un", "una", "de", "del"}
def normalizar(texto: str, quitar_articulos: bool = False) -> str:
# 1. minúsculas
t = texto.lower().strip()
# 2. quitar tildes: descompone y bota los diacríticos
t = unicodedata.normalize("NFD", t)
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
# 3. quitar puntuación
t = re.sub(r"[^\w\s]", " ", t)
# 4. colapsar espacios
t = re.sub(r"\s+", " ", t).strip()
if quitar_articulos:
t = " ".join(p for p in t.split() if p not in ARTICULOS)
return t
def exact_match(obtenido: str, esperado: str) -> bool:
return normalizar(obtenido) == normalizar(esperado)
def contains_match(obtenido: str, esperado: str) -> bool:
"""Más laxo: la respuesta correcta aparece dentro de una frase.
Útil cuando el modelo contesta '¡Claro! Son 30 días.'"""
return normalizar(esperado) in normalizar(obtenido)
assert exact_match(" Santiago. ", "santiago") is True
assert exact_match("Devolución", "devolucion") is True
assert contains_match("El plazo es de 30 días hábiles", "30 días") is True
Cuidado: el exact match es tentador porque es gratis, y su límite es duro. Sirve para valores cerrados. En cuanto la respuesta correcta admite redacción libre, el exact match mide tu capacidad de adivinar cómo va a redactar el modelo, no la calidad de la respuesta. Si te encuentras agregando la décima variante aceptada a un caso, esa métrica está mal elegida: ese caso es para el juez.
4. Clasificación: precision, recall y F1
Cuando la salida es una etiqueta de un conjunto cerrado — intención, categoría, sentimiento, nivel de urgencia — el eval es clasificación clásica y tiene un siglo de teoría detrás.
Los tres conteos base, para una clase C:
- TP (verdaderos positivos): casos de clase C que predijiste como C.
- FP (falsos positivos): casos de otra clase que predijiste como C. Falsas alarmas.
- FN (falsos negativos): casos de clase C que predijiste como otra cosa. Omisiones.
Y las métricas:
Precision = TP / (TP + FP) — de todo lo que marqué como C, ¿cuánto era realmente C?
Recall = TP / (TP + FN) — de todo lo que era C, ¿cuánto logré marcar?
F1 = 2 · (precision · recall) / (precision + recall) — la media armónica de las dos.
La media armónica castiga el desbalance: un sistema con precision 1.0 y recall 0.1 tiene un promedio simple de 0.55 y un F1 de 0.18, que es mucho más honesto sobre lo inútil que es.
"""Métricas de clasificación con scikit-learn.
classification_report da precision, recall y F1 POR CLASE, que es lo
que hay que mirar. El promedio global esconde a la clase rara.
"""
from sklearn.metrics import classification_report, confusion_matrix
y_true = ["consulta", "devolucion", "urgencia", "consulta",
"consulta", "urgencia", "devolucion", "consulta"]
y_pred = ["consulta", "devolucion", "consulta", "consulta",
"consulta", "consulta", "devolucion", "consulta"]
print(classification_report(y_true, y_pred, digits=3, zero_division=0))
# El sistema acierta 6 de 8 (75%) y falla el 100% de 'urgencia',
# que es justo la clase que importa.
print(confusion_matrix(y_true, y_pred,
labels=["consulta", "devolucion", "urgencia"]))
# Las filas son la verdad, las columnas la predicción. La celda
# (urgencia, consulta) = 2 dice exactamente cuál es el bug.
La lección está en el comentario: el promedio global miente cuando las clases están desbalanceadas. Un agente que acierta el 90% puede estar fallando el 100% de las urgencias — que son el 4% de los casos y el 100% del riesgo. Mira siempre el recall por clase, y para las clases críticas ponle su propio umbral en la ficha.
5. Exactitud de enrutamiento
En sistemas con router (un clasificador que decide qué rama, qué agente o qué prompt atiende cada mensaje), acertar la ruta merece métrica propia por una razón simple: un error de ruteo invalida todo lo posterior. La mejor respuesta del mundo en la rama equivocada sigue siendo una mala respuesta.
Es determinista y no necesita juez, con una condición: hay que leer la decisión real del sistema, no inferirla del texto final. Eso significa que tu agente tiene que exponer la ruta elegida en su traza o en su estado.
"""Exactitud de enrutamiento. La decisión se LEE del sistema."""
from collections import Counter
def evaluar_routing(casos: list[dict], enrutar) -> dict:
"""Cada caso: {'entrada': str, 'ruta_esperada': str}.
`enrutar(entrada)` devuelve el id de la ruta elegida."""
aciertos = 0
confusiones = Counter()
for c in casos:
obtenida = enrutar(c["entrada"])
if obtenida == c["ruta_esperada"]:
aciertos += 1
else:
confusiones[(c["ruta_esperada"], obtenida)] += 1
return {
"accuracy": round(aciertos / len(casos), 3),
# las confusiones son el diagnóstico: dicen QUÉ par se cruza
"peores": confusiones.most_common(3),
}
Un dataset de ruteo bien hecho tiene, como mínimo: un caso por ruta, varios fraseos por ruta (formal, coloquial, con typos), casos deliberadamente ambiguos, y casos que no calzan con ninguna ruta, que son los que validan el fallback — qué hace el sistema cuando nada corresponde.
6. Verificación de efectos
En agentes con herramientas hay una familia determinista que casi nadie mide y es la más importante de todas: verificar que la acción ocurrió y ocurrió bien. El texto de vuelta puede ser perfecto y la cita haber quedado agendada el martes en vez del jueves.
"""Verificar el EFECTO, no el texto. Se corre contra un backend
de prueba (sandbox) donde se puede inspeccionar el estado final."""
def verificar_efecto(caso: dict, ejecutar, leer_estado) -> tuple[bool, str]:
estado_antes = leer_estado()
ejecutar(caso["entrada"])
estado_despues = leer_estado()
for campo, valor_esperado in caso["efecto_esperado"].items():
if estado_despues.get(campo) != valor_esperado:
return False, (f"{campo}: esperado {valor_esperado!r}, "
f"quedó {estado_despues.get(campo)!r}")
# Guardrail: efectos NO deseados (escribió de más)
tocados = {k for k in estado_despues
if estado_despues[k] != estado_antes.get(k)}
de_mas = tocados - set(caso["efecto_esperado"])
if de_mas:
return False, f"efectos_colaterales: {sorted(de_mas)}"
return True, "ok"
Ese segundo chequeo — los efectos colaterales — es el que atrapa al agente que resuelve la tarea pedida y además modifica algo que nadie le pidió. En el módulo 6 desarrollamos esto en serio.
Qué NO usar, y por qué
Hay dos métricas que aparecen en todos los tutoriales de evaluación de texto y que no debes usar como métrica de calidad de una respuesta libre: BLEU y ROUGE.
BLEU (Bilingual Evaluation Understudy) mide qué proporción de los n-gramas (secuencias de n palabras consecutivas) de la salida aparece en una traducción de referencia. Nació en traducción automática, donde tiene sentido: si traduces la misma frase, la redacción correcta está bastante acotada.
ROUGE (Recall-Oriented Understudy for Gisting Evaluation) hace lo simétrico para resumen automático: qué proporción de los n-gramas de la referencia aparece en el resumen generado.
Por qué fallan en respuestas libres, con un ejemplo:
Referencia: "El plazo de devolución es de 30 días hábiles."
Respuesta A: "Tienes 30 días hábiles para devolverlo."
Respuesta B: "El plazo de devolución es de 90 días hábiles."
Solapamiento de 4-gramas con la referencia:
A ──► 0 de 3 BLEU bajo
B ──► 3 de 6 BLEU alto
A es CORRECTA y puntúa bajo.
B es FALSA y puntúa alto.
Ese es el problema completo: miden superficie léxica, no significado ni veracidad. Una respuesta correcta redactada distinto puntúa mal; una respuesta falsa que calca la redacción puntúa bien. Como métrica de calidad de un chatbot son peor que no medir, porque dan un número con apariencia de rigor.
¿Cuándo sí? En su terreno original — traducción y resumen contra referencias múltiples — y como métrica secundaria cuando la fraseología importa de verdad y hay un formato estable. Nunca como gate.
| Tipo de salida | Método determinista aplicable | Método que NO sirve |
|---|---|---|
| JSON / structured output | Validación de esquema (Pydantic) | Similitud de texto |
| Etiqueta de un set cerrado | Precision / recall / F1 por clase | BLEU, juez LLM (caro y peor) |
| Ruta elegida por el router | Routing accuracy + matriz de confusión | Inferirla del texto final |
| Valor cerrado (código, fecha, monto) | Exact match normalizado | Juez LLM (innecesario) |
| Formato y largo | Constraints (regex, conteo, listas) | Juez LLM (innecesario) |
| Llamada a tool (nombre + args) | Comparación estructurada de argumentos | Buscar el nombre en el texto |
| Efecto en un sistema externo | Verificación de estado en sandbox | Leer la respuesta del agente |
| Presencia de PII | Regex y detectores dedicados | Juez LLM como única defensa |
| Texto libre: tono, utilidad | Ninguno | BLEU, ROUGE, similitud léxica |
| Texto libre: fidelidad al contexto | Ninguno | Exact match |
Las dos últimas filas son la frontera del módulo. Todo lo de arriba se resuelve con código; esas dos necesitan un juez, y ese es el módulo 4.
El runner mínimo: sesenta líneas y ninguna magia
Cerramos construyendo el motor completo. Todos los frameworks de evals que veremos en el módulo 10 hacen esto por debajo, con más features y más configuración, pero la estructura es exactamente esta: cargar casos, ejecutar el sistema, aplicar puntuadores, agregar, comparar con umbrales, reportar.
EL FLUJO DEL RUNNER
┌──────────────┐
│ dataset │ casos.jsonl
│ (n casos) │ entrada + esperado + checks
└──────┬───────┘
│ por cada caso
▼
┌──────────────┐
│ EJECUTAR │ llama al sistema
│ el sistema │ bajo prueba
└──────┬───────┘
│ salida cruda
▼
┌──────────────┐
│ PUNTUAR │ aplica cada checker
│ N checkers │ declarado en el caso
└──────┬───────┘
│ resultados por caso
▼
┌──────────────┐
│ AGREGAR │ tasa por checker
│ y comparar │ vs umbrales
└──────┬───────┘
│
▼
┌──────────────┐ falla ┌──────────────┐
│ ¿bajo el │ ────────► │ exit code 1 │ ──► CI ROJO
│ umbral? │ └──────────────┘
└──────┬───────┘
│ pasa
▼
exit code 0 ──► CI VERDE
Y el código:
"""runner.py — motor de evals deterministas, sin frameworks.
Uso: python runner.py casos.jsonl
Sale con código 1 si alguna métrica queda bajo su umbral.
"""
import json
import sys
from collections import defaultdict
from statistics import mean
from mi_agente import responder # el sistema bajo prueba
from checkers import CHECKERS # dict nombre -> función
UMBRALES = {"esquema": 1.00, "largo": 0.95, "exact": 0.90}
def cargar(path: str) -> list[dict]:
"""Un caso por línea (JSONL). Formato de cada caso:
{"id", "entrada", "esperado", "checks": ["esquema", "largo"]}"""
with open(path, encoding="utf-8") as f:
return [json.loads(l) for l in f if l.strip()]
def correr(casos: list[dict]) -> tuple[dict, list[dict]]:
puntajes = defaultdict(list) # checker -> [1.0, 0.0, ...]
fallas = [] # detalle para el reporte
for caso in casos:
try:
salida = responder(caso["entrada"])
except Exception as e: # un crash es un fallo
for nombre in caso["checks"]:
puntajes[nombre].append(0.0)
fallas.append({"id": caso["id"], "check": "crash",
"motivo": repr(e)})
continue
for nombre in caso["checks"]:
ok, motivo = CHECKERS[nombre](salida, caso)
puntajes[nombre].append(1.0 if ok else 0.0)
if not ok:
fallas.append({"id": caso["id"], "check": nombre,
"motivo": motivo, "salida": salida[:180]})
resumen = {k: round(mean(v), 3) for k, v in puntajes.items()}
return resumen, fallas
def reportar(resumen: dict, fallas: list[dict], n: int) -> bool:
print(f"\n{n} casos evaluados\n" + "-" * 42)
todo_ok = True
for nombre, valor in sorted(resumen.items()):
umbral = UMBRALES.get(nombre)
if umbral is None:
print(f" {nombre:<12} {valor:>6.3f} (sin umbral)")
continue
pasa = valor >= umbral
todo_ok &= pasa
marca = "PASA" if pasa else "FALLA"
print(f" {nombre:<12} {valor:>6.3f} umbral {umbral} {marca}")
if fallas: # los fallos son lo que se arregla
print(f"\nPrimeras fallas ({len(fallas)} en total):")
for f in fallas[:10]:
print(f" [{f['id']}] {f['check']}: {f['motivo']}")
return todo_ok
if __name__ == "__main__":
casos = cargar(sys.argv[1])
resumen, fallas = correr(casos)
sys.exit(0 if reportar(resumen, fallas, len(casos)) else 1)
Los CHECKERS son las funciones de más arriba, envueltas para devolver (bool, motivo):
"""checkers.py — cada checker: (salida, caso) -> (bool, motivo)."""
from validacion import validar_esquema
from restricciones import cumple_largo, sin_frases_prohibidas
from normalizacion import exact_match
CHECKERS = {
"esquema": lambda s, c: validar_esquema(s),
"largo": lambda s, c: (
cumple_largo(s, c.get("max_palabras", 80)), "muy largo"),
"prohibidas": lambda s, c: (
sin_frases_prohibidas(s, c["prohibidas"]), "frase prohibida"),
"exact": lambda s, c: (
exact_match(s, c["esperado"]), f"esperaba {c['esperado']!r}"),
}
Cuatro decisiones de diseño de ese runner que vale la pena copiar:
El crash cuenta como fallo, no rompe la corrida. Un try/except alrededor de la ejecución. Si el caso 12 revienta, los otros 199 igual se evalúan y el reporte muestra el crash como lo que es: un caso reprobado.
Cada caso declara sus checks. No todos los casos se miden igual. El caso de “nunca prometer fecha” solo corre el checker de frases prohibidas; el de extracción corre esquema y exact match. Eso mantiene el dataset expresivo sin duplicar código.
El sys.exit(1) es el punto completo del ejercicio. Un proceso que termina con código distinto de cero es, para cualquier CI, un fallo. Ahí es donde el eval deja de ser un reporte y se convierte en el gate con consecuencia del que hablaba el módulo 2.
Se imprimen las fallas, no solo el promedio. El número te dice si mejoraste; la lista de fallos te dice qué arreglar. Un runner que solo imprime 0.87 te obliga a hacer arqueología para saber qué pasó.
Ojo: este runner es honesto sobre su alcance — mide lo determinista. La razón para escribirlo tú antes de adoptar un framework no es evitar dependencias, es entender qué hace la herramienta cuando después te dé un número raro. Un framework de evals es esto más reintentos, caché, paralelismo, persistencia de corridas y una UI. Nada de eso es magia y todo eso es trabajo que agradeces no hacer.
Errores comunes
- Partir por el juez LLM. Es lo más caro, lo más lento y lo más ruidoso. La mayoría de las fallas reales son de formato, esquema y ruteo, y esas se atrapan gratis. El juez es para lo que sobra.
- Usar BLEU o ROUGE como gate de calidad. Miden solapamiento de palabras: una respuesta correcta con otra redacción puntúa mal y una respuesta falsa que calca la referencia puntúa bien. Peor que no medir, porque el número parece riguroso.
- Exact match sin normalizar. Reportar fallas por una tilde o un punto final convierte el eval en ruido y el equipo aprende a ignorarlo. Normaliza siempre: minúsculas, tildes, puntuación, espacios.
- Acumular variantes aceptadas en un exact match. Si vas por la décima redacción permitida para el mismo caso, ese caso no es determinista: es del juez. Estás peleando contra la métrica equivocada.
- Mirar solo el accuracy global en clasificación. Esconde a la clase rara y crítica. Revisa el recall por clase y la matriz de confusión, que además te dice qué par de clases se está cruzando.
- Inferir la ruta desde el texto final. El routing se lee de la decisión real del sistema. Si tu agente no la expone, el arreglo es instrumentarlo, no adivinar.
- Validar el texto y no el efecto. En agentes con herramientas, la respuesta puede ser perfecta y la acción incorrecta. Verifica el estado del sistema, no la prosa.
- Un runner que solo imprime el promedio. Sin la lista de casos fallados, el número no accionable. Los diez peores casos después de cada corrida es el hábito que separa medir de mejorar.
La frase para llevarse
“Antes de pagarle a un LLM para que juzgue tu sistema, revisa cuánto de lo que quieres medir cabe en un
if. Casi siempre es más de lo que crees, y ese eval corre en tres segundos, cuesta cero y nunca te miente distinto dos veces.”
Qué viene
Módulo 4: el juez que también hay que evaluar. Llegamos a la frontera de lo determinista: tono, utilidad, fidelidad al contexto, completitud. Eso se mide con un LLM como juez, y ahí aparece la pregunta que casi nadie se hace — ¿y quién evalúa al juez? Vamos a ver cuándo conviene juzgar de a un caso, de a pares o contra una referencia, y sobre todo el protocolo completo de calibración: cuántos casos anotar a mano, cómo anotar con dos personas, cómo medir el acuerdo entre ellas y cómo medir el acuerdo del juez con los humanos usando kappa de Cohen, con la fórmula, un ejemplo numérico paso a paso y qué hacer cuando el número sale bajo.
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.