CrewAI avanzado 02
Segunda parte del tutorial dedicado a CrewAI, donde veremos herramientas, memoria, observabilidad, caché..
- Herramientas personalizadas 3.1. El decorador @tool y la anatomía de una tool 3.2. Wikipedia para contexto cultural 3.3. Nominatim para geocoding 3.4. Open-Meteo para meteorología 3.5. Asignar las herramientas a los agentes
- Human-in-the-loop 4.1. Qué es y cuándo tiene sentido 4.2. Confirmación de Herodoto antes de arrancar 4.3. El parámetro human_input en tareas
- Caché, memoria y conocimiento 5.1. Caché de resultados de herramientas 5.2. Memoria de la crew (memory=True) 5.3. Knowledge sources: dar contexto persistente a un agente
- Observabilidad y control de ejecución 6.1. Callbacks (step_callback, task_callback) 6.2. Planning (planning=True) 6.3. Reasoning a nivel de agente
- Ejecución asíncrona 7.1. kickoff_async() y casos de uso 7.2. kickoff_for_each() para procesar lotes
- Más allá de la Crew: Flows (visión general) 8.1. Qué problema resuelven los Flows 8.2. Diferencias con Crew 8.3. Cuándo usar uno u otro
- Cierre 9.1. Lo que hemos construido 9.2. Buenas prácticas recapituladas 9.3. Siguientes pasos
7. Herramientas personalizadas
7.1. Qué es una herramienta
Al igual que en otros ámbitos agénticos, en CrewAI una herramienta (tool) es una función Python que un agente puede invocar durante su razonamiento para obtener información o ejecutar una acción que no podría hacer solo con su LLM. Es la forma que tienen los agentes de salir de su mundo de tokens y tocar el mundo real: leer un fichero, llamar a una API, consultar una base de datos, ejecutar código, hacer una búsqueda en internet, lo que sea.
Sin herramientas, un agente solo puede generar texto a partir de lo que ya sabe, su entrenamiento, que llega hasta determinada fecha, pero con herramientas puede consultar Wikipedia para saber qué hay en una ciudad, geolocalizar un punto, mirar el tiempo, leer un PDF o publicar un post. La diferencia entre un agente sin herramientas y uno con ellas es la diferencia entre un experto encerrado en una habitación y uno con teléfono y ordenador.
CrewAI trae varias herramientas de serie (SerperDevTool para buscar en Google, FileReadTool, WebsiteSearchTool…) que ya usamos en el primer artículo. Pero hay tres situaciones donde nos puede interesar programar herramientas customizadas.
1. Cuando el API que necesitamos no está cubierta por las que trae de fábrica CrewAI, como puede ser Wikipedia, Nominatim, Open-Meteo, un scraping concreto… En síntesis, cuando lo que buscamos no existe en los tools de CrewAI ni en crewai-tools, no queda otra que hacerla a medida.
2. Cuando necesitamos componer varias llamadas en una sola tool. Por ejemplo, una tool que busca un lugar en Nominatim y consulta su elevación en otra API y devuelve ambos datos juntos. El agente la ve como una sola operación.
3. Se necesita un control fino sobre la lógica. A veces las tools de serie no hacen exactamente lo que necesitamos, pero con una tool propia podemos decidir exactamente qué parámetros acepta, qué validaciones aplica, qué formato devuelve, etcétera.
7.2. Anatomía de una herramienta
La forma más sencilla de preparar una herramienta es usar el decorador @tool de crewai.tools, que convierte una función Python normal en una herramienta que el agente puede invocar.
from crewai.tools import tool
@tool("Nombre visible para el agente")
def my_tool(param1: str, param2: int = 10) -> str:
"""Descripción detallada de lo que hace la tool.
Explica qué hace, qué espera y qué devuelve. El LLM lee este
docstring para decidir cuándo invocarla.
"""
# lógica
return "resultado"
Hay 4 elementos clave:
- El nombre del decorador (@tool(”…”)). Es lo que el agente ve como nombre de la herramienta en su lista de capacidades. Conviene que sea descriptivo y específico.
- La firma de la función (parámetros con tipos). El LLM la usa para saber qué argumentos pasarle. Los tipos ayudan al agente a invocarla correctamente.
- El docstring. Esto es crítico. El agente decide cuándo usar la tool leyendo esta descripción. Un docstring mal redactado provocará una tool que el agente no usa o usa mal.
- El return. Idealmente un string o un dict serializable. El agente va a leer esto como texto en su contexto, así que hay que devolver algo legible.
Las buenas prácticas recomendadas son de sentido común:
- Una tool, una responsabilidad. Igual que sucede con los agentes, las tools especializadas se invocan mejor y se depuran más fácil que tools multiuso.
- Siempre que se pueda, hay que manejar los errores de forma útil. Si la API falla, igual no es necesario lanzar una excepción que rompa el agente; quizás baste con devolver un string no bloqueante explicando qué ha pasado: “No se ha encontrado información sobre ‘X’ en Wikipedia”).
- Conviene devolver información estructurada cuando se pueda. Un dict con campos claros es más útil que un párrafo de texto.
- Cuidado con los tokens. Cada vez que un agente llama a una tool, el resultado completo entra al contexto y se queda ahí mientras el agente sigue razonando. Si se llama a la tool tres veces, los tres resultados ocupan espacio simultáneamente. Hay que recortar, resumir y deshechar si ya no necesitamos esa info. Profundizaremos en esto cuando veamos la tool para la wikipedia, que es crucial.
- El docstring es el prompt de la tool y hay que trátalo como tal, redacta con cuidado, indicar qué hace, qué se espera, qué devuelve, qué casos cubre y cuáles no.
7.3. Wikipedia para contexto cultural
Wikipedia tiene una API REST pública, gratuita, sin clave, que devuelve el resumen de cualquier artículo. Es perfecta para que el cultural_researcher enriquezca destinos con contexto histórico breve.
El endpoint que vamos a usar es:
https://en.wikipedia.org/api/rest_v1/page/summary/{título}
Por ejemplo:
https://en.wikipedia.org/api/rest_v1/page/summary/verona
Trabajaremos con la wiki inglesa ya que tiene más artículos, pero si prefieres otro idioma, basta con cambiar el en por es o el lang que sea.
Este endpoint devuelve un JSON con varios campos. Los que nos interesan son title, extract (el resumen) y description (una línea corta). El resto lo ignoramos. Así, siguiendo las buenas prácticas mencionadas, podríamos preparar una herramienta parecida a esta.
# src/itinera/tools/wikipedia.py
import requests
from crewai.tools import tool
@tool("Wikipedia Search")
def wikipedia_search(query: str) -> str:
# El docstring que permitirá identificar para qué sirve esta tool
"""Busca en Wikipedia (en inglés) el resumen de un lugar, monumento,
obra de arte o concepto cultural y devuelve hasta 800 caracteres.
Útil para obtener contexto histórico, artístico o gastronómico breve
sobre un destino antes de incluirlo en un itinerario.
Argumentos:
query: el término a buscar tal cual aparece en Wikipedia
(por ejemplo "Lake Como", "Mantua", "Last Supper Milan").
Devuelve:
Un string con el resumen del artículo, o un mensaje claro
si no se ha encontrado nada.
"""
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}"
# Indicar el User-Agent es buena práctica con cualquier API pública
headers = {"User-Agent": "Itinera/1.0 (tutorial)"}
try:
response = requests.get(url, headers=headers, timeout=10)
except requests.RequestException as e:
return f"Error al consultar Wikipedia: {e}"
# Manejo de errores como strings, no como excepcione
if response.status_code == 404:
return f"No se ha encontrado un artículo de Wikipedia para '{query}'."
if response.status_code != 200:
return f"Wikipedia ha respondido con código {response.status_code}."
data = response.json()
title = data.get("title", query)
description = data.get("description", "")
extract = data.get("extract", "")
if not extract:
return f"El artículo '{title}' existe pero no tiene resumen."
# Recortamos a 800 caracteres para no inundar el contexto del agente.
if len(extract) > 800:
extract = extract[:800].rsplit(" ", 1)[0] + "..."
return f"{title} ({description}): {extract}" if description else f"{title}: {extract}"
7.4. Nominatim para geocoding
Aunque algunos lugares de wikipedia ya vienen con latitud y longitud, por didáctica, que me interesa que veamos dos tools, para la geolcalización vamos a usar Nominatim, que es el servicio de geocoding oficial de OpenStreetMap. Convierte un nombre de lugar en coordenadas (latitud y longitud) y al revés. Es gratuito y no requiere clave, pero tiene un uso aceptable importante: máximo 1 petición por segundo y obligación de identificarse con un User-Agent descriptivo. Si lo saltas, te bloquean.
El endpoint contra el que vamos a trabajar es
https://nominatim.openstreetmap.org/search?q={lugar}&format=json&limit=1
Por ejemplo:
https://nominatim.openstreetmap.org/search?q=verona&format=json&limit=1
Devuelve una lista de coincidencias con un montón de campos. Solo nos interesan lat, lon y display_name y la herramienta puede ser algo parecido a esto.
# src/itinera/tools/geocoding.py
import requests
from crewai.tools import tool
@tool("Geocode Location")
def geocode_location(place: str) -> str:
"""Obtiene las coordenadas geográficas (latitud y longitud) de un lugar
usando OpenStreetMap/Nominatim.
Útil para georreferenciar un destino antes de incluirlo en un itinerario,
de modo que la planificadora pueda agrupar paradas cercanas y evitar
rutas que obliguen a backtracking.
Argumentos:
place: nombre del lugar a geolocalizar. Cuanto más específico, mejor
(por ejemplo "Bergamo, Italy" en vez de solo "Bergamo").
Devuelve:
Un string con el nombre canónico del lugar y sus coordenadas
en formato "lat, lon", o un mensaje claro si no se encuentra.
"""
url = "https://nominatim.openstreetmap.org/search"
params = {"q": place, "format": "json", "limit": 1}
headers = {"User-Agent": "Itinera/1.0 (tutorial)"}
try:
response = requests.get(url, params=params, headers=headers, timeout=10)
except requests.RequestException as e:
return f"Error al consultar Nominatim: {e}"
if response.status_code != 200:
return f"Nominatim ha respondido con código {response.status_code}."
results = response.json()
if not results:
return f"No se han encontrado coordenadas para '{place}'."
result = results[0]
lat = result["lat"]
lon = result["lon"]
name = result.get("display_name", place)
return f"{name} -> lat: {lat}, lon: {lon}"