Lección 7.3.4 · 65 min

Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción

Un modelo entrenado es un objeto de Python dentro de un proceso; un modelo en producción es un fichero que otro proceso carga, alimenta y vigila. Esta lección exporta un clasificador de scikit-learn a ONNX, comprueba que da lo mismo, lo sirve por HTTP con un contrato de entrada, mide qué cuesta cada capa y termina con la otra factura de la inferencia: la de los modelos de lenguaje, calculada con los precios publicados por Anthropic y OpenAI y vigilada con un guardián de presupuesto.

Fig. — Coste mensual de las tres cargas de trabajo de la lección (titulares, informes y agente; 21 sesiones) con cada modelo, a tarifa estándar y con caché y lotes, frente a un servidor GPU de alquiler. Precios de las páginas oficiales consultadas el 23 de octubre de 2026
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

Del objeto al fichero

Las lecciones 7.3.2 y 7.3.3 dejaron un modelo registrado, con alias y vigilado. Falta decidir cómo se ejecuta: en qué formato se guarda, qué proceso lo carga, en qué hardware y a qué precio. Para un modelo propio de scikit-learn la respuesta suele ser sencilla y barata; para un modelo de lenguaje de un proveedor, la factura llega por token y puede crecer sin que nadie lo note.

Tres formas de servir un modelo, de la más simple a la más compleja:

en el procesoHTTP localHTTP o gRPCProceso de tradingONNX RuntimeServicio propioONNX RuntimeServidor de modelosCPU o GPU
Fuente Mermaid
flowchart TB
  A[Proceso de trading] -->|en el proceso| M[ONNX Runtime]
  A -->|HTTP local| S[Servicio propio]
  S --> M2[ONNX Runtime]
  A -->|HTTP o gRPC| V[Servidor de modelos]
  V --> G[CPU o GPU]
  • En el proceso: el sistema de trading carga el fichero del modelo y llama a una función. Sin red, sin serialización, con la latencia mínima. Cada proceso tiene su copia.
  • Un servicio propio: un proceso pequeño que carga el modelo y responde peticiones. Varios procesos comparten un modelo, y el modelo se despliega sin tocar el sistema de trading. Cuesta una ida y vuelta por la red local.
  • Un servidor de modelos (BentoML, Ray Serve, NVIDIA Triton; vLLM para modelos de lenguaje): agrupa peticiones en lotes, gestiona varias versiones y GPU, escala con la carga. Compensa con muchos modelos o mucho tráfico; para una señal diaria es infraestructura de sobra.

Y un formato. La lección 5.6.4 exportó una red de Keras con torch.export y dejó ONNX sin probar porque no estaba en el entorno del nivel 5. El entorno ops sí lo tiene: skl2onnx 1.20.0 y onnxruntime 1.31.0.

El modelo y su exportación a ONNX

El modelo es un clasificador de dirección del día siguiente con ocho features, sobre datos sintéticos: un pipeline con StandardScaler y un GradientBoostingClassifier de 200 árboles de profundidad 3.

IN [01]python
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

rng = np.random.default_rng(734)
n, K = 20_000, 8
X = rng.standard_normal((n, K))           # 8 features de un día
logit = (0.6 * X[:, 0] - 0.4 * X[:, 1] + 0.3 * X[:, 2] * X[:, 3]
         + 0.2 * np.tanh(2 * X[:, 4]))
y = (logit + rng.logistic(size=n) > 0).astype(int)   # ¿sube mañana?
Xtr, Xte, ytr, yte = X[:15_000], X[15_000:], y[:15_000], y[15_000:]

gbc = GradientBoostingClassifier(n_estimators=200, max_depth=3,
                                 random_state=0)
modelo = make_pipeline(StandardScaler(), gbc).fit(Xtr, ytr)
print("acierto fuera de muestra:",
      round((modelo.predict(Xte) == yte).mean(), 3))
OUT [01]El modelo que hay que servir: un pipeline de scikit-learn
acierto fuera de muestra: 0.64

Un 64 % de acierto fuera de muestra es lo que dan los datos (la relación tiene ruido logístico); aquí el modelo es el paquete, no el contenido. ONNX (Open Neural Network Exchange) describe un modelo como un grafo de operadores estándar con versiones (los opsets), y cualquier runtime que implemente esos operadores lo puede ejecutar: ONNX Runtime en Python, C++, C#, Java o el navegador, sin scikit-learn ni el resto del entorno de entrenamiento.

IN [02]python
from collections import Counter

import onnx
from skl2onnx import to_onnx

onx = to_onnx(modelo, Xtr[:1].astype(np.float32),
              options={id(gbc): {"zipmap": False}})
onnx.checker.check_model(onx)
with open("senal.onnx", "wb") as f:
    f.write(onx.SerializeToString())

g = onx.graph
dims = g.input[0].type.tensor_type.shape.dim
print("entrada:", g.input[0].name, [d.dim_value or "N" for d in dims])
print("salidas:", [o.name for o in g.output])
print("nodos:", dict(Counter(nd.op_type for nd in g.node)))
print("opsets:", {o.domain or "ai.onnx": o.version
                  for o in onx.opset_import})
print(f"tamaño: {len(onx.SerializeToString()) / 1024:.0f} KB")
OUT [02]Exportar a ONNX y mirar dentro
entrada: X ['N', 8]
salidas: ['label', 'probabilities']
nodos: {'Scaler': 1, 'TreeEnsembleClassifier': 1, 'Identity': 1}
opsets: {'ai.onnx.ml': 1, 'ai.onnx': 21}
tamaño: 106 KB

