← Todo el material
Curso Intermedio 23 de agosto de 2026

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

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:

ComportamientoAceptableCuándo
Reintenta igualA vecesSolo si el error es transitorio (timeout, 429, 503)
Corrige y reintentaSíEl error indicaba qué estaba mal en los argumentos
Cambia de estrategiaSíOtra tool que llega al mismo dato
Se rinde y avisaSíNo hay alternativa. Mejor que inventar
Alucina el resultadoNuncaEs 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ó.

FalloMétricaCómo se mide¿Necesita LLM?
1. SelecciónPrecisión de selección + matriz de confusiónComparar nombre contra la referencia, paso a pasoNo
2. ArgumentosValidez de esquema / exactitud de valoresPydantic o JSON Schema + comparación con toleranciaNo
3. AlucinadasTasa de nombres inexistentesnombre not in set(tools)No
4. RecuperaciónTasa de recuperación / de alucinación tras errorInyectar errores a propósito y ver el desenlacePara la alucinación, sí
5. OrdenViolaciones de dependenciaPares (A→B) declarados sobre la secuencia observadaNo

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 pobreQué le faltaDescripción que sí funciona
buscar(query) — “Busca información.”Todobuscar_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:

  1. Nombres que se distinguen entre sí. buscar_pedido y buscar_cliente se distinguen; buscar y consultar no.
  2. 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.
  3. 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.
  4. Precondiciones declaradas. “Obtén el id con buscar_cliente primero” 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_producto en vez de buscar_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_credito son 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

EvalsAgentesToolsTestingMocks

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.