El módulo 4 terminó con una frontera dibujada con precisión: DocuBot sabe — responde con fuentes sobre todo lo que dice la documentación de Nubelia — pero no puede hacer. La pregunta que dejamos en el aire, "¿cuántas tareas tiene ahora mismo mi proyecto Atlas?", no tiene respuesta en ninguna wiki: el dato vive en la API de Nubelia, cambia cada minuto, y ningún pipeline RAG del mundo lo va a recuperar de una colección de chunks. En esta lección cruzamos esa frontera con el mecanismo llamado function calling (o tool use): le describimos al modelo qué funciones tenemos, el modelo decide cuándo y con qué argumentos conviene llamarlas, y nuestro código las ejecuta y le devuelve el resultado. Es probablemente la idea con peor nombre de todo el ecosistema — el modelo no llama a ninguna función — y a la vez una de las más transformadoras: es la pieza que separa un chatbot de un asistente que opera sobre tu sistema. Aquí construiremos la primera herramienta de DocuBot, consultar_proyecto, y cerraremos por fin la pregunta de Atlas.

Contenido

  1. La idea clave: el modelo decide, tu código ejecuta
  2. Definir una herramienta: JSON Schema y descripciones que son prompt engineering
  3. El flujo completo con el SDK: tool_use → ejecutar → tool_result → respuesta final
  4. La primera herramienta de DocuBot: consultar_proyecto contra la API de Nubelia
  5. Cuando la herramienta falla: errores como tool_result con is_error
  6. tool_choice: elección automática, forzada y prohibida
  7. Function calling como evolución de "pedir JSON" (02-03)

La idea clave: el modelo decide, tu código ejecuta

Desmontemos primero el malentendido que da nombre a la técnica. En function calling el LLM no ejecuta absolutamente nada: no tiene acceso a tu red, ni a tu base de datos, ni a la API de Nubelia. Lo único que hace el modelo es lo que siempre ha hecho — generar tokens —, solo que ahora puede generar un tipo especial de salida estructurada que significa "quiero que llames a esta función con estos argumentos". Tu aplicación lee esa petición, decide si la atiende, ejecuta la función con código Python normal y corriente, y le devuelve el resultado al modelo en un segundo turno para que redacte la respuesta final.

sequenceDiagram
    participant U as Usuario
    participant A as Tu aplicación (Python)
    participant L as LLM (API)
    participant N as API de Nubelia
    U->>A: "¿Cuántas tareas tiene mi proyecto Atlas?"
    A->>L: pregunta + definiciones de herramientas
    L-->>A: tool_use: consultar_proyecto({"nombre": "Atlas"})
    Note over L: El modelo NO ejecuta nada.<br/>Solo pide, con argumentos estructurados.
    A->>N: obtener_proyecto("Atlas") — código tuyo
    N-->>A: {"tareas_abiertas": 27, ...}
    A->>L: tool_result con ese JSON
    L-->>A: "Tu proyecto Atlas tiene 27 tareas abiertas..."
    A-->>U: respuesta final en lenguaje natural

Esta división de trabajo tiene consecuencias importantes, todas buenas:

  • El control es tuyo. Entre el tool_use y la ejecución hay una línea de código Python que escribes tú. Puedes validar los argumentos, aplicar permisos, registrar la llamada o negarte a ejecutarla. El modelo propone; tu código dispone.
  • La seguridad se razona como siempre. La función que ejecutas es código tuyo con las credenciales de tu aplicación — le aplican los mismos principios de toda la vida, empezando por el de mínimo privilegio: una herramienta de consulta debe usar credenciales de solo lectura. consultar_proyecto no puede borrar nada porque el código que la implementa no sabe borrar nada. (Las acciones de escritura y sus salvaguardas llegan en 05-02, y los ataques que explotan herramientas, en 06-01.)
  • El modelo aporta lo que ningún if puede: entender que "¿cómo va Atlas?", "dime el estado de mi proyecto Atlas" y "how many open tasks in Atlas?" son la misma intención, y traducirla a una llamada con argumentos correctos.

Definir una herramienta: JSON Schema y descripciones que son prompt engineering

Para que el modelo pueda pedir una función, primero hay que describírsela. Cada herramienta se define con tres piezas:

