A0 — Fundamentos LLM
Outcome del módulo: entender qué gobierna costo, límites y comportamiento de un LLM desde el backend, y exponer un endpoint FastAPI con salida estructurada validada — sin parsear texto libre a mano.
Concepto 1 — Tokens y costo
Idea núcleo: el modelo no lee texto, lee tokens (subpalabras); el costo, la latencia y los límites de contexto se miden en tokens, no en caracteres ni en palabras.
Entender (capa 1)
Texto: un tokenizador BPE (Byte Pair Encoding) parte el texto en fragmentos frecuentes, no en palabras completas. En inglés, ~4 caracteres por token en promedio; en español y otros idiomas no-inglés, el ratio es peor (más tokens para el mismo contenido, porque los tokenizadores se entrenan mayoritariamente sobre corpus en inglés). El costo de una llamada = tokens_input × precio_input + tokens_output × precio_output. Contar por palabras subestima sistemáticamente el gasto real.
Visual:
flowchart LR
T[texto crudo] --> TK[tokenizador BPE] --> TOK[secuencia de tokens]
TOK --> IN[tokens de input<br/>prompt + historial + contexto]
TOK --> OUT[tokens de output<br/>respuesta generada]
IN --> COSTO[costo = in×precio_in + out×precio_out]
OUT --> COSTO
Video: Let’s build the GPT Tokenizer — Andrej Karpathy
Ejemplo (mal → bien):
import tiktoken
texto = "Los agentes de IA no son magia, son loops con tools."
# mal: contar "tokens" como palabras
def contar_mal(texto: str) -> int:
return len(texto.split()) # ignora subwords, puntuación, idioma
# bien: contar los tokens reales que factura el modelo
def contar_bien(texto: str, modelo: str = "gpt-4o") -> int:
enc = tiktoken.encoding_for_model(modelo)
return len(enc.encode(texto))
print(contar_mal(texto)) # 10 "palabras"
print(contar_bien(texto)) # ~15-18 tokens reales, la factura sale de acá
PRECIO_INPUT_POR_1M = 2.50 # USD [verificar] contra pricing vigente del modelo
PRECIO_OUTPUT_POR_1M = 10.00
def costo_llamada(tokens_in: int, tokens_out: int) -> float:
return (tokens_in / 1_000_000) * PRECIO_INPUT_POR_1M + (tokens_out / 1_000_000) * PRECIO_OUTPUT_POR_1M
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué contar palabras subestima el costo real de una llamada a un LLM?
- Back: porque el modelo factura por tokens (subpalabras vía BPE), no por palabras; una palabra puede ser 1 o varios tokens según el idioma y la frecuencia en el corpus de entrenamiento del tokenizador. Español suele costar más tokens que inglés para el mismo contenido.
Feynman: “el tokenizador no corta por espacios, corta por piezas de Lego que aprendió a reconocer. Una palabra rara se parte en 3-4 piezas; una común es 1 sola pieza. La factura cuenta piezas, no palabras.”
Aplicar (capa 3)
Tomá 3 textos (uno en español, uno en inglés, uno con jerga técnica/código) del mismo largo aproximado en caracteres. Contá tokens reales con tiktoken y comparalos. Calculá el costo de cada uno con precios reales del modelo que uses.
Límites
- Precios y tokenizadores cambian por modelo y por proveedor — no asumas que un valor de hoy sigue vigente sin verificarlo.
tiktokenestá atado a modelos OpenAI; otros proveedores (Anthropic, Google) tienen sus propios tokenizadores y no dan el mismo conteo para el mismo texto.
Concepto 2 — Context window y sus límites
Idea núcleo: el context window es la memoria de trabajo del modelo en esa llamada; todo lo que no entra ahí, el modelo directamente no lo ve.
Entender (capa 1)
Texto: el context window es un límite compartido entre input (system prompt + historial + contexto recuperado) y output. Si el input ya usa la mayoría de la ventana, queda poco presupuesto para que el modelo responda largo. Además, el rendimiento del modelo no es uniforme dentro de la ventana: hay evidencia de degradación de atención en el medio de contextos muy largos (“lost in the middle”) — el modelo presta más atención al principio y al final del contexto que al centro. Context window no es memoria persistente: cada llamada es stateless salvo que vos reenvíes el historial.
Visual:
flowchart TD
subgraph Ventana["Context window (límite fijo)"]
SYS[system prompt]
HIST[historial de chat]
CTX[contexto recuperado / RAG]
OUT[espacio reservado para output]
end
SYS --> Total[input tokens]
HIST --> Total
CTX --> Total
Total -->|si supera el límite| ERR[error 400 / truncamiento silencioso]
Video: What is a Context Window? Unlocking LLM Secrets — IBM Technology
Ejemplo (mal → bien):
# mal: mandar todo el historial sin control
def armar_mensajes_mal(historial: list[dict]) -> list[dict]:
return historial # crece sin límite; un día supera la ventana o se come el presupuesto de output
# bien: presupuesto explícito de tokens, prioriza lo reciente
def armar_mensajes_bien(
historial: list[dict], modelo: str, max_contexto: int, max_output: int
) -> list[dict]:
enc = tiktoken.encoding_for_model(modelo)
presupuesto = max_contexto - max_output - 500 # margen para system prompt
mensajes, usados = [], 0
for msg in reversed(historial): # prioriza los mensajes más recientes
costo = len(enc.encode(msg["content"]))
if usados + costo > presupuesto:
break
mensajes.insert(0, msg)
usados += costo
return mensajes
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué “más contexto siempre ayuda” es falso?
- Back: porque input y output comparten la misma ventana (más contexto = menos espacio para responder) y porque la atención del modelo degrada hacia el centro de contextos muy largos (“lost in the middle”); meter todo sin filtrar agrega ruido, no señal.
Feynman: “el context window es un escritorio de tamaño fijo. Podés poner más papeles, pero si lo llenás no te queda espacio para escribir la respuesta, y los papeles del medio de la pila son los que menos mirás.”
Aplicar (capa 3)
Armá un historial de chat sintético de 50 turnos. Escribí una función que lo trunque a un presupuesto de tokens dado, priorizando los últimos N turnos. Medí cuántos turnos entran con max_contexto=8000 y max_output=1000.
Límites
- El tamaño de la ventana varía por modelo (y a veces por tier de acceso) — verificar el valor exacto antes de diseñar contra él, no asumirlo.
- Ventana grande no es excusa para no truncar/resumir: más tokens de input = más costo y más latencia, aunque el modelo “aguante”.
Concepto 3 — Structured output (JSON/pydantic)
Idea núcleo: en backend nunca parseás texto libre de un LLM a ciegas; forzás un schema y validás, porque el output del modelo es una interfaz con el resto del sistema, no una conversación.
Entender (capa 1)
Texto: pedirle al modelo “respondé en JSON” dentro del prompt no garantiza nada — puede agregar texto antes, envolver en markdown, o inventar un campo. Ojo con dos mecanismos distintos que se confunden: JSON mode (response_format={"type":"json_object"}) garantiza JSON sintácticamente válido, pero NO que tenga los campos correctos (puede faltar/sobrar). Structured Outputs (response_format con un json_schema strict, o .parse() con un modelo pydantic) sí restringe el decoding al schema — es el que querés. Combinado con pydantic, el resultado es un objeto Python tipado y validado, no un string que hay que rezar que parsee.
Visual:
flowchart LR
P[prompt] --> LLM[modelo]
LLM -->|structured output mode| RAW[JSON restringido al schema]
RAW --> VAL[validación pydantic]
VAL -->|ok| OBJ[objeto tipado en Python]
VAL -->|falla| ERR[error explícito / retry]
Video: OpenAI Structured Outputs with Pydantic (No Manual JSON) — Leon van Zyl
Ejemplo (mal → bien):
from pydantic import BaseModel, Field
from openai import OpenAI
import json
client = OpenAI()
class Factura(BaseModel):
proveedor: str
total: float = Field(ge=0)
fecha: str
# mal: pedís JSON en el prompt y parseás a ciegas
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Extraé la factura en JSON: ..."}],
)
datos = json.loads(resp.choices[0].message.content) # explota si agrega texto, markdown, o cambia un campo
# bien: schema forzado, validación automática
resp = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": "Extraé la factura: ..."}],
response_format=Factura,
)
factura: Factura = resp.choices[0].message.parsed # objeto tipado, ya validado
Fijar (capa 2)
Nota atómica:
- Front: ¿por qué “pedí JSON en el prompt” no es suficiente en backend?
- Back: porque es una instrucción en lenguaje natural, no una restricción real — el modelo puede desviarse (texto extra, markdown, campo faltante). El modo de salida estructurada restringe el muestreo al schema; pydantic valida y castea el resultado antes de que toque el resto del sistema.
Feynman: “pedirle JSON en el prompt es pedirle a alguien que hable en verso ‘por favor’. Structured output es darle una plantilla con casilleros: solo puede llenar los casilleros, no puede escribir afuera del margen.”
Aplicar (capa 3)
Definí un modelo pydantic para extraer de un texto libre: nombre, fecha y una lista de temas mencionados. Hacé la llamada con response_format apuntando a ese modelo y confirmá que el objeto resultante pasa validación sin try/except de JSON.
Límites
- Structured output restringe la forma, no la veracidad — el modelo igual puede inventar un valor que “calza” en el schema (alucinación con forma correcta).
- No todos los proveedores/modelos soportan el mismo mecanismo de schema; verificar compatibilidad (algunos exigen JSON Schema plano, otros aceptan pydantic directo).
Concepto 4 — Function / tool calling
Idea núcleo: tool calling es el mecanismo por el cual el modelo devuelve una intención estructurada de ejecutar código externo, en vez de texto — es la base de todo agente.
Entender (capa 1)
Texto: el modelo nunca ejecuta código. Cuando le das una lista de tools (nombre + descripción + schema de argumentos), el modelo puede responder con un tool_call: el nombre de la función que “quiere” invocar y los argumentos en JSON. El backend es quien realmente ejecuta esa función, y le devuelve el resultado al modelo en un segundo turno para que arme la respuesta final. Ese loop —modelo decide, backend ejecuta, modelo interpreta el resultado— es el patrón que después se convierte en agente cuando se repite varias veces con memoria de estado.
Visual:
sequenceDiagram
participant U as Usuario
participant M as Modelo
participant B as Backend
U->>M: "¿cómo está el clima en Bogotá?"
M->>B: tool_call: consultar_clima(ciudad="Bogotá")
B->>B: ejecuta la función real (API externa)
B->>M: resultado de la tool
M->>U: respuesta final en lenguaje natural
Video: OpenAI Developer Course: API & Function Calling, Prompt Engineering, Embeddings — DataCamp
Ejemplo (mal → bien):
# mal: intentar que el modelo "resuelva" el clima solo, sin tool → alucina un valor
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "¿Cómo está el clima en Bogotá ahora mismo?"}],
) # el modelo no tiene datos en vivo, puede inventar un número plausible
# bien: tool real, el backend ejecuta, el modelo solo decide e interpreta
tools = [{
"type": "function",
"function": {
"name": "consultar_clima",
"description": "Devuelve el clima actual de una ciudad",
"parameters": {
"type": "object",
"properties": {"ciudad": {"type": "string"}},
"required": ["ciudad"],
},
},
}]
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "¿Cómo está el clima en Bogotá?"}],
tools=tools,
)
tool_call = resp.choices[0].message.tool_calls[0]
if tool_call.function.name == "consultar_clima":
args = json.loads(tool_call.function.arguments)
resultado = consultar_clima(args["ciudad"]) # ejecución real, no inventada
segunda_resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "user", "content": "¿Cómo está el clima en Bogotá?"},
resp.choices[0].message,
{"role": "tool", "tool_call_id": tool_call.id, "content": str(resultado)},
],
)
Fijar (capa 2)
Nota atómica:
- Front: ¿quién ejecuta la función cuando el modelo hace un tool call?
- Back: el backend, siempre. El modelo solo devuelve nombre de función + argumentos en JSON; nunca corre código. El backend valida los argumentos, ejecuta, y le devuelve el resultado al modelo en un mensaje
role: tool.
Feynman: “el modelo es un cliente en un restaurante que hace un pedido específico por escrito (nombre del plato + ingredientes). El mesero (backend) es quien va a la cocina y cocina de verdad. El cliente nunca entra a la cocina.”
Aplicar (capa 3)
Definí una tool calcular_impuesto(monto: float, tasa: float). Armá el loop completo: primera llamada con tools, detección del tool_call, ejecución real de la función Python, segunda llamada con el resultado, y respuesta final del modelo.
Límites
- El modelo puede pedir argumentos inválidos o inventar un nombre de tool que no existe — el backend tiene que validar antes de ejecutar, nunca confiar ciego.
- Tool calling no es agente todavía: un agente agrega loop multi-paso, memoria de estado y criterio de corte (eso es A3).
Concepto 5 — Sampling básico (temperature / top-p)
Idea núcleo: temperature y top-p controlan cuánta aleatoriedad tiene el muestreo del siguiente token — no son “creatividad mágica”, son parámetros sobre una distribución de probabilidad.
Entender (capa 1)
Texto: en cada paso, el modelo calcula una distribución de probabilidad sobre el siguiente token posible. temperature aplana o afila esa distribución (alta = más plana = más variación entre corridas; baja/0 = casi determinista, favorece siempre el token más probable). top-p (nucleus sampling) recorta la distribución a los tokens cuya probabilidad acumulada llega a p, descartando la cola larga de opciones improbables. Para tareas de extracción/clasificación en backend, se busca comportamiento estable: temperature baja.
Visual:
flowchart LR
D[distribución de probabilidad<br/>sobre próximo token] -->|temperature alta| P1[distribución plana<br/>más variación]
D -->|temperature baja / 0| P2[distribución afilada<br/>casi determinista]
Video: The Secret Controls for your LLM: Temperature, Top-K, Top-P — Gary Explains
Ejemplo:
# temperature alta: variación entre corridas, útil para brainstorming/copy
resp_creativo = client.chat.completions.create(
model="gpt-4o", messages=[...], temperature=1.0,
)
# temperature baja: casi determinista, útil para extracción/clasificación en backend
resp_deterministico = client.chat.completions.create(
model="gpt-4o", messages=[...], temperature=0.0,
)
Fijar (capa 2)
Nota atómica:
- Front: ¿qué
temperatureusarías para un endpoint de clasificación de tickets de soporte, y por qué? - Back: baja (cerca de 0). Clasificación necesita consistencia: la misma entrada debería tender a la misma salida. Temperature alta introduce variación que en un endpoint de backend es un bug, no una feature.
Feynman: “temperature es cuánto ‘tiembla la mano’ al elegir la siguiente palabra. Mano firme (temperature 0) = casi siempre el mismo trazo. Mano temblorosa (temperature alta) = trazos distintos cada vez, útil para dibujo libre, mal para firmar un contrato.”
Aplicar (capa 3)
Corré la misma llamada de clasificación 5 veces con temperature=1.0 y 5 veces con temperature=0.0. Compará cuántas respuestas distintas obtenés en cada set.
Límites
temperature=0no garantiza determinismo bit-a-bit (infraestructura de inferencia distribuida puede introducir variación mínima) — es “casi determinista”, no una garantía absoluta.- No uses
temperaturealta para intentar “arreglar” respuestas pobres: si el problema es falta de contexto o de datos, la solución es RAG (A1), no aleatoriedad. - Algunos modelos de razonamiento (familias o1/o3/gpt-5-reasoning) rechazan
temperature ≠ defaulty devuelven error — verificá el soporte por modelo antes de fijarlo. [verificar por proveedor/modelo]
Build step
Setup del proyecto ancla:
- FastAPI + cliente LLM configurado (variables de entorno para API key, sin hardcodear).
- Primer endpoint
POST /extraerque recibe texto libre y devuelve un objeto validado con pydantic víaresponse_format, sinjson.loadsmanual.
from fastapi import FastAPI
from pydantic import BaseModel
from openai import OpenAI
app = FastAPI()
client = OpenAI()
class ExtraccionRequest(BaseModel):
texto: str
class ExtraccionResponse(BaseModel):
resumen: str
temas: list[str]
sentimiento: str
@app.post("/extraer", response_model=ExtraccionResponse)
def extraer(req: ExtraccionRequest) -> ExtraccionResponse:
resp = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content": f"Analizá el siguiente texto: {req.texto}"}],
response_format=ExtraccionResponse,
temperature=0.0,
)
return resp.choices[0].message.parsed
Checklist de dominio A0
Marcás cuando podés explicar (Feynman) + aplicar cada uno:
- Tokens y costo (por qué contar palabras subestima el gasto real)
- Context window y sus límites (presupuesto compartido input/output, “lost in the middle”)
- Structured output (schema forzado + pydantic, no parseo a ciegas)
- Function/tool calling (el backend ejecuta, el modelo solo decide)
- Sampling (temperature/top-p, cuándo determinismo vs variación)
Salida verificable del módulo: API FastAPI corriendo con un endpoint que devuelve salida estructurada validada con pydantic, temperature elegida a propósito (no por default), y capacidad de explicar el costo en tokens de esa llamada.