---
title: "7.3.4 Servir modelos: ONNX, CPU o GPU y coste de los LLM"
description: "Un modelo de scikit-learn exportado a ONNX con paridad comprobada, servido por HTTP con contrato, y el coste de los LLM con precios reales y un límite."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/servir-modelos-onnx-y-coste-de-llms/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/servir-modelos-onnx-y-coste-de-llms.png"
---

# 7.3.4 · 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.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.3 · MLOps para modelos de trading
- Duración: 65 min
- Requisitos: [5.6.4 · Callbacks, una pérdida de Sharpe y el modelo en producción](https://pythonparatrading.com/curso-python-trading/nivel-5/callbacks-perdidas-personalizadas-y-exportacion/), [6.2.1 · Cómo funciona un LLM: del siguiente token a la API](https://pythonparatrading.com/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales/), [7.3.2 · Reentrenamiento programado, validación automática y champion/challenger](https://pythonparatrading.com/curso-python-trading/nivel-7/reentrenamiento-validacion-y-champion-challenger/)

## Al terminar esta lección

- Exportar un pipeline de scikit-learn a ONNX, inspeccionar el grafo y comprobar la paridad de las predicciones con ONNX Runtime.
- Elegir entre inferencia en el proceso, un servicio HTTP o un servidor de modelos, y entre CPU y GPU, con mediciones y no con intuiciones.
- Poner un contrato de entrada a un servicio de predicción y saber qué deja pasar.
- Calcular el coste mensual de un uso de LLM con tokens explícitos y precios fechados, y reducirlo con caché de prompts y API por lotes.
- Limitar el gasto con un guardián de presupuesto que autoriza cada llamada por su coste máximo y no repite peticiones idénticas.

## 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:

```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.

```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))
```
```output
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.

```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")
```
```output
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.

<aside class="callout" data-kind="nota">

Un fichero ONNX es un grafo, no un programa: cargarlo no ejecuta código de Python arbitrario. Con `pickle` (y con
`joblib`, que lo usa) cargar un modelo **sí** puede ejecutar código, y la documentación de Python advierte que no es
seguro con datos que no sean de confianza. Es la misma razón por la que MLflow 3 guarda por defecto los modelos de scikit-learn
con skops (lección 7.3.1): un modelo es un artefacto que viaja entre máquinas y personas.

</aside>

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:

```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())
```
```output
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:

```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))
```
```output
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:

```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)
```
```output
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.

```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}))
```
```output
(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?

```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()
```
```output
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ón | Latencia por llamada (máquina del curso) | Cuándo |
|---|---|---|
| scikit-learn en el proceso | ≈ 410 µs | investigació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 µs | varios procesos, despliegue independiente |
| Servidor de modelos | depende de la carga y del lote | muchos 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.0[^1]):

```python skip
# 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 cortos[^2]:

```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}")
```
```output
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.

```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
```
```output
                   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).* Barras horizontales por pares. Titulares: 144 USD al mes a tarifa estándar y 25 con caché. Informes: 50 y 25 con la API por lotes. Agente, destacado: 655 USD a tarifa estándar, el 77 % del total, y 265 con caché.

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.

<aside class="callout" data-kind="aviso">

Dos modelos con la misma tarifa por millón (Claude Sonnet 5.5 y gpt-6.1-sol; Claude Haiku 5.5 y gpt-6-luna) no cuestan
lo mismo para el mismo texto: cada uno cuenta los tokens con su tokenizador. La página de precios de Anthropic avisa
de que el de Claude 4.7 y posteriores produce aproximadamente un 30 % más de tokens para el mismo texto. Los tokens de
estas cargas son supuestos; antes de comparar proveedores hay que contar una muestra de los textos reales con cada
uno. Y los precios cambian: se guardan con su fecha, como en la celda.

</aside>

## ¿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 alta[^3]. ¿A partir de qué volumen sale más barato que la API?

```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")
```
```output
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 propio | Motivo 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: reproducibilidad | Nada que operar, actualizar ni vigilar |
| Latencia sin red ni colas de terceros | Pago por uso: cero si no se usa |
| Volúmenes enormes y constantes | Picos 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:

```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
```

```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")
```
```output
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.

<aside class="callout" data-kind="riesgo">

La caché de respuestas solo vale para tareas deterministas o donde una respuesta anterior sirve igual (clasificar el
mismo titular). Con temperatura mayor que cero, o cuando la respuesta depende de lo que ha pasado desde entonces (una noticia
nueva, el precio del día), devolver lo de ayer es un error. La clave de la caché incluye el modelo y `max_tokens`; en producción, también la versión del
prompt.

</aside>

## 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.**

```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é")
```
```output
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 $n$ pasos,
$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.

2. 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.**

```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"])
```
```output
{"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.

3. 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).

[^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.

## 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?

   - A. De un error en el conversor
   - B. De que ONNX Runtime usa otra semilla
   - C. De que ONNX Runtime calcula en coma flotante de 32 bits y scikit-learn en 64
   - D. De que el grafo ONNX elimina árboles poco importantes

   *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?

   - A. ONNX siempre hace más rápido un sistema
   - B. HTTP es lento y hay que evitarlo siempre
   - C. Para una señal diaria, ninguna de las tres latencias importa
   - 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

   *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?

   - A. No necesariamente: cada proveedor cuenta los tokens con su tokenizador, y el mismo texto puede dar números distintos
   - B. Sí, por definición
   - C. No, porque OpenAI cobra la caché aparte y Anthropic no
   - D. Sí, salvo con la API por lotes

   *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?

   - A. Porque responde textos muy largos
   - B. Porque cada paso reenvía todo el historial: el contexto crece paso a paso y se paga entero en cada llamada
   - C. Porque usa el modelo más caro
   - D. Porque la API cobra por herramienta usada

   *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é?

   - A. Porque así gasta más
   - B. Porque las APIs cobran siempre max_tokens
   - C. Porque el coste esperado no se puede calcular
   - 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

   *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

### Research

- [El entorno de trabajo (Docker para trading)](https://pythonparatrading.com/docker-para-trading/) · Research-0013 · 4 min

### Documentación

- [sklearn-onnx: documentación (skl2onnx)](https://onnx.ai/sklearn-onnx/) · onnx.ai
- [ONNX Runtime: Execution Providers (CPU, CUDA, TensorRT…)](https://onnxruntime.ai/docs/execution-providers/) · onnxruntime.ai
- [ONNX Runtime: Quantize ONNX models](https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html) · onnxruntime.ai
- [Anthropic: Pricing (Claude API)](https://platform.claude.com/docs/en/about-claude/pricing) · platform.claude.com
- [OpenAI: API Pricing](https://developers.openai.com/api/docs/pricing) · developers.openai.com
- [vLLM: documentación](https://docs.vllm.ai/) · docs.vllm.ai

Lección anterior: [7.3.3 · Monitorización del modelo en vivo y desactivación automática](https://pythonparatrading.com/curso-python-trading/nivel-7/monitorizacion-en-vivo-y-desactivacion-automatica/)

Lección siguiente: [7.4.1 · MiFID II: clientes, idoneidad y trading algorítmico](https://pythonparatrading.com/curso-python-trading/nivel-7/mifid-ii-clientes-idoneidad-y-trading-algoritmico/)