El pipeline entero cabe en tres nodos: Scaler (la estandarización, con sus medias y escalas dentro), TreeEnsembleClassifier (los 200 árboles con sus umbrales) y un Identity que da nombre a la salida. La entrada se llama X y tiene forma [N, 8]: cualquier número de filas, ocho columnas. El fichero pesa 106 KB. zipmap: False hace que las probabilidades salgan como una matriz y no como una lista de diccionarios, que es más lenta y más incómoda.

Exportar un modelo es traducirlo, y una traducción se comprueba. La prueba de paridad compara las predicciones del modelo original y del exportado sobre muchos datos, y forma parte de la integración continua del proyecto, junto a la exportación misma:

IN [03]python
import onnxruntime as ort

opciones = ort.SessionOptions()
opciones.intra_op_num_threads = 1        # resultado y tiempo estables
sesion = ort.InferenceSession("senal.onnx", opciones,
                              providers=["CPUExecutionProvider"])
ENTRADA = sesion.get_inputs()[0].name
etiquetas, probs = sesion.run(None, {ENTRADA: Xte.astype(np.float32)})
p_sk = modelo.predict_proba(Xte)[:, 1]
print("máx. |diferencia| de probabilidad:",
      f"{np.abs(probs[:, 1] - p_sk).max():.1e}")
distintas = int((etiquetas != modelo.predict(Xte)).sum())
print("etiquetas distintas:", distintas, "de", len(Xte))
print("proveedores disponibles:", ort.get_available_providers())
OUT [03]Paridad: lo mismo en ONNX Runtime que en scikit-learn
máx. |diferencia| de probabilidad: 2.1e-07
etiquetas distintas: 0 de 5000
proveedores disponibles: ['AzureExecutionProvider', 'CPUExecutionProvider']

Las 5.000 etiquetas coinciden y las probabilidades difieren como mucho en 2,1·10⁻⁷: la entrada se declaró en float32 y el grafo trabaja en esa precisión, mientras scikit-learn usa float64. Con modelos de árboles conviene contar etiquetas, no solo diferencias de probabilidad: un valor que cae justo en un umbral de un árbol puede irse al otro lado al pasar a 32 bits. Si eso importa, skl2onnx admite convertir a doble precisión. Los proveedores disponibles son dos: CPUExecutionProvider, el que se usa, y AzureExecutionProvider, que no se usa (lo trae la rueda de PyPI).

La exportación no siempre funciona. Con las versiones exactas de este entorno, el otro clasificador de árboles de scikit-learn falla al convertirse:

IN [04]python
from sklearn.ensemble import HistGradientBoostingClassifier
import skl2onnx
import sklearn

hgb = HistGradientBoostingClassifier(max_iter=20, random_state=0)
hgb.fit(Xtr, ytr)
try:
    to_onnx(hgb, Xtr[:1].astype(np.float32),
            options={id(hgb): {"zipmap": False}})
except ValueError as err:
    print("scikit-learn", sklearn.__version__, "· skl2onnx",
          skl2onnx.__version__, "· onnx", onnx.__version__)
    print(type(err).__name__ + ":", str(err)[:46])
    causa = err.__cause__ or err.__context__
    print("causa:", type(causa).__name__ + ":", str(causa))
OUT [04]Cuando el conversor no sigue a la librería
scikit-learn 1.9.1 · skl2onnx 1.20.0 · onnx 1.23.2
ValueError: Unable to create node 'TreeEnsembleClassifier'
causa: TypeError: Field onnx.AttributeProto.ints: Expected an int, got a boolean.

El conversor genera un atributo con valores booleanos donde la especificación de ONNX espera enteros, y la librería onnx lo rechaza. Es un fallo de compatibilidad entre versiones, de los que aparecen cuando scikit-learn cambia la estructura interna de un modelo y el conversor aún no lo ha seguido. La lección práctica no es este fallo concreto, que se arreglará, sino el procedimiento: versiones fijadas en el lockfile del proyecto (lecciones 0.2.2 y 7.1.1), y una prueba que exporta, carga y compara cada modelo que va a producción antes de cada actualización de dependencias.

Latencia: fila a fila, por lotes y por la red

¿Cuánto se gana con ONNX Runtime? Los tiempos dependen de la máquina y no pueden aparecer en la salida de una celda que se comprueba en cada ejecución; la celda imprime conclusiones con margen, y los números van en el texto:

IN [05]python
import timeit

fila64 = Xte[:1]
fila32 = fila64.astype(np.float32)
lote32 = Xte[:1000].astype(np.float32)

def mejor(f, n):
    """Mejor de 5 repeticiones, en microsegundos por llamada."""
    return min(timeit.repeat(f, number=n, repeat=5)) / n * 1e6

t_sk = mejor(lambda: modelo.predict_proba(fila64), 50)
t_ort = mejor(lambda: sesion.run(None, {ENTRADA: fila32}), 500)
t_lote = mejor(lambda: sesion.run(None, {ENTRADA: lote32}), 20)
print("una fila: ONNX Runtime al menos 10 veces más rápido:",
      t_sk / t_ort > 10)
print("lote de 1000: por fila, más barato que una fila suelta:",
      t_lote / 1000 < t_ort)
OUT [05]Latencia de una fila y de un lote
una fila: ONNX Runtime al menos 10 veces más rápido: True
lote de 1000: por fila, más barato que una fila suelta: True

En la máquina del curso, predict_proba de scikit-learn tarda unos 410 µs en una fila, y ONNX Runtime unos 9 µs. Casi todo lo de scikit-learn es trabajo fijo por llamada (validar la entrada, recorrer el pipeline en Python), no cálculo. Un lote de 1.000 filas tarda unos 2,7 ms en ONNX Runtime: unos 2,7 µs por fila, tres veces menos que una fila suelta. El cálculo de un árbol por fila es tan pequeño que el coste fijo de cada llamada domina, y agrupar filas lo reparte.

