Lesson 7.3.4 · 65 min

Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production

A trained model is a Python object inside a process; a model in production is a file that another process loads, feeds and monitors. This lesson exports a scikit-learn classifier to ONNX, checks that it gives the same results, serves it over HTTP with an input contract, measures what each layer costs and ends with the other inference bill: that of language models, computed with the prices published by Anthropic and OpenAI and kept in check by a budget guard.

Fig. — Monthly cost of the lesson's three workloads (headlines, reports and agent; 21 sessions) with each model, at standard rates and with caching and batching, against a rented GPU server. Prices from the official pages as of 23 October 2026
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control

From object to file

Lessons 7.3.2 and 7.3.3 left a model registered, with aliases and monitored. What remains is to decide how it runs: in what format it is saved, which process loads it, on what hardware and at what price. For an in-house scikit-learn model the answer is usually simple and cheap; for a language model from a provider, the bill arrives per token and can grow without anyone noticing.

Three ways to serve a model, from the simplest to the most complex:

in processlocal HTTPHTTP or gRPCTrading processONNX RuntimeIn-house serviceONNX RuntimeModel serverCPU or GPU
Mermaid source
flowchart TB
  A[Trading process] -->|in process| M[ONNX Runtime]
  A -->|local HTTP| S[In-house service]
  S --> M2[ONNX Runtime]
  A -->|HTTP or gRPC| V[Model server]
  V --> G[CPU or GPU]
  • In the process: the trading system loads the model file and calls a function. No network, no serialization, minimum latency. Each process has its own copy.
  • An in-house service: a small process that loads the model and answers requests. Several processes share one model, and the model is deployed without touching the trading system. It costs a round trip over the local network.
  • A model server (BentoML, Ray Serve, NVIDIA Triton; vLLM for language models): it groups requests into batches, manages several versions and GPUs, and scales with load. It pays off with many models or a lot of traffic; for a daily signal it is more infrastructure than needed.

And a format. Lesson 5.6.4 exported a Keras network with torch.export and left ONNX untested because it was not in the level 5 environment. The ops environment does have it: skl2onnx 1.20.0 and onnxruntime 1.31.0.

The model and its export to ONNX

The model is a next-day direction classifier with eight features, on synthetic data: a pipeline with a StandardScaler and a GradientBoostingClassifier of 200 trees of depth 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 for one day
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)   # up tomorrow?
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("out-of-sample accuracy:",
      round((modelo.predict(Xte) == yte).mean(), 3))
OUT [01]The model to serve: a scikit-learn pipeline
out-of-sample accuracy: 0.64

A 64% out-of-sample accuracy is what the data allows (the relationship has logistic noise); here the model is the package, not the content. ONNX (Open Neural Network Exchange) describes a model as a graph of standard operators with versions (the opsets), and any runtime that implements those operators can run it: ONNX Runtime in Python, C++, C#, Java or the browser, without scikit-learn or the rest of the training environment.

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("input:", g.input[0].name, [d.dim_value or "N" for d in dims])
print("outputs:", [o.name for o in g.output])
print("nodes:", 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"size: {len(onx.SerializeToString()) / 1024:.0f} KB")
OUT [02]Exporting to ONNX and looking inside
input: X ['N', 8]
outputs: ['label', 'probabilities']
nodes: {'Scaler': 1, 'TreeEnsembleClassifier': 1, 'Identity': 1}
opsets: {'ai.onnx.ml': 1, 'ai.onnx': 21}
size: 106 KB

The whole pipeline fits in three nodes: Scaler (the standardization, with its means and scales inside), TreeEnsembleClassifier (the 200 trees with their thresholds) and an Identity that names the output. The input is called X and has shape [N, 8]: any number of rows, eight columns. The file weighs 106 KB. zipmap: False makes the probabilities come out as a matrix rather than as a list of dictionaries, which is slower and more awkward.

Exporting a model is translating it, and a translation gets checked. The parity test compares the predictions of the original model and of the exported one on a lot of data, and it is part of the project's continuous integration, alongside the export itself:

IN [03]python
import onnxruntime as ort

opciones = ort.SessionOptions()
opciones.intra_op_num_threads = 1        # stable result and timing
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("max |difference| in probability:",
      f"{np.abs(probs[:, 1] - p_sk).max():.1e}")
