Evals — Módulo 6: evaluar el uso de herramientas
Sexto módulo del Curso de Evals: donde más se rompen los agentes en la práctica. Los cinco fallos de tool use que hay que medir por separado —selección, argumentos, tools alucinadas, recuperación tras error y orden de dependencias—, cómo mockear las tools para que el eval sea determinista y barato, y por qué muchas veces el bug está en la descripción de la herramienta y no en el modelo.
☰ Índice del contenido
- La tesis del módulo
- Los cinco fallos
- Fallo 1 — Selección de la tool correcta
- Fallo 2 — Argumentos correctos y bien tipados
- Fallo 3 — Tools alucinadas
- Fallo 4 — Recuperación tras un error de la tool
- Fallo 5 — Orden y dependencias entre llamadas
- La técnica clave: mockear las tools
- Código: un harness de eval con tools simuladas
- El bug suele estar en la descripción, no en el modelo
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 5 aprendimos a mirar la trayectoria completa y a puntuarla: eficiencia, bucles, pasos redundantes, atribución del fallo al paso N. Teníamos una lupa sobre el camino. Hoy la apuntamos al punto exacto donde ese camino se rompe con más frecuencia. Si registras los fallos de un agente en producción y los clasificas por causa, el reparto es siempre parecido: una porción minoritaria es “el modelo redactó mal”, y la mayoría aplastante es el agente le pegó a la herramienta equivocada, o a la correcta con los argumentos equivocados.
La tesis del módulo
“Tool use” no es una métrica: son cinco fallos distintos con cinco arreglos distintos. Reportar un solo número de “tool accuracy” es como reportar un solo número de “salud”: técnicamente medible, clínicamente inútil. La métrica que sirve es la que te dice qué reescribir esta tarde.
Los cinco fallos
Antes de las métricas, el mapa. Estos cinco se pueden dar en la misma trayectoria y cada uno se arregla en un lugar distinto del sistema.
DÓNDE SE ROMPE UNA LLAMADA A TOOL
START ──► ┌─────────────────────┐
│ el modelo decide │
└──────────┬──────────┘
▼
┌─────────────────────┐ no ──► FALLO 3
│ ¿la tool existe? │ alucinada
└──────────┬──────────┘
sí ▼
┌─────────────────────┐ no ──► FALLO 1
│ ¿es la tool que │ selección
│ corresponde? │
└──────────┬──────────┘
sí ▼
┌─────────────────────┐ no ──► FALLO 2
│ ¿argumentos válidos │ argumentos
│ y bien tipados? │
└──────────┬──────────┘
sí ▼
┌─────────────────────┐ no ──► FALLO 5
│ ¿respeta el orden │ dependencias
│ de dependencias? │
└──────────┬──────────┘
sí ▼
┌─────────────────────┐
│ se ejecuta la tool │
└──────────┬──────────┘
▼
┌─────────────────────┐ no ──► FALLO 4
│ ¿devolvió ok? │ recuperación
└──────────┬──────────┘ (qué hace ahora)
sí ▼
END
Fallo 1 — Selección de la tool correcta
El agente eligió buscar_producto cuando debía usar buscar_pedido. Es el fallo más común y el más barato de medir.
Precisión de selección = llamadas con la herramienta correcta / llamadas totales, evaluada paso a paso contra la trayectoria de referencia.
Conviene desglosarla en una matriz de confusión de tools, exactamente igual que la matriz de confusión de un clasificador en el módulo 4 de Machine Learning. Porque eso es lo que hay debajo: elegir tool es un problema de clasificación con N clases, y el promedio global esconde que el 100% de los errores está en un solo par de herramientas que se parecen demasiado.
Fallo 2 — Argumentos correctos y bien tipados
Eligió bien la tool y le pasó {"fecha": "el martes pasado"} donde el esquema pide YYYY-MD-DD, o {"monto": "15.000"} con puntos donde el tipo es entero, o simplemente omitió un campo obligatorio. Se mide en dos niveles, y separarlos importa:
Validez de esquema = llamadas cuyos argumentos pasan la validación del esquema / llamadas totales.
Exactitud de argumentos = llamadas cuyos valores coinciden con los esperados / llamadas con esquema válido.
El primero es puro JSON Schema o Pydantic: gratis, determinista, sin LLM. El segundo necesita saber qué valor era el correcto, y a veces admite tolerancia (una fecha equivalente escrita distinto, un limit de 10 en vez de 5 cuando el límite no importa). Define esa tolerancia por campo en el caso, no globalmente.
Fallo 3 — Tools alucinadas
El modelo llamó a cancelar_pedido, que no existe en el conjunto de herramientas que le entregaste. En APIs con tool calling nativo esto es cada vez más raro, pero no desapareció, y aparece con fuerza en dos escenarios: agentes con muchas herramientas (a partir de unas veinte o treinta la tasa sube visiblemente) y agentes donde las tools cambian entre versiones y el prompt del sistema quedó con nombres viejos.
Tasa de alucinación de tools = llamadas a nombres inexistentes / llamadas totales.
Es la métrica más fácil de todo el curso —una comparación contra un set de nombres— y su umbral es el más duro que vas a escribir: cero. Cualquier valor distinto de cero es un bug, no una degradación.
Fallo 4 — Recuperación tras un error de la tool
La herramienta devolvió un timeout, un 500 o un “no encontrado”. ¿Qué hace el agente? Hay cuatro comportamientos posibles y solo dos son aceptables:
| Comportamiento | Aceptable | Cuándo |
|---|---|---|
| Reintenta igual | A veces | Solo si el error es transitorio (timeout, 429, 503) |
| Corrige y reintenta | Sí | El error indicaba qué estaba mal en los argumentos |
| Cambia de estrategia | Sí | Otra tool que llega al mismo dato |
| Se rinde y avisa | Sí | No hay alternativa. Mejor que inventar |
| Alucina el resultado | Nunca | Es el fallo grave: responde como si la tool hubiera funcionado |
Tasa de recuperación = casos con error inyectado que igual terminaron en éxito / casos con error inyectado.
Tasa de alucinación tras error = casos donde el agente respondió con datos inventados después de un error / casos con error inyectado.
La segunda es la que hay que vigilar. Un agente que se rinde y dice “no pude consultar el saldo, intenta más tarde” es un agente correcto. Uno que dice “tu saldo es $142.900” después de que la tool devolvió 500 es un incidente esperando fecha.
Fallo 5 — Orden y dependencias entre llamadas
Llamó a emitir_nota_credito(id) antes de verificar_compra(id). Aquí es donde se reutiliza directo el aparato del módulo anterior: es un problema de coincidencia como subsecuencia contra la trayectoria de referencia. La diferencia es que ahora lo modelamos explícito, con las dependencias declaradas por caso, para que el fallo se pueda atribuir a un par concreto de herramientas.
Violaciones de dependencia = pares (A antes de B) declarados que la trayectoria no respetó.
| Fallo | Métrica | Cómo se mide | ¿Necesita LLM? |
|---|---|---|---|
| 1. Selección | Precisión de selección + matriz de confusión | Comparar nombre contra la referencia, paso a paso | No |
| 2. Argumentos | Validez de esquema / exactitud de valores | Pydantic o JSON Schema + comparación con tolerancia | No |
| 3. Alucinadas | Tasa de nombres inexistentes | nombre not in set(tools) | No |
| 4. Recuperación | Tasa de recuperación / de alucinación tras error | Inyectar errores a propósito y ver el desenlace | Para la alucinación, sí |
| 5. Orden | Violaciones de dependencia | Pares (A→B) declarados sobre la secuencia observada | No |
Cuatro de los cinco son deterministas y gratis. Es la parte más barata de todo tu programa de evals y la que más bugs encuentra por peso: si tienes presupuesto para una sola cosa, es esta.
La técnica clave: mockear las tools
Correr evals de tool use contra las herramientas reales tiene cuatro problemas: es lento (cada llamada a una API externa son cientos de milisegundos), es caro, es no reproducible (la API devuelve datos distintos hoy que ayer, y tu métrica se mueve sin que hayas cambiado nada) y, sobre todo, tiene efectos secundarios: un eval de 200 casos que ejecute emitir_nota_credito de verdad emite 200 notas de crédito.
La solución es la misma que en el módulo 6 del Curso de Deploy: mockear, es decir, reemplazar la implementación real por una simulada que devuelve datos fijos. La diferencia es una vuelta de tuerca importante para evals.
HARNESS DE EVAL CON TOOLS MOCKEADAS
┌────────────────────────────┐
caso ──► │ AGENTE │
│ modelo real, prompt real │
│ y tools reales en su │
│ DESCRIPCIÓN │
└─────────────┬──────────────┘
│ llamada
▼
┌────────────────────────────┐
│ MOCK ROUTER │
│ ¿inyectar error en esta │
│ llamada? │
└────┬──────────────────┬────┘
sí │ │ no
▼ ▼
┌─────────┐ ┌────────────┐
│ ERROR │ │ dato fijo │
│ 503 │ │ del mock │
└────┬────┘ └──────┬─────┘
│ │
└─────────┬────────┘
│ observación
▼
┌────────────────────────────┐
│ REGISTRO DE LLAMADAS │ ──► métricas 1-5
└────────────────────────────┘
El punto que casi nadie aplica: lo que se mockea es la ejecución, nunca la descripción. El modelo tiene que ver exactamente el mismo nombre, la misma docstring y el mismo esquema de argumentos que en producción, porque eso es precisamente lo que estás evaluando. Un mock que además simplifica la firma de la tool convierte tu eval en una ficción.
Y la vuelta de tuerca: como controlas el mock, puedes inyectar errores a propósito. Un agente que nunca vio fallar una herramienta no es un agente probado. En producción las APIs se caen, y ese día quieres saber de antemano si tu agente se rinde con elegancia o inventa el saldo.
Código: un harness de eval con tools simuladas
"""Harness de evaluación de tool use con mocks e inyección de errores.
Dependencias: pydantic (validación de esquema). El resto es stdlib.
pip install pydantic
"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Callable
from pydantic import BaseModel, ValidationError
# --- 1. Definición de las tools: esquema real + mock ---------------------
class BuscarClienteArgs(BaseModel):
rut: str
class VerSaldoArgs(BaseModel):
id: int # int, no str: el tipado es parte del eval
class EmitirNotaArgs(BaseModel):
id: int
monto: int
motivo: str
@dataclass
class ToolMock:
"""Una tool simulada: mismo nombre y esquema que la real,
ejecución reemplazada por datos fijos."""
nombre: str
esquema: type[BaseModel]
descripcion: str # LA MISMA que en producción
respuesta: Callable[[dict], str] # dato fijo, determinista
TOOLS: dict[str, ToolMock] = {
"buscar_cliente": ToolMock(
nombre="buscar_cliente",
esquema=BuscarClienteArgs,
descripcion=(
"Busca un cliente por su RUT chileno y devuelve su id interno. "
"Usa esta herramienta SIEMPRE antes de cualquier consulta que "
"requiera el id del cliente. Formato del rut: 12345678-9, "
"sin puntos y con guion."
),
respuesta=lambda a: '{"id": 8812, "nombre": "Ana Rojas"}',
),
"ver_saldo": ToolMock(
nombre="ver_saldo",
esquema=VerSaldoArgs,
descripcion=(
"Devuelve el saldo en pesos de un cliente. Requiere el id "
"interno (entero), NO el rut. Obtén el id con buscar_cliente."
),
respuesta=lambda a: '{"saldo": 142900}',
),
"emitir_nota_credito": ToolMock(
nombre="emitir_nota_credito",
esquema=EmitirNotaArgs,
descripcion=(
"Emite una nota de crédito. Operación IRREVERSIBLE: solo "
"después de verificar la compra con el cliente."
),
respuesta=lambda a: '{"nota": "NC-40021", "estado": "emitida"}',
),
}
# --- 2. El router de mocks, con inyección de errores --------------------
@dataclass
class Llamada:
nombre: str
argumentos: dict
observacion: str = ""
existe: bool = True
esquema_ok: bool = True
error_inyectado: bool = False
@dataclass
class MockRouter:
"""Ejecuta tools simuladas y registra todo lo necesario para las
métricas. `fallar_en` es una lista de índices (0-indexado) de
llamadas que deben devolver error, para medir recuperación."""
tools: dict[str, ToolMock]
fallar_en: set[int] = field(default_factory=set)
error_msg: str = "ERROR 503: servicio no disponible"
registro: list[Llamada] = field(default_factory=list)
def ejecutar(self, nombre: str, argumentos: dict) -> str:
i = len(self.registro)
# FALLO 3: la tool no existe
if nombre not in self.tools:
obs = f"ERROR: la herramienta '{nombre}' no existe"
self.registro.append(
Llamada(nombre, argumentos, obs, existe=False)
)
return obs
tool = self.tools[nombre]
# FALLO 2: argumentos que no pasan el esquema
try:
validados = tool.esquema(**argumentos).model_dump()
except ValidationError as e:
obs = f"ERROR de argumentos: {e.errors()[0]['msg']}"
self.registro.append(
Llamada(nombre, argumentos, obs, esquema_ok=False)
)
return obs
# Inyección deliberada de error para medir recuperación (FALLO 4)
if i in self.fallar_en:
self.registro.append(
Llamada(nombre, validados, self.error_msg,
error_inyectado=True)
)
return self.error_msg
obs = tool.respuesta(validados)
self.registro.append(Llamada(nombre, validados, obs))
return obs
# --- 3. Métricas de los cinco fallos ------------------------------------
def _valores_coinciden(obtenido: Any, esperado: Any) -> bool:
"""Comparación tolerante: strings sin distinguir mayúsculas ni bordes."""
if isinstance(esperado, str) and isinstance(obtenido, str):
return obtenido.strip().lower() == esperado.strip().lower()
return obtenido == esperado
def metricas_tool_use(registro: list[Llamada], caso: dict) -> dict:
"""Calcula los cinco fallos sobre el registro de una ejecución.
`caso` trae: llamadas_esperadas (lista de {nombre, argumentos}) y
dependencias (lista de pares [A, B] que significan 'A antes que B').
"""
esperadas = caso["llamadas_esperadas"]
total = len(registro) or 1
# FALLO 3 — tools alucinadas. Umbral: cero.
alucinadas = [ll for ll in registro if not ll.existe]
# FALLO 2 — validez de esquema y exactitud de valores
esquema_malo = [ll for ll in registro if not ll.esquema_ok]
validas = [ll for ll in registro if ll.existe and ll.esquema_ok]
# FALLO 1 — selección: comparación posicional contra la referencia,
# solo sobre las llamadas que sí se ejecutaron.
aciertos_nombre = sum(
1 for ll, esp in zip(validas, esperadas)
if ll.nombre == esp["nombre"]
)
# Exactitud de argumentos: solo sobre las llamadas con nombre correcto
aciertos_args = sum(
1 for ll, esp in zip(validas, esperadas)
if ll.nombre == esp["nombre"] and all(
_valores_coinciden(ll.argumentos.get(k), v)
for k, v in esp["argumentos"].items()
)
)
# FALLO 5 — orden: cada par (A, B) exige que A aparezca antes que B
secuencia = [ll.nombre for ll in validas]
violaciones = []
for a, b in caso.get("dependencias", []):
if b in secuencia:
i_b = secuencia.index(b)
if a not in secuencia[:i_b]:
violaciones.append((a, b))
return {
"tasa_alucinadas": len(alucinadas) / total,
"validez_esquema": 1 - len(esquema_malo) / total,
"precision_seleccion": aciertos_nombre / max(len(esperadas), 1),
"exactitud_argumentos": aciertos_args / max(aciertos_nombre, 1),
"violaciones_dependencia": violaciones,
"n_llamadas": len(registro),
}
Y la parte que casi nadie escribe: la suite de inyección de errores que mide la recuperación.
"""Medir recuperación: correr el mismo caso N veces, fallando la
llamada i-ésima cada vez, y ver qué hace el agente."""
FRASES_DE_RENDICION = (
"no pude", "no está disponible", "intenta más tarde",
"hubo un problema", "no logré",
)
def evaluar_recuperacion(agente, caso: dict) -> dict:
"""Para cada posición de llamada, inyecta un error ahí y clasifica
el desenlace en: recuperado / rendido / alucinado.
`agente(pregunta, router)` debe ejecutar el agente usando `router`
como ejecutor de tools y devolver el texto de la respuesta final.
"""
n_pasos = len(caso["llamadas_esperadas"])
desenlaces = Counter()
for i in range(n_pasos):
router = MockRouter(tools=TOOLS, fallar_en={i})
respuesta = agente(caso["pregunta"], router)
respuesta_l = respuesta.lower()
hubo_reintento = len(router.registro) > n_pasos
dato_correcto = caso["dato_esperado"] in respuesta
se_rindio = any(f in respuesta_l for f in FRASES_DE_RENDICION)
if dato_correcto and hubo_reintento:
# Reintentó o cambió de estrategia y llegó al dato real
desenlaces["recuperado"] += 1
elif se_rindio and not dato_correcto:
# Avisó honestamente: comportamiento aceptable
desenlaces["rendido"] += 1
elif dato_correcto and not hubo_reintento:
# ROJO: dio el dato sin que ninguna tool se lo entregara
desenlaces["alucinado"] += 1
else:
desenlaces["confuso"] += 1
return {
"tasa_recuperacion": desenlaces["recuperado"] / n_pasos,
"tasa_rendicion": desenlaces["rendido"] / n_pasos,
"tasa_alucinacion_tras_error": desenlaces["alucinado"] / n_pasos,
"detalle": dict(desenlaces),
}
Cuidado: la detección de “se rindió” por frases fijas es frágil —el modelo puede decirlo de mil formas y en otro idioma— y aquí funciona como primera aproximación barata. Para producción, esa clasificación de desenlace es un buen trabajo para el juez calibrado del módulo 4 con tres categorías cerradas:
recuperado,avisó,inventó. Es de los pocos lugares de este módulo donde vale la pena pagar un LLM.
El bug suele estar en la descripción, no en el modelo
Aquí está el hallazgo que más tiempo ahorra de todo el módulo. Cuando la precisión de selección sale baja, el reflejo del equipo es cambiar de modelo o subir el tamaño del prompt del sistema. Y en una fracción grande de los casos el problema es más simple: la descripción de la herramienta no permite decidir.
Piénsalo desde donde está el modelo. Lo único que ve de tu tool es su nombre, su descripción y su esquema de argumentos. Con eso tiene que decidir. Si la descripción dice “busca información del cliente” y hay otras tres tools que también “buscan información del cliente”, el modelo no está fallando: está adivinando, porque le entregaste un problema sin información suficiente.
| Descripción pobre | Qué le falta | Descripción que sí funciona |
|---|---|---|
buscar(query) — “Busca información.” | Todo | buscar_pedido(numero) — “Busca un pedido por su número (formato PED-12345). Devuelve estado, fecha de despacho y monto. NO sirve para buscar clientes: para eso usa buscar_cliente." |
| "Obtiene el saldo del cliente.” | El tipo y origen del argumento | ”Devuelve el saldo en pesos. Requiere el id interno (entero), no el RUT. Obtén el id con buscar_cliente primero." |
| "Cancela una operación.” | Consecuencias y precondiciones | ”Cancela un pedido. Irreversible. Solo si el estado es pendiente; si ya está despachado, usa iniciar_devolucion." |
| "Fecha del evento.” | Formato exacto | ”Fecha en formato YYYY-MM-DD. Si el usuario dice ‘el martes pasado’, calcúlala tú antes de llamar.” |
Los cuatro patrones que suben la tasa de acierto sin cambiar de modelo:
- Nombres que se distinguen entre sí.
buscar_pedidoybuscar_clientese distinguen;buscaryconsultarno. - Delimitación negativa explícita. Decir para qué no sirve la tool y cuál usar en su lugar. Es lo que más rinde cuando hay tools parecidas.
- Formato y tipo del argumento en la descripción, no solo en el esquema. El esquema valida después; la descripción es lo que el modelo lee antes.
- Precondiciones declaradas. “Obtén el id con
buscar_clienteprimero” convierte una dependencia implícita en una instrucción, y hace desaparecer buena parte de los fallos 2 y 5 de una sola vez.
Ojo: por eso la matriz de confusión de tools no es un lujo. Cuando ves que el 90% de los errores es “eligió
buscar_productoen vez debuscar_pedido”, el arreglo es un párrafo en dos descripciones y lo haces en diez minutos. Con un solo número global de tool accuracy, ese diagnóstico no existe y terminas evaluando modelos durante una semana.
La consecuencia metodológica es que las descripciones de tools son código versionado y con evals propios. Cambiar una descripción es un cambio de comportamiento del sistema, exactamente igual que cambiar el prompt, y merece pasar por el mismo gate en CI que describimos en el módulo 7 del Curso de Deploy.
Errores comunes
- Reportar un solo número de “tool accuracy”. Mezcla cinco fallos con cinco arreglos distintos. Cuando baja, no sabes si reescribir una descripción, arreglar un esquema o revisar el manejo de errores. Cinco métricas separadas, siempre.
- Correr los evals contra las tools reales. Es lento, caro, no reproducible y con efectos secundarios. Doscientos casos de eval sobre
emitir_nota_creditoson doscientas notas de crédito y una conversación incómoda con contabilidad. - Mockear también la descripción. Simplificar la firma de la tool para que el mock sea más fácil de escribir destruye lo que estabas midiendo. La ejecución se simula; el contrato que el modelo lee es sagrado.
- No probar nunca el camino de error. Si todas tus tools mockeadas responden bien siempre, no tienes ninguna evidencia sobre qué hace tu agente el día que la API se cae. Y ese día llega.
- Aceptar la alucinación tras error como “el modelo se equivocó”. Responder con un dato inventado después de que la tool falló no es un error de calidad: es un incidente. Va como caso bloqueante, con umbral cero.
- Culpar al modelo antes de leer las descripciones. Antes de gastar en el modelo grande, lee tus cuatro descripciones más confundidas en la matriz y reescríbelas con delimitación negativa. Es gratis y frecuentemente resuelve el problema.
- Validar solo el texto de la respuesta y no el efecto. El agente puede responder “listo, agendé tu hora para el martes” con una llamada que agendó el jueves. En agentes con acciones, el eval verifica el estado final del mock, no solo lo que el agente dice que hizo.
La frase para llevarse
“El modelo no elige mal la herramienta: elige lo mejor que puede con la descripción que le escribiste. Antes de cambiar de modelo, lee tus descripciones como si fueras el modelo.”
Qué viene
Módulo 7: evals multi-turno con un usuario simulado. Hasta aquí evaluamos tareas de un solo disparo: una petición, una trayectoria, un resultado. Pero un agente de soporte conversa diez turnos, y un eval de un turno no dice absolutamente nada sobre él. Vamos a construir un usuario simulado —con objetivo, con información que no revela de entrada, con tono y con paciencia limitada—, a dibujar el loop agente ↔ usuario ↔ evaluador, y a enfrentar el problema que arruina la mayoría de estos evals: el simulador es demasiado cooperativo, y tu métrica sale preciosa mientras el usuario real se frustra en el turno tres.
Material relacionado
- Módulo 5: evaluar el camino, no solo la respuesta
- Deploy — Módulo 6: unit tests y mockear el LLM
- Deploy — Módulo 7: evals como tests que bloquean el deploy
- LangGraph — Módulo 5: tools y el patrón ReAct
- Machine Learning — Módulo 4: evaluar un modelo
- Evaluación de agentes y flujos LLM: buenas prácticas
- Glosario de Evals y evaluación de agentes
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.