Lección 6.2.1 · 70 min
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.
- Módulo
- 6.2 · LLMs para investigación y trading
- Duración
- 70 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Un 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.
Fuente 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.
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}")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.1Los 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
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))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:
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 , uno por pieza del vocabulario. La probabilidad de que la siguiente pieza sea la es la softmax:
Basta una pasada hacia delante para verla. El prompt es una frase de noticia a medio escribir:
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}")
top151,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.3El 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 divide los logits antes de la softmax:
Con las diferencias entre logits crecen y la probabilidad se concentra en los primeros; con queda toda en el más probable (la decodificación voraz). Con 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 , y vuelve a normalizar dentro de él. Los dos parámetros se pueden combinar.
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") 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 39393Con el primer token se lleva el 77,5 % y bastan dos tokens para llegar al 90 % de la probabilidad. Con (la distribución del modelo, sin tocar) el núcleo del 90 % tiene 370 tokens. Con , 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:
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)))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 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 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 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.
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.
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 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 1Los 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»1Nota 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).. 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ó.
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=" "))[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 USDSin 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 alucinaciones2Nota 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)..
¿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).
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:
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)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:
Fuente 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]
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")servidor local en 127.0.0.1, puerto elegido por el SOEl 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):
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)) [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 objetEl 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.
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() [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 objetLa 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):
ollama pull qwen2.5:0.5b-instruct
vllm serve Qwen/Qwen2.5-1.5B-Instructfrom 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
- 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 y con . ¿Es más o menos concentrada que la de la lección?
Solución
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))0.7 50.1 7
1.0 26.5 113El 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 produce textos más o menos variados según lo abierta que esté la frase.
- Estima cuántos tokens de entrada costaría enviar todos los documentos de
filings6_documentos.parqueta 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
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")HTML: 1,530,557 tokens · 3.06 USD
texto: 1,066,910 tokens · 2.13 USDQuitar 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.
- 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
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()BadRequestError 400 context_length_exceededEl 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.
Footnotes
-
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). ↩
-
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). ↩
Respuesta plegada en cada pregunta
Autoevaluación
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?
- AQue el modelo ha leído noticias de esa empresa y sabe que el 11,9 % fueron negativas
- BQue son probabilidades del siguiente trozo de texto según lo aprendido en el entrenamiento: « posit» es poco probable, pero con muestreo saldrá alguna vez
- CQue el modelo está seguro al 11,9 % de que la noticia es negativa
- DQue hay un error: un modelo bien entrenado daría el 100 % a « neg»
Ver respuesta
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.
¿Qué hace subir la temperatura de 0,7 a 1,5 en la distribución del siguiente token de la lección?
- ANada: la temperatura solo afecta a la longitud de la respuesta
- BConcentra la probabilidad en el token más probable
- CAplana 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
- DElimina los tokens poco probables
Ver respuesta
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.
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?
- ABusmar 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
- BEl modelo tiene datos antiguos de la empresa
- CUn error de redondeo del modelo
- DLa temperatura era demasiado alta
Ver respuesta
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.
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?
- AEl precio por millón de tokens
- BLa velocidad de respuesta
- CEl número de parámetros del modelo
- DDó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
Ver respuesta
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.
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=0para obtener respuestas reproducibles…- ADevuelve siempre la misma respuesta
- BDevuelve un error 400:
temperature,top_pytop_kse retiraron en esa versión - CSe ignora en silencio
- DActiva la decodificación voraz
Ver respuesta
Respuesta B. Devuelve un error 400:
temperature,top_pytop_kse retiraron en esa versiónLa 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 = 0nunca 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.