distintas = int((etiquetas != modelo.predict(Xte)).sum())
print("different labels:", distintas, "of", len(Xte))
print("available providers:", ort.get_available_providers())
OUT [03]Parity: the same in ONNX Runtime as in scikit-learn
max |difference| in probability: 2.1e-07
different labels: 0 of 5000
available providers: ['AzureExecutionProvider', 'CPUExecutionProvider']

All 5,000 labels match and the probabilities differ by at most 2.1·10⁻⁷: the input was declared as float32 and the graph works at that precision, while scikit-learn uses float64. With tree models it is worth counting labels, not just probability differences: a value that falls exactly on a tree threshold can move to the other side when going to 32 bits. If that matters, skl2onnx supports converting to double precision. There are two available providers: CPUExecutionProvider, the one in use, and AzureExecutionProvider, which is not used (it ships with the PyPI wheel).

Export does not always work. With the exact versions of this environment, scikit-learn's other tree classifier fails to convert:

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("cause:", type(causa).__name__ + ":", str(causa))
OUT [04]When the converter does not keep up with the library
scikit-learn 1.9.1 · skl2onnx 1.20.0 · onnx 1.23.2
ValueError: Unable to create node 'TreeEnsembleClassifier'
cause: TypeError: Field onnx.AttributeProto.ints: Expected an int, got a boolean.

The converter generates an attribute with boolean values where the ONNX specification expects integers, and the onnx library rejects it. It is a compatibility failure between versions, of the kind that appears when scikit-learn changes the internal structure of a model and the converter has not caught up yet. The practical lesson is not this particular failure, which will get fixed, but the procedure: pinned versions in the project's lockfile (lessons 0.2.2 and 7.1.1), and a test that exports, loads and compares every model going to production before each dependency update.

Latency: row by row, in batches and over the network

How much is gained with ONNX Runtime? Timings depend on the machine and cannot appear in the output of a cell that is checked on every run; the cell prints conclusions with a margin, and the numbers go in the text:

IN [05]python
import timeit

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

def mejor(f, n):
    """Best of 5 repetitions, in microseconds per call."""
    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("one row: ONNX Runtime at least 10 times faster:",
      t_sk / t_ort > 10)
print("batch of 1000: per row, cheaper than a single row:",
      t_lote / 1000 < t_ort)
OUT [05]Latency of one row and of a batch
one row: ONNX Runtime at least 10 times faster: True
batch of 1000: per row, cheaper than a single row: True

On the course machine, scikit-learn's predict_proba takes about 410 µs for one row, and ONNX Runtime about 9 µs. Almost all of scikit-learn's time is fixed work per call (validating the input, walking through the pipeline in Python), not computation. A batch of 1,000 rows takes about 2.7 ms in ONNX Runtime: about 2.7 µs per row, three times less than a single row. The computation of a tree per row is so small that the fixed cost of each call dominates, and grouping rows spreads it out.

Now the service. A minimal HTTP server with the standard library, the ONNX model loaded once and an input contract with Pydantic (lesson 6.2.2): exact fields, strict types, eight features. It listens on 127.0.0.1, with a port chosen by the system; never on all interfaces without authentication in front.

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):          # no logging to 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}),
      "| in process:", 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]An HTTP service with an input contract
(200, {'p_sube': 0.51919}) | in process: 0.51919
(422, {'error': 'List should have at least 8 items after validation, not 7'})
(422, {'error': 'Extra inputs are not permitted'})

The service returns the same probability as the in-process model, and rejects with a 422 a request with seven features and another with a field that is not in the contract (umbral, a threshold). And the price?

IN [07]python
cuerpo = {"modelo": "senal-n7", "features": f0}
t_http = mejor(lambda: pedir(cuerpo), 50)
print("local HTTP at least 10 times slower than in process:",
      t_http / t_ort > 10)
srv.shutdown()
OUT [07]What the network adds
local HTTP at least 10 times slower than in process: True

Over local HTTP, about 540 µs per prediction: more than scikit-learn in the process. The model itself now costs only 9 µs; the rest is serializing the JSON, opening the connection, going through the server loop and coming back. That is the conclusion of the section: architecture weighs more than format. For a daily signal, 0.5 ms does not matter at all and the service brings order (one model, one deployment, one contract). For a system that reacts to every quote (lesson 4.5.1), the model goes inside the process.

