ruta ai/agentes / módulo 0

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:

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


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:

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


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:

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


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:

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


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:

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


Build step

Setup del proyecto ancla:

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:

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.