Lección 6.4.1 · 70 min
Agentes con herramientas: tool calling, ReAct y planificación
Un agente es un bucle: el modelo pide una herramienta, el programa la ejecuta y le devuelve el resultado, hasta que el modelo responde. Esta lección construye ese bucle con tres herramientas deterministas sobre los datos del nivel, lo ejecuta con un LLM local de 0,5B y con las APIs de OpenAI y Anthropic simuladas, y cataloga cómo falla: herramienta equivocada, argumentos inventados, bucles y respuestas que ignoran lo que devolvieron las herramientas.
- Módulo
- 6.4 · Agentes de IA para trading
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Un agente es un bucle con herramientas
Un LLM solo produce texto. Para que consulte un precio, busque noticias o haga una cuenta exacta, se le da una lista
de herramientas descritas con un nombre, una explicación y un esquema JSON de sus argumentos. Cuando el modelo
decide usar una, no la ejecuta: escribe una petición estructurada («llama a precio con ticker=BUSI y
fecha=2024-03-15»). El programa que rodea al modelo valida esa petición, ejecuta la función, y le devuelve el
resultado como un mensaje más. El modelo lee el resultado y decide si pide otra herramienta o responde. Eso es el
tool calling (o function calling), y el bucle completo es un agente.
Fuente Mermaid
flowchart TB
U[Pregunta] --> M[LLM]
M --> D{¿Pide herramienta?}
D -->|sí| V[Validar argumentos]
V --> E[Ejecutar en código]
E --> O[Observación]
O --> M
D -->|no| R[Respuesta final]
R --> C[Verificar]El patrón ReAct (Yao et al., 2022) le dio nombre a esta forma de trabajar: alternar razonamiento (reason) y acción (act), con la observación de cada acción como entrada del siguiente razonamiento. Frente a pedir la respuesta de una vez, ReAct permite que el modelo use datos que no están en sus pesos y que corrija el rumbo con lo que observa. Frente a ReAct está la planificación explícita: primero se decide el plan completo (qué herramientas, en qué orden) y después se ejecuta. Cuando la tarea es conocida, el plan lo puede fijar el programador y el modelo solo rellena los huecos.
Dos ideas guían toda la lección:
- El programa manda. Cada llamada pasa por código que valida los argumentos, decide si se ejecuta y registra lo que ocurrió. El modelo propone; el código dispone. La lección 6.4.5 lleva esta idea al límite con un motor de riesgo determinista entre el agente y las órdenes.
- Las herramientas deben ser deterministas. Mismo argumento, mismo resultado: así la traza de un agente se puede repetir, auditar y testear, y los fallos son del modelo o del diseño, no de la herramienta.
Tres herramientas deterministas sobre los datos del nivel
Las herramientas trabajan sobre los datos sintéticos del nivel 6: los cierres diarios de las 120 empresas del panel
(acciones6_precios.parquet) y el corpus de noticias (noticias6_corpus.parquet). Cada una tiene un modelo Pydantic
para sus argumentos (lección 6.2.2): el esquema JSON que verá el modelo sale de ahí, y la validación también.
import ast
import json
import operator
import re
import numpy as np
import pandas as pd
from pydantic import BaseModel, Field, ValidationError
precios = pd.read_parquet("acciones6_precios.parquet")
noticias = pd.read_parquet("noticias6_corpus.parquet")
TICKERS = set(precios.ticker)
FECHA = r"^\d{4}-\d{2}-\d{2}$"
class ArgsPrecio(BaseModel):
ticker: str = Field(pattern=r"^[A-Z]{4}$")
fecha: str = Field(pattern=FECHA)
class ArgsNoticias(BaseModel):
ticker: str = Field(pattern=r"^[A-Z]{4}$")
desde: str = Field(pattern=FECHA)
hasta: str = Field(pattern=FECHA)
class ArgsCalculadora(BaseModel):
expresion: str
def precio(ticker, fecha):
if ticker not in TICKERS:
raise ValueError(f"ticker desconocido: {ticker}")
s = precios[(precios.ticker == ticker) & (precios.fecha <= fecha)]
if s.empty:
raise ValueError(f"sin datos hasta {fecha}")
r = s.iloc[-1]
return {"ticker": ticker, "fecha": f"{r.fecha:%Y-%m-%d}",
"cierre": round(float(r.cierre), 2)}
def buscar_noticias(ticker, desde, hasta):
if ticker not in TICKERS:
raise ValueError(f"ticker desconocido: {ticker}")
ini = pd.Timestamp(desde, tz="UTC")
fin = pd.Timestamp(hasta, tz="UTC") + pd.Timedelta(days=1)
x = noticias[(noticias.ticker == ticker) & (noticias.ts >= ini)
& (noticias.ts < fin)].drop_duplicates("titular")
return [{"fecha": f"{t:%Y-%m-%d}", "titular": h}
for t, h in zip(x.ts[:5], x.titular[:5])]
OPS = {ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.USub: operator.neg}
def _evalua(n):
if isinstance(n, ast.Constant) and type(n.value) in (int, float):
return n.value
if isinstance(n, ast.BinOp) and type(n.op) in OPS:
return OPS[type(n.op)](_evalua(n.left), _evalua(n.right))
if isinstance(n, ast.UnaryOp) and type(n.op) in OPS:
return OPS[type(n.op)](_evalua(n.operand))
raise ValueError("solo números, + - * / y paréntesis")
def calculadora(expresion):
"""Aritmética sin eval(): se recorre el árbol de la expresión."""
return round(_evalua(ast.parse(expresion, mode="eval").body), 4)
HERRAMIENTAS = {
"precio": (precio, ArgsPrecio, "Cierre de una acción en una "
"sesión (la última sesión en o antes de la fecha)."),
"buscar_noticias": (buscar_noticias, ArgsNoticias, "Titulares de "
"noticias de una acción entre dos fechas "
"(AAAA-MM-DD)."),
"calculadora": (calculadora, ArgsCalculadora, "Evalúa una "
"expresión aritmética con + - * / y paréntesis."),
}
def ejecutar(nombre, args):
"""Valida y ejecuta; los errores vuelven como datos, no como
excepciones, para que el modelo los vea."""
if nombre not in HERRAMIENTAS:
return {"error": f"herramienta desconocida: {nombre}"}
f, Args, _ = HERRAMIENTAS[nombre]
try:
a = Args.model_validate(args)
except ValidationError as e:
return {"error": "argumentos no válidos: " + "; ".join(
f"{x['loc'][0]}: {x['msg']}" for x in e.errors())}
try:
return {"resultado": f(**a.model_dump())}
except Exception as e:
return {"error": str(e)}
print(ejecutar("precio", {"ticker": "BUSI", "fecha": "2024-03-16"}))
print(ejecutar("precio", {"ticker": "BUSI", "fecha": "16/03/2024"}))
print(ejecutar("calculadora", {"expresion": "(27.88/26.19 - 1)*100"}))
print(ejecutar("calculadora",
{"expresion": "__import__('os').getcwd()"}))
print(ejecutar("buscar_noticias", {"ticker": "BUSI",
"desde": "2024-03-18", "hasta": "2024-03-20"})){'resultado': {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}}
{'error': "argumentos no válidos: fecha: String should match pattern '^\\d{4}-\\d{2}-\\d{2}$'"}
{'resultado': 6.4528}
{'error': 'solo números, + - * / y paréntesis'}
{'resultado': [{'fecha': '2024-03-19', 'titular': 'Un fondo ofrece comprar Busmar Hogar'}]}Cuatro decisiones de diseño que se repiten en cualquier agente serio:
- Los errores son datos. Una fecha en formato europeo o un ticker inexistente no lanzan una excepción que tumbe el
programa: vuelven como
{"error": …}, que el modelo leerá como observación y podrá corregir. - Sin
eval. La calculadora recorre el árbol sintáctico y solo admite números y cuatro operaciones. Un modelo (o un texto inyectado en una noticia que el modelo lee) no puede ejecutar código por esa vía. - Fechas y sesiones explícitas. El 16 de marzo de 2024 es sábado:
preciodevuelve el cierre del viernes 15 y dice qué fecha usó. Una herramienta que respondiera en silencio con otra sesión sería fuente de errores difíciles de ver. - Resultados acotados.
buscar_noticiasquita las copias sindicadas (mismo titular) y devuelve como mucho cinco titulares. Lo que vuelve de una herramienta entra en el contexto del modelo en cada paso siguiente.
El tool calling por dentro: la plantilla del modelo local
Con un modelo local se ve que el tool calling no es magia: es texto. La plantilla de chat de Qwen2.5-0.5B-Instruct
(Apache-2.0, el modelo pequeño del entorno ia; lección 6.2.1) mete los esquemas de las herramientas en el mensaje de
sistema entre etiquetas <tools> y pide al modelo que conteste con <tool_call>{"name": …, "arguments": …}</tool_call>.
El modelo fue ajustado para seguir ese formato; el programa tiene que extraer las llamadas del texto y comprobar que
son JSON válido. La documentación de Qwen lo advierte: no está garantizado que el modelo siga siempre el protocolo, y
en producción conviene parsear y validar por cuenta propia.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
modelo = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(modelo)
llm = AutoModelForCausalLM.from_pretrained(modelo, dtype=torch.float32)
def esquema(nombres):
"""Herramientas en el formato de funciones de la plantilla."""
return [{"type": "function", "function": {
"name": n, "description": HERRAMIENTAS[n][2],
"parameters": HERRAMIENTAS[n][1].model_json_schema()}}
for n in nombres]
SISTEMA = ("Eres un asistente de research de renta variable. Usa las "
"herramientas para obtener datos; no inventes cifras. Cuando "
"tengas la respuesta, contesta en español en una o dos frases.")
mensajes = [{"role": "system", "content": SISTEMA},
{"role": "user", "content": "¿Cierre de BUSI el 2024-03-15?"}]
texto = tok.apply_chat_template(mensajes, tools=esquema(["precio"]),
add_generation_prompt=True,
tokenize=False)
print(texto[texto.index("# Tools"):texto.index("<|im_start|>user")])# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "precio", "description": "Cierre de una acción en una sesión (la última sesión en o antes de la fecha).", "parameters": {"properties": {"ticker": {"pattern": "^[A-Z]{4}$", "title": "Ticker", "type": "string"}, "fecha": {"pattern": "^\\d{4}-\\d{2}-\\d{2}$", "title": "Fecha", "type": "string"}}, "required": ["ticker", "fecha"], "title": "ArgsPrecio", "type": "object"}}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>Un bucle ReAct con el LLM local
El bucle es corto: generar, buscar etiquetas <tool_call>, ejecutar cada llamada con ejecutar y devolver el
resultado como mensaje con rol tool; si no hay llamadas, esa es la respuesta final. Los guardarraíles mínimos van
dentro del bucle:
- Límite de pasos (
max_pasos): un agente que no converge se corta. - JSON inválido y herramienta desconocida: vuelven como error.
- Llamada repetida con los mismos argumentos: no se ejecuta otra vez; se le recuerda al modelo que ya tiene el resultado.
- Traza: cada llamada, con sus argumentos y su resultado, queda registrada para auditar y verificar.
Dos tareas con su trampa: una variación porcentual (dos precios y una cuenta) y el cierre de un ticker que no existe. Cada paso del modelo pequeño cuesta varios segundos de CPU, así que la lección se queda en dos; la herramienta de noticias se usa en el ejercicio 1.
LLAMADA = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
def agente_local(pregunta, nombres, max_pasos=5):
msgs = [{"role": "system", "content": SISTEMA},
{"role": "user", "content": pregunta}]
traza, vistos = [], set()
for paso in range(1, max_pasos + 1):
e = tok.apply_chat_template(msgs, tools=esquema(nombres),
add_generation_prompt=True,
return_tensors="pt",
return_dict=True)
with torch.inference_mode():
out = llm.generate(**e, max_new_tokens=80, do_sample=False,
pad_token_id=tok.eos_token_id)
n = e["input_ids"].shape[1]
txt = tok.decode(out[0, n:], skip_special_tokens=True).strip()
llamadas = LLAMADA.findall(txt)
print(f" paso {paso} ({n} tokens de entrada)")
if not llamadas:
return txt, traza
msgs.append({"role": "assistant", "content": txt})
for crudo in llamadas:
try:
c = json.loads(crudo)
nombre, args = c["name"], c.get("arguments", {})
except (json.JSONDecodeError, KeyError, TypeError):
nombre, args = None, None
clave = json.dumps([nombre, args], sort_keys=True)
if nombre is None:
r = {"error": "llamada con JSON no válido"}
elif clave in vistos:
r = {"error": "llamada repetida: ya tienes el resultado"}
else:
r = ejecutar(nombre, args)
vistos.add(clave)
traza.append((nombre, args, r))
print(" ", nombre, args, "=>",
json.dumps(r, ensure_ascii=False)[:70])
msgs.append({"role": "tool",
"content": json.dumps(r, ensure_ascii=False)})
return None, traza
BASICAS = ["precio", "buscar_noticias", "calculadora"]
TAREAS = [
"¿Cuánto cambió en porcentaje el cierre de BUSI entre el "
"2024-03-15 y el 2024-03-22?",
"¿Cuál fue el cierre de ACME el 2024-03-15?"]
resultados = []
for pregunta in TAREAS:
print(pregunta)
final, traza = agente_local(pregunta, BASICAS)
resultados.append((pregunta, final, traza))
print(" RESPUESTA:", final)¿Cuánto cambió en porcentaje el cierre de BUSI entre el 2024-03-15 y el 2024-03-22?
paso 1 (567 tokens de entrada)
precio {'ticker': 'BUSI', 'fecha': '2024-03-15'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-15", "cierre": 26.1
precio {'ticker': 'BUSI', 'fecha': '2024-03-22'} => {"resultado": {"ticker": "BUSI", "fecha": "2024-03-22", "cierre": 27.8
paso 2 (734 tokens de entrada)
RESPUESTA: El cambio en el cierre del producto BUSI entre el 2024-03-15 y el 2024-03-22 fue de 27.88%.
¿Cuál fue el cierre de ACME el 2024-03-15?
paso 1 (548 tokens de entrada)
precio {'ticker': 'ACME', 'fecha': '2024-03-15'} => {"error": "ticker desconocido: ACME"}
paso 2 (612 tokens de entrada)
RESPUESTA: El cierre del ticker ACME el 2024-03-15 fue de $10.En la primera tarea el modelo hace bien lo que parecía difícil: pide los dos cierres en el mismo turno (dos
<tool_call> seguidos), con el ticker y las fechas correctos, y recibe 26,19 y 27,88. Y falla en lo que parecía fácil:
no llama a la calculadora y responde que el cambio «fue de 27.88%», el segundo cierre convertido en porcentaje. La
variación real es del 6,45 %. En la segunda, el ticker no existe y la herramienta lo dice; el modelo recibe el error y
responde que el cierre «fue de $10». Ningún guardarraíl del bucle salta: las llamadas eran válidas, no hubo
repeticiones y sobraron pasos. Los tokens de entrada crecen en cada paso (de 567 a 734 en la primera tarea) porque el
modelo vuelve a leer toda la conversación, resultados incluidos.
Verificar la respuesta final contra la traza
Los guardarraíles del bucle no ven el error más peligroso, porque está en la respuesta final y no en una llamada. La respuesta se puede verificar contra la traza con dos reglas sencillas, deterministas y baratas:
- Toda cifra de la respuesta tiene que aparecer en algún resultado correcto de una herramienta (o en la pregunta).
- Todo porcentaje tiene que salir de una herramienta que calcula (aquí, la calculadora), no de la cabeza del modelo.
Y una tercera, de proceso: si una herramienta devolvió un error y nada lo resolvió después, la respuesta no puede dar el dato que esa herramienta debía traer.
NUM = re.compile(r"\d[\d.,]*\d|\d")
def cifras(s):
out = []
for t in NUM.findall(s or ""):
if "," in t:
t = t.replace(".", "").replace(",", ".")
elif re.fullmatch(r"\d{1,3}(\.\d{3})+", t):
t = t.replace(".", "")
out.append(float(t.rstrip(".")))
return out
def cerca(x, ys):
return any(abs(x - y) <= 0.006 * max(1.0, abs(y)) for y in ys)
def verificar(pregunta, final, traza):
if final is None:
return ["sin respuesta: límite de pasos"]
vistas, calculadas, errores = [], [], []
for nombre, args, r in traza:
if "resultado" in r:
vistas += cifras(json.dumps(r["resultado"]))
if nombre in ("calculadora", "variacion"):
calculadas += cifras(json.dumps(r["resultado"]))
else:
errores.append(nombre)
fallos = []
for x in cifras(final):
if not cerca(x, vistas + cifras(pregunta)):
fallos.append(f"cifra sin fuente: {x:g}")
for p in re.findall(r"(\d[\d.,]*)\s*%", final):
if not cerca(cifras(p)[0], calculadas):
fallos.append(f"porcentaje sin cálculo: {p}%")
if errores and not vistas:
fallos.append("error de herramienta sin resolver")
return fallos
for pregunta, final, traza in resultados:
print(pregunta[:45], "|", verificar(pregunta, final, traza) or "OK")¿Cuánto cambió en porcentaje el cierre de BUS | ['porcentaje sin cálculo: 27.88%']
¿Cuál fue el cierre de ACME el 2024-03-15? | ['cifra sin fuente: 10', 'error de herramienta sin resolver']El verificador rechaza las dos respuestas: el 27,88 % no sale de ningún cálculo, y el 10 no sale de ningún resultado y además quedó un error sin resolver. Qué se hace con una respuesta rechazada depende del uso: devolver el motivo al modelo como una observación más y darle otro intento, contestar «no se ha podido verificar» o pasar el caso a una persona. Lo que no se hace es enseñarla como buena. Las reglas son pocas y burdas (una cifra puede estar en la traza y aun así estar mal usada), pero cuestan microsegundos y cazan los dos fallos más caros: cifras inventadas y cuentas no hechas.
Planificar en vez de improvisar
Cuando la tarea es conocida («variación de un precio entre dos fechas»), no hace falta que el modelo descubra el plan en cada ejecución. Hay dos formas de fijarlo:
- Una herramienta de más alto nivel.
variacion(ticker, desde, hasta)hace las dos consultas y la cuenta en código. El modelo solo tiene que extraer tres parámetros de la pregunta y transcribir el resultado. - Planificar y ejecutar. El programa tiene el plan (precio inicial, precio final, cuenta, informe) y el modelo solo rellena los parámetros, o solo redacta al final. Es el patrón plan-and-execute: menos libertad, menos fallos, trazas más predecibles.
Fuente Mermaid
flowchart TB P[Pregunta] --> E[LLM extrae datos] E --> V[Validar esquema] V --> H[Cálculo en código] H --> R[LLM redacta] R --> C[Verificar]
class ArgsVariacion(BaseModel):
ticker: str = Field(pattern=r"^[A-Z]{4}$")
desde: str = Field(pattern=FECHA)
hasta: str = Field(pattern=FECHA)
def variacion(ticker, desde, hasta):
a, b = precio(ticker, desde), precio(ticker, hasta)
return {"desde": a, "hasta": b, "variacion_pct":
round(100 * (b["cierre"] / a["cierre"] - 1), 2)}
HERRAMIENTAS["variacion"] = (
variacion, ArgsVariacion, "Variación porcentual del cierre de una "
"acción entre dos fechas (AAAA-MM-DD), calculada con los dos "
"cierres.")
final, traza = agente_local(TAREAS[0], ["variacion", "buscar_noticias"])
print(" RESPUESTA:", final)
print(" verificación:", verificar(TAREAS[0], final, traza) or "OK") paso 1 (533 tokens de entrada)
variacion {'ticker': 'BUSI', 'desde': '2024-03-15', 'hasta': '2024-03-22'} => {"resultado": {"desde": {"ticker": "BUSI", "fecha": "2024-03-15", "cie
paso 2 (691 tokens de entrada)
RESPUESTA: El cambio en el cierre de la acción BUSI entre el 2024-03-15 y el 2024-03-22 fue de 6.45%.
verificación: OKCon variacion el modelo hace una sola llamada con los tres parámetros correctos, recibe la variación calculada en
código y la transcribe: 6,45 %, y el verificador la da por buena. Es el mismo modelo con el mismo prompt de sistema; lo
único que ha cambiado es que ya no tiene que encadenar tres llamadas ni hacer una cuenta. En un agente, el diseño de
las herramientas pesa tanto como el modelo: cuanto más alto el nivel de la herramienta, menos decisiones quedan en
manos del modelo y menos formas hay de fallar. El precio es la flexibilidad: variacion no sirve para preguntas que no
se previeron, y para esas sigue haciendo falta un bucle ReAct (con su verificador).
Las APIs comerciales: mismas ideas, otra forma
Las APIs de OpenAI y Anthropic hacen lo mismo con mensajes estructurados en lugar de etiquetas en el texto:
| OpenAI (Responses API) | Anthropic (Messages API) | |
|---|---|---|
| Definir una herramienta | {"type": "function", "name", "description", "parameters"} | {"name", "description", "input_schema"} |
| El modelo pide una llamada | elemento {"type": "function_call", "call_id", "name", "arguments"} (argumentos como texto JSON) | bloque {"type": "tool_use", "id", "name", "input"} (argumentos como objeto) y stop_reason: "tool_use" |
| Devolver el resultado | {"type": "function_call_output", "call_id", "output"} en input | bloque {"type": "tool_result", "tool_use_id", "content"} en un mensaje de usuario (is_error para errores) |
| Varias llamadas a la vez | posibles (parallel_tool_calls) | posibles; todos los resultados en un solo mensaje |
Formas según la documentación de cada proveedor, consultada el 2026-10-231Nota 1 · OpenAI, «Function calling» (Responses API), y Anthropic, «Tool use with Claude», consultadas el 2026-10-23.
Las dos documentaciones advierten, cada una a su manera, de que el modelo puede pedir varias llamadas en un mismo
turno; OpenAI permite desactivarlo con parallel_tool_calls y Anthropic con disable_parallel_tool_use.. El código real, que no se ejecuta aquí
porque necesita claves y red:
from openai import OpenAI
cliente = OpenAI() # OPENAI_API_KEY del entorno
herramientas = [{"type": "function", "name": n,
"description": HERRAMIENTAS[n][2],
"parameters": HERRAMIENTAS[n][1].model_json_schema()}
for n in BASICAS]
entrada = [{"role": "user", "content": TAREAS[0]}]
r = cliente.responses.create(model="gpt-5", input=entrada,
tools=herramientas)
for item in r.output:
if item.type == "function_call":
res = ejecutar(item.name, json.loads(item.arguments))
entrada += [item, {"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps(res)}]import anthropic
cliente = anthropic.Anthropic() # ANTHROPIC_API_KEY del entorno
herramientas = [{"name": n, "description": HERRAMIENTAS[n][2],
"input_schema": HERRAMIENTAS[n][1].model_json_schema()}
for n in BASICAS]
mensajes = [{"role": "user", "content": TAREAS[0]}]
r = cliente.messages.create(model="claude-opus-5-5", max_tokens=1024,
tools=herramientas, messages=mensajes)
if r.stop_reason == "tool_use":
resultados = [{"type": "tool_result", "tool_use_id": b.id,
"content": json.dumps(ejecutar(b.name, b.input))}
for b in r.content if b.type == "tool_use"]
mensajes += [{"role": "assistant", "content": r.content},
{"role": "user", "content": resultados}]Para ejecutar ese mismo código sin red, los clientes oficiales se apuntan a simuladores locales: un
MockTransport (de httpx para el SDK de OpenAI y de httpx2 para el de Anthropic 2.x) que responde con la forma
documentada. Detrás del simulador no hay un modelo sino un guion: una política fija que decide la siguiente
acción mirando cuántos resultados de herramientas hay ya en la conversación. Así se pueden provocar a voluntad los
fallos típicos y comprobar que el bucle los contiene:
| Escenario | Qué hace el «modelo» simulado |
|---|---|
correcto | Pide los dos precios a la vez, después la calculadora y responde con el porcentaje calculado |
herramienta_equivocada | Busca noticias en lugar de precios y responde sin cifra |
argumentos_inventados | Pide el precio de «BUSMAR» con fecha «15/03/2024»; tras el error de validación, corrige y sigue como el correcto |
bucle | Pide una y otra vez el mismo precio, aunque el bucle le avisa de que ya lo tiene |
import anthropic
import httpx
import httpx2
import openai
import tiktoken
PREGUNTA = TAREAS[0]
DESDE, HASTA = "2024-03-15", "2024-03-22"
def guion(escenario, resultados):
"""Siguiente acción del «modelo» simulado: lista de llamadas
(nombre, args) o el texto final. `resultados` = salidas de
herramientas recibidas hasta ahora (diccionarios)."""
n = len(resultados)
if escenario == "bucle":
return [("precio", {"ticker": "BUSI", "fecha": DESDE})]
if escenario == "herramienta_equivocada":
if n == 0:
return [("buscar_noticias", {"ticker": "BUSI",
"desde": DESDE, "hasta": HASTA})]
return "BUSI subió por la oferta de compra de un fondo."
if escenario == "argumentos_inventados":
if n == 0:
return [("precio", {"ticker": "BUSMAR",
"fecha": "15/03/2024"})]
resultados = resultados[1:]
n -= 1
if n == 0:
return [("precio", {"ticker": "BUSI", "fecha": DESDE}),
("precio", {"ticker": "BUSI", "fecha": HASTA})]
if n == 2:
a, b = (r["resultado"]["cierre"] for r in resultados[:2])
return [("calculadora", {"expresion": f"({b}/{a} - 1)*100"})]
v = resultados[2]["resultado"]
return f"El cierre de BUSI varió un {v:.2f} % entre {DESDE} y {HASTA}."
ENC = tiktoken.get_encoding("o200k_base")
REGISTRO = [] # tokens de cada petición recibida
def sim_openai(request):
cuerpo = json.loads(request.content)
REGISTRO.append(len(ENC.encode(request.content.decode())))
res = [json.loads(x["output"]) for x in cuerpo["input"]
if isinstance(x, dict) and x.get("type") == "function_call_output"]
accion = guion(cuerpo["model"], res)
if isinstance(accion, str):
salida = [{"type": "message", "id": "msg_1", "role": "assistant",
"status": "completed", "content": [
{"type": "output_text", "text": accion,
"annotations": []}]}]
else:
salida = [{"type": "function_call", "id": f"fc_{len(res)}_{i}",
"call_id": f"call_{len(res)}_{i}", "name": nom,
"arguments": json.dumps(args), "status": "completed"}
for i, (nom, args) in enumerate(accion)]
return httpx.Response(200, json={
"id": "resp_sim", "object": "response", "created_at": 0,
"status": "completed", "model": cuerpo["model"],
"output": salida, "parallel_tool_calls": True,
"tool_choice": "auto", "tools": []})
def sim_anthropic(request):
cuerpo = json.loads(request.content)
REGISTRO.append(len(ENC.encode(request.content.decode())))
res = [json.loads(b["content"]) for m in cuerpo["messages"]
if m["role"] == "user" and isinstance(m["content"], list)
for b in m["content"] if b.get("type") == "tool_result"]
accion = guion(cuerpo["model"], res)
if isinstance(accion, str):
contenido, parada = [{"type": "text", "text": accion}], "end_turn"
else:
contenido = [{"type": "tool_use", "id": f"toolu_{len(res)}_{i}",
"name": nom, "input": args}
for i, (nom, args) in enumerate(accion)]
parada = "tool_use"
return httpx2.Response(200, json={
"id": "msg_sim", "type": "message", "role": "assistant",
"model": cuerpo["model"], "content": contenido,
"stop_reason": parada, "stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0}})
oai = openai.OpenAI(
api_key="sk-simulado", base_url="http://simulado/v1",
http_client=httpx.Client(transport=httpx.MockTransport(sim_openai)))
ant = anthropic.Anthropic(
api_key="sk-simulado", base_url="http://simulado",
http_client=httpx2.Client(
transport=httpx2.MockTransport(sim_anthropic)))
r = oai.responses.create(model="correcto", input=PREGUNTA)
print([(o.type, o.name, o.arguments) for o in r.output])
m = ant.messages.create(model="correcto", max_tokens=200,
messages=[{"role": "user", "content": PREGUNTA}])
print(m.stop_reason, [(b.type, b.name, b.input) for b in m.content])[('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-15"}'), ('function_call', 'precio', '{"ticker": "BUSI", "fecha": "2024-03-22"}')]
tool_use [('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-15'}), ('tool_use', 'precio', {'ticker': 'BUSI', 'fecha': '2024-03-22'})]El bucle es uno solo, con un pequeño adaptador por proveedor: cómo se piden las llamadas, cómo se leen y cómo se devuelven los resultados. Los guardarraíles son los mismos del agente local, más un límite de tokens enviados.
def ejecutar_seguro(nombre, args, vistos):
clave = json.dumps([nombre, args], sort_keys=True)
if clave in vistos:
return {"error": "llamada repetida: ya tienes el resultado"}
vistos.add(clave)
return ejecutar(nombre, args)
def agente_api(proveedor, escenario, max_pasos=6):
REGISTRO.clear()
historial = [{"role": "user", "content": PREGUNTA}]
vistos, eventos = set(), []
for paso in range(1, max_pasos + 1):
if proveedor == "openai":
r = oai.responses.create(model=escenario, input=historial)
calls = [(o.call_id, o.name, json.loads(o.arguments))
for o in r.output if o.type == "function_call"]
final = r.output_text
historial += [o.model_dump(exclude_none=True)
for o in r.output]
else:
r = ant.messages.create(model=escenario, max_tokens=500,
messages=historial)
calls = [(b.id, b.name, b.input) for b in r.content
if b.type == "tool_use"]
final = "".join(b.text for b in r.content
if b.type == "text")
historial.append({"role": "assistant", "content": [
b.model_dump(exclude_none=True) for b in r.content]})
if not calls:
eventos.append((paso, "respuesta"))
return final, eventos, list(REGISTRO)
bloques = []
for cid, nombre, args in calls:
res = ejecutar_seguro(nombre, args, vistos)
tipo = "error" if "error" in res else "ok"
eventos.append((paso, f"{nombre}:{tipo}"))
if proveedor == "openai":
historial.append({"type": "function_call_output",
"call_id": cid,
"output": json.dumps(res)})
else:
bloques.append({"type": "tool_result",
"tool_use_id": cid,
"content": json.dumps(res),
"is_error": "error" in res})
if bloques:
historial.append({"role": "user", "content": bloques})
eventos.append((max_pasos, "corte"))
return None, eventos, list(REGISTRO)
ESCENARIOS = ["correcto", "herramienta_equivocada",
"argumentos_inventados", "bucle"]
for esc in ESCENARIOS:
for prov in ("openai", "anthropic"):
final, ev, tokens = agente_api(prov, esc)
print(f"{esc:22s} {prov:9s} pasos={ev[-1][0]}"
f" llamadas={sum(':' in e for _, e in ev)}"
f" errores={sum(e.endswith('error') for _, e in ev)}"
f" tokens={sum(tokens):5d} | {final}")correcto openai pasos=3 llamadas=3 errores=0 tokens= 630 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
correcto anthropic pasos=3 llamadas=3 errores=0 tokens= 643 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
herramienta_equivocada openai pasos=2 llamadas=1 errores=0 tokens= 221 | BUSI subió por la oferta de compra de un fondo.
herramienta_equivocada anthropic pasos=2 llamadas=1 errores=0 tokens= 235 | BUSI subió por la oferta de compra de un fondo.
argumentos_inventados openai pasos=4 llamadas=4 errores=1 tokens= 1065 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
argumentos_inventados anthropic pasos=4 llamadas=4 errores=1 tokens= 1101 | El cierre de BUSI varió un 6.45 % entre 2024-03-15 y 2024-03-22.
bucle openai pasos=6 llamadas=6 errores=5 tokens= 1643 | None
bucle anthropic pasos=6 llamadas=6 errores=5 tokens= 1763 | NoneLos dos proveedores dan exactamente la misma traza, porque el guion es el mismo y el bucle solo cambia en la forma de los mensajes. Por escenarios:
- Correcto: tres peticiones (dos precios en paralelo, la calculadora, la respuesta) y un 6,45 % calculado.
- Herramienta equivocada: busca noticias, recibe el titular de la oferta y responde algo plausible («subió por la oferta de compra de un fondo») que no contesta a la pregunta: no hay porcentaje. El bucle no tiene forma de saberlo; una regla más del verificador (si la pregunta pide un porcentaje, la respuesta tiene que darlo, calculado) lo marcaría.
- Argumentos inventados: el ticker «BUSMAR» y la fecha «15/03/2024» no pasan la validación; el error vuelve como observación, el «modelo» corrige y el agente termina bien en cuatro pasos. Un modelo capaz suele hacer esto; uno pequeño, no siempre.
- Bucle: la primera llamada funciona y las cinco siguientes son repeticiones que el bucle no ejecuta. El límite de seis pasos lo corta sin respuesta, que es el resultado correcto: mejor nada que algo sin verificar.
final, ev, tokens_bucle = agente_api("anthropic", "bucle", max_pasos=30)
_, _, tokens_ok = agente_api("anthropic", "correcto")
acum = np.cumsum(tokens_bucle)
print("tokens por petición, pasos 1-6:", tokens_bucle[:6])
print("pasos 25-30:", tokens_bucle[24:30])
print("acumulado tras 6, 12 y 30 pasos:", acum[5], acum[11], acum[29])
print("caso correcto, total:", sum(tokens_ok), "en", len(tokens_ok),
"peticiones")tokens por petición, pasos 1-6: [53, 160, 251, 342, 433, 524]
pasos 25-30: [2253, 2344, 2435, 2526, 2617, 2708]
acumulado tras 6, 12 y 30 pasos: 1763 6818 41639
caso correcto, total: 643 en 3 peticionesCada petición reenvía la conversación entera, así que lo que se envía por paso crece linealmente: 53 tokens la primera, 524 la sexta, 2.708 la trigésima. El total crece con el cuadrado de los pasos: 1.763 tokens con el límite de seis, 6.818 con doce y 41.639 con treinta, frente a 643 del caso correcto. En una API que cobra por token, un agente sin límite de pasos es un gasto sin techo; con un modelo local, minutos de CPU. Por eso el límite de pasos es también un límite de presupuesto, y conviene complementarlo con uno explícito de tokens.
Errores típicos
- Ejecutar sin validar. Los argumentos de una llamada son texto generado: un ticker inventado, una fecha en otro formato, una expresión con código. Siempre esquema, validación y, en herramientas con efectos (órdenes, correos, escrituras), aprobación fuera del modelo (6.4.5).
- Herramientas con efectos o no deterministas. Una herramienta que lee la hora real, una web viva o un estado mutable hace que la misma traza no se repita y que no se pueda testear. Primero deterministas; lo demás, detrás de una capa que registra y fija los datos.
- Excepciones en vez de errores como datos. Si una herramienta lanza una excepción, el agente muere y el modelo no aprende nada; si devuelve el error, el modelo puede corregir y el registro lo cuenta.
- Sin límite de pasos ni de gasto. Un bucle no converge solo. Cada paso reenvía toda la conversación: el coste es cuadrático en el número de pasos.
- No verificar la respuesta final. Un modelo puede pedir los datos correctos y después redactar otra cosa. Se comprueba la respuesta contra la traza, con reglas deterministas.
- Darle al modelo la aritmética. Cuentas, conversiones y fechas, en herramientas o en código. El modelo extrae y redacta.
- Dejar que improvise lo que es un procedimiento. Si la tarea es conocida, un plan fijo (o una herramienta de alto nivel) es más fiable, más barato y más fácil de auditar que un ReAct libre.
- Confiar en lo que devuelven las herramientas como instrucciones. Un titular o un documento pueden contener texto que parezca una orden («ignora lo anterior y…»): es una inyección de instrucciones. Lo que vuelve de una herramienta es dato, nunca instrucción (6.4.5).
Ejercicios
- Añade al verificador una regla para preguntas de noticias: todo titular entrecomillado en la respuesta tiene que
aparecer literalmente en algún resultado de
buscar_noticias. Pruébala con la traza de una búsqueda de noticias de BUSI entre el 18 y el 20 de marzo de 2024 y dos respuestas escritas a mano, una fiel y otra inventada.
Solución
def titulares_ok(final, traza):
reales = {n["titular"] for nom, _, r in traza
if nom == "buscar_noticias" and "resultado" in r
for n in r["resultado"]}
citados = re.findall(r"[«\"“]([^»\"”]+)[»\"”]", final or "")
return [c for c in citados if c not in reales]
args = {"ticker": "BUSI", "desde": "2024-03-18", "hasta": "2024-03-20"}
traza = [("buscar_noticias", args, ejecutar("buscar_noticias", args))]
fiel = 'El 19 de marzo: «Un fondo ofrece comprar Busmar Hogar».'
falsa = 'Hubo una noticia: "Busmar Hogar dispara su beneficio un 40 %".'
print("fiel:", titulares_ok(fiel, traza) or "OK")
print("inventada:", titulares_ok(falsa, traza))fiel: OK
inventada: ['Busmar Hogar dispara su beneficio un 40 %']La regla deja pasar el titular real y marca el inventado. Es una comprobación literal: una paráfrasis honesta de un titular real («un fondo lanza una oferta por Busmar») también la suspendería. Una comparación aproximada (por palabras o por embeddings, lección 6.3.1) tolera la paráfrasis a cambio de dejar pasar más errores; en un informe que va a clientes, lo prudente es exigir la cita literal.
- Con el guion
argumentos_inventados, ¿qué pasaría siejecutarno validara el formato de la fecha? Llama apreciodirectamente con «15/03/2024» y con «01/03/2024» (1 de marzo, a la española) y compara con las fechas ISO.
Solución
for f in ["15/03/2024", "2024-03-15", "01/03/2024", "2024-03-01"]:
print(f, precio("BUSI", f))15/03/2024 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
2024-03-15 {'ticker': 'BUSI', 'fecha': '2024-03-15', 'cierre': 26.19}
01/03/2024 {'ticker': 'BUSI', 'fecha': '2024-01-03', 'cierre': 27.75}
2024-03-01 {'ticker': 'BUSI', 'fecha': '2024-03-01', 'cierre': 25.2}Sin validación, «15/03/2024» funciona por casualidad: pandas reconoce el formato y lo interpreta bien. Pero «01/03/2024», el 1 de marzo a la española, se lee con el mes primero como 3 de enero, y la herramienta devuelve sin ningún error el cierre de otra fecha (27,75 en lugar de 25,20). Un fallo silencioso es peor que uno ruidoso: por eso el esquema exige AAAA-MM-DD, la validación rechaza lo demás y el resultado dice qué fecha se usó.
- Diseña, sin código, las herramientas de un agente que prepara un resumen diario de una cartera: qué herramientas le darías, cuáles no, y qué haría el código en lugar del modelo.
Solución
Herramientas de solo lectura, deterministas para una fecha dada: posiciones de la cartera a la fecha, cierres y rentabilidades del día (calculadas en código), noticias del día por ticker con la hora de publicación (para separar lo de antes y lo de después del cierre), y eventos del calendario (resultados, dividendos). Todas con la fecha como argumento obligatorio, para poder reproducir el informe de cualquier día y para no mezclar información posterior. No le daría nada que escriba o envíe órdenes: el agente redacta, no opera (6.4.5). El código haría las cifras (P&L por posición, contribuciones, variaciones), la selección de qué entra en el informe (umbrales de movimiento, materialidad) y la verificación final: toda cifra del texto en la traza, todo titular citado existente. El modelo solo redacta el resumen a partir de una tabla ya calculada y, como mucho, elige qué noticias explicar. Con ese diseño, un modelo pequeño puede hacer el trabajo; con un ReAct libre sobre herramientas de bajo nivel, ni uno grande es fiable cada día.
Footnotes
-
OpenAI, «Function calling» (Responses API), y Anthropic, «Tool use with Claude», consultadas el 2026-10-23. Las dos documentaciones advierten, cada una a su manera, de que el modelo puede pedir varias llamadas en un mismo turno; OpenAI permite desactivarlo con
parallel_tool_callsy Anthropic condisable_parallel_tool_use. ↩
Respuesta plegada en cada pregunta
Autoevaluación
En una API con tool calling, ¿quién ejecuta la función
precio(ticker, fecha)?- AEl modelo, en los servidores del proveedor
- BEl programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje
- CNadie: el modelo se inventa el resultado
- DEl navegador del usuario
Ver respuesta
Respuesta B. El programa que llama a la API: el modelo solo devuelve el nombre de la herramienta y los argumentos en JSON, y espera el resultado en el siguiente mensaje
En las herramientas definidas por el cliente, la respuesta del modelo trae un bloque
tool_use(Anthropic) o un elementofunction_call(OpenAI) con nombre y argumentos. El programa la ejecuta y devuelve untool_resulto unfunction_call_output. Por eso el programa puede validar, limitar y auditar cada llamada.El LLM local pide los dos cierres correctamente (26,19 y 27,88) y responde que el cambio «fue de 27.88%». ¿Qué guardarraíl lo detecta?
- AEl límite de pasos
- BLa validación Pydantic de los argumentos
- CLa detección de llamadas repetidas
- DUn verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado
Ver respuesta
Respuesta D. Un verificador de la respuesta final que exige que todo porcentaje salga de una llamada a la calculadora (o a una herramienta que calcula) y toda cifra, de algún resultado
Los argumentos eran válidos y no hubo bucle: el error está en la respuesta final, que copia un cierre como si fuera el porcentaje. Solo una comprobación de la respuesta contra la traza de herramientas lo detecta.
¿Para qué sirve devolver al modelo un error de validación («fecha: String should match pattern…») en lugar de cortar el agente?
- APara que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados
- BPara gastar menos tokens
- CPorque las APIs no permiten cortar un agente
- DNo sirve de nada: el modelo nunca corrige
Ver respuesta
Respuesta A. Para que el modelo pueda corregir los argumentos en el paso siguiente, como hace el escenario simulado de argumentos inventados
Un error informativo es una observación más del bucle ReAct. Los modelos capaces suelen corregir el formato al verlo; uno pequeño quizá no, y por eso el error se cuenta y hay un límite de pasos. Lo que no se hace nunca es ejecutar la herramienta con argumentos no validados.
En el escenario de bucle, sin límite de pasos, el agente repite la misma llamada. ¿Por qué el coste crece más deprisa que el número de pasos?
- APorque cada llamada repetida cuesta el doble
- BPorque la herramienta tarda más cada vez
- CPorque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática
- DNo crece: el coste es constante
Ver respuesta
Respuesta C. Porque cada petición reenvía toda la conversación anterior: los tokens enviados por paso crecen linealmente y el total, de forma cuadrática
Las APIs son sin estado: cada paso envía el historial completo, con todas las llamadas y resultados previos. Con 30 pasos se envían muchas veces más tokens que con 6. El límite de pasos y la detección de repeticiones son también un límite de gasto.
Para «¿cuánto varió en % el cierre de BUSI entre dos fechas?», ¿qué diseño es más fiable con un modelo pequeño?
- ADarle solo una calculadora y confiar en que haga la cuenta
- BDarle una herramienta
variacion(ticker, desde, hasta)que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetros - CPedirle que piense paso a paso sin herramientas
- DUsar temperatura alta para que explore
Ver respuesta
Respuesta B. Darle una herramienta
variacion(ticker, desde, hasta)que hace el cálculo en código, o un plan fijo en el que el modelo solo extrae los parámetrosCuanto menos tenga que encadenar el modelo, menos puntos de fallo. Una herramienta de más alto nivel convierte tres llamadas y una cuenta en una, y la aritmética la hace el código. Es planificación fijada por el programador para una tarea conocida.
Ideas clave
- Tool calling: el modelo emite peticiones estructuradas (nombre y argumentos JSON); el programa las valida, las ejecuta y devuelve el resultado. Nada se ejecuta sin pasar por el código.
- ReAct alterna razonamiento, acción (llamada a herramienta) y observación (resultado) hasta responder; planificar primero y ejecutar después es la alternativa cuando la tarea es conocida.
- Guardarraíles mínimos del bucle: validación de argumentos con esquema, errores devueltos como observación, detección de repeticiones, límite de pasos y verificación de la respuesta final contra la traza.
- El LLM local de 0,5B formula bien llamadas sencillas, pero ignora errores y resultados al redactar: inventa un precio para un ticker inexistente y confunde un cierre con un porcentaje.
- Las formas de OpenAI (
function_callyfunction_call_output) y de Anthropic (tool_useytool_result) son equivalentes; el mismo bucle sirve para las dos con un adaptador, y se prueba sin red contra simuladores.