OptionLatency per call (course machine)When
scikit-learn in the process≈ 410 µsresearch, daily signals
ONNX Runtime in the process≈ 9 µs (≈ 2.7 µs per row in batches of 1,000)real time, many models
Local HTTP service≈ 540 µsseveral processes, independent deployment
Model serverdepends on load and batchmany models, GPU, variable traffic

CPU or GPU

A GPU executes thousands of identical operations in parallel. It pays off when there is a lot of computation per call: large networks, large batches, language models. A tree ensemble on one row is the opposite: little computation, many branches. For almost all tabular trading models, the CPU is more than enough, and it avoids copying data to GPU memory and back (lesson 6.6.5 covered the GPU for data processing, with RAPIDS).

With ONNX, changing hardware means changing the execution provider. The code is this one, not executed because the course machine has no GPU (it requires the onnxruntime-gpu package instead of onnxruntime, which since version 1.27 is published on PyPI built with CUDA 13.01Note 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”, and installation with pip install onnxruntime-gpu[cuda,cudnn]. Consulted on October 23, 2026.):

IN [08]python
# pip install "onnxruntime-gpu[cuda,cudnn]"   (another environment, with a GPU)
import onnxruntime as ort

sesion = ort.InferenceSession(
    "senal.onnx",
    providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
print(sesion.get_providers())   # the first available one is used

The two techniques that most reduce inference cost on CPU are batching (the latency cell) and quantizing: storing the weights as 8-bit integers, or fewer bits, instead of float32. ONNX Runtime quantizes ONNX models (its documentation recommends dynamic quantization for transformers and recurrent networks, and static quantization for convolutions), and it is what makes it possible to run language models with billions of parameters on a single consumer GPU (lesson 6.2.4). For a tree model it adds nothing: there are no weight matrices to compress.

The cost of LLMs in production

With language models the calculation changes in nature. The cost is no longer a machine but a price per token (lesson 6.2.1), with four prices per model: input tokens, output tokens, input read from the prompt cache and input written to it. The ones in the table come from the official pages of Anthropic and OpenAI, consulted on October 23, 2026, in USD per million tokens, for short contexts2Note 2 · Anthropic, “Pricing” (https://platform.claude.com/docs/en/about-claude/pricing): Claude Opus 5.5, 4 USD per million input tokens and 20 output; Sonnet 5.5, 2 and 10; Haiku 5.5, 0.10 and 0.50 (prompts of up to 100,000 tokens); writes to the 5-minute cache at 1.25 times input; reads at 0.05 times on Opus 5.5 and Sonnet 5.5 and at 0.1 times on the rest; batch API with a 50% discount on input and output. OpenAI, “Pricing” (https://developers.openai.com/api/docs/pricing), standard price and short context: gpt-6-astra, 10 and 50, cached input 1.00 and 30-minute write 12.50; gpt-6.1-sol, 2 and 10, 0.10 and 2.50; gpt-6-luna, 0.10 and 0.50, 0.01 and 0.125; the batch table is half. Both consulted on October 23, 2026.:

IN [09]python
# USD per million tokens, short context. Official pricing pages of
# Anthropic and OpenAI, consulted on October 23, 2026.
PRECIOS = {   # input, output, cache read, cache write
    "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          # batch API: half price at both providers

def coste(modelo, entrada, salida, cache_r=0, cache_w=0, lote=False):
    """USD for one request; `entrada` = input tokens without cache."""
    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]API prices, with their date
2.00 25.00

Two discounts change the result a lot:

  • Prompt caching. An initial part of the prompt that repeats across requests (instructions, examples, a document, an agent's history) is written once to the cache, somewhat more expensive than normal input (1.25 times), and is then read much more cheaply: 0.05 times the input price on Claude Opus 5.5, Claude Sonnet 5.5 and gpt-6.1-sol, 0.1 times on the other models in the table. The cache lasts from minutes to hours, depending on the option, and is refreshed when read.
  • Batch API. Requests that can wait (hours, not seconds) are sent together and paid at half price at both providers.

Three typical workloads of a research system, with the tokens written out by hand:

  • Headlines: 2,000 a day, classified with a prompt of 1,500 tokens of instructions and examples, always the same, plus the headline (60 tokens); it answers with a JSON label (30).
  • Reports: 40 a day of 25,000 tokens, summarized in 1,000. Each report is different, so the cache does not help; they can wait until night, so the batch API does.
  • Agent: 200 tasks a day of 8 steps (lesson 6.4.1). At each step it resends everything before: 3,000 initial tokens and 1,500 more per step, and it answers with 300.
IN [10]python
import pandas as pd

SESIONES = 21

def titulares(m, cache):
    """2,000 headlines a day: instructions and examples (1,500 tokens,
    the same in all) + headline (60) and a JSON label (30)."""
    if cache:     # 10 writes a day; the rest, reads
        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 reports of 25,000 tokens; summary of 1,000."""
    return 40 * coste(m, 25_000, 1_000, lote=lote)

def agente(m, cache):
    """200 tasks of 8 steps. Each step resends the whole history:
    3,000 fixed tokens + 1,500 per previous step; answers 300."""
    total = 0.0
    for i in range(8):
        contexto = 3000 + 1500 * i
        if cache and i:    # reads what came before and writes the new part
            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] = {"headlines": titulares(m, False) * SESIONES,
                "reports": informes(m, False) * SESIONES,
                "agent": agente(m, False) * SESIONES,
                "total": base * SESIONES,
                "with cache and batch": opt * SESIONES}
mes = pd.DataFrame(filas).T.round(0).astype(int)
mes
OUT [10]Three workloads, a month of 21 sessions
                   headlines  reports  agent  total  with cache and batch
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

The figure at the top shows the totals on a logarithmic scale. From the most expensive model to the cheapest there is a factor of 100: 4,246 USD a month with gpt-6-astra versus 42 with Claude Haiku 5.5 or gpt-6-luna. And on every model, caching and batching leave the bill at between 37% and 40% of the standard price. The breakdown by workload, with Claude Sonnet 5.5:

Fig. — Monthly cost of each of the lesson's workloads with Claude Sonnet 5.5, at standard rates and with the matching optimisation (caching for headlines and agent, batch API for reports)

The agent is 77% of the spend: 655 USD out of 849. It does not answer much (300 tokens per step); what is expensive is that each step resends the whole conversation, 66,000 input tokens per task. Caching brings it down to 265, because what has already been sent is read at 0.05 times the price. Headlines fall from 144 to 25 with caching (the fixed prompt is 94% of each request) and reports, from 50 to 25 with batching.

An in-house server?

The alternative to paying per token is renting a GPU and serving an open model with vLLM or Ollama (lesson 6.2.1). A real price: Hetzner's GEX45 server, with a 24 GB NVIDIA RTX PRO 4000 Blackwell, costs 214 EUR a month excluding VAT plus a 209 EUR setup fee3Note 3 · Hetzner, dedicated servers with GPU (https://www.hetzner.com/dedicated-rootserver/matrix-gpu/): GEX45, “€ 214.00 per month, excl. VAT” and “€ 209.00 one-time setup fee”, with a 24 GB NVIDIA RTX PRO 4000 Blackwell SFF Edition. ECB reference rate of October 23, 2026: 1 EUR = 1.1193 USD. Consulted on October 23, 2026.. Above what volume is it cheaper than the API?

IN [11]python
GEX45_EUR = 214.00           # per month, excl. VAT (Hetzner, 2026-10-23)
ALTA_EUR = 209.00            # one-off
EURUSD = 1.1193              # ECB, reference rate of 2026-10-23
servidor_usd = GEX45_EUR * EURUSD

def tokens_equivalentes(m, frac_salida=0.02):
    """Tokens per month at which the API costs as much as the server,
    with 2% output tokens (as in the workloads above)."""
    pe, ps, *_ = PRECIOS[m]
    usd_mtok = (1 - frac_salida) * pe + frac_salida * ps
    return servidor_usd / usd_mtok * 1e6

print(f"server: {servidor_usd:.2f} USD a month")
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} billion tokens a month"
          f" = {por_s:6.0f} tokens/s nonstop")
OUT [11]An in-house server with a GPU?
server: 239.53 USD a month
Claude Haiku 5.5     2.22 billion tokens a month =    856 tokens/s nonstop
gpt-6-luna           2.22 billion tokens a month =    856 tokens/s nonstop
Claude Sonnet 5.5    0.11 billion tokens a month =     43 tokens/s nonstop

Against the small API models (Claude Haiku 5.5, gpt-6-luna), the server is only cheaper above roughly 2.2 billion tokens a month: 856 tokens per second, day and night, all month long. Against Claude Sonnet 5.5, above 43 tokens per second, but a model that fits in 24 GB is not comparable with Sonnet: the honest comparison is with the small models. Whether a GPU like that sustains 856 tokens per second with a useful model is something that has to be measured with the specific model and server (vLLM batches requests and its throughput depends heavily on batch size); there is no reliable figure to give here.

The decision, therefore, is rarely about price at the scale of an individual or a small team:

Reason to serve your own modelReason to use an API
Confidentiality: the data does not leave the machine (6.2.1)Quality of the large models
Version pinned forever: reproducibilityNothing to operate, update or monitor
Latency without network or third-party queuesPay per use: zero if unused
Huge, constant volumesLoad peaks without idle capacity

A budget guard

An API bill has no ceiling by itself. A badly written loop that repeats the same call, an agent that does not finish (lesson 6.4.5) or a change that multiplies the length of the prompt shows up at the end of the month. The control is the same as the order risk engine, applied to spending: each call is authorized beforehand with its maximum possible cost (the estimated input plus max_tokens of output), recorded with the real cost returned by the API and, once the daily limit is reached, there are no more calls. And a request identical to one already made is served from a local cache, without calling:

yesnonoyesRequestIn cacheResponseWithin limitReject and alertAPIRecord cost
Mermaid source
flowchart TB
  P[Request] --> C{In cache}
  C -->|yes| R[Response]
  C -->|no| B{Within limit}
  B -->|no| X[Reject and alert]
  B -->|yes| A[API]
  A --> G[Record cost]
  G --> R
IN [12]python
import hashlib

class Presupuesto:
    """Authorizes each call with its maximum possible cost and cuts off
    when the daily limit is reached. Identical requests are served
    from the cache without calling the 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     # prudent estimate
        peor = coste(self.modelo, n_in, max_salida)
        if self.gastado + peor > self.limite:
            self.rechazadas += 1
            raise RuntimeError("daily budget exhausted")
        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):
    """Always answers the same and counts tokens like an 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(["word"] * 6000)        # an 8,000-token report
for i in range(400):                     # a badly written loop
    g.llamar("Classify: Nore rises 3%", 50, api_simulada)
for i in range(400):                     # 400 different reports
    try:
        g.llamar(f"Report {i}: {largo}", 1000, api_simulada)
    except RuntimeError:
        break
print(f"calls {g.llamadas} · from cache {g.de_cache} · "
      f"rejected {g.rechazadas} · spent {g.gastado:.2f} USD")
OUT [12]A budget guard with a response cache
calls 305 · from cache 399 · rejected 1 · spent 4.99 USD

The badly written loop repeats the same classification 400 times: one real call and 399 served from the cache. The 400 different reports exhaust the 5 USD budget at call 305 (each 8,000-token report costs about 0.016 USD, and the worst case with 1,000 output tokens, 0.026): the guard rejects the next one, the loop stops and spending stays at 4.99 USD. In production, the rejection triggers an alert (lesson 7.2.3), the cost of each call is published as a metric, and limits are also set in the provider's console, which is the last line of defense if the guard fails.

Exercises

  1. Repeat the agent cost with Claude Opus 5.5 for tasks of 16 steps instead of 8, with and without caching. Why does the cost without caching more than double?
Solution
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, "steps:", round(agente_n(m, pasos, False)),
          "USD a month without cache ·", round(agente_n(m, pasos, True)),
          "with cache")
OUT [13]An agent twice as long
8 steps: 1310 USD a month without cache · 529 with cache
16 steps: 4234 USD a month without cache · 1109 with cache

With 16 steps, the agent costs 4,234 USD a month without caching, 3.2 times more than with 8 (1,310), and 1,109 with caching, 2.1 times more (529). Without caching, the input of a task is the sum of a context that grows at every step: with nn steps, 3000 n+750 n(n−1)3000\,n + 750\,n(n-1) tokens. It is quadratic: 66,000 tokens with 8 steps and 228,000 with 16, 3.5 times more. The output only doubles, and that is why the total ends up at 3.2. With caching, what has already been sent is read at 0.05 times the price (on Opus 5.5) and what is paid in full is the new part of each step, which grows linearly: the cost gets close to doubling.

It is the practical reason to limit the number of steps of an agent, summarize the history when it gets long and use caching: the cost of an agent is set by the length of its conversation, not by the length of its answers.

  1. A client of the HTTP service sends a request with NaN in the first feature (json.dumps writes it that way by default). Does the contract accept it? Fix it.
Solution
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("accepted; 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("rejected:", e.errors()[0]["msg"], e.errors()[0]["loc"])
OUT [14]NaN in the service input
{"modelo": "senal-n7", "features": [NaN,
accepted; p_sube = 0.47686
rejected: Input should be a finite number ('features', 0)

It accepts it. json.dumps writes NaN (which is not standard JSON, but Python produces and reads it by default), Pydantic converts the list to float without complaint because a NaN is a valid float, and the tree model returns a probability, 0.47686, without any warning: in the graph, the NaN follows the branch that the converter assigns to missing values. It is the worst kind of failure: a good-looking answer computed from a value that does not exist.

FiniteFloat requires finite numbers and the request is rejected with an error that says which field and which position fail. In the service, the right contract is the one that ends in a 422; and on the client side, json.dumps(..., allow_nan=False) raises an exception before sending a NaN. Both defenses at once: the contract does not trust the clients.

  1. A colleague proposes moving headline classification to an open model on an in-house server “to save money”. With the lesson's numbers, what would you answer?
Solution

That with the small API models headline classification costs about 7 USD a month at the standard price (and 1 or 2 with caching), and the GPU server about 240 USD a month plus the setup fee, plus the time to install it, update it, monitor it and keep it available. On cost it does not pay off until volumes of the order of 2.2 billion tokens a month. It can pay off for other reasons: if the headlines or the prompt contain information that must not leave the firm (positions, proprietary signals), if the model has to be pinned forever for reproducibility, or if the API latency is a problem. If any of those reasons is real, the in-house server is justified by it, and it is worth saying so and measuring the quality of the open model with the task's evaluation set before switching (lesson 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”, and installation with pip install onnxruntime-gpu[cuda,cudnn]. Consulted on October 23, 2026. ↩

  2. Anthropic, “Pricing” (https://platform.claude.com/docs/en/about-claude/pricing): Claude Opus 5.5, 4 USD per million input tokens and 20 output; Sonnet 5.5, 2 and 10; Haiku 5.5, 0.10 and 0.50 (prompts of up to 100,000 tokens); writes to the 5-minute cache at 1.25 times input; reads at 0.05 times on Opus 5.5 and Sonnet 5.5 and at 0.1 times on the rest; batch API with a 50% discount on input and output. OpenAI, “Pricing” (https://developers.openai.com/api/docs/pricing), standard price and short context: gpt-6-astra, 10 and 50, cached input 1.00 and 30-minute write 12.50; gpt-6.1-sol, 2 and 10, 0.10 and 2.50; gpt-6-luna, 0.10 and 0.50, 0.01 and 0.125; the batch table is half. Both consulted on October 23, 2026. ↩

  3. Hetzner, dedicated servers with GPU (https://www.hetzner.com/dedicated-rootserver/matrix-gpu/): GEX45, “€ 214.00 per month, excl. VAT” and “€ 209.00 one-time setup fee”, with a 24 GB NVIDIA RTX PRO 4000 Blackwell SFF Edition. ECB reference rate of October 23, 2026: 1 EUR = 1.1193 USD. Consulted on October 23, 2026. ↩

Each answer is folded under its question

Self-assessment

  1. The model exported to ONNX gives probabilities that differ from scikit-learn's by at most 2.1·10⁻⁷. Where does that difference come from?

    • AFrom a bug in the converter
    • BFrom ONNX Runtime using a different seed
    • CFrom ONNX Runtime computing in 32-bit floating point and scikit-learn in 64-bit
    • DFrom the ONNX graph removing unimportant trees
    Show answer

    Answer C. From ONNX Runtime computing in 32-bit floating point and scikit-learn in 64-bit

    The input is declared as float32 and the graph works at that precision; scikit-learn uses float64. Differences of 10⁻⁷ are rounding; what matters is that none of the 5,000 labels changes. With tree models, a value sitting exactly on a threshold could fall to the other side: that is why the parity test counts labels, not just probability differences.

  2. On the course machine, a one-row prediction takes about 410 µs with scikit-learn, about 9 µs with ONNX Runtime in the process and about 540 µs over local HTTP. Which conclusion is correct?

    • AONNX always makes a system faster
    • BHTTP is slow and must always be avoided
    • CFor a daily signal, none of the three latencies matters
    • DExporting to ONNX removes almost all the compute cost, but putting it behind HTTP adds more than was saved: architecture weighs more than format
    Show answer

    Answer D. Exporting to ONNX removes almost all the compute cost, but putting it behind HTTP adds more than was saved: architecture weighs more than format

    The HTTP service has advantages (a single model for several processes, independent deployment), and its cost per call is hundreds of microseconds. For a system that decides once a day it makes no difference; for one that decides on every tick, the model has to live inside the process. The right question is how many predictions there are per second and with what latency budget.

  3. Claude Sonnet 5.5 and gpt-6.1-sol have the same price per million tokens. Do they cost the same for the same job?

    • ANot necessarily: each provider counts tokens with its own tokenizer, and the same text can give different numbers
    • BYes, by definition
    • CNo, because OpenAI charges for caching separately and Anthropic does not
    • DYes, except with the batch API
    Show answer

    Answer A. Not necessarily: each provider counts tokens with its own tokenizer, and the same text can give different numbers

    The price is per token, and the token is defined by each model's tokenizer. Anthropic's own pricing page warns that the tokenizer of Claude 4.7 and later produces approximately 30% more tokens for the same text. For a real comparison you have to count the tokens of a sample of the real texts with each provider.

  4. In the lesson's scenario, the agent accounts for 655 of the 849 USD a month with Claude Sonnet 5.5. Why is it so expensive?

    • ABecause it writes very long answers
    • BBecause each step resends the whole history: the context grows step by step and is paid in full on every call
    • CBecause it uses the most expensive model
    • DBecause the API charges per tool used
    Show answer

    Answer B. Because each step resends the whole history: the context grows step by step and is paid in full on every call

    At step i, 3,000 + 1,500·i tokens are sent: over 8 steps, 66,000 input tokens for 2,400 of output. With prompt caching, what has already been sent is read at 0.05 times the input price (on Sonnet 5.5) and the agent's cost falls to 265 USD.

  5. The lesson's budget guard authorizes each call with its maximum possible cost, not the expected one. Why?

    • ABecause that way it spends more
    • BBecause APIs always charge max_tokens
    • CBecause the expected cost cannot be computed
    • DBecause the real cost is only known when the call finishes, and a response that reaches max_tokens can cost quite a lot more than the average: authorizing by the maximum guarantees the limit is not exceeded
    Show answer

    Answer D. Because the real cost is only known when the call finishes, and a response that reaches max_tokens can cost quite a lot more than the average: authorizing by the maximum guarantees the limit is not exceeded

    APIs charge for the tokens they generate, not for max_tokens; but that number is not known until the end. Authorizing by the worst case makes the limit an actual limit. In the lesson, the guard cuts off at 4.99 USD out of 5.00 after 305 calls.

Key points

  • ONNX describes the model as a graph of standard operators that ONNX Runtime runs without scikit-learn: the lesson's pipeline weighs 106 KB, gives the same 5,000 labels and probabilities within 2·10⁻⁷.
  • The converter lags behind the library: with scikit-learn 1.9.1 and skl2onnx 1.20.0, HistGradientBoostingClassifier does not export. Pinned versions and an export-and-parity test in continuous integration.
  • Latency depends more on architecture than on format: about 410 µs with scikit-learn, 9 µs with ONNX Runtime in the process and 540 µs over local HTTP on the course machine. A GPU pays off with large batches and large networks, not with one-row trees.
  • A prediction service validates its input with a strict contract; even so, the lesson's contract let NaN through until it required finite numbers.
  • The cost of an LLM is computed with explicit tokens and dated prices. In the lesson's workloads, the agent that resends its history is 77% of the spend; caching and the batch API bring the total down from 849 to 314 USD a month with Claude Sonnet 5.5.
  • A rented GPU server (about 240 USD a month) only competes on cost with the small API models above roughly 2.2 billion tokens a month; it is chosen for confidentiality or control, not price. And every API use sits behind a budget guard.

Resources