Campo Qué es Quién lo "lee"
name Identificador de la función (letras, números, guiones bajos) Tu código, para saber qué ejecutar
description Explicación en lenguaje natural de qué hace, cuándo usarla y cuándo no El modelo — es la pieza más importante
input_schema JSON Schema de los argumentos: tipos, campos obligatorios, enums El modelo (para generar argumentos válidos) y la API (que los valida)

La description merece que nos detengamos: es prompt engineering. Todo lo aprendido en el módulo 2 aplica aquí — el modelo decidirá si usa la herramienta, y con qué argumentos, basándose casi exclusivamente en ese texto. Compara:

# Descripción pobre: el modelo tendrá que adivinar
{"description": "Consulta un proyecto"}

# Descripción trabajada: cuándo usarla, qué devuelve, qué NO hace
{"description": (
    "Consulta el estado EN TIEMPO REAL de un proyecto del usuario en Nubelia: "
    "tareas abiertas y completadas, miembros del equipo y fecha límite. "
    "Úsala cuando el usuario pregunte por datos actuales de un proyecto concreto "
    "('¿cuántas tareas tiene Atlas?', '¿cómo va mi proyecto Boreal?'). "
    "NO la uses para preguntas sobre cómo funciona Nubelia (planes, permisos, "
    "integraciones): eso se responde con la documentación."
)}

La segunda versión hace tres cosas que la primera no: delimita el cuándo sí con ejemplos (few-shot en miniatura, 02-02), delimita el cuándo no (evita que el modelo llame a la API para preguntas de documentación), y anuncia qué devuelve (el modelo redactará mejor la respuesta final sabiendo qué esperar).

El flujo completo con el SDK: tool_use → ejecutar → tool_result → respuesta final

Veamos el mecanismo con el SDK anthropic que usamos desde 03-01 — el flujo es conceptualmente idéntico en OpenAI, Google o Mistral (cambian los nombres de campo: tools/tool_calls/function, pero el baile es el mismo). Los pasos:

  1. Primera llamada: envías la pregunta y la lista de herramientas (parámetro tools).
  2. Si el modelo decide usar una, la respuesta llega con stop_reason == "tool_use" y un bloque de contenido de tipo tool_use con name, input (los argumentos, ya como dict) y un id de correlación.
  3. Tu código ejecuta la función correspondiente.
  4. Segunda llamada: reenvías la conversación completa — incluida la respuesta del asistente con su bloque tool_use — y añades un mensaje de usuario con un bloque tool_result que referencia aquel id.
  5. El modelo, ya con el dato en la mano, redacta la respuesta final (stop_reason == "end_turn").

Los puntos 2 y 4 esconden los dos detalles que más errores causan: el bloque tool_use del asistente debe volver en el historial (si no, el modelo no sabe qué pidió), y el tool_result va dentro de un mensaje con rol user — desde el punto de vista de la conversación, el resultado de la herramienta es algo que "le cuentas" al modelo, y debe ser el primer contenido de ese mensaje.

La primera herramienta de DocuBot: consultar_proyecto contra la API de Nubelia

Vamos a construirlo de verdad. Como en este curso no tenemos una API de Nubelia desplegada, la simulamos con un módulo mock — la gracia didáctica es que al modelo le da exactamente igual: él solo ve la definición de la herramienta y los resultados; que detrás haya un dict o un microservicio con base de datos es invisible desde su lado de la frontera.

# api_nubelia.py — simulación de la API interna de Nubelia
# En producción, estas funciones harían llamadas HTTP autenticadas
# (con credenciales de SOLO LECTURA: mínimo privilegio).

class ProyectoNoEncontrado(Exception):
    pass

_PROYECTOS = {
    "atlas":  {"nombre": "Atlas",  "estado": "activo",  "tareas_abiertas": 27,
               "tareas_completadas": 143, "miembros": 6, "fecha_limite": "2026-09-30"},
    "boreal": {"nombre": "Boreal", "estado": "activo",  "tareas_abiertas": 4,
               "tareas_completadas": 89,  "miembros": 3, "fecha_limite": "2026-07-15"},
    "cierzo": {"nombre": "Cierzo", "estado": "pausado", "tareas_abiertas": 12,
               "tareas_completadas": 51,  "miembros": 4, "fecha_limite": None},
}