Ahora el servicio. Un servidor HTTP mínimo con la biblioteca estándar, el modelo ONNX cargado una vez y un contrato de entrada con Pydantic (lección 6.2.2): campos exactos, tipos estrictos, ocho features. Escucha en 127.0.0.1, con un puerto que elige el sistema; nunca en todas las interfaces sin autenticación delante.

IN [06]python
import json
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from pydantic import BaseModel, ConfigDict, Field, ValidationError

class Peticion(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    modelo: str = Field(pattern=r"^senal-n7$")
    features: list[float] = Field(min_length=K, max_length=K)

class Servicio(BaseHTTPRequestHandler):
    def log_message(self, *args):          # sin registro en stderr
        pass

    def responder(self, codigo, cuerpo):
        datos = json.dumps(cuerpo).encode()
        self.send_response(codigo)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(datos)))
        self.end_headers()
        self.wfile.write(datos)

    def do_POST(self):
        crudo = self.rfile.read(int(self.headers["Content-Length"]))
        try:
            p = Peticion.model_validate_json(crudo)
        except ValidationError as e:
            return self.responder(422, {"error": e.errors()[0]["msg"]})
        x = np.array([p.features], dtype=np.float32)
        _, pr = sesion.run(None, {ENTRADA: x})
        self.responder(200, {"p_sube": round(float(pr[0, 1]), 6)})

