---
title: "6.2.1 Cómo funcionan los LLM: APIs y modelos locales"
description: "Siguiente token, logits, temperatura, top-p, ventana de contexto y alucinaciones con un LLM local, y las APIs de OpenAI y Anthropic contra un simulador."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/como-funcionan-los-llm-apis-y-modelos-locales.png"
---

# 6.2.1 · Cómo funciona un LLM: del siguiente token a la API

> Un LLM no consulta una base de datos: calcula la probabilidad del siguiente token y elige uno. Esta lección abre esa caja con un modelo de 0,5B parámetros que corre en la CPU (logits, temperatura, top-p, contexto y una alucinación sobre una empresa ficticia) y ejecuta el código de las APIs de OpenAI y Anthropic contra un servidor local que imita sus respuestas.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.2 · LLMs para investigación y trading
- Duración: 70 min
- Requisitos: [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/), [5.7.2 · Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer](https://pythonparatrading.com/curso-python-trading/nivel-5/tcn-n-beats-patchtst-e-itransformer/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [6.1.1 · Tokenización, embeddings, diccionarios financieros y FinBERT](https://pythonparatrading.com/curso-python-trading/nivel-6/tokenizacion-embeddings-loughran-mcdonald-y-finbert/)

## Al terminar esta lección

- Explicar la generación autorregresiva: tokens, logits, softmax y la elección del siguiente token.
- Medir cómo cambian la temperatura y top-p la distribución del siguiente token y las continuaciones muestreadas con semilla fija.
- Calcular cuántos tokens ocupa un documento financiero y qué cabe en una ventana de contexto.
- Provocar y reconocer una alucinación, y comprobar cómo la reducen la instrucción de abstenerse y el contexto.
- Llamar a un modelo con los SDK oficiales de OpenAI y Anthropic, y decidir entre API comercial y modelo local (Ollama, vLLM).

## Un modelo que predice el siguiente token

Un modelo de lenguaje grande (LLM) hace una sola cosa: dado un texto, calcula la probabilidad de cada posible
continuación inmediata y elige una. Repite el paso con la continuación añadida y así escribe párrafos enteros. Todo lo
que parece razonamiento, memoria o conocimiento sale de ese bucle. La arquitectura que lo hace posible a gran escala es
el *transformer* (Vaswani et al., 2017), que ya apareció en las series temporales (lección 5.7.2): capas de atención
que dejan que cada posición mire a todas las anteriores.

```mermaid
flowchart TB
  A[Texto] --> B[Tokens]
  B --> C[Transformer]
  C --> D[Logits]
  D --> E[Softmax]
  E --> F[Elegir token]
  F --> B
```

El texto no entra como letras ni como palabras, sino como **tokens**: piezas de un vocabulario fijo que el tokenizador
aprendió al construir el modelo (la lección 6.1.1 explica BPE y por qué el español cuesta más tokens que el inglés).
El modelo devuelve un número real, un **logit**, por cada pieza del vocabulario; la softmax los convierte en
probabilidades; una regla de decisión (la más probable, o un sorteo) elige una pieza; y la pieza elegida se añade a la
entrada.

Esta lección usa **Qwen2.5-0.5B-Instruct**, un modelo abierto de Alibaba con licencia Apache-2.0 y 0,49B parámetros,
fijado en el entorno `ia` del curso por commit y sha256 y cargado sin red. Es pequeño a propósito: cabe en la CPU de un
portátil y deja ver el mecanismo. No es un modelo de calidad comercial y la lección lo usa para enseñar cómo funciona
y cómo falla, no para prometer resultados.

```python
import numpy as np
import pandas as pd
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
NOMBRE = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(NOMBRE)
llm = AutoModelForCausalLM.from_pretrained(
    NOMBRE, dtype=torch.float32).eval()

cfg, gen = llm.config, llm.generation_config
print(f"parámetros: {llm.num_parameters():,}")
print(f"vocabulario: {len(tok):,} piezas")
print(f"capas: {cfg.num_hidden_layers}, "
      f"contexto: {cfg.max_position_embeddings:,} tokens")
print(f"por defecto: do_sample={gen.do_sample}, "
      f"temperature={gen.temperature}, top_p={gen.top_p}, "
      f"top_k={gen.top_k}, "
      f"repetition_penalty={gen.repetition_penalty}")
```
```output
parámetros: 494,032,768
vocabulario: 151,665 piezas
capas: 24, contexto: 32,768 tokens
por defecto: do_sample=True, temperature=0.7, top_p=0.8, top_k=20, repetition_penalty=1.1
```

Los pesos se guardan en bf16 y se cargan en float32, el formato con el que el entorno se comprobó determinista. La
última línea es la configuración de generación que publica el modelo: por defecto **muestrea** (`do_sample=True`) con
temperatura 0,7, top-p 0,8 y top-k 20, y penaliza las repeticiones con un factor 1,1. Cuando esta lección pide
decodificación voraz (`do_sample=False`), la penalización de repetición sigue activa; el resto de parámetros de muestreo
se ignoran.

## Tokens: lo que el modelo ve y lo que se paga

```python
frases = [
    "La compañía prevé unas ventas de entre 2.440 y 2.569 "
    "millones de dólares.",
    "We expect revenue in the range of $2,440 million to "
    "$2,569 million.",
]
for f in frases:
    ids = tok(f)["input_ids"]
    piezas = [tok.decode([i]) for i in ids]
    print(len(f), "caracteres,", len(ids), "tokens")
    print("|".join(piezas))
```
```output
73 caracteres, 25 tokens
La| compañía| prev|é| unas| ventas| de| entre| |2|.|4|4|0| y| |2|.|5|6|9| millones| de| dólares|.
67 caracteres, 23 tokens
We| expect| revenue| in| the| range| of| $|2|,|4|4|0| million| to| $|2|,|5|6|9| million|.
```

Las dos frases dicen lo mismo con 73 y 67 caracteres; el español usa 25 tokens y el inglés 23. Hay dos detalles que
importan en finanzas. Qwen **escribe cada dígito como un token aparte** (`2|.|4|4|0`): el modelo no ve el número 2.440
sino cinco piezas, y el separador de miles es un token más, que significa cosas opuestas en español y en inglés. Y las
palabras se parten en trozos (` prev|é`), que el modelo tiene que recomponer.

Contar tokens no es un ejercicio académico. Las APIs comerciales cobran por token de entrada y de salida, la ventana
de contexto se mide en tokens y el tiempo de cálculo crece con ellos. El formato del documento pesa tanto como el
idioma:

*Fig. — Tokens de Qwen2.5 por cada 1.000 caracteres en los textos sintéticos del nivel 6, por formato e idioma.* Siete barras horizontales. El 10-K en HTML necesita 277 tokens por cada 1.000 caracteres (destacado) y su texto sin etiquetas, 221. La tabla Markdown tipo CNMV, 330. Noticias: 280 en español y 245 en inglés. Llamadas: 284 en español y 211 en inglés.

La figura mide tokens por cada 1.000 caracteres en los textos sintéticos del nivel con el tokenizador de Qwen. El HTML
de un 10-K cuesta 277 tokens por cada 1.000 caracteres y su texto sin etiquetas, 221: las etiquetas `<td>`, `<tr>` y
`<sup>` son un 25 % de tokens que no aportan nada al modelo. La tabla Markdown de los documentos tipo CNMV es lo más
caro (330), por las barras y las cifras con separadores. Limpiar el formato antes de enviar un documento a una API es
el ahorro más fácil.

## De los logits a las probabilidades

Para un texto de entrada, la última posición del transformer produce un vector de logits $z_i$, uno por pieza del
vocabulario. La probabilidad de que la siguiente pieza sea la $i$ es la softmax:

$$
p_i = \frac{e^{z_i}}{\sum_j e^{z_j}}
$$

Basta una pasada hacia delante para verla. El prompt es una frase de noticia a medio escribir:

```python
prompt = "Las acciones cayeron un 8 % tras unos resultados"
x = tok(prompt, return_tensors="pt")
with torch.inference_mode():
    z = llm(**x).logits[0, -1].double()   # logits del último

p = torch.softmax(z, dim=-1).numpy()
orden = np.argsort(-p)
top = pd.DataFrame({
    "token": [repr(tok.decode([i])) for i in orden[:10]],
    "prob_%": (100 * p[orden[:10]]).round(1)})
print(f"{len(p):,} probabilidades, suman {p.sum():.4f}")
top
```
```output
151,936 probabilidades, suman 1.0000
       token  prob_%
0     ' neg'    11.9
1  ' económ'     8.6
2      ' de'     7.4
3     ' más'     3.5
4   ' posit'     2.8
5     ' que'     1.9
6     ' muy'     1.7
7     ' baj'     1.6
8   ' optim'     1.5
9      ' fu'     1.3
```

El vector de salida tiene 151.936 posiciones, algo más que las 151.665 piezas del tokenizador: la capa de salida se
redondea a un tamaño cómodo para el cálculo y las posiciones sobrantes no corresponden a ninguna pieza (su probabilidad
es prácticamente nula). La figura de la cabecera dibuja la tabla. El token más probable es « neg» (11,9 %), el comienzo de «negativos»;
le siguen « económ» (8,6 %), de «económicos», y « de» (7,4 %). Hay dos lecciones en estas diez filas. La primera:
la probabilidad está muy repartida; el más probable no llega al 12 % y los diez primeros suman algo menos de la mitad.
La segunda: « posit» (2,8 %) y « optim» (1,5 %) están entre los diez primeros aunque las acciones hayan caído un
8 %. El modelo no razona sobre la noticia; reproduce cómo suelen continuar frases parecidas en su entrenamiento, y
«resultados positivos» es una expresión muy frecuente.

## Temperatura y top-p

La **temperatura** $T$ divide los logits antes de la softmax:

$$
p_i(T) = \frac{e^{z_i / T}}{\sum_j e^{z_j / T}}
$$

Con $T < 1$ las diferencias entre logits crecen y la probabilidad se concentra en los primeros; con $T \to 0$ queda
toda en el más probable (la decodificación voraz). Con $T > 1$ la distribución se aplana. El **muestreo de núcleo** o
top-p (Holtzman et al., 2020) sortea solo entre el conjunto más pequeño de tokens cuya probabilidad suma al menos $p$,
y vuelve a normalizar dentro de él. Los dos parámetros se pueden combinar.

```python
def con_temperatura(z, T):
    return torch.softmax(z / T, dim=-1).numpy()

filas = []
for T in [0.2, 0.5, 0.7, 1.0, 1.5, 2.0]:
    q = np.sort(con_temperatura(z, T))[::-1]
    nucleo = int(np.searchsorted(np.cumsum(q), 0.9) + 1)
    filas.append({"T": T, "p_primero_%": round(100 * q[0], 1),
                  "nucleo_top_p_0.9": nucleo})
pd.DataFrame(filas).set_index("T")
```
```output
     p_primero_%  nucleo_top_p_0.9
T
0.2         77.5                 2
0.5         43.9                 6
0.7         27.8                43
1.0         11.9               370
1.5          2.8              7651
2.0          0.7             39393
```

*Fig. — Tamaño del núcleo de top-p 0,9 (tokens necesarios para sumar el 90 % de la probabilidad) según la temperatura, para el mismo prompt y Qwen2.5-0.5B-Instruct.* Línea creciente en escala logarítmica. Con temperatura 0,1 basta un token; con 0,2, dos; con temperatura 1 (destacado) hacen falta 370 tokens; con 2, 39.393 tokens de las 151.665 piezas del vocabulario.

Con $T = 0{,}2$ el primer token se lleva el 77,5 % y bastan dos tokens para llegar al 90 % de la probabilidad. Con
$T = 1$ (la distribución del modelo, sin tocar) el núcleo del 90 % tiene 370 tokens. Con $T = 2$, el primer token baja
al 0,7 % y el núcleo pasa de 39.000 piezas: cualquier cosa puede salir. La figura dibuja el tamaño del núcleo en escala
logarítmica para toda la rejilla de temperaturas.

Lo que eso significa en un texto se ve muestreando. Cada configuración genera cinco continuaciones de 10 tokens con la
**misma semilla**, así que la celda da lo mismo en cada ejecución:

```python
def ver(s):
    """Texto para mostrar: los caracteres fuera del alfabeto
    latino (chino, cirílico…) pasan a «·» y los saltos de
    línea, a «¶»."""
    s = s.replace("\n", "¶")
    return "".join(c if ord(c) < 256 else "·" for c in s)

configs = [(0.2, 1.0), (1.0, 1.0), (1.0, 0.9), (1.6, 1.0)]
for T, top_p in configs:
    torch.manual_seed(42)
    with torch.inference_mode():
        o = llm.generate(**x, do_sample=True, temperature=T,
                         top_p=top_p, top_k=0, max_new_tokens=10,
                         num_return_sequences=5,
                         pad_token_id=tok.eos_token_id)
    n = x["input_ids"].shape[1]
    print(f"T={T}, top_p={top_p}")
    for fila in o:
        print("  …" + ver(tok.decode(fila[n:],
                                      skip_special_tokens=True)))
```
```output
T=0.2, top_p=1.0
  … negativos en la semana pasada, mientras que
  … económicos negativos en los últimos días, mientras
  … negativos en la semana pasada, mientras que
  … negativos de la semana pasada, mientras que
  … económicos negativos en los últimos meses, pero
T=1.0, top_p=1.0
  … menos fuertes y brutalidades en marzo.
  … económicos injustamente subidas. Desde luego,
  … de carteles rentables para ERC y BBVA
  … negativos de la feria.¶······
  … sucios, asentándose en 1.
T=1.0, top_p=0.9
  … menos fuertes y oportunistas de
  … económicos aún poca posibilidad para la economía
  … de carteles - Correo¶La pizar
  … negativos de la feria.¶Además,
  … sucios, asentándose en 1.
T=1.6, top_p=1.0
  … menos fuertes sub·T pivó acompañ
  … f_PROPERTIES injusta manos pos-JPP, según
  … comparativos terribles para ERC mit pizar
  … ameritados primero, estos protagonistas buscan
  … sucieron, asent ba·ar·os desde que··
```

Con $T = 0{,}2$ las cinco continuaciones son variaciones de «negativos en la semana pasada» y «económicos
negativos», frases plausibles y casi repetidas (dos son idénticas). Con $T = 1$ aparecen continuaciones absurdas
(«brutalidades en marzo», «carteles rentables para ERC y BBVA», con nombres reales sacados de la nada), un salto de
línea (que la celda muestra como «¶») y caracteres de otros alfabetos (que muestra como «·»): el vocabulario de Qwen
cubre muchos idiomas y en la cola de la distribución hay de todo. Con top-p 0,9 y la misma semilla, las cinco
continuaciones empiezan igual que con top-p 1 (los primeros tokens estaban dentro del núcleo) y se separan cuando el
corte cambia la distribución: «menos fuertes y brutalidades» pasa a «menos fuertes y oportunistas». Con $T = 1{,}6$ el
texto se desintegra en fragmentos como «f_PROPERTIES» o «pos-JPP». `top_k=0` desactiva el top-k 20 que trae la configuración por defecto, para ver
el efecto de la temperatura sin otro filtro.

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

**Reproducibilidad.** Aquí cada ejecución da lo mismo porque el modelo es local, los pesos están fijados y la semilla
también. Con una API no se controla nada de eso. La documentación de OpenAI dice del parámetro `seed` que es beta y que
«our system will make a best effort to sample deterministically», sin garantía. Anthropic retiró `temperature`, `top_p`
y `top_k` en su versión de API 2026-10-21 (enviarlos da un error 400) y su guía de migración a Claude Opus 5.5 recuerda
que `temperature = 0` «never guaranteed identical outputs». Un backtest que llama a una API no se puede repetir
exactamente: hay que guardar las respuestas (lección 6.2.5).

</aside>

## La ventana de contexto

El modelo solo ve lo que cabe en su **ventana de contexto**, medida en tokens y compartida entre la entrada y la
respuesta. La ficha de Qwen2.5-0.5B-Instruct da 32.768 tokens de contexto y hasta 8.192 de generación; Claude Opus 5.5
admite 1 millón de tokens según la página de modelos de Anthropic. Una API no guarda memoria entre llamadas: en una
conversación, cada petición reenvía el historial entero y se paga entero.

```python
from bs4 import BeautifulSoup

fil = pd.read_parquet("filings6_documentos.parquet")
ll = pd.read_parquet("llamadas6_transcripciones.parquet")

def n_tok(t):
    return len(tok(t)["input_ids"])

def texto(html):
    return BeautifulSoup(html, "html.parser").get_text(" ")

k10 = fil[fil["tipo"] == "10-K"]
llamadas = ll.groupby("id_llamada")["texto"].apply(" ".join)
emisor = fil[fil["ticker"] == k10["ticker"].iloc[0]]
filas = {
    "10-K (HTML)": k10["texto"].map(n_tok).median(),
    "10-K (texto)": k10["texto"].map(texto).map(n_tok).median(),
    "llamada completa": llamadas.map(n_tok).median(),
    "8 años de un emisor": emisor["texto"].map(n_tok).sum(),
}
t = pd.Series(filas, name="tokens").round().astype(int).to_frame()
t["caben_en_32k"] = 32_768 // t["tokens"]
t
```
```output
                     tokens  caben_en_32k
10-K (HTML)             776            42
10-K (texto)            538            60
llamada completa        344            95
8 años de un emisor   23578             1
```

Los documentos sintéticos del curso son cortos (un 10-K real tiene decenas de miles de palabras, no tres mil
caracteres), pero el cálculo es el mismo con documentos reales. Aquí un 10-K cabe decenas de veces y toda la historia
documental de un emisor en ocho años cabe una sola vez. Un 10-K real, con sus anexos, puede no caber en una ventana de
32.000 tokens y obliga a trocearlo (lección 6.3.2).

Que quepa no quiere decir que se use bien. Liu et al. (2024) midieron que el acierto de varios modelos al responder
con un dato del contexto es mayor cuando el dato está al principio o al final y cae cuando está en medio, «even for
explicitly long-context models»[^1]. Meter un informe anual entero en el prompt y preguntar por una cifra de la página
40 no es lo mismo que recuperar la sección y dársela al modelo (lección 6.3.3).

## Alucinaciones: respuestas con forma de respuesta

Una **alucinación** es una respuesta fluida y segura que no es verdad. Para provocarla basta con preguntar por algo
que el modelo no puede saber. Busmar Hogar es una empresa del panel ficticio del curso: ningún LLM la ha visto. La
celda le hace la misma pregunta de tres maneras: sin más, con una instrucción para que se abstenga si no sabe el dato,
y con el comunicado de la empresa (un «hecho relevante» sintético tipo CNMV) en el contexto. La decodificación es voraz
y la celda calcula también la probabilidad media que el modelo dio a cada token que escribió.

```python
import textwrap

doc = fil.loc[fil["id_doc"] == "D000007", "texto"].iloc[0]
pregunta = ("¿Cuánto facturó Busmar Hogar en el cuarto trimestre "
            "de 2018?")
prompts = {
    "sin contexto": [{"role": "user", "content": pregunta}],
    "con abstención": [
        {"role": "system", "content":
         "Responde solo con datos que conozcas con certeza. Si no "
         "conoces el dato, responde exactamente: No lo sé."},
        {"role": "user", "content": pregunta}],
    "con el documento": [
        {"role": "system", "content":
         "Responde solo con el documento. Si el dato no está, "
         "di: No consta."},
        {"role": "user", "content":
         f"Documento:\n{doc}\n\nPregunta: {pregunta}"}],
}
tok.padding_side = "left"
textos = [tok.apply_chat_template(m, add_generation_prompt=True,
                                  tokenize=False)
          for m in prompts.values()]
b = tok(textos, return_tensors="pt", padding=True)
with torch.inference_mode():
    o = llm.generate(**b, do_sample=False, max_new_tokens=40,
                     pad_token_id=tok.eos_token_id,
                     output_scores=True,
                     return_dict_in_generate=True)
lp = llm.compute_transition_scores(o.sequences, o.scores,
                                   normalize_logits=True)
n = b["input_ids"].shape[1]
for k, nombre in enumerate(prompts):
    nuevos = o.sequences[k, n:]
    vale = nuevos != tok.eos_token_id
    p_media = lp[k][vale].exp().mean().item()
    print(f"[{nombre}] prob. media {p_media:.2f}")
    print(textwrap.fill(ver(tok.decode(nuevos,
                                       skip_special_tokens=True)),
                        68, initial_indent="  ",
                        subsequent_indent="  "))
```
```output
[sin contexto] prob. media 0.58
  Busmar Hogar ha notificado al mercado que su facturación en el
  cuarto trimestre de 2018 fue de $35 millones de dólares. Este
  cifra se ref
[con abstención] prob. media 0.91
  No lo sé.
[con el documento] prob. media 0.86
  2.942,6 millones de USD
```

Sin contexto, el modelo inventa: dice que Busmar Hogar «ha notificado al mercado» una facturación de 35 millones de
dólares. La cifra real del documento es 2.942,6 millones; el modelo no tenía forma de saberlo y aun así escribe una
frase con la estructura de un dato verificado. Con la instrucción de abstenerse responde «No lo sé.», y con el
documento en el contexto copia la cifra correcta, 2.942,6 millones de USD.

La probabilidad media de los tokens es más baja en la respuesta inventada (0,58) que en las otras dos (0,91 y 0,86).
Es tentador usarla como detector, pero no es fiable: un modelo puede inventar con mucha seguridad, sobre todo en
cifras con forma típica, y puede dudar en respuestas correctas. Farquhar et al. (2024) proponen medir la incertidumbre
sobre el **significado** de varias respuestas muestreadas (entropía semántica), no sobre las palabras, y aun así
detectan solo un subconjunto de las alucinaciones[^2].

¿Por qué alucinan? Kalai, Nachum, Vempala y Zhang (2025) lo atribuyen a cómo se entrenan y evalúan: «the training and
evaluation procedures reward guessing over acknowledging uncertainty». En un examen tipo test, contestar algo puntúa
más que dejarlo en blanco, y los modelos se optimizan para esos exámenes. La consecuencia práctica es que la
instrucción de abstenerse ayuda, pero lo que de verdad corrige el problema es **darle al modelo el documento** y
pedirle que conteste solo con él. Esa es la idea del RAG (lecciones 6.3.1 a 6.3.3).

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

En research de trading, una alucinación no suele ser un disparate evidente: es una previsión de ventas con un
decimal mal, una fecha de resultados del trimestre anterior o un ratio de una empresa parecida. Toda cifra que un LLM
introduzca en un modelo, un informe o una señal se contrasta con su fuente (el documento, la base de datos) de forma
automática. La lección 6.2.2 enseña a forzar el formato de las respuestas y la 6.2.3 a medir cuántas cifras acierta.

</aside>

## APIs comerciales: el mismo protocolo, otro servidor

Las APIs de OpenAI y Anthropic son servicios HTTP: el cliente envía un JSON con los mensajes y los parámetros, y el
servidor devuelve otro JSON con la respuesta y el recuento de tokens. Las dos tienen SDK oficiales para Python
(`openai` 3.37.0 y `anthropic` 2.1.0 en el entorno del curso). OpenAI recomienda su Responses API para proyectos
nuevos y mantiene la Chat Completions API; Anthropic tiene la Messages API. Este es el código real, sin ejecutar
porque necesita clave y red:

```python skip title="api_openai.py"
from openai import OpenAI

cliente = OpenAI()           # lee OPENAI_API_KEY del entorno
r = cliente.responses.create(
    model="gpt-6-astra",
    instructions="Eres un analista. Responde en una frase.",
    input="¿Qué es el guidance de una empresa?",
)
print(r.output_text)
```

```python skip title="api_anthropic.py"
import anthropic

cliente = anthropic.Anthropic()   # lee ANTHROPIC_API_KEY
r = cliente.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    system="Eres un analista. Responde en una frase.",
    messages=[{"role": "user",
               "content": "¿Qué es el guidance de una empresa?"}],
)
print(r.content[0].text, r.stop_reason, r.usage.output_tokens)
```

Los nombres de modelo son los que usa la documentación de cada proveedor a la fecha de la lección (`gpt-6-astra` en
el inicio rápido de OpenAI, `claude-opus-5-5` en la página de modelos de Anthropic); cambian con frecuencia y conviene
fijarlos en la configuración del proyecto, no repartidos por el código. La clave nunca va en el código: los dos SDK la
leen de una variable de entorno.

Como no hay red ni claves, la lección monta un **servidor local** que habla el mismo protocolo. Responde a
`POST /v1/chat/completions` con la forma de un objeto `chat.completion` de OpenAI y a `POST /v1/messages` con la de un
`message` de Anthropic, tal como los describe la referencia de cada API, y genera el texto con el Qwen local. Es, en
miniatura, lo que hacen Ollama y vLLM:

```mermaid
flowchart TB
  A[Tu código] --> B[SDK oficial]
  B -->|base_url| C[API comercial]
  B -->|base_url| D[Servidor local]
  D --> E[Modelo local]
```

```python
import json
import threading
from http.server import BaseHTTPRequestHandler
from http.server import ThreadingHTTPServer

def responder(mensajes, max_tokens):
    """Generación voraz con el Qwen local. Devuelve el texto,
    los tokens de entrada y de salida y si terminó solo."""
    x = tok.apply_chat_template(mensajes, return_tensors="pt",
                                add_generation_prompt=True,
                                return_dict=True)
    with torch.inference_mode():
        o = llm.generate(**x, do_sample=False,
                         max_new_tokens=max_tokens,
                         pad_token_id=tok.eos_token_id)
    nuevos = o[0, x["input_ids"].shape[1]:]
    fin = bool(torch.isin(nuevos, torch.tensor(
        gen.eos_token_id)).any())
    n_out = len(nuevos) - int(fin)
    texto = tok.decode(nuevos, skip_special_tokens=True)
    return texto, x["input_ids"].shape[1], n_out, fin

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

    def do_POST(self):
        n = int(self.headers["Content-Length"])
        req = json.loads(self.rfile.read(n))
        auth = self.headers.get("Authorization", "").split()[0]
        print(f"  [servidor] POST {self.path} · auth={auth} · "
              f"anthropic-version="
              f"{self.headers.get('anthropic-version')}")
        if self.path == "/v1/chat/completions":
            limite = (req.get("max_completion_tokens")
                      or req.get("max_tokens") or 64)
            txt, n_in, n_out, fin = responder(req["messages"],
                                              limite)
            r = {"id": "chatcmpl-local-1",
                 "object": "chat.completion",
                 "created": 1767225600, "model": req["model"],
                 "choices": [{
                     "index": 0, "logprobs": None,
                     "finish_reason": "stop" if fin else "length",
                     "message": {"role": "assistant",
                                 "content": txt, "refusal": None}}],
                 "usage": {"prompt_tokens": n_in,
                           "completion_tokens": n_out,
                           "total_tokens": n_in + n_out}}
        elif self.path == "/v1/messages":
            sis = req.get("system")
            msgs = ([{"role": "system", "content": sis}]
                    if sis else []) + req["messages"]
            txt, n_in, n_out, fin = responder(msgs,
                                              req["max_tokens"])
            r = {"id": "msg_local_1", "type": "message",
                 "role": "assistant", "model": req["model"],
                 "content": [{"type": "text", "text": txt}],
                 "stop_reason": "end_turn" if fin else "max_tokens",
                 "stop_sequence": None,
                 "usage": {"input_tokens": n_in,
                           "output_tokens": n_out}}
        else:
            self.send_error(404)
            return
        cuerpo = json.dumps(r).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(cuerpo)))
        self.end_headers()
        self.wfile.write(cuerpo)

srv = ThreadingHTTPServer(("127.0.0.1", 0), APILocal)
threading.Thread(target=srv.serve_forever, daemon=True).start()
URL = f"http://127.0.0.1:{srv.server_port}"
print("servidor local en 127.0.0.1, puerto elegido por el SO")
```
```output
servidor local en 127.0.0.1, puerto elegido por el SO
```

El servidor escucha solo en `127.0.0.1` y en un puerto que elige el sistema operativo. `created` es una fecha fija
para que la salida no dependa del reloj. Ahora, el SDK oficial de OpenAI con su método de Chat Completions, sin más
cambio que `base_url` (la Responses API tiene otra forma de respuesta y el simulador no la imita):

```python
from openai import OpenAI

oa = OpenAI(base_url=URL + "/v1", api_key="local",
            max_retries=0)
r = oa.chat.completions.create(
    model="qwen2.5-0.5b-instruct", max_completion_tokens=24,
    messages=[
        {"role": "system",
         "content": "Eres un analista. Responde en una frase."},
        {"role": "user",
         "content": "¿Qué es el guidance de una empresa?"}])
print(type(r).__name__, "·", r.choices[0].finish_reason, "·",
      r.usage.prompt_tokens, "+", r.usage.completion_tokens,
      "tokens")
print(textwrap.fill(ver(r.choices[0].message.content), 68))
```
```output
  [servidor] POST /v1/chat/completions · auth=Bearer · anthropic-version=None
ChatCompletion · length · 34 + 24 tokens
El Guidance de una empresa se refiere a los planes y estrategias que
la empresa utiliza para alcanzar sus objet
```

El SDK ha enviado la clave como `Authorization: Bearer` y ha convertido el JSON en un objeto `ChatCompletion` con sus
campos tipados. `finish_reason` es `length`: la respuesta llegó al límite de 24 tokens y se cortó a media frase. Es el
primer campo que hay que mirar en producción, porque una respuesta truncada es una respuesta incompleta (y un JSON
truncado no se puede leer, lección 6.2.2). Los 34 tokens de entrada incluyen las marcas de la plantilla de chat de
Qwen, no solo el texto.

```python
import anthropic

an = anthropic.Anthropic(base_url=URL, api_key="local",
                         max_retries=0)
r = an.messages.create(
    model="qwen2.5-0.5b-instruct", max_tokens=24,
    system="Eres un analista. Responde en una frase.",
    messages=[{"role": "user",
               "content": "¿Qué es el guidance de una empresa?"}])
print(type(r).__name__, "·", r.stop_reason, "·",
      r.usage.input_tokens, "+", r.usage.output_tokens, "tokens")
print(textwrap.fill(ver(r.content[0].text), 68))
srv.shutdown()
```
```output
  [servidor] POST /v1/messages · auth=Bearer · anthropic-version=2026-10-21
Message · max_tokens · 34 + 24 tokens
El Guidance de una empresa se refiere a los planes y estrategias que
la empresa utiliza para alcanzar sus objet
```

La misma respuesta con otra envoltura: el `system` va aparte de los mensajes, el contenido es una lista de bloques
(`content[0].text`) y el motivo de parada se llama `stop_reason` (`max_tokens` en vez de `length`). El servidor ha
visto la cabecera `anthropic-version: 2026-10-21`, la versión de la API que fija el SDK 2.1.0, y la clave también como
`Authorization: Bearer`; la documentación de Anthropic presenta `x-api-key` como alternativa heredada que sigue
funcionando. Las diferencias entre proveedores están en estos detalles, y por eso conviene aislar la llamada al modelo
en una función propia: cambiar de proveedor o pasar a un modelo local toca un solo sitio.

## Ollama y vLLM: el modelo en tu máquina

Los dos servidores locales más usados hacen a lo grande lo que el simulador hace en una celda:

| | Ollama | vLLM |
|---|---|---|
| Para qué | Un modelo en un portátil o un servidor pequeño, sin configurar nada | Servir un modelo a muchos usuarios con GPU y alto rendimiento |
| Dirección por defecto | `127.0.0.1:11434` | `localhost:8000` |
| APIs que imita | OpenAI (`/v1/chat/completions`, `/v1/responses`…) y un subconjunto de la Messages API de Anthropic | OpenAI (Completions, Chat Completions, Responses) y Messages de Anthropic |
| Salidas JSON con esquema | Parámetro `format` con un JSON Schema | `response_format` con `json_schema` (motores xgrammar o guidance) |

Fuentes: documentación de Ollama (compatibilidad con OpenAI y con Anthropic, salidas estructuradas y preguntas
frecuentes) y de vLLM (inicio rápido, *online serving* y *structured outputs*), consultadas el 2026-10-23. El código,
sin ejecutar (no hay ni Ollama ni GPU en el entorno del curso):

```bash
ollama pull qwen2.5:0.5b-instruct
vllm serve Qwen/Qwen2.5-1.5B-Instruct
```

```python skip title="cliente_local.py"
from openai import OpenAI

# Ollama: la clave es obligatoria en el SDK pero se ignora
cliente = OpenAI(base_url="http://localhost:11434/v1/",
                 api_key="ollama")
r = cliente.chat.completions.create(
    model="qwen2.5:0.5b-instruct",
    messages=[{"role": "user", "content": "Hola"}])
# vLLM: base_url="http://localhost:8000/v1" y el nombre del
# modelo servido ("Qwen/Qwen2.5-1.5B-Instruct")
```

El código del cliente es el mismo de la celda anterior. Esa compatibilidad es la razón práctica para escribir la capa
de acceso al modelo contra el formato de OpenAI o de Anthropic: el mismo programa se prueba con un modelo local
pequeño y gratuito, y se ejecuta con el que convenga.

## Qué elegir para research de trading

| Criterio | API comercial | Modelo local |
|---|---|---|
| Confidencialidad | Los datos salen de la casa; retención y uso según contrato y configuración | Los datos no salen |
| Calidad | Los mejores modelos disponibles | Limitada por el hardware: modelos de 0,5B a decenas de miles de millones de parámetros |
| Reproducibilidad | Versiones que se retiran, sin semilla garantizada | Pesos fijados por hash, decodificación determinista |
| Coste | Por token, sin inversión | Hardware y energía; marginal casi nulo |
| Latencia y volumen | Límites de peticiones por minuto | La de tu máquina |
| Sesgo de anticipación | Corte de entrenamiento declarado, a veces impreciso (lección 6.2.5) | El mismo problema, con la ventaja de poder elegir un modelo con corte anterior al periodo |

La información privilegiada o sujeta a confidencialidad (órdenes de clientes, posiciones, notas del comité) es una
restricción, no un criterio más: o se procesa en local o con un contrato que lo cubra. Para research con documentos
públicos, la calidad suele mandar, y una API comercial gana con claridad a un modelo de 0,5B. Las lecciones 6.2.2 y
6.2.3 lo miden: en qué tareas el modelo pequeño basta y en cuáles no.

## Ejercicios

1. Repite la tabla de temperatura con otro prompt, «Los analistas esperan que el beneficio por acción», y calcula el
   tamaño del núcleo de top-p 0,9 con $T = 0{,}7$ y con $T = 1$. ¿Es más o menos concentrada que la de la lección?

**Solución.**

```python
x2 = tok("Los analistas esperan que el beneficio por acción",
         return_tensors="pt")
with torch.inference_mode():
    z2 = llm(**x2).logits[0, -1].double()
for T in [0.7, 1.0]:
    q = np.sort(con_temperatura(z2, T))[::-1]
    print(T, round(100 * q[0], 1),
          int(np.searchsorted(np.cumsum(q), 0.9) + 1))
```
```output
0.7 50.1 7
1.0 26.5 113
```

El primer token (« de») tiene mucha más probabilidad que en la frase de la lección y el núcleo es mucho más pequeño:
el modelo tiene pocas maneras habituales de seguir esa frase. La concentración depende del contexto, no solo de la
temperatura: el mismo $T$ produce textos más o menos variados según lo abierta que esté la frase.

2. Estima cuántos tokens de entrada costaría enviar todos los documentos de `filings6_documentos.parquet` a una API,
   en HTML y en texto plano, y el coste con un precio de entrada de ejemplo de 2 dólares por millón de tokens (no es
   el de ningún proveedor: consulta su página de precios).

**Solución.**

```python
html = fil["texto"].map(n_tok).sum()
plano = fil["texto"].map(texto).map(n_tok).sum()
PRECIO = 2.0                       # USD por millón (de ejemplo)
for nombre, n in [("HTML", html), ("texto", plano)]:
    print(f"{nombre}: {n:,} tokens · "
          f"{n / 1e6 * PRECIO:.2f} USD")
```
```output
HTML: 1,530,557 tokens · 3.06 USD
texto: 1,066,910 tokens · 2.13 USD
```

Quitar las etiquetas ahorra un 30 % de los tokens del total: de 1,53 a 1,07 millones (los documentos tipo CNMV ya
son Markdown y apenas cambian). Con 4.020 documentos el importe es pequeño; con el archivo completo de EDGAR, con
transcripciones y con varias preguntas por documento, el formato decide buena parte de la factura.

3. Las APIs devuelven un error cuando la entrada no cabe en el contexto. Escribe un servidor como el de la lección que
   rechace con un 400 y un JSON de error cualquier petición de más de 50 tokens de entrada, y comprueba qué excepción
   lanza el SDK de OpenAI.

**Solución.**

```python
import openai

class APIPequena(APILocal):
    def do_POST(self):
        n = int(self.headers["Content-Length"])
        req = json.loads(self.rfile.read(n))
        n_in = n_tok(tok.apply_chat_template(
            req["messages"], add_generation_prompt=True,
            tokenize=False))
        cuerpo = json.dumps({"error": {
            "message": f"{n_in} tokens > 50",
            "type": "invalid_request_error",
            "code": "context_length_exceeded"}}).encode()
        self.send_response(400)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(cuerpo)))
        self.end_headers()
        self.wfile.write(cuerpo)

srv2 = ThreadingHTTPServer(("127.0.0.1", 0), APIPequena)
threading.Thread(target=srv2.serve_forever, daemon=True).start()
oa2 = OpenAI(base_url=f"http://127.0.0.1:{srv2.server_port}/v1",
             api_key="local", max_retries=0)
try:
    oa2.chat.completions.create(
        model="local", messages=[{"role": "user",
                                  "content": doc}])
except openai.BadRequestError as e:
    print(type(e).__name__, e.status_code, e.code)
srv2.shutdown()
```
```output
BadRequestError 400 context_length_exceeded
```

El SDK convierte el 400 en `openai.BadRequestError` con el código del cuerpo. Un 400 no se reintenta (los SDK solo
reintentan errores de conexión, 408, 409, 429 y 5xx): hay que acortar la entrada, trocearla o resumirla antes.

[^1]: Liu, N. F. et al. (2024): «Lost in the Middle: How Language Models Use Long Contexts», *Transactions of the
    Association for Computational Linguistics* 12, 157-173 (arXiv:2307.03172, https://arxiv.org/abs/2307.03172).

[^2]: Farquhar, S., Kossen, J., Kuhn, L. y Gal, Y. (2024): «Detecting hallucinations in large language models using
    semantic entropy», *Nature* 630, 625-630 (https://doi.org/10.1038/s41586-024-07421-0).

## Autoevaluación

1. Con el prompt «Las acciones cayeron un 8 % tras unos resultados», Qwen2.5-0.5B da un 11,9 % al token « neg» y un 2,8 % a « posit». ¿Qué significan esas cifras?

   - A. Que el modelo ha leído noticias de esa empresa y sabe que el 11,9 % fueron negativas
   - B. Que son probabilidades del siguiente trozo de texto según lo aprendido en el entrenamiento: « posit» es poco probable, pero con muestreo saldrá alguna vez
   - C. Que el modelo está seguro al 11,9 % de que la noticia es negativa
   - D. Que hay un error: un modelo bien entrenado daría el 100 % a « neg»

   *Respuesta B: Que son probabilidades del siguiente trozo de texto según lo aprendido en el entrenamiento: « posit» es poco probable, pero con muestreo saldrá alguna vez.* El modelo devuelve una distribución sobre las 151.665 piezas de su vocabulario. No es una probabilidad sobre el mundo ni sobre la noticia, sino sobre cómo suele continuar un texto así. Con muestreo, un token del 2,8 % aparece en una de cada 36 continuaciones.

2. ¿Qué hace subir la temperatura de 0,7 a 1,5 en la distribución del siguiente token de la lección?

   - A. Nada: la temperatura solo afecta a la longitud de la respuesta
   - B. Concentra la probabilidad en el token más probable
   - C. Aplana la distribución: el token más probable baja del 27,8 % al 2,8 % y el núcleo que suma el 90 % pasa de 43 a 7.651 tokens
   - D. Elimina los tokens poco probables

   *Respuesta C: Aplana la distribución: el token más probable baja del 27,8 % al 2,8 % y el núcleo que suma el 90 % pasa de 43 a 7.651 tokens.* Dividir los logits por T > 1 los acerca entre sí y la softmax reparte la probabilidad. Con T = 1,5 las continuaciones muestreadas mezclan idiomas y palabras sin sentido. top-p corta la cola, pero con una distribución tan plana el núcleo es enorme.

3. El modelo de 0,5B responde que Busmar Hogar facturó «35 millones de dólares» en el cuarto trimestre de 2018. El documento de la empresa dice 2.942,6 millones. ¿Qué lo explica mejor?

   - A. Busmar Hogar es una empresa ficticia que el modelo no puede conocer; genera una respuesta con la forma de una respuesta correcta porque nada en su entrenamiento premia decir que no sabe
   - B. El modelo tiene datos antiguos de la empresa
   - C. Un error de redondeo del modelo
   - D. La temperatura era demasiado alta

   *Respuesta A: Busmar Hogar es una empresa ficticia que el modelo no puede conocer; genera una respuesta con la forma de una respuesta correcta porque nada en su entrenamiento premia decir que no sabe.* La generación fue voraz (sin muestreo). El modelo no tiene ningún dato de una empresa inventada y aun así escribe una cifra con su moneda y su contexto. Con la instrucción de abstenerse responde «No lo sé» y con el documento en el contexto da 2.942,6 millones.

4. Una gestora quiere resumir con un LLM las notas internas de su comité de inversiones, que contienen información privilegiada. ¿Qué criterio pesa más al elegir entre una API comercial y un modelo local?

   - A. El precio por millón de tokens
   - B. La velocidad de respuesta
   - C. El número de parámetros del modelo
   - D. Dónde se procesan y se conservan los datos: con un modelo local no salen de la casa; con una API dependen del contrato y de la configuración de retención del proveedor

   *Respuesta D: Dónde se procesan y se conservan los datos: con un modelo local no salen de la casa; con una API dependen del contrato y de la configuración de retención del proveedor.* La calidad y el coste importan, pero la confidencialidad es una restricción, no una preferencia. Un modelo local (con Ollama o vLLM, o en proceso como en esta lección) mantiene los datos dentro; con una API hay que revisar retención, ubicación y uso de los datos en el contrato.

5. Con la versión de API 2026-10-21 de Anthropic, que es la que envía el SDK 2.1.0, una petición con `temperature=0` para obtener respuestas reproducibles…

   - A. Devuelve siempre la misma respuesta
   - B. Devuelve un error 400: `temperature`, `top_p` y `top_k` se retiraron en esa versión
   - C. Se ignora en silencio
   - D. Activa la decodificación voraz

   *Respuesta B: Devuelve un error 400: `temperature`, `top_p` y `top_k` se retiraron en esa versión.* La página de versiones de Anthropic dice que en 2026-10-21 esos parámetros se retiran y que enviar cualquiera de ellos da un error 400 en todos los modelos. Su guía de migración añade que `temperature = 0` nunca garantizó salidas idénticas. La reproducibilidad exacta solo la da un modelo local con pesos fijados y decodificación determinista.

## Ideas clave

- Un LLM genera de uno en uno: tokeniza el texto, calcula un logit por pieza del vocabulario (151.665 en Qwen2.5), los pasa por la softmax y elige un token, que se añade a la entrada para el siguiente paso.
- La temperatura divide los logits antes de la softmax: por debajo de 1 concentra, por encima aplana. top-p muestrea solo del núcleo de tokens que suma p. Con T = 1,5 el modelo pequeño mezcla idiomas.
- La ventana de contexto limita lo que el modelo ve en una llamada (32.768 tokens en Qwen2.5-0.5B) y cada token cuesta: el HTML de un 10-K ocupa un 25 % más de tokens que su texto.
- Un LLM alucina con la forma de una respuesta correcta: sobre una empresa ficticia inventa una cifra, y la probabilidad media de sus tokens no basta para detectarlo. Instrucciones para abstenerse y, sobre todo, el documento en el contexto lo corrigen.
- Las APIs de OpenAI y Anthropic, Ollama y vLLM hablan formatos HTTP parecidos: el mismo SDK oficial apunta a un servidor u otro cambiando `base_url`.
- API comercial o modelo local se decide por confidencialidad, reproducibilidad (versión fijada), coste, latencia y calidad, en ese orden cuando hay información sensible.

## Recursos

### Documentación

- [Ficha de Qwen/Qwen2.5-0.5B-Instruct en Hugging Face](https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct) · huggingface.co
- [Ollama: compatibilidad con la API de OpenAI](https://docs.ollama.com/api/openai-compatibility) · docs.ollama.com
- [vLLM: servidor compatible con OpenAI (online serving)](https://docs.vllm.ai/en/latest/serving/online_serving/) · docs.vllm.ai

### Libros

- [Vaswani, A. et al. (2017): Attention Is All You Need (arXiv:1706.03762)](https://arxiv.org/abs/1706.03762) · arxiv.org
- [Holtzman, A. et al. (2020): The Curious Case of Neural Text Degeneration, ICLR 2020 (arXiv:1904.09751)](https://arxiv.org/abs/1904.09751) · arxiv.org
- [Kalai, A. T., Nachum, O., Vempala, S. S. y Zhang, E. (2025): Why Language Models Hallucinate (arXiv:2509.04664)](https://arxiv.org/abs/2509.04664) · arxiv.org

### Datos

- [noticias6_corpus.parquet y filings6_documentos.parquet: textos financieros sintéticos](https://pythonparatrading.com/curso/datos/nivel-6/filings6_documentos.parquet) · Descarga · filings6_documentos.parquet

Lección anterior: [6.1.3 · Event studies con señales de texto, alineación temporal y topic modeling](https://pythonparatrading.com/curso-python-trading/nivel-6/event-studies-con-texto-y-topic-modeling/)

Lección siguiente: [6.2.2 · Prompting estructurado: JSON que se puede validar](https://pythonparatrading.com/curso-python-trading/nivel-6/prompting-estructurado-y-json-con-pydantic/)