def obtener_proyecto(nombre: str) -> dict:
    """Devuelve el estado actual de un proyecto. Lanza ProyectoNoEncontrado si no existe."""
    clave = nombre.strip().lower()
    if clave not in _PROYECTOS:
        disponibles = ", ".join(p["nombre"] for p in _PROYECTOS.values())
        raise ProyectoNoEncontrado(
            f"No existe ningún proyecto llamado '{nombre}'. Proyectos disponibles: {disponibles}."
        )
    return _PROYECTOS[clave]

Ahora la definición de la herramienta y su despachador, en un módulo nuevo que crecerá durante el módulo:

# herramientas.py — definiciones de herramientas de DocuBot y su ejecución
import api_nubelia

HERRAMIENTAS = [
    {
        "name": "consultar_proyecto",
        "description": (
            "Consulta el estado EN TIEMPO REAL de un proyecto del usuario en Nubelia: "
            "tareas abiertas y completadas, miembros y fecha límite. Úsala cuando el "
            "usuario pregunte por datos actuales de un proyecto concreto. NO la uses "
            "para preguntas sobre el funcionamiento de Nubelia (documentación)."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "nombre": {
                    "type": "string",
                    "description": "Nombre del proyecto tal y como lo menciona el usuario, p. ej. 'Atlas'.",
                },
            },
            "required": ["nombre"],
        },
    },
]

def ejecutar_herramienta(nombre: str, argumentos: dict) -> dict:
    """Puente entre lo que el modelo pide y el código que lo hace."""
    if nombre == "consultar_proyecto":
        return api_nubelia.obtener_proyecto(argumentos["nombre"])
    raise ValueError(f"Herramienta desconocida: {nombre}")

Y el flujo completo. Fíjate en que reutilizamos llamar_con_reintentos (03-02) y MODELO (03-01) sin tocarlos — las herramientas son un parámetro más de la llamada. Lo que sí necesitamos es un sistema nuevo: el SYSTEM_DOCUBOT de 02-01 ordena basarse exclusivamente en el bloque <documentacion>, y esa regla, perfecta para RAG, dejaría a la herramienta sin uso. Añadimos a prompts.py una variante (y subimos PROMPT_VERSION, como manda 02-04):

# prompts.py (añadido en 05-01) — versión del sistema consciente de herramientas
SYSTEM_DOCUBOT_HERRAMIENTAS = """Eres DocuBot, el asistente interno de Nubelia.
Solo respondes sobre Nubelia. Para datos en tiempo real de los proyectos del
usuario, usa las herramientas disponibles. Nunca inventes datos de proyectos:
si una herramienta falla o no encuentra algo, explica el problema con claridad.
Responde en el idioma del usuario, con tono profesional y conciso."""
# docubot_herramientas.py — el flujo tool_use -> ejecutar -> tool_result
import json
from cliente import MODELO, extraer_texto
from reintentos import llamar_con_reintentos
from prompts import SYSTEM_DOCUBOT_HERRAMIENTAS
from herramientas import HERRAMIENTAS, ejecutar_herramienta

def responder_con_herramienta(pregunta: str) -> str:
    messages = [{"role": "user", "content": pregunta}]

    # 1) Primera llamada: pregunta + herramientas disponibles
    respuesta = llamar_con_reintentos(
        model=MODELO,
        max_tokens=1024,
        system=SYSTEM_DOCUBOT_HERRAMIENTAS,
        tools=HERRAMIENTAS,
        messages=messages,
    )

    # ¿El modelo respondió directamente, sin necesitar herramienta?
    if respuesta.stop_reason != "tool_use":
        return extraer_texto(respuesta)

    # 2) El modelo pidió una herramienta: localizar el bloque tool_use
    bloque = next(b for b in respuesta.content if b.type == "tool_use")
    print(f"[tool_use] {bloque.name}({bloque.input})")   # traza mínima, por ahora

    # 3) EJECUTAR — esto es Python nuestro, no el modelo
    try:
        resultado = ejecutar_herramienta(bloque.name, bloque.input)
        contenido = json.dumps(resultado, ensure_ascii=False)
        es_error = False
    except Exception as e:
        contenido = str(e)            # el error, contado en texto
        es_error = True

    # 4) Segunda llamada: historial completo + tool_result correlacionado por id
    messages.append({"role": "assistant", "content": respuesta.content})
    messages.append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": bloque.id,
            "content": contenido,
            "is_error": es_error,
        }],
    })
    final = llamar_con_reintentos(
        model=MODELO, max_tokens=1024,
        system=SYSTEM_DOCUBOT_HERRAMIENTAS,
        tools=HERRAMIENTAS, messages=messages,
    )
    return extraer_texto(final)