srv = ThreadingHTTPServer(("127.0.0.1", 0), Servicio)
threading.Thread(target=srv.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{srv.server_port}/predecir"

def pedir(cuerpo):
    req = urllib.request.Request(URL, json.dumps(cuerpo).encode(),
                                 {"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, json.load(r)
    except urllib.error.HTTPError as e:
        return e.code, json.load(e)

f0 = [float(v) for v in Xte[0]]
print(pedir({"modelo": "senal-n7", "features": f0}),
      "| en proceso:", round(float(p_sk[0]), 6))
print(pedir({"modelo": "senal-n7", "features": f0[:7]}))
print(pedir({"modelo": "senal-n7", "features": f0, "umbral": 0.4}))
OUT [06]Un servicio HTTP con contrato de entrada
(200, {'p_sube': 0.51919}) | en proceso: 0.51919
(422, {'error': 'List should have at least 8 items after validation, not 7'})
(422, {'error': 'Extra inputs are not permitted'})

El servicio devuelve la misma probabilidad que el modelo en el proceso, y rechaza con un 422 una petición con siete features y otra con un campo que no está en el contrato (umbral). ¿Y el precio?

IN [07]python
cuerpo = {"modelo": "senal-n7", "features": f0}
t_http = mejor(lambda: pedir(cuerpo), 50)
print("HTTP local al menos 10 veces más lento que en proceso:",
      t_http / t_ort > 10)
srv.shutdown()
OUT [07]Lo que añade la red
HTTP local al menos 10 veces más lento que en proceso: True

Por HTTP local, unos 540 µs por predicción: más que scikit-learn en el proceso. El modelo ya solo cuesta 9 µs; el resto es serializar el JSON, abrir la conexión, pasar por el bucle del servidor y volver. Es la conclusión de la sección: la arquitectura pesa más que el formato. Para una señal diaria, 0,5 ms no importa nada y el servicio aporta orden (un modelo, un despliegue, un contrato). Para un sistema que reacciona a cada cotización (lección 4.5.1), el modelo va dentro del proceso.

OpciónLatencia por llamada (máquina del curso)Cuándo
scikit-learn en el proceso≈ 410 µsinvestigación, señales diarias
ONNX Runtime en el proceso≈ 9 µs (≈ 2,7 µs por fila en lotes de 1.000)tiempo real, muchos modelos
Servicio HTTP local≈ 540 µsvarios procesos, despliegue independiente
Servidor de modelosdepende de la carga y del lotemuchos modelos, GPU, tráfico variable

CPU o GPU

Una GPU ejecuta miles de operaciones iguales en paralelo. Compensa cuando hay mucho cálculo por llamada: redes grandes, lotes grandes, modelos de lenguaje. Un ensemble de árboles sobre una fila es lo contrario: poco cálculo, muchas ramas. Para casi todos los modelos tabulares de trading, la CPU basta y sobra, y evita copiar datos a la memoria de la GPU y de vuelta (la lección 6.6.5 trató la GPU para procesar datos, con RAPIDS).

Con ONNX, cambiar de hardware es cambiar de proveedor de ejecución. El código es este, sin ejecutar porque la máquina del curso no tiene GPU (requiere el paquete onnxruntime-gpu en vez de onnxruntime, que desde la versión 1.27 se publica en PyPI compilado con CUDA 13.01Nota 1 · ONNX Runtime, «CUDA Execution Provider» (https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html): «Starting with version 1.27, GPU packages published to PyPI (onnxruntime-gpu) … are built with CUDA 13.0 by default», e instalación con pip install onnxruntime-gpu[cuda,cudnn]. Consultada el 23 de octubre de 2026.):

IN [08]python
# pip install "onnxruntime-gpu[cuda,cudnn]"   (otro entorno, con GPU)
import onnxruntime as ort

sesion = ort.InferenceSession(
    "senal.onnx",
    providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
print(sesion.get_providers())   # el primero disponible se usa

Las dos técnicas que más reducen el coste de inferencia en CPU son agrupar en lotes (la celda de latencia) y cuantizar: guardar los pesos en enteros de 8 bits, o menos, en vez de float32. ONNX Runtime cuantiza modelos ONNX (su documentación recomienda la cuantización dinámica para transformers y redes recurrentes, y la estática para convoluciones), y es lo que permite ejecutar modelos de lenguaje de miles de millones de parámetros en una sola GPU de consumo (lección 6.2.4). Para un modelo de árboles no aporta nada: no hay matrices de pesos que comprimir.

El coste de los LLM en producción

Con los modelos de lenguaje la cuenta cambia de naturaleza. El coste ya no es una máquina sino una tarifa por token (lección 6.2.1), con cuatro precios por modelo: tokens de entrada, de salida, de entrada leída de la caché de prompts y de entrada escrita en ella. Los de la tabla son los de las páginas oficiales de Anthropic y OpenAI, consultadas el 23 de octubre de 2026, en USD por millón de tokens, para contextos cortos2Nota 2 · Anthropic, «Pricing» (https://platform.claude.com/docs/en/about-claude/pricing): Claude Opus 5.5, 4 USD por millón de tokens de entrada y 20 de salida; Sonnet 5.5, 2 y 10; Haiku 5.5, 0,10 y 0,50 (prompts de hasta 100.000 tokens); escritura en la caché de 5 minutos a 1,25 veces la entrada; lectura a 0,05 veces en Opus 5.5 y Sonnet 5.5 y a 0,1 veces en los demás; API por lotes con un 50 % de descuento en entrada y salida. OpenAI, «Pricing» (https://developers.openai.com/api/docs/pricing), tarifa estándar y contexto corto: gpt-6-astra, 10 y 50, entrada en caché 1,00 y escritura de 30 minutos 12,50; gpt-6.1-sol, 2 y 10, 0,10 y 2,50; gpt-6-luna, 0,10 y 0,50, 0,01 y 0,125; la tabla por lotes es la mitad. Las dos consultadas el 23 de octubre de 2026.:

IN [09]python
# USD por millón de tokens, contexto corto. Páginas oficiales de
# precios de Anthropic y OpenAI, consultadas el 23 de octubre de 2026.
PRECIOS = {   # entrada, salida, lectura de caché, escritura de caché
    "Claude Opus 5.5":   (4.00, 20.00, 0.20, 5.00),
    "Claude Sonnet 5.5": (2.00, 10.00, 0.10, 2.50),
    "Claude Haiku 5.5":  (0.10, 0.50, 0.01, 0.125),
    "gpt-6-astra":       (10.00, 50.00, 1.00, 12.50),
    "gpt-6.1-sol":       (2.00, 10.00, 0.10, 2.50),
    "gpt-6-luna":        (0.10, 0.50, 0.01, 0.125),
}
LOTE = 0.5          # API por lotes: mitad de precio en los dos

def coste(modelo, entrada, salida, cache_r=0, cache_w=0, lote=False):
    """USD de una petición; `entrada` = tokens de entrada sin caché."""
    pe, ps, pr, pw = PRECIOS[modelo]
    usd = (entrada * pe + salida * ps + cache_r * pr
           + cache_w * pw) / 1e6
    return usd * (LOTE if lote else 1.0)

print(f"{coste('Claude Sonnet 5.5', 1_000_000, 0):.2f}",
      f"{coste('gpt-6-astra', 0, 1_000_000, lote=True):.2f}")
OUT [09]Precios de las APIs, con su fecha
2.00 25.00

Dos descuentos cambian mucho el resultado:

  • Caché de prompts. Una parte inicial del prompt que se repite entre peticiones (instrucciones, ejemplos, un documento, el historial de un agente) se escribe una vez en la caché, algo más cara que la entrada normal (1,25 veces), y se lee después mucho más barata: 0,05 veces el precio de entrada en Claude Opus 5.5, Claude Sonnet 5.5 y gpt-6.1-sol, 0,1 veces en el resto de modelos de la tabla. La caché dura de minutos a horas, según la opción, y se renueva al leerla.
  • API por lotes. Las peticiones que pueden esperar (horas, no segundos) se envían juntas y se pagan a mitad de precio en los dos proveedores.

Tres cargas de trabajo típicas de un sistema de research, con los tokens escritos a mano:

  • Titulares: 2.000 al día, clasificados con un prompt de 1.500 tokens de instrucciones y ejemplos, siempre igual, más el titular (60 tokens); responde una etiqueta JSON (30).
  • Informes: 40 al día de 25.000 tokens, resumidos en 1.000. Cada informe es distinto, así que la caché no ayuda; pueden esperar a la noche, así que la API por lotes sí.
  • Agente: 200 tareas al día de 8 pasos (lección 6.4.1). En cada paso reenvía todo lo anterior: 3.000 tokens iniciales y 1.500 más por paso, y responde 300.
IN [10]python
import pandas as pd

SESIONES = 21

def titulares(m, cache):
    """2.000 titulares al día: instrucciones y ejemplos (1.500 tokens,
    iguales en todas) + titular (60) y una etiqueta JSON (30)."""
    if cache:     # 10 escrituras al día; el resto, lecturas
        return (10 * coste(m, 60, 30, cache_w=1500)
                + 1990 * coste(m, 60, 30, cache_r=1500))
    return 2000 * coste(m, 1560, 30)

def informes(m, lote):
    """40 informes de 25.000 tokens; resumen de 1.000."""
    return 40 * coste(m, 25_000, 1_000, lote=lote)

def agente(m, cache):
    """200 tareas de 8 pasos. Cada paso reenvía todo el historial:
    3.000 tokens fijos + 1.500 por paso anterior; responde 300."""
    total = 0.0
    for i in range(8):
        contexto = 3000 + 1500 * i
        if cache and i:    # lee lo de antes y escribe lo nuevo
            total += coste(m, 0, 300, cache_r=contexto - 1500,
                           cache_w=1500)
        elif cache:
            total += coste(m, 0, 300, cache_w=contexto)
        else:
            total += coste(m, contexto, 300)
    return 200 * total

filas = {}
for m in PRECIOS:
    base = titulares(m, False) + informes(m, False) + agente(m, False)
    opt = titulares(m, True) + informes(m, True) + agente(m, True)
    filas[m] = {"titulares": titulares(m, False) * SESIONES,
                "informes": informes(m, False) * SESIONES,
                "agente": agente(m, False) * SESIONES,
                "total": base * SESIONES,
                "con caché y lotes": opt * SESIONES}
mes = pd.DataFrame(filas).T.round(0).astype(int)
mes
OUT [10]Tres cargas de trabajo, un mes de 21 sesiones
                   titulares  informes  agente  total  con caché y lotes
Claude Opus 5.5          287       101    1310   1698                629
Claude Sonnet 5.5        144        50     655    849                314
Claude Haiku 5.5           7         3      33     42                 17
gpt-6-astra              718       252    3276   4246               1714
gpt-6.1-sol              144        50     655    849                314
gpt-6-luna                 7         3      33     42                 17

La figura de la cabecera pone los totales en escala logarítmica. Del modelo más caro al más barato hay un factor de 100: 4.246 USD al mes con gpt-6-astra frente a 42 con Claude Haiku 5.5 o gpt-6-luna. Y en todos los modelos, la caché y los lotes dejan la factura entre el 37 % y el 40 % de la tarifa estándar. El reparto por carga de trabajo, con Claude Sonnet 5.5:

Fig. — Coste mensual de cada carga de trabajo de la lección con Claude Sonnet 5.5, a tarifa estándar y con la optimización que le corresponde (caché para titulares y agente, API por lotes para informes)

El agente es el 77 % del gasto: 655 USD de 849. No responde mucho (300 tokens por paso); lo caro es que cada paso reenvía la conversación entera, 66.000 tokens de entrada por tarea. La caché lo baja a 265, porque lo que ya se envió se lee a 0,05 veces el precio. Los titulares bajan de 144 a 25 con la caché (el prompt fijo es el 94 % de cada petición) y los informes, de 50 a 25 con los lotes.

¿Un servidor propio?

La alternativa a pagar por token es alquilar una GPU y servir un modelo abierto con vLLM u Ollama (lección 6.2.1). Un precio real: el servidor GEX45 de Hetzner, con una NVIDIA RTX PRO 4000 Blackwell de 24 GB, cuesta 214 EUR al mes sin IVA más 209 EUR de alta3Nota 3 · Hetzner, servidores dedicados con GPU (https://www.hetzner.com/dedicated-rootserver/matrix-gpu/): GEX45, «€ 214.00 per month, excl. VAT» y «€ 209.00 one-time setup fee», con NVIDIA RTX PRO 4000 Blackwell SFF Edition de 24 GB. Cambio de referencia del BCE del 23 de octubre de 2026: 1 EUR = 1,1193 USD. Consultados el 23 de octubre de 2026.. ¿A partir de qué volumen sale más barato que la API?

IN [11]python
GEX45_EUR = 214.00           # al mes, sin IVA (Hetzner, 23-10-2026)
ALTA_EUR = 209.00            # una vez
EURUSD = 1.1193              # BCE, referencia del 23-10-2026
servidor_usd = GEX45_EUR * EURUSD

def tokens_equivalentes(m, frac_salida=0.02):
    """Tokens al mes con los que la API cuesta lo que el servidor,
    con un 2 % de tokens de salida (como en las cargas de arriba)."""
    pe, ps, *_ = PRECIOS[m]
    usd_mtok = (1 - frac_salida) * pe + frac_salida * ps
    return servidor_usd / usd_mtok * 1e6

print(f"servidor: {servidor_usd:.2f} USD al mes")
for m in ("Claude Haiku 5.5", "gpt-6-luna", "Claude Sonnet 5.5"):
    tok = tokens_equivalentes(m)
    por_s = tok / (30 * 24 * 3600)
    print(f"{m:18} {tok / 1e9:6.2f} mil millones de tokens al mes"
          f" = {por_s:6.0f} tokens/s sin parar")
OUT [11]¿Un servidor propio con GPU?
servidor: 239.53 USD al mes
Claude Haiku 5.5     2.22 mil millones de tokens al mes =    856 tokens/s sin parar
gpt-6-luna           2.22 mil millones de tokens al mes =    856 tokens/s sin parar
Claude Sonnet 5.5    0.11 mil millones de tokens al mes =     43 tokens/s sin parar

Frente a los modelos pequeños de API (Claude Haiku 5.5, gpt-6-luna), el servidor solo sale más barato a partir de unos 2.200 millones de tokens al mes: 856 tokens por segundo, día y noche, todo el mes. Frente a Claude Sonnet 5.5, a partir de 43 tokens por segundo, pero un modelo que cabe en 24 GB no es comparable con Sonnet: la comparación honrada es con los modelos pequeños. Si una GPU así sostiene 856 tokens por segundo con un modelo útil es algo que hay que medir con el modelo y el servidor concretos (vLLM agrupa peticiones y su rendimiento depende mucho del tamaño del lote); no hay una cifra fiable que dar aquí.

La decisión, por tanto, rara vez es de precio a la escala de un particular o de un equipo pequeño:

Motivo para servir un modelo propioMotivo para usar una API
Confidencialidad: los datos no salen de la máquina (6.2.1)Calidad de los modelos grandes
Versión fijada para siempre: reproducibilidadNada que operar, actualizar ni vigilar
Latencia sin red ni colas de tercerosPago por uso: cero si no se usa
Volúmenes enormes y constantesPicos de carga sin capacidad ociosa

Un guardián de presupuesto

Una factura de API no tiene techo por sí misma. Un bucle mal escrito que repite la misma llamada, un agente que no termina (lección 6.4.5) o un cambio que multiplica la longitud del prompt se notan a fin de mes. El control es el mismo que el motor de riesgo de las órdenes, aplicado al gasto: cada llamada se autoriza antes con su coste máximo posible (la entrada estimada más max_tokens de salida), se anota con el coste real que devuelve la API y, al llegar al límite del día, no hay más llamadas. Y una petición idéntica a una ya hecha se sirve de una caché local, sin llamar:

sínonosíPeticiónEn cachéRespuestaCabe en límiteRechazo y alertaAPIAnotar coste
Fuente Mermaid
flowchart TB
  P[Petición] --> C{En caché}
  C -->|sí| R[Respuesta]
  C -->|no| B{Cabe en límite}
  B -->|no| X[Rechazo y alerta]
  B -->|sí| A[API]
  A --> G[Anotar coste]
  G --> R
IN [12]python
import hashlib

class Presupuesto:
    """Autoriza cada llamada con su coste máximo posible y corta al
    llegar al límite del día. Las peticiones idénticas se sirven
    desde la caché sin llamar a la API."""

    def __init__(self, modelo, limite_usd):
        self.modelo, self.limite = modelo, limite_usd
        self.gastado, self.cache = 0.0, {}
        self.llamadas = self.de_cache = self.rechazadas = 0

    def llamar(self, prompt, max_salida, api):
        clave = hashlib.sha256(f"{self.modelo}|{max_salida}|{prompt}"
                               .encode()).hexdigest()
        if clave in self.cache:
            self.de_cache += 1
            return self.cache[clave]
        n_in = len(prompt.split()) * 4 // 3     # estimación prudente
        peor = coste(self.modelo, n_in, max_salida)
        if self.gastado + peor > self.limite:
            self.rechazadas += 1
            raise RuntimeError("presupuesto del día agotado")
        texto, uso = api(prompt, max_salida)
        self.gastado += coste(self.modelo, uso["in"], uso["out"])
        self.llamadas += 1
        self.cache[clave] = texto
        return texto

def api_simulada(prompt, max_salida):
    """Responde siempre lo mismo y cuenta tokens como una API."""
    n = len(prompt.split())
    return "neutral", {"in": n * 4 // 3, "out": min(max_salida, 40)}

g = Presupuesto("Claude Sonnet 5.5", limite_usd=5.00)
largo = " ".join(["palabra"] * 6000)     # un informe de 8.000 tokens
for i in range(400):                     # un bucle mal escrito
    g.llamar("Clasifica: Nore sube un 3 %", 50, api_simulada)
for i in range(400):                     # 400 informes distintos
    try:
        g.llamar(f"Informe {i}: {largo}", 1000, api_simulada)
    except RuntimeError:
        break
print(f"llamadas {g.llamadas} · de caché {g.de_cache} · "
      f"rechazadas {g.rechazadas} · gastado {g.gastado:.2f} USD")
OUT [12]Un guardián de presupuesto con caché de respuestas
llamadas 305 · de caché 399 · rechazadas 1 · gastado 4.99 USD

El bucle mal escrito repite 400 veces la misma clasificación: una llamada real y 399 servidas desde la caché. Los 400 informes distintos agotan el presupuesto de 5 USD en la llamada 305 (cada informe de 8.000 tokens cuesta unos 0,016 USD, y el peor caso con 1.000 tokens de salida, 0,026): el guardián rechaza la siguiente, el bucle se detiene y el gasto queda en 4,99 USD. En producción, el rechazo dispara una alerta (lección 7.2.3), el coste de cada llamada se publica como métrica, y los límites se ponen también en la consola del proveedor, que es la última línea de defensa si el guardián falla.

Ejercicios

  1. Repite el coste del agente con Claude Opus 5.5 para tareas de 16 pasos en vez de 8, con y sin caché. ¿Por qué el coste sin caché crece más que el doble?
Solución
IN [13]python
def agente_n(m, pasos, cache):
    total = 0.0
    for i in range(pasos):
        contexto = 3000 + 1500 * i
        if cache and i:
            total += coste(m, 0, 300, cache_r=contexto - 1500,
                           cache_w=1500)
        elif cache:
            total += coste(m, 0, 300, cache_w=contexto)
        else:
            total += coste(m, contexto, 300)
    return 200 * total * SESIONES

m = "Claude Opus 5.5"
for pasos in (8, 16):
    print(pasos, "pasos:", round(agente_n(m, pasos, False)),
          "USD al mes sin caché ·", round(agente_n(m, pasos, True)),
          "con caché")
OUT [13]Un agente el doble de largo
8 pasos: 1310 USD al mes sin caché · 529 con caché
16 pasos: 4234 USD al mes sin caché · 1109 con caché

Con 16 pasos, el agente cuesta 4.234 USD al mes sin caché, 3,2 veces más que con 8 (1.310), y 1.109 con caché, 2,1 veces más (529). Sin caché, la entrada de una tarea es la suma de un contexto que crece en cada paso: con nn pasos, 3000 n+750 n(n−1)3000\,n + 750\,n(n-1) tokens. Es cuadrática: 66.000 tokens con 8 pasos y 228.000 con 16, 3,5 veces más. La salida solo se duplica, y por eso el total queda en 3,2. Con caché, lo que ya se envió se lee a 0,05 veces el precio (en Opus 5.5) y lo que se paga entero es lo nuevo de cada paso, que crece de forma lineal: el coste se acerca a duplicarse.

Es la razón práctica para limitar el número de pasos de un agente, resumir el historial cuando se alarga y usar la caché: el coste de un agente lo marca la longitud de su conversación, no la de sus respuestas.

  1. Un cliente del servicio HTTP manda una petición con NaN en la primera feature (json.dumps lo escribe así por defecto). ¿La acepta el contrato? Corrígelo.
Solución
IN [14]python
from pydantic import FiniteFloat

crudo = json.dumps({"modelo": "senal-n7",
                    "features": [float("nan")] + f0[1:]})
print(crudo[:40])
p = Peticion.model_validate_json(crudo)
x = np.array([p.features], dtype=np.float32)
print("aceptada; p_sube =",
      round(float(sesion.run(None, {ENTRADA: x})[1][0, 1]), 6))

class PeticionFinita(Peticion):
    features: list[FiniteFloat] = Field(min_length=K, max_length=K)

try:
    PeticionFinita.model_validate_json(crudo)
except ValidationError as e:
    print("rechazada:", e.errors()[0]["msg"], e.errors()[0]["loc"])
OUT [14]NaN en la entrada del servicio
{"modelo": "senal-n7", "features": [NaN,
aceptada; p_sube = 0.47686
rechazada: Input should be a finite number ('features', 0)

La acepta. json.dumps escribe NaN (que no es JSON estándar, pero Python lo produce y lo lee por defecto), Pydantic convierte la lista a float sin quejarse porque un NaN es un float válido, y el modelo de árboles devuelve una probabilidad, 0,47686, sin ningún aviso: en el grafo, el NaN sigue la rama que el conversor asigna a los valores que faltan. Es el peor tipo de fallo: una respuesta con buen aspecto calculada con un dato que no existe.

FiniteFloat exige números finitos y la petición se rechaza con un error que dice qué campo y qué posición fallan. En el servicio, el contrato bueno es el que acaba en un 422; y del lado del cliente, json.dumps(..., allow_nan=False) lanza una excepción antes de enviar un NaN. Las dos defensas a la vez: el contrato no se fía de los clientes.

  1. Un compañero propone mover la clasificación de titulares a un modelo abierto en un servidor propio «para ahorrar». Con los números de la lección, ¿qué le contestarías?
Solución

Que con los modelos pequeños de API la clasificación de titulares cuesta unos 7 USD al mes a tarifa estándar (y 1 o 2 con caché), y el servidor GPU unos 240 USD al mes más el alta, más el tiempo de instalarlo, actualizarlo, vigilarlo y tenerlo disponible. Por coste no compensa hasta volúmenes del orden de 2.200 millones de tokens al mes. Puede compensar por otros motivos: si los titulares o el prompt contienen información que no debe salir de la casa (posiciones, señales propias), si hace falta fijar el modelo para siempre por reproducibilidad, o si la latencia de la API es un problema. Si alguno de esos motivos es real, el servidor propio se justifica por él, y conviene decirlo así y medir la calidad del modelo abierto con el conjunto de evaluación de la tarea antes de cambiar (lección 6.2.1).

Footnotes

  1. ONNX Runtime, «CUDA Execution Provider» (https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html): «Starting with version 1.27, GPU packages published to PyPI (onnxruntime-gpu) … are built with CUDA 13.0 by default», e instalación con pip install onnxruntime-gpu[cuda,cudnn]. Consultada el 23 de octubre de 2026. ↩

  2. Anthropic, «Pricing» (https://platform.claude.com/docs/en/about-claude/pricing): Claude Opus 5.5, 4 USD por millón de tokens de entrada y 20 de salida; Sonnet 5.5, 2 y 10; Haiku 5.5, 0,10 y 0,50 (prompts de hasta 100.000 tokens); escritura en la caché de 5 minutos a 1,25 veces la entrada; lectura a 0,05 veces en Opus 5.5 y Sonnet 5.5 y a 0,1 veces en los demás; API por lotes con un 50 % de descuento en entrada y salida. OpenAI, «Pricing» (https://developers.openai.com/api/docs/pricing), tarifa estándar y contexto corto: gpt-6-astra, 10 y 50, entrada en caché 1,00 y escritura de 30 minutos 12,50; gpt-6.1-sol, 2 y 10, 0,10 y 2,50; gpt-6-luna, 0,10 y 0,50, 0,01 y 0,125; la tabla por lotes es la mitad. Las dos consultadas el 23 de octubre de 2026. ↩

  3. Hetzner, servidores dedicados con GPU (https://www.hetzner.com/dedicated-rootserver/matrix-gpu/): GEX45, «€ 214.00 per month, excl. VAT» y «€ 209.00 one-time setup fee», con NVIDIA RTX PRO 4000 Blackwell SFF Edition de 24 GB. Cambio de referencia del BCE del 23 de octubre de 2026: 1 EUR = 1,1193 USD. Consultados el 23 de octubre de 2026. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. El modelo exportado a ONNX da probabilidades que difieren de las de scikit-learn en 2,1·10⁻⁷ como máximo. ¿De dónde sale esa diferencia?

    • ADe un error en el conversor
    • BDe que ONNX Runtime usa otra semilla
    • CDe que ONNX Runtime calcula en coma flotante de 32 bits y scikit-learn en 64
    • DDe que el grafo ONNX elimina árboles poco importantes
    Ver respuesta

    Respuesta C. De que ONNX Runtime calcula en coma flotante de 32 bits y scikit-learn en 64

    La entrada se declara como float32 y el grafo trabaja en esa precisión; scikit-learn usa float64. Diferencias de 10⁻⁷ son redondeo; lo que importa es que ninguna de las 5.000 etiquetas cambia. Con modelos de árboles, un valor justo en un umbral podría caer al otro lado: por eso la prueba de paridad cuenta etiquetas, no solo diferencias de probabilidad.

  2. En la máquina del curso, una predicción de una fila tarda unos 410 µs con scikit-learn, unos 9 µs con ONNX Runtime en el proceso y unos 540 µs por HTTP local. ¿Qué conclusión es la correcta?

    • AONNX siempre hace más rápido un sistema
    • BHTTP es lento y hay que evitarlo siempre
    • CPara una señal diaria, ninguna de las tres latencias importa
    • DExportar a ONNX quita casi todo el coste de cálculo, pero ponerlo detrás de HTTP añade más de lo que se ahorró: la arquitectura pesa más que el formato
    Ver respuesta

    Respuesta D. Exportar a ONNX quita casi todo el coste de cálculo, pero ponerlo detrás de HTTP añade más de lo que se ahorró: la arquitectura pesa más que el formato

    El servicio HTTP tiene ventajas (un solo modelo para varios procesos, despliegue independiente), y su coste por llamada es de cientos de microsegundos. Para un sistema que decide una vez al día da igual; para uno que decide en cada tick, el modelo tiene que ir dentro del proceso. La pregunta correcta es cuántas predicciones hay por segundo y con qué presupuesto de latencia.

  3. Claude Sonnet 5.5 y gpt-6.1-sol tienen la misma tarifa por millón de tokens. ¿Cuestan lo mismo para el mismo trabajo?

    • ANo necesariamente: cada proveedor cuenta los tokens con su tokenizador, y el mismo texto puede dar números distintos
    • BSí, por definición
    • CNo, porque OpenAI cobra la caché aparte y Anthropic no
    • DSí, salvo con la API por lotes
    Ver respuesta

    Respuesta A. No necesariamente: cada proveedor cuenta los tokens con su tokenizador, y el mismo texto puede dar números distintos

    El precio es por token, y el token lo define el tokenizador de cada modelo. La propia página de precios de Anthropic avisa de que el tokenizador de Claude 4.7 y posteriores produce aproximadamente un 30 % más de tokens para el mismo texto. Para comparar de verdad hay que contar los tokens de una muestra de los textos reales con cada proveedor.

  4. En el escenario de la lección, el agente supone 655 de los 849 USD al mes con Claude Sonnet 5.5. ¿Por qué es tan caro?

    • APorque responde textos muy largos
    • BPorque cada paso reenvía todo el historial: el contexto crece paso a paso y se paga entero en cada llamada
    • CPorque usa el modelo más caro
    • DPorque la API cobra por herramienta usada
    Ver respuesta

    Respuesta B. Porque cada paso reenvía todo el historial: el contexto crece paso a paso y se paga entero en cada llamada

    En el paso i se envían 3.000 + 1.500·i tokens: en 8 pasos, 66.000 tokens de entrada para 2.400 de salida. Con caché de prompts, lo que ya se envió se lee a 0,05 veces el precio de entrada (en Sonnet 5.5) y el coste del agente baja a 265 USD.

  5. El guardián de presupuesto de la lección autoriza cada llamada con su coste máximo posible, no con el esperado. ¿Por qué?

    • APorque así gasta más
    • BPorque las APIs cobran siempre max_tokens
    • CPorque el coste esperado no se puede calcular
    • DPorque el coste real solo se conoce al terminar la llamada, y una respuesta que llegue a max_tokens puede costar bastante más que la media: autorizar por el máximo garantiza no pasar del límite
    Ver respuesta

    Respuesta D. Porque el coste real solo se conoce al terminar la llamada, y una respuesta que llegue a max_tokens puede costar bastante más que la media: autorizar por el máximo garantiza no pasar del límite

    Las APIs cobran los tokens que generan, no max_tokens; pero ese número no se sabe hasta el final. Autorizar por el peor caso hace que el límite sea un límite. En la lección, el guardián corta en 4,99 USD de 5,00 tras 305 llamadas.

Ideas clave

  • ONNX describe el modelo como un grafo de operadores estándar que ONNX Runtime ejecuta sin scikit-learn: el pipeline de la lección pesa 106 KB, da las mismas 5.000 etiquetas y probabilidades a 2·10⁻⁷.
  • El conversor va detrás de la librería: con scikit-learn 1.9.1 y skl2onnx 1.20.0, HistGradientBoostingClassifier no se exporta. Versiones fijadas y una prueba de exportación y paridad en la integración continua.
  • La latencia depende más de la arquitectura que del formato: unos 410 µs con scikit-learn, 9 µs con ONNX Runtime en el proceso y 540 µs por HTTP local en la máquina del curso. La GPU compensa con lotes grandes y redes grandes, no con árboles de una fila.
  • Un servicio de predicción valida su entrada con un contrato estricto; aun así, el de la lección dejaba pasar NaN hasta exigir números finitos.
  • El coste de un LLM se calcula con tokens explícitos y precios fechados. En las cargas de la lección, el agente que reenvía su historial es el 77 % del gasto; la caché y la API por lotes bajan el total de 849 a 314 USD al mes con Claude Sonnet 5.5.
  • Un servidor GPU de alquiler (unos 240 USD al mes) solo compite en coste con los modelos pequeños de API a partir de unos 2.200 millones de tokens al mes; se elige por confidencialidad o control, no por precio. Y todo uso de API va detrás de un guardián de presupuesto.

Recursos