print(responder_con_herramienta("¿Cuántas tareas tiene ahora mismo mi proyecto Atlas?"))

Una ejecución típica:

[tool_use] consultar_proyecto({'nombre': 'Atlas'})
Tu proyecto Atlas tiene actualmente 27 tareas abiertas (y 143 completadas).
El proyecto está activo, con 6 miembros y fecha límite el 30 de septiembre de 2026.

La pregunta que en 01-04 nos servía de ejemplo de lo imposible acaba de tener respuesta — y con el dato real del sistema, no con una alucinación plausible. Tres observaciones antes de seguir:

  • bloque.input llega como dict ya parseado por el SDK. Aun así, trátalo como entrada no confiable: el modelo puede pasar un nombre de proyecto que no existe o un valor inesperado. Nuestra defensa está en obtener_proyecto (normaliza y valida), no en confiar en el modelo.
  • El resultado viaja como texto (por eso el json.dumps): un tool_result es contenido para el modelo, y JSON compacto y con claves descriptivas es un formato que entiende de maravilla. No hace falta prosa.
  • Este flujo resuelve un paso de herramienta y termina. Si el modelo necesitara encadenar varias llamadas (consultar la documentación y luego la API), haría falta un bucle — exactamente el tema de 05-02, no lo adelantemos.

Cuando la herramienta falla: errores como tool_result con is_error

Ya viste el try/except en el código anterior; ahora la filosofía. Cuando una herramienta falla, la peor opción es dejar que la excepción reviente el flujo, y la segunda peor es devolver al usuario un traceback. La opción buena es la que implementamos: devolver el error al modelo como tool_result con is_error: True, con un mensaje de error informativo — y dejar que el modelo haga lo que mejor sabe: explicarlo en lenguaje natural.

Pregunta: "¿cómo va mi proyecto Zafiro?". La herramienta lanza ProyectoNoEncontrado con nuestro mensaje ("No existe ningún proyecto llamado 'Zafiro'. Proyectos disponibles: Atlas, Boreal, Cierzo."), y el modelo responde algo como:

No encuentro ningún proyecto llamado "Zafiro" en tu cuenta de Nubelia.
Tus proyectos actuales son Atlas, Boreal y Cierzo — ¿te referías a alguno de ellos?

Fíjate en la cadena de valor: el mensaje de la excepción también es prompt engineering. Como incluimos la lista de proyectos disponibles en el error, el modelo puede ofrecer alternativas en vez de un "no existe" seco. Escribe los errores de tus herramientas pensando en que su lector es un LLM que va a re-explicarlos: qué falló, por qué, y qué se puede hacer. Y la misma regla de siempre: en el mensaje de error, nada de secretos ni detalles internos (rutas, SQL, stack traces) — irían directos al contexto del modelo y, potencialmente, a la respuesta del usuario.

tool_choice: elección automática, forzada y prohibida

Por defecto el modelo decide libremente si usa una herramienta o responde directamente. El parámetro tool_choice te da el mando cuando lo necesitas:

Valor Comportamiento Cuándo usarlo
{"type": "auto"} El modelo decide (defecto con tools) Asistentes como DocuBot: unas preguntas necesitan herramienta y otras no
{"type": "any"} Obligado a usar alguna herramienta Routers puros: la respuesta directa no es una opción válida
{"type": "tool", "name": "..."} Obligado a usar esa herramienta Extracción estructurada: quieres los argumentos, no una conversación
{"type": "none"} Prohibido usar herramientas Forzar una respuesta de cierre con las herramientas aún declaradas

El tercer caso es más útil de lo que parece y conecta directamente con la siguiente sección: si fuerzas una herramienta concreta, el modelo siempre genera un bloque tool_use con argumentos que cumplen tu input_schema. Es decir: acabas de conseguir salida estructurada garantizada.

Function calling como evolución de "pedir JSON" (02-03)

En 02-03 montamos el contrato JSON de DocuBot pidiendo el formato en el prompt y defendiéndonos con limpiar_json y validar_contrato — el modelo a veces envolvía el JSON en prosa o en vallas de código, y había que limpiarlo. Function calling es la versión industrializada de aquella idea:

Aspecto Pedir JSON en el prompt (02-03) Function calling
Cómo se especifica el formato Prosa + ejemplo en el prompt input_schema formal (JSON Schema)
Fiabilidad sintáctica Alta pero no garantizada (de ahí limpiar_json) Muy alta: el modelo está entrenado específicamente para emitir argumentos conformes al esquema
"Charla" alrededor del JSON Frecuente sin instrucciones férreas No existe: el bloque tool_use es estructura pura
Tipos y enums Se piden por favor Se declaran en el esquema
Validación semántica (valores con sentido) Tuya (validar_contrato) Sigue siendo tuya

La última fila es la moraleja: function calling elimina la fontanería sintáctica, pero la validación defensiva de valores no se jubila — que {"nombre": "Zafiro"} sea JSON perfecto no hace que Zafiro exista. El instinto de 02-03 ("no confíes, valida") sigue siendo la ley; solo ha cambiado dónde aplicarlo.

Errores Comunes y Consejos

  • Olvidar reenviar el turno del asistente. El error más frecuente del flujo: mandar el tool_result sin haber añadido antes {"role": "assistant", "content": respuesta.content} al historial. La API rechazará la petición — el tool_result referencia un tool_use_id que, sin ese mensaje, no existe en la conversación.
  • Descripciones de herramienta escuálidas. "Consulta un proyecto" produce llamadas de más (para preguntas de documentación) y de menos (para formulaciones indirectas). Trata la description con el mismo rigor que trataste SYSTEM_DOCUBOT: cuándo sí, cuándo no, ejemplos.
  • Creer que el esquema valida por ti. El input_schema garantiza forma, no verdad. Valida los valores en tu función (¿existe el proyecto?, ¿el rango es razonable?) como si los hubiera tecleado un desconocido — porque, en cierto modo, así es.
  • No contemplar el camino sin herramienta. Con tool_choice: auto, el modelo puede responder directamente (stop_reason == "end_turn"). Si tu código asume que siempre habrá un bloque tool_use, ese next(...) lanzará StopIteration en la primera pregunta trivial. Comprueba siempre stop_reason primero.
  • Varios tool_use en un mismo turno. El modelo puede pedir más de una herramienta a la vez (llamadas paralelas). Nuestro flujo de hoy procesa una porque su única herramienta no da para más; el bucle de 05-02 iterará sobre todos los bloques. Si te ocurre antes, responde con un tool_result por cada tool_use_id.
  • Errores de herramienta que no ayudan a nadie. KeyError: 'zafiro' como tool_result obliga al modelo a improvisar. Escribe errores con contexto y alternativas — y sin filtrar información interna.

Ejercicios

  1. Segunda herramienta: listar_proyectos. Añade a herramientas.py una herramienta sin argumentos que devuelva la lista de proyectos del usuario (nombre y estado), y amplía ejecutar_herramienta. Prueba con "¿qué proyectos tengo ahora mismo?" y con "¿cómo va Atlas?" para comprobar que el modelo elige la herramienta correcta en cada caso.
  2. El error como conversación. Con el código de la lección, haz la pregunta "¿cuántas tareas tiene mi proyecto Zafiro?" y observa la respuesta. Después, empobrece a propósito el mensaje de ProyectoNoEncontrado (déjalo en "error") y repite. Compara ambas respuestas finales y escribe en una frase la conclusión.
  3. Extracción estructurada con herramienta forzada. Define una herramienta extraer_consulta cuyo input_schema tenga categoria (enum con las 6 categorías de prompt_clasificacion() de 02-02) y proyecto (string opcional). Llámala con tool_choice: {"type": "tool", "name": "extraer_consulta"} sobre la pregunta "no me deja facturar las horas del proyecto Boreal" y examina bloque.input. ¿Qué pieza de 02-03 acabas de sustituir?

Soluciones

  1. Definición y despacho:
# En HERRAMIENTAS, añadir:
{
    "name": "listar_proyectos",
    "description": (
        "Lista todos los proyectos del usuario en Nubelia con su nombre y estado. "
        "Úsala cuando el usuario pregunte qué proyectos tiene o pida una vista general."
    ),
    "input_schema": {"type": "object", "properties": {}},   # sin argumentos
}

# En api_nubelia.py:
def listar_proyectos() -> list[dict]:
    return [{"nombre": p["nombre"], "estado": p["estado"]} for p in _PROYECTOS.values()]

# En ejecutar_herramienta:
    if nombre == "listar_proyectos":
        return api_nubelia.listar_proyectos()

Con "¿qué proyectos tengo?" el modelo debe elegir listar_proyectos; con "¿cómo va Atlas?", consultar_proyecto. Si se confunde, la palanca es la description (¡no el código!).

  1. Con el error rico, el modelo responde con alternativas concretas ("tus proyectos son Atlas, Boreal y Cierzo, ¿te referías a alguno?"). Con "error" a secas, el modelo solo puede decir que algo falló, o peor, especular sobre el motivo. Conclusión en una frase: la calidad de la respuesta final ante un fallo está limitada por la calidad del mensaje de error que devuelve tu herramienta — los errores de las herramientas son parte del prompt.

  2. Esquema y llamada:

HERRAMIENTA_EXTRAER = {
    "name": "extraer_consulta",
    "description": "Extrae la categoría y el proyecto mencionado de una consulta de usuario.",
    "input_schema": {
        "type": "object",
        "properties": {
            "categoria": {"type": "string",
                          "enum": ["facturacion", "permisos", "integraciones",
                                   "api", "incidencias", "otros"]},
            "proyecto": {"type": "string",
                         "description": "Nombre del proyecto mencionado, si lo hay."},
        },
        "required": ["categoria"],
    },
}

respuesta = llamar_con_reintentos(
    model=MODELO, max_tokens=256,
    tools=[HERRAMIENTA_EXTRAER],
    tool_choice={"type": "tool", "name": "extraer_consulta"},
    messages=[{"role": "user", "content": "no me deja facturar las horas del proyecto Boreal"}],
)
bloque = next(b for b in respuesta.content if b.type == "tool_use")
print(bloque.input)   # {'categoria': 'facturacion', 'proyecto': 'Boreal'}

Acabas de sustituir el tándem "pedir JSON en el prompt + limpiar_json": con la herramienta forzada no hay prosa que limpiar ni vallas de código que quitar. validar_contrato (la validación semántica) seguiría teniendo trabajo — el enum garantiza que categoria es una de las seis, pero nadie garantiza que sea la correcta.

Conclusión

DocuBot acaba de hacer algo que ningún RAG podía: responder con un dato que no estaba escrito en ningún sitio cuando arrancó la conversación. El mecanismo, una vez desmontado el nombre, es honesto y simple — el modelo decide qué función llamar y con qué argumentos, tu código ejecuta y le devuelve el resultado, el modelo redacta —, y sus piezas son viejas conocidas con ropa nueva: la description es prompt engineering (módulo 2), el input_schema es el contrato JSON de 02-03 con garantías sintácticas de fábrica, los errores como tool_result con is_error son la degradación elegante de 03-02 contada al modelo, y tool_choice forzado nos regala de paso la extracción estructurada más robusta que conocemos. Pero nuestra responder_con_herramienta() tiene una limitación estructural que quizá ya te haya hecho ruido: ejecuta un paso de herramienta y termina. Si el usuario pregunta "¿mi proyecto Atlas supera el límite de tareas de mi plan?", hacen falta dos consultas — la documentación para el límite del plan, la API para las tareas de Atlas — y una comparación entre ambas. Eso ya no es un flujo de dos llamadas: es un bucle en el que el modelo razona, actúa, observa el resultado y decide si necesita seguir. Ese bucle tiene nombre — agente — y es exactamente lo que construimos en la siguiente lección, donde además el RAG del módulo 4 sufrirá una transformación elegante: de pipeline a herramienta.

© Copyright 2026. Todos los derechos reservados