01

AI Agent Engineer · Módulo 1

Fundamentos de los Large Language Models

Cómo funciona un LLM por dentro y las seis piezas sobre las que se construye todo agente de IA: generación autoregresiva, tokens, context window, temperature, embeddings, function calling y structured outputs.

54 min de lectura

Objetivo del módulo

Al terminar este módulo vas a entender qué es realmente un LLM por dentro: cómo genera texto, por qué a veces "alucina", qué son los tokens y por qué se cobra por ellos, qué limita el context window, cómo la temperature cambia el comportamiento del modelo, y tres mecanismos que son la base de todo lo que viene después: embeddings, function calling y structured outputs.

¿Por qué importa? Porque un AI Agent Engineer no es alguien que "usa ChatGPT bien". Es alguien que entiende el comportamiento del componente central de su sistema. Cuando en el Módulo 3 un planner de Semantic Kernel falle, o en el Módulo 7 un sistema RAG devuelva resultados absurdos, la causa raíz casi siempre estará en algo de este módulo. Es el equivalente a entender el event loop de Node antes de escribir código asíncrono serio.

Cómo funcionan los modelos

Qué hace realmente un LLM

Olvida la idea de que el modelo "entiende" o "sabe" cosas en el sentido humano. Un LLM hace una sola cosa, y la hace millones de veces por segundo:

Dado un texto, predice cuál es la pieza de texto más probable que viene después.

Eso es todo. No hay base de datos de hechos, no hay motor de razonamiento simbólico, no hay lógica programada. Solo predicción del siguiente fragmento.

Analogía: piensa en el autocompletado de tu teléfono, pero llevado a un extremo absurdo. Tu teclado predice la siguiente palabra basándose en dos o tres palabras anteriores. Un LLM predice el siguiente fragmento basándose en todo lo que lleva escrito hasta ese momento (que pueden ser cientos de páginas), y fue entrenado con una fracción gigantesca de todo el texto de internet. A esa escala, "predecir lo que sigue" empieza a parecerse muchísimo a razonar, porque para predecir bien la continuación de "la capital de Francia es", el modelo tuvo que aprender patrones sobre geografía. Para predecir la continuación de código, tuvo que aprender patrones de programación.

El término técnico para esto es modelo autoregresivo: genera una pieza, la añade al texto, y vuelve a predecir la siguiente usando el texto ampliado. Un bucle:

texto actual → predecir siguiente pieza → añadirla al texto → repetir

Cada palabra que ves aparecer en un chat es una iteración de ese bucle. El texto "aparece escribiéndose" no como efecto visual: es literalmente el modelo generando pieza por pieza.

Cómo funciona internamente: el pipeline

El recorrido completo de una sola predicción:

"El gato se subió al"

   Tokenizer            ← parte el texto en piezas (tokens)

 [1012, 8842, 511, 2290, 401]

   Embeddings           ← convierte cada token en un vector de números

   Transformer          ← N capas de "atención": cada token mira a los demás

   Logits               ← una puntuación cruda para CADA token del vocabulario

   Softmax              ← convierte puntuaciones en probabilidades

   Sampling             ← se elige UN token según esas probabilidades

     "árbol"            → se añade al texto y el ciclo se repite

Pieza por pieza, de forma intuitiva primero:

Tokenizer. El modelo no trabaja con letras ni con palabras: trabaja con tokens, fragmentos de texto de tamaño variable. "gato" puede ser un token; "electroencefalografía" serán cuatro o cinco. Más adelante en este módulo hay una sección completa sobre tokens, porque tienen implicaciones directas en costos y límites.

Embeddings (versión mínima por ahora). Cada token se convierte en un vector: una lista larga de números que codifica su significado. Por ejemplo, "gato" podría ser [0.18, -0.42, 0.73, 0.09, ...]; en un modelo real, cientos o miles de números. No son características legibles como "animal" o "mascota" — el modelo los organiza de modo que los tokens de significado parecido queden cerca en ese espacio. Este concepto tiene su propia sección más adelante en el módulo, y es la base de toda búsqueda semántica y de RAG (Módulo 7).

Transformer y atención. Es la arquitectura de red neuronal que procesa esos vectores. Su superpoder es el mecanismo de atención: al procesar cada token, el modelo puede "mirar" a todos los demás tokens del texto y decidir cuáles son relevantes. En "el banco donde me senté estaba junto al banco donde tengo mi cuenta", la atención es lo que permite que cada "banco" se interprete distinto según sus vecinos. Analogía: es como si cada palabra de la frase pudiera hacerle preguntas a todas las demás antes de decidir qué significa.

Logits. La última capa del modelo produce un número para cada token del vocabulario (unos 100,000 números en total). Estos valores se llaman logits. No son probabilidades: pueden ser positivos, negativos o cero, y por sí solos no significan nada — solo indican qué tan "preferido" es cada token en relación con los demás. Un logit de 4.0 no dice nada por sí mismo; dice algo cuando lo comparas con el 2.0 de otro token.

Softmax. Es la función que convierte esos logits en una distribución de probabilidad, es decir, en valores que cumplen tres condiciones: cada probabilidad queda entre 0 y 1, la suma de todas es exactamente 1, y a mayor logit, mayor probabilidad.

Con un vocabulario de solo tres tokens:

TokenLogitProbabilidad tras softmax
gato4.00.84
perro2.00.11
árbol0.50.05

Antes de softmax, 4.0, 2.0 y 0.5 no son probabilidades. Después, sí: suman 1 y pueden usarse para elegir el siguiente token.

Un detalle que importará más adelante: softmax no se limita a reescalar. Como usa la función exponencial, amplifica las diferencias — el token con logit 4.0 no es "el doble de probable" que el de 2.0, sino casi 8 veces más probable. Esta amplificación es la que manipula el parámetro temperature, como veremos en su propia sección.

Opcional, para quien quiera verla en notación matemática:

donde zᵢ es el logit del token i y el denominador suma sobre todos los tokens j del vocabulario. En palabras: se eleva e a cada logit y se divide cada resultado entre la suma de todos.

Sampling. Con las probabilidades listas queda una decisión: ¿elegimos siempre el token más probable, o dejamos algo de azar? Ese dial es la temperature, y tiene su propia sección más adelante en este módulo.

Dos consecuencias de ingeniería

El modelo no distingue entre la verdad y lo que parece creíble. Si le preguntas algo que no está bien representado en su entrenamiento, igualmente generará la continuación más probable, aunque sea falsa. Eso es una alucinación, y no es un bug: es el sistema funcionando exactamente como fue diseñado. Gran parte de la ingeniería de agentes (RAG, tools, grounding) existe para compensar esto. Se ve a fondo en el Módulo 7.

El modelo es stateless: no recuerda nada entre llamadas. Cada request a la API empieza de cero. Si quieres que "recuerde" la conversación, tienes que reenviarle todo el historial en cada llamada. Por eso el proyecto de este módulo es un chat con memoria: implementarás esa gestión tú mismo y el concepto quedará clarísimo.

Pregunta de reflexión: si el modelo solo predice el siguiente token más probable, ¿por qué el mismo prompt puede dar respuestas de calidad muy distinta según cómo lo redactes?

Porque el prompt no es una "pregunta" que el modelo interpreta — es el inicio de un texto que el modelo va a continuar de la forma más probable posible. Cambiar la redacción cambia qué tipo de texto es probable que venga después. Tres mecanismos concretos:

1. El prompt define el "registro" del texto. Ante "oye q es react", la continuación estadísticamente probable es la de un foro casual: breve, imprecisa. Ante "Explica la arquitectura de reconciliación de React y su fiber tree", la continuación probable se parece a documentación técnica escrita por alguien que sabe. No es que el modelo "decida esforzarse más" — es que le hiciste probable otro tipo de continuación.

2. El contexto restringe la distribución de probabilidad. Sin contexto, ante "¿cómo manejo errores?", hay miles de continuaciones probables (¿en qué lenguaje? ¿HTTP? ¿UI?), y el modelo genera algo genérico que las promedia. Si el prompt incluye "en una API de FastAPI con Pydantic, quiero errores tipados que el frontend pueda mapear", eliminaste el 99% de las continuaciones irrelevantes. Un buen prompt es, literalmente, una restricción sobre la distribución de tokens siguientes — el equivalente a tipar en TypeScript: reduces el espacio de valores posibles hasta que lo que queda es lo que querías.

3. Los ejemplos en el prompt son evidencia estadística. Si muestras dos ejemplos del formato de salida que quieres, la continuación más probable del texto "ejemplo 1... ejemplo 2... ahora este caso:" es un tercer ejemplo con el mismo formato. Por eso funciona el few-shot prompting: no le estás "enseñando" nada al modelo, estás construyendo un texto cuya continuación natural es exactamente lo que necesitas.

La conclusión de ingeniería: el prompt engineering no es magia ni frases mágicas — es manipular deliberadamente qué continuación es la más probable.

Tokens y Context Window

El problema que resuelven los tokens

El modelo necesita un vocabulario finito. Si usara palabras completas, el vocabulario sería infinito (nombres propios, typos, palabras nuevas, código). Si usara caracteres individuales, cada texto sería larguísimo y el modelo gastaría capacidad en aprender que "q-u-e" forma "que". Los tokens son el punto medio: fragmentos frecuentes de texto.

Analogía: es como la compresión de un ZIP. Los patrones que aparecen mucho en los datos de entrenamiento se comprimen en una sola pieza ("the", " de", "ción", "function"), y lo raro se parte en piezas pequeñas. Palabras comunes = 1 token. Palabras raras = varios tokens. Cualquier string puede representarse, porque en el peor caso se cae a nivel de bytes.

El algoritmo estándar se llama BPE (Byte Pair Encoding): se entrena contando qué pares de piezas aparecen juntos con más frecuencia y fusionándolos, iterativamente, hasta llegar a un vocabulario de unos 100,000 tokens.

Ejemplo de cómo se parte texto (aproximado, varía por modelo):

"El gato programa en TypeScript"

["El", " gato", " program", "a", " en", " Type", "Script"]
   7 palabras → 7 tokens (casualidad, no regla)
 
"electroencefalografía"

["electro", "ence", "fal", "ograf", "ía"]
   1 palabra → 5 tokens

Regla práctica: en inglés, 1 token equivale a unos 4 caracteres; 100 tokens, a unas 75 palabras. En español la proporción es algo peor (los tokenizers se entrenan con datos dominados por inglés): la misma idea puede costar 20-40% más tokens que en inglés. Esto importa: un system prompt en español cuesta más dinero que el mismo prompt en inglés, en cada llamada.

Por qué los tokens son la unidad de todo

Tres cosas se miden en tokens, y las tres te afectan como ingeniero:

Costo. Las APIs cobran por token, con precios distintos para entrada (lo que envías) y salida (lo que el modelo genera). La salida suele costar 3-5 veces más que la entrada — la razón está en la pregunta de reflexión al final de esta sección. Órdenes de magnitud típicos (verifica precios actuales, cambian rápido): un modelo de gama alta ronda 2-3 dólares por millón de tokens de entrada y 8-15 por millón de salida; los modelos pequeños son 10-20 veces más baratos.

Velocidad. La latencia de una respuesta es, aproximadamente, la suma del tiempo hasta el primer token y el tiempo necesario para generar el resto de los tokens. Una respuesta de 2,000 tokens tarda unas 4 veces más que una de 500. Cuando un agente sea lento, lo primero que hay que mirar es cuántos tokens está generando.

Límites. Cada llamada tiene un número máximo de tokens que puede procesar. Si el contenido supera ese límite, el exceso no se procesa. Como este límite influye directamente en lo que el modelo puede hacer, lo veremos con más detalle en la siguiente sección.

Context Window

El context window es la cantidad máxima de tokens que el modelo puede procesar en una sola llamada — y aquí está el detalle que todos malinterpretan al principio:

Es un límite compartido entre la entrada y la salida. No significa que puedas enviar 200K tokens y recibir otros 200K. Ambos consumen el mismo presupuesto de tokens:

┌─────────────────── Context Window (ej: 200K tokens) ────────────────────┐
│                                                                          │
│  system prompt │ historial de conversación │ pregunta │ RESPUESTA        │
│  (tuyo)        │ (crece cada turno)        │ actual   │ (generada)       │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

Los tamaños actuales van de 128K a 1M tokens según el modelo (GPT-4.1, que se usa en el Módulo 2, tiene 1M). Para dimensionar: 200K tokens equivalen a unas 500 páginas de texto.

La consecuencia que define el proyecto de este módulo: como el modelo es stateless, en cada turno de un chat reenvías TODO el historial. Eso significa que el costo de una conversación crece de forma cuadrática con su longitud:

Turno 1:  envías [system + msg1]                          →  1K tokens
Turno 2:  envías [system + msg1 + resp1 + msg2]           →  3K tokens
Turno 3:  envías [system + msg1 + resp1 + msg2 + ...]     →  6K tokens
...
Turno 50: envías todo lo anterior                         → 80K tokens
                                                     EN CADA LLAMADA

Cada turno pagas de nuevo por todo lo anterior. Una conversación larga de soporte al cliente puede costar 50 veces más en sus últimos turnos que en los primeros. (Existe un mecanismo llamado prompt caching que mitiga esto cobrando unas 10 veces menos por tokens repetidos — se toca en el Módulo 10 al hablar de costos.)

Lost in the middle

Que la información quepa en la ventana de contexto no significa que el modelo la use bien. Hay un fenómeno bien documentado, el lost in the middle: los modelos prestan más atención al inicio y al final del contexto, y degradan en el medio. Si metes 300 páginas y la respuesta está en la página 150, la probabilidad de que la ignore es real.

Consecuencia de ingeniería: "cabe en el contexto" no es lo mismo que "el modelo lo encontrará". Por eso existe RAG (Módulo 7): en vez de meter todos los documentos, buscas los fragmentos relevantes y metes solo esos. Contexto pequeño y relevante supera a contexto gigante y ruidoso. Guarda esta frase: es de las más importantes del curso.

Errores comunes

Contar caracteres en vez de tokens. "Mi prompt tiene 4,000 caracteres, caben de sobra" — hasta que un usuario pega un JSON gigante y revienta el límite. El problema se detecta cuando la API devuelve el error context_length_exceeded en producción. Se evita contando tokens de verdad (librería tiktoken en Python) y validando antes de llamar.

Dejar crecer el historial sin límite. El chat funciona perfecto en las pruebas (conversaciones de 5 turnos) y explota en costos o en errores con usuarios reales (conversaciones de 100 turnos). Este es exactamente el problema que se resuelve en el proyecto del módulo.

Pedirle al modelo operaciones a nivel de carácter. "¿Cuántas letras 'r' tiene 'strawberry'?" puede fallar, y no por falta de inteligencia: el modelo no recibe el texto como letras. Antes de que procese nada, el tokenizer parte "strawberry" en tokens —["straw", "berry"]— y los convierte en números (IDs), algo como [19813, 15717]. Lo mismo con invertir strings o aritmética de muchos dígitos ("1234567" puede tokenizarse como ["123", "4567"]). Se evita no delegando al modelo lo que es una operación determinista — para eso están las tools (Módulo 6).

Ignorar el costo de la salida. Optimizas el prompt de entrada y luego le pides al modelo "explica detalladamente paso a paso", generando 3,000 tokens de salida a precio premium. A menudo el mayor ahorro está en pedir respuestas concisas o en limitar max_tokens.

Buenas prácticas

Un AI Engineer debe gestionar el consumo de tokens igual que otros recursos del sistema, como la memoria o las solicitudes a una API. Desde el inicio del proyecto conviene definir cuánto historial conservar por conversación y qué estrategia usar cuando se alcance el límite. En el proyecto del módulo implementaremos dos opciones: una ventana deslizante y un resumen del historial más antiguo. Loguea tokens de entrada y salida de cada llamada — la API los devuelve en el campo usage — porque lo que no se mide no se puede optimizar. Y elige el modelo por tarea: usar el modelo de gama alta para clasificar un email en 3 categorías es pagar 20 veces más por nada; los sistemas de producción mezclan modelos grandes y pequeños (esto se llama model routing y aparece en el Módulo 2).

Ejercicio práctico (~10 min)

Ver tokens de verdad, en Python:

# pip install tiktoken
import tiktoken
 
enc = tiktoken.get_encoding("o200k_base")  # tokenizer de los modelos GPT-4o/4.1
 
textos = [
    "El gato se subió al árbol",
    "The cat climbed the tree",
    "electroencefalografía",
    "const handleClick = async () => { await fetch('/api') }",
    "1234567890",
]
 
for t in textos:
    tokens = enc.encode(t)
    print(f"{len(tokens):3d} tokens ← {t!r}")
    print(f"     {[enc.decode([tok]) for tok in tokens]}")

Observa tres cosas: (1) el español vs el inglés para la misma frase, (2) cómo se parte el código — fíjate qué pasa con los espacios y símbolos, (3) cómo se parte el número. Luego calcula: si tu system prompt tiene 500 tokens y esperas conversaciones de 30 turnos con unos 200 tokens por mensaje, ¿cuántos tokens de entrada pagas en total en la conversación completa? (Pista: es una suma acumulativa, no 30 multiplicado por algo.)

Pregunta de reflexión: ¿por qué la salida cuesta 3-5 veces más que la entrada, si son los mismos tokens?

Porque leer es paralelo y generar es secuencial. Cuando envías 1,000 tokens de entrada, el modelo los procesa todos en una sola pasada: como ya existen, la atención de cada token sobre los demás se calcula en paralelo en la GPU. Esta fase se llama prefill. Pero generar 1,000 tokens requiere 1,000 pasadas secuenciales, porque el token 501 no puede calcularse hasta que existe el token 500 — es el bucle autoregresivo. Esta fase se llama decode, y en cada una de esas pasadas el token nuevo debe atender a todo lo anterior (entrada más todo lo ya generado), así que el esfuerzo se acumula.

PREFILL (entrada):           DECODE (salida):
1,000 tokens                 token 1 → pasada 1
     ↓                       token 2 → pasada 2 (atiende a todo lo anterior)
  1 pasada paralela          token 3 → pasada 3
     ↓                       ...
  GPU feliz                  token 1000 → pasada 1000
                             GPU ocupada mucho más tiempo

Por eso la salida no cuesta lo mismo: son tokens que ocupan la GPU secuencialmente. Y por eso la latencia de un agente depende sobre todo de cuántos tokens genera, no de cuántos lee. Las APIs reportan métricas separadas de time to first token (duración del prefill) y tokens per second (velocidad del decode).

Temperature y Sampling

El dial de la ruleta

Volvamos al final del pipeline. El modelo ya calculó las probabilidades del siguiente token:

{ "árbol": 0.42, "techo": 0.31, "sofá": 0.11, "coche": 0.04, ... }

Falta una decisión: ¿cuál elegimos? Ese proceso se llama sampling, y tiene un dial principal: la temperature.

Analogía: imagina que las probabilidades son una ruleta donde cada token tiene una porción proporcional a su probabilidad. La temperature controla cuánto deformas esa ruleta antes de girarla. Su rango depende del proveedor; en la API de OpenAI va de 0 a 2, y 1 es el punto neutro (la ruleta sin deformar):

  • Temperature 0: no hay ruleta. Siempre eliges el token más probable. Comportamiento (casi) determinista: el mismo prompt produce (casi) siempre la misma respuesta.
  • Temperature 1: giras la ruleta tal cual. "árbol" sale el 42% de las veces, "techo" el 31%.
  • Temperature 2: aplanas la ruleta — las porciones pequeñas crecen y las grandes encogen. Tokens improbables salen con frecuencia. El texto se vuelve errático y, en el extremo, incoherente.

Mecánicamente: la temperature divide los logits antes del softmax:

Con T pequeña, las diferencias entre puntuaciones se amplifican (el ganador arrasa); con T grande, se comprimen (todo se empareja). Es un solo escalar deformando la distribución.

El matiz que separa a un junior de un profesional: aunque pongas temperature 0, el modelo no se vuelve una calculadora perfecta. Y por dos razones distintas.

Primero, temperature 0 no garantiza determinismo total. Casi siempre da la misma respuesta, pero pueden colarse pequeñas variaciones por cómo se ejecuta el modelo en la GPU (operaciones en paralelo y empates entre valores casi idénticos).

Segundo, y más importante: temperature 0 no significa "respuesta correcta", sino "la respuesta más probable para el modelo". Si el modelo cree que la capital de Australia es Sídney, con temperature 0 responderá Sídney una y otra vez — consistente, pero equivocado.

La lección: la temperature controla cuánto varían las respuestas, no su calidad. Ninguna, alta o baja, mejora por sí sola lo que el modelo sabe.

Cuándo usar cada valor

Caso de usoTemperaturePor qué
Extracción de datos, clasificación, parsing0 - 0.2Quieres consistencia y reproducibilidad
Function calling / agentes decidiendo acciones0 - 0.3Un agente errático llama tools equivocadas
Asistente conversacional general0.5 - 0.8Natural sin ser errático
Generación creativa (marketing, brainstorming)0.8 - 1.2Quieres variedad entre ejecuciones

La regla profesional: en sistemas agénticos, temperature baja por defecto. Un agente que a veces decide llamar a la tool de borrar y a veces a la de archivar, ante el mismo input, es un agente que no se puede testear ni debuggear. La creatividad se reserva para pasos específicos de generación de contenido, no para pasos de decisión.

Existe un segundo dial llamado top_p (nucleus sampling): en vez de deformar la ruleta, la recorta — "considera solo los tokens que acumulan el 90% de probabilidad, ignora la cola". La práctica estándar: ajusta temperature o top_p, no ambos a la vez, porque sus efectos se entrelazan y hacen el comportamiento impredecible.

Errores comunes

Usar temperature alta en pipelines estructurados. Síntoma: la extracción de JSON funciona el 95% de las veces y falla aleatoriamente el 5%. El azar del sampling acaba eligiendo un token que rompe el formato.

Bajar la temperature reduce esos fallos, pero no garantiza una estructura válida. Lo que sí ayuda es a detectarlos: con temperature 0 el fallo se vuelve reproducible —la misma entrada falla siempre—, así que lo cazas al probar esa entrada en vez de sufrirlo al azar en producción.

Pero si los datos los va a consumir una aplicación, la solución de fondo no es la temperature: son las salidas estructuradas con JSON Schema, validar el resultado y tener una estrategia de reintento (lo vemos más adelante en el módulo).

Testear una sola vez con temperature mayor que 0. Ejecutas el prompt, funciona, lo despliegas. Pero con sampling aleatorio, una ejecución no prueba nada: la siguiente puede tomar otro camino. Regla: si la temperature es mayor que 0, evalúa con múltiples ejecuciones (esto conecta con Evaluación, Módulo 9).

Intentar "arreglar" alucinaciones bajando la temperature. No funciona, y el ejemplo de antes lo explica: el modelo que "cree" que la capital de Australia es Sídney no está dudando — para él, "Sídney" ya es el token más probable. Bajar la temperature no lo corrige; solo consigue que responda "Sídney" una y otra vez. Las alucinaciones no son un problema de variabilidad, sino de conocimiento, y la temperature solo toca la variabilidad. La cura es grounding: darle el dato real con RAG y tools.

Ejercicio práctico (~10 min)

Ejecuta el mismo prompt varias veces a dos temperaturas y cuenta cuántas respuestas distintas salen:

from dotenv import load_dotenv
from openai import OpenAI
 
load_dotenv()
client = OpenAI()
 
MODEL = "gpt-4.1-mini"
PROMPT = "Dame un nombre para una startup de logística. Responde solo el nombre."
N = 5
 
def correr(temperature: float) -> list[str]:
    salidas = []
    for i in range(N):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": PROMPT}],
            temperature=temperature,
        )
        texto = (resp.choices[0].message.content or "").strip()
        salidas.append(texto)
        print(f"  [{i + 1}/{N}] {texto}")
    return salidas
 
print("=== temperature = 0 ===")
t0 = correr(0.0)
print("\n=== temperature = 1.2 ===")
t12 = correr(1.2)
 
print("\n--- Resumen ---")
print(f"temp=0   → {len(set(t0))} respuestas únicas de {N}")
print(f"temp=1.2 → {len(set(t12))} respuestas únicas de {N}")

Con temperature=1.2 casi siempre verás 5 nombres distintos. Con temperature=0 verás menos variedad… pero puede que también te salgan varios nombres, y no está roto: es lo que vimos antes — para un prompt tan abierto hay muchísimos tokens casi empatados, y basta una variación mínima de ejecución para que el "más probable" cambie entre llamadas. Temperature 0 no garantiza respuestas idénticas.

Donde el determinismo sí se nota es en decisiones acotadas. Repite el experimento cambiando el prompt por uno de clasificación —"¿Este email es urgente? Responde solo 'urgente' o 'no urgente'"— sobre un correo ambiguo: con temperature=0 obtendrás la misma etiqueta siempre; con temperature=1.2 puede cambiar entre ejecuciones. Una decisión que cambia sola es justo lo que no quieres en un agente en producción.

Pregunta de reflexión: un sistema multiagente tiene un supervisor que decide a qué agente delegar cada tarea, y un agente de reporting que redacta resúmenes ejecutivos. ¿Qué temperature le pondrías a cada uno?

Al supervisor, temperature baja (0 - 0.2): su salida es una decisión — a qué agente delegar, con qué instrucciones — y necesitas que ante el mismo input tome consistentemente la misma decisión, para poder testearlo, debuggearlo y confiar en él. Un supervisor errático hace impredecible todo el sistema que orquesta.

Al agente de reporting, temperature media (0.5 - 0.8): redactar un resumen ejecutivo es generación de texto para humanos, donde algo de variedad léxica hace la prosa más natural, y no hay una "decisión" que deba ser reproducible. Aun así no conviene subir mucho más: un reporte ejecutivo valora precisión sobre creatividad.

El principio general: la temperature se asigna por rol dentro del sistema, no globalmente. Pasos de decisión y extracción, fría; pasos de redacción para humanos, templada; brainstorming, caliente.

Embeddings

El problema primero

Hasta aquí vimos cómo el modelo genera texto. Los embeddings resuelven un problema distinto pero fundamental: ¿cómo se representa el significado de un texto con números, de forma que una máquina pueda compararlo con otros?

Supón que construyes un buscador para la base de conocimiento de tu empresa. Un empleado busca "¿cómo pido vacaciones?" y el documento relevante se titula "Política de solicitud de ausencias y días libres". No comparten ni una sola palabra. La búsqueda clásica por palabras clave falla por completo, aunque para cualquier humano es obvio que hablan de lo mismo. Lo que se necesita es buscar por significado, no por coincidencia de texto. Eso es exactamente lo que permiten los embeddings.

La idea

Un embedding es la representación de un texto como un punto en un espacio de muchas dimensiones — una lista larga de números (un vector) donde la posición codifica el significado. La propiedad clave:

Textos con significados parecidos quedan en puntos cercanos. Textos con significados distintos quedan lejos.

Analogía: piensa en un mapa. Madrid y Toledo están cerca; Madrid y Tokio están lejos. Un mapa codifica relaciones geográficas con solo 2 números por ciudad (latitud y longitud). Un embedding hace lo mismo con el significado: es el "GPS de los conceptos". Solo que 2 dimensiones no alcanzan para capturar todos los matices del lenguaje — los modelos reales usan entre 768 y 3,072 dimensiones.

Un ejemplo imaginario con solo 3 dimensiones para verlo concreto:

"¿cómo pido vacaciones?"              → [ 0.82, -0.15,  0.44 ]
"política de solicitud de ausencias"  → [ 0.79, -0.11,  0.48 ]   ← cerca
"receta de lasaña boloñesa"           → [-0.63,  0.72, -0.05 ]   ← lejos

Los dos primeros vectores son casi iguales número a número, aunque los textos no compartan palabras. El tercero apunta en otra dirección. El modelo de embeddings aprendió esto durante su entrenamiento viendo miles de millones de textos: aprendió que "vacaciones", "ausencias" y "días libres" aparecen en los mismos contextos, y por tanto los coloca en la misma región del espacio.

Cómo se mide "cerca". La métrica estándar es la similitud de coseno (cosine similarity): mide el ángulo entre dos vectores y da un valor entre -1 y 1:

SimilitudInterpretación
~1.0Prácticamente el mismo significado
0.7 - 0.9Muy relacionados
0.3 - 0.6Relación débil o temática general compartida
~0.0Sin relación

No hace falta la fórmula para seguir el curso, pero aquí está como referencia:

Es el producto punto de los vectores dividido entre sus magnitudes. En palabras: si dos vectores apuntan en la misma dirección, su coseno es alto, sin importar sus longitudes.

Una distinción que evita mucha confusión. La palabra "embedding" aparece en dos lugares y no son lo mismo:

  1. Dentro del LLM (lo vimos en el pipeline): cada token se convierte en un vector como primer paso del procesamiento. Es interno; no lo ves ni lo usas directamente.
  2. Modelos de embeddings (lo que usarás como ingeniero): modelos separados y especializados — como text-embedding-3-large — cuya única función es recibir un texto completo (una frase, un párrafo, un documento) y devolver un solo vector que representa su significado global. No generan texto. Solo convierten texto → vector. Y son muy baratos: cuestan unas 100 veces menos por token que un LLM de gama alta.

Cuando en este curso se dice "calcular el embedding de un documento", se habla del segundo caso.

Cómo funciona internamente

"¿Cómo pido vacaciones?"

   Tokenizer                 ← mismo mecanismo que ya conoces

   [tokens]

   Transformer               ← procesa con atención, igual que un LLM

   Un vector por token       ← aquí un LLM seguiría hacia logits...

   Pooling                   ← ...pero el modelo de embeddings COMBINA
        ↓                       todos los vectores en uno solo (promedio
   [0.82, -0.15, 0.44, ...]     ponderado, típicamente)
   un único vector de
   1,536 o 3,072 números

La diferencia arquitectónica con un LLM está al final: en lugar de predecir el siguiente token, el modelo condensa toda la secuencia en un vector único (paso llamado pooling). Estos modelos se entrenan con un objetivo distinto: se les muestran pares de textos relacionados (pregunta/respuesta, título/artículo, frase/paráfrasis) y se ajustan sus pesos para que los pares relacionados produzcan vectores cercanos y los no relacionados, vectores lejanos. Este tipo de entrenamiento se llama contrastive learning: aprender por contraste entre lo que va junto y lo que no.

Dos propiedades de ingeniería que se derivan de esto:

Los embeddings son unidireccionales. Texto → vector funciona; vector → texto no. El vector no "contiene" el texto, contiene su posición semántica. No se puede reconstruir el documento original desde su embedding (lo cual, de paso, tiene implicaciones buenas para privacidad).

Cada modelo define su propio espacio. Un embedding de text-embedding-3-large y uno de otro modelo son incomparables, aunque tengan las mismas dimensiones. Es como comparar coordenadas de dos mapas con proyecciones distintas: los números no significan lo mismo. La consecuencia práctica está en los errores comunes.

Ejemplos reales

Búsqueda semántica (el caso rey). El flujo completo que se implementa en el Módulo 7:

FASE DE INDEXADO (una vez):
  documentos → partir en fragmentos → embedding de cada fragmento
            → guardar vectores en una base de datos vectorial
 
FASE DE BÚSQUEDA (cada consulta):
  pregunta del usuario → embedding de la pregunta
                      → buscar los N vectores más cercanos
                      → devolver esos fragmentos

Esa "base de datos vectorial" (vector database) es una base de datos optimizada para una sola operación: "dado este vector, dame los K más cercanos entre millones, en milisegundos". Azure AI Search, que se usa en este curso, es una de ellas. Los detalles — cómo partir documentos, búsqueda híbrida, re-ranking — son el corazón del Módulo 7.

Otros usos constantes en producción: detección de duplicados (dos tickets de soporte con similitud mayor a 0.92 probablemente reportan lo mismo), clasificación sin entrenamiento (embeddings de las categorías, y cada texto se asigna a la más cercana), recomendación ("artículos similares al que estás leyendo"), y memoria semántica de agentes — en el Módulo 3 se ve cómo Semantic Kernel usa embeddings para que un agente "recuerde" conversaciones pasadas buscando por significado, no por fecha.

Ejemplo empresarial concreto: el Employee AI Assistant del proyecto final del curso recibirá preguntas como "¿cuánto me queda de presupuesto de formación?". Ese texto se convertirá en embedding, se buscarán los fragmentos más cercanos en los documentos de políticas de RRHH indexados desde SharePoint, y esos fragmentos se inyectarán en el prompt del LLM para que responda con datos reales. Sin embeddings, ese producto no existe.

Errores comunes

Mezclar modelos de embeddings. Indexas los documentos con el modelo A; meses después, alguien cambia la configuración y las consultas se procesan con el modelo B. Los vectores viven en espacios distintos: la búsqueda devuelve resultados absurdos, sin lanzar ningún error. Es de los bugs más traicioneros del área porque todo "funciona" — solo que mal. Prevención: guarda el nombre y versión del modelo junto al índice, y valida en el arranque que coincidan.

Esperar que capturen coincidencias exactas. Los embeddings entienden significado, pero son mediocres con códigos, SKUs, nombres propios raros o números de factura. "INV-2024-00871" e "INV-2024-00872" tienen embeddings casi idénticos y significados totalmente distintos para el negocio. Por eso los sistemas serios combinan búsqueda vectorial con búsqueda clásica por palabras clave (hybrid search, Módulo 7).

Convertir textos demasiado largos en un solo vector. Si conviertes un documento de 40 páginas en un solo embedding, el significado se diluye: el vector termina representando "el promedio" de todo el documento y no encuentra nada específico. Es como resumir un libro entero en una sola coordenada del mapa. La solución es partir en fragmentos (chunking) — y decidir el tamaño de esos fragmentos es una de las decisiones de diseño centrales del Módulo 7.

Comparar similitudes absolutas entre dominios. Un 0.75 de similitud no significa lo mismo en un conjunto de contratos legales que en uno de tweets. Los umbrales ("¿a partir de qué similitud considero que es relevante?") se calibran empíricamente con tus datos, no se copian de un tutorial.

Buenas prácticas

Un AI Engineer profesional trata el modelo de embeddings como parte del schema de su sistema: cambiar de modelo implica re-indexar todo, así que la decisión se toma con cuidado y se documenta. Elige dimensiones según el caso (más dimensiones significa más precisión semántica, pero más almacenamiento y búsquedas más lentas; los modelos actuales permiten truncar dimensiones para ajustar este trade-off). Y nunca da por buena la búsqueda semántica "a ojo": arma un set pequeño de consultas de prueba con resultados esperados y mide si los fragmentos correctos aparecen en el top-K (esto se llama retrieval evaluation y se formaliza en el Módulo 9).

Ejercicio práctico (~15 min)

Ver la cercanía semántica con números reales, usando la API de OpenAI o Azure OpenAI:

# pip install openai numpy
from openai import OpenAI
import numpy as np
 
client = OpenAI()  # o AzureOpenAI con tu endpoint
 
frases = [
    "¿Cómo solicito mis vacaciones?",
    "Política de ausencias y días libres del empleado",
    "El servidor de producción está caído",
    "Receta de lasaña boloñesa",
]
 
resp = client.embeddings.create(model="text-embedding-3-small", input=frases)
vectores = [np.array(d.embedding) for d in resp.data]
 
def cos(a, b):
    return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
 
for i in range(len(frases)):
    for j in range(i + 1, len(frases)):
        print(f"{cos(vectores[i], vectores[j]):.3f}  {frases[i][:35]!r} vs {frases[j][:35]!r}")

Predice antes de ejecutar: ¿qué par tendrá la similitud más alta? ¿La lasaña estará más cerca del servidor caído o de las vacaciones? Luego experimenta: añade la misma frase en inglés ("How do I request vacation days?") y observa que la similitud con su versión en español es altísima — los modelos de embeddings modernos son multilingües: el significado trasciende el idioma.

Pregunta de reflexión: ¿por qué un LLM con un context window de 1 millón de tokens no elimina la necesidad de embeddings y búsqueda vectorial? Si "todo cabe", ¿para qué buscar?

Por tres razones vistas en secciones anteriores. Costo: meter 800K tokens de documentos en cada llamada cuesta dólares por consulta; buscar los 5 fragmentos relevantes y meter 3K tokens cuesta centavos. Calidad: por el fenómeno lost in the middle, el modelo degrada su atención en contextos enormes — contexto pequeño y relevante supera a contexto gigante y ruidoso. Latencia: el prefill de 800K tokens tarda varios segundos antes de generar el primer token. Los embeddings permiten pagar, procesar y esperar solo por lo relevante. El context window grande y la búsqueda vectorial no compiten: se complementan.

Function Calling

El límite que rompe

Todo lo visto hasta ahora tiene un límite duro: el modelo solo produce texto. Puede describir cómo enviar un correo, pero no puede enviarlo. Puede explicar qué consulta SQL resolvería tu pregunta, pero no puede ejecutarla. Y hay cosas que no puede ni describir bien: ¿qué reuniones tienes mañana? El modelo no lo sabe — es stateless, no tiene acceso a tu calendario, y su conocimiento se congeló en su fecha de entrenamiento.

Function calling es el mecanismo que rompe ese límite. Y la idea central, la que hay que grabarse porque desarma la mayoría de las confusiones, es esta:

El modelo nunca ejecuta nada. El modelo solo genera una petición estructurada; tu código es quien ejecuta.

Analogía: un arquitecto y un contratista. El arquitecto (el modelo) no pone un solo ladrillo — analiza el problema y produce un plano preciso: "construir muro de 3 metros en la coordenada X con material Y". El contratista (tu código) toma ese plano, lo ejecuta en el mundo real, y le reporta al arquitecto cómo quedó. El arquitecto decide entonces el siguiente paso. La inteligencia está en uno; las manos, en el otro.

El flujo concreto:

  1. Tú le describes al modelo qué funciones tiene disponibles (nombre, para qué sirve, qué parámetros acepta).
  2. El usuario pide algo: "¿qué clima hace en Monterrey?"
  3. El modelo, en vez de responder con texto, responde con una estructura: llamar a get_weather con {"city": "Monterrey"}.
  4. Tu código detecta esa estructura, ejecuta tu función real get_weather("Monterrey") — un fetch a una API de clima, por ejemplo — y le devuelve el resultado al modelo.
  5. El modelo recibe {"temp": 34, "condition": "soleado"} y ahora sí genera texto: "En Monterrey hace 34°C y está soleado."

Nota que hubo dos llamadas al modelo: una donde decidió llamar la función, y otra donde redactó la respuesta final con el resultado. Ese ida y vuelta es el corazón de todo.

Y aquí viene la definición más importante del curso: cuando envuelves ese flujo en un bucle — el modelo puede pedir una función, ver el resultado, pedir otra, ver el resultado, y así hasta decidir que ya tiene todo para responder — eso, exactamente eso, es un agente. No hay magia adicional. Un agente es un LLM en un bucle con acceso a herramientas y la capacidad de decidir cuál usar en cada paso. Los frameworks de los Módulos 3 y 4 (Semantic Kernel, LangChain) son, en esencia, implementaciones sofisticadas de este bucle. El Módulo 5 (MCP) es un protocolo estándar para conectar herramientas a ese bucle. El Módulo 8 son varios de estos bucles cooperando. Todo el edificio se apoya en esta pieza.

La terminología: a las funciones descritas al modelo se les llama tools (herramientas), a la respuesta estructurada del modelo se le llama tool call, al resultado que le devuelves se le llama tool result, y al bucle completo, agentic loop.

Cómo funciona internamente

┌─────────────────────────────────────────────────────────────┐
│                      AGENTIC LOOP                           │
│                                                             │
│   Tu request a la API:                                      │
│   [system prompt + tools (JSON Schema) + mensajes]          │
│        ↓                                                    │
│   LLM decide:                                               │
│        ├── "puedo responder ya" → genera texto → FIN        │
│        └── "necesito una tool" → genera tool call:          │
│                { name: "get_weather",                       │
│                  arguments: '{"city": "Monterrey"}' }       │
│                     ↓                                       │
│             TU CÓDIGO ejecuta la función real               │
│                     ↓                                       │
│             resultado → se añade a los mensajes             │
│                     ↓                                       │
│             nueva llamada al LLM (vuelve al inicio)         │
└─────────────────────────────────────────────────────────────┘

¿Cómo sabe el modelo qué tools existen? Se las describes en cada request usando JSON Schema, un estándar para describir la forma de datos JSON (qué campos existen, de qué tipo, cuáles son obligatorios). Ejemplo real:

{
  "name": "get_weather",
  "description": "Obtiene el clima actual de una ciudad. Usar cuando el usuario pregunte por clima, temperatura o condiciones meteorológicas.",
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "Nombre de la ciudad, sin país. Ej: 'Monterrey'"
      },
      "units": {
        "type": "string",
        "enum": ["celsius", "fahrenheit"],
        "description": "Unidad de temperatura. Default: celsius"
      }
    },
    "required": ["city"]
  }
}

¿Y cómo "decide" el modelo? No hay un módulo especial de decisión: es el mismo mecanismo de predicción de tokens de siempre. Los modelos modernos fueron afinados (fine-tuned) con millones de ejemplos de conversaciones que incluyen tool calls, de modo que aprendieron el patrón: cuando el contexto contiene definiciones de tools y la petición del usuario encaja con la descripción de una, la continuación más probable es la secuencia de tokens que forma un tool call válido. Es predicción de texto plausible, igual que siempre — solo que el texto plausible aquí es un JSON estructurado. Por eso la calidad de las description importa tanto: son parte del prompt. El modelo elige la tool cuya descripción hace más plausible la llamada.

Esto también explica un comportamiento que verás: el modelo puede generar tool calls con argumentos inventados (una ciudad que el usuario nunca mencionó) o llamar la tool equivocada. No está "fallando el sistema de decisión" — está generando texto plausible, y a veces lo plausible está mal. De ahí las prácticas defensivas de más abajo.

Un detalle más del protocolo: los modelos actuales pueden generar varios tool calls en paralelo en una sola respuesta ("dame el clima de Monterrey Y de Guadalajara" produce dos calls simultáneos). Tu código debe ejecutarlos todos y devolver todos los resultados, cada uno asociado a su call por un id.

Ejemplo real: el bucle completo en código

Python con la API de OpenAI (el patrón es idéntico en Azure OpenAI, que se usa desde el Módulo 2):

import json
from openai import OpenAI
 
client = OpenAI()
 
def get_weather(city: str, units: str = "celsius") -> dict:
    # En producción: fetch real a una API meteorológica
    return {"city": city, "temp": 34, "condition": "soleado"}
 
TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Obtiene el clima actual de una ciudad.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]
 
FUNCIONES = {"get_weather": get_weather}  # registro nombre → función real
 
messages = [{"role": "user", "content": "¿Qué clima hace en Monterrey?"}]
 
while True:  # ← este while ES el agente
    resp = client.chat.completions.create(
        model="gpt-4.1", messages=messages, tools=TOOLS, temperature=0
    )
    msg = resp.choices[0].message
    messages.append(msg)
 
    if not msg.tool_calls:          # el modelo respondió con texto: terminamos
        print(msg.content)
        break
 
    for call in msg.tool_calls:     # el modelo pidió ejecutar tools
        args = json.loads(call.function.arguments)
        resultado = FUNCIONES[call.function.name](**args)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,        # asocia resultado ↔ llamada
            "content": json.dumps(resultado),
        })
    # el loop vuelve a llamar al modelo, ahora con los resultados a la vista

Léelo dos veces: este patrón de unas 30 líneas es el esqueleto de todo agente que se construye en el curso. Semantic Kernel y LangChain entregan este bucle ya hecho, con reintentos, logging y streaming — pero por dentro es esto.

Ejemplo empresarial: el Employee AI Assistant del proyecto final tendrá tools como buscar_documentos_sharepoint(query), obtener_reuniones(fecha) (Microsoft Graph), consultar_sql(pregunta) y enviar_correo(para, asunto, cuerpo). La petición "resume los documentos del proyecto Fénix y agenda una reunión con el equipo para revisarlos" se convierte en una cadena de tool calls que el modelo orquesta solo, dentro de este mismo bucle.

Errores comunes

Creer que el modelo ejecuta. El malentendido raíz. Síntoma: "¿y si el modelo borra mi base de datos?" El modelo no puede borrar nada — solo puede pedir que se ejecute lo que tú expusiste. Si expusiste borrar_tabla sin controles, el problema es tu diseño de API, no el modelo. La superficie de riesgo la defines tú.

Descripciones pobres. Una tool llamada proc_data con descripción "procesa datos" no le da al modelo forma de saber cuándo usarla, y la usará mal o nunca. Detección: el modelo ignora tools que deberían activarse, o confunde dos tools parecidas. Prevención: escribe descripciones como si documentaras para un colega nuevo: qué hace, cuándo usarla, y si hay ambigüedad con otra tool, explicita la diferencia.

Confiar en los argumentos sin validar. Los argumentos los generó un modelo probabilístico: pueden venir mal tipados, con valores inventados o fuera de rango. Si los pasas directo a tu SQL o a tu API, tienes el equivalente a un input de usuario sin sanitizar. Trátalos exactamente igual: valida con Pydantic (Python) o Zod (TypeScript) antes de ejecutar.

Demasiadas tools a la vez. Con 40 tools en el contexto, la precisión de selección se degrada (además de pagar tokens por todas las definiciones en cada llamada). A partir de unas 15-20 tools conviene agrupar, filtrar por contexto, o dividir en varios agentes especializados — que es literalmente la motivación del Módulo 8.

Bucles infinitos. El modelo llama una tool, el resultado no le sirve, la vuelve a llamar con los mismos argumentos, y así hasta agotar el presupuesto. Todo while True de agente necesita un límite de iteraciones (max_turns) y un timeout. Sin excepción.

Buenas prácticas

Un AI Engineer profesional diseña tools con la misma disciplina que endpoints públicos: nombres explícitos, un solo propósito por tool, parámetros mínimos, validación estricta de entrada y errores devueltos al modelo como texto útil ("la ciudad 'Monterrey' no existe en la región 'Europa', ¿quizás quisiste decir...?") — porque el modelo puede leer el error y autocorregirse en la siguiente iteración; ese es uno de los superpoderes del bucle. Para acciones destructivas o irreversibles (enviar correo, borrar, pagar) inserta confirmación humana antes de ejecutar: el patrón se llama human-in-the-loop y se implementa en el Módulo 8. Temperature baja (0 - 0.3) en agentes con tools, como se estableció en la sección de sampling. Y loguea cada tool call con sus argumentos y resultado — cuando el agente haga algo raro, esa traza será la única forma de reconstruir qué pasó (la observabilidad formal llega en el Módulo 9).

Ejercicio práctico (~15 min)

Toma el código del bucle de arriba y extiéndelo:

  1. Agrega una segunda tool: get_time(city: str) que devuelva la hora local (puedes hardcodear resultados).
  2. Pregunta: "¿Qué clima hace en Monterrey y qué hora es en Tokio?" — observa en los logs cómo genera dos tool calls, posiblemente en paralelo.
  3. Haz que get_weather lance una excepción si la ciudad es "Atlantis". Captúrala y devuelve al modelo {"error": "ciudad no encontrada"} como tool result. Pregunta por el clima en Atlantis y observa cómo el modelo lee el error y responde con gracia en lugar de romperse.
  4. Bonus: agrega max_turns = 5 al bucle y verifica que todo sigue funcionando.

El paso 3 es el importante: entender que los errores son información para el modelo, no solo para tus logs, cambia cómo se diseña todo lo demás.

Pregunta de reflexión: el modelo genera el tool call como texto (tokens, igual que siempre). ¿Qué podría salir mal en esa generación, y qué mecanismos lo mitigan?

Como el tool call es texto generado probabilísticamente, puede fallar en tres niveles: JSON malformado (mitigado por los propios proveedores, que restringen la generación para que sea sintácticamente válida — el mecanismo se explica en la sección de Structured Outputs), argumentos inválidos o inventados (mitigado por tu validación con schemas antes de ejecutar — Pydantic/Zod), y selección de tool equivocada o innecesaria (mitigado por descripciones precisas, temperature baja, pocas tools bien diferenciadas, y en última instancia por el propio bucle: si el resultado no tiene sentido, el modelo puede corregir el rumbo en la siguiente iteración). La lección general: cada eslabón generado por el modelo se trata como input no confiable, con la misma mentalidad defensiva que se aplica a inputs de usuario.

Structured Outputs

El problema primero

En la sección anterior quedó un cabo suelto: se dijo que los proveedores "restringen la generación para que el JSON sea válido". Esta sección explica ese mecanismo, porque se usa constantemente y porque entender cómo funciona te dice exactamente qué garantiza y qué no.

Muchísimos usos de LLMs no son conversacionales: son piezas de un pipeline de software. Extraer campos de una factura, clasificar un ticket, convertir una petición en lenguaje natural a filtros de búsqueda. En todos esos casos, el consumidor de la respuesta no es un humano — es tu código. Y tu código necesita una estructura exacta:

{ "urgencia": "alta", "categoria": "facturacion", "requiere_humano": true }

El enfoque ingenuo es pedirlo en el prompt: "Responde SOLO con un JSON con los campos urgencia, categoria y requiere_humano. No agregues nada más." Y funciona... el 97% de las veces. El otro 3%, el modelo responde "Claro, aquí está el JSON:" antes del objeto, o envuelve todo en un bloque de Markdown, o inventa un campo prioridad que no pediste, o escribe "urgencia": "muy alta" cuando los valores válidos eran alta/media/baja. En un chat, ese 3% es invisible. En un pipeline que procesa 10,000 tickets diarios, son 300 crashes al día.

Structured Outputs es la solución a nivel de protocolo: le pasas a la API un JSON Schema (el mismo estándar que ya usaste para describir tools) y el proveedor garantiza que la salida cumple ese schema. No es una instrucción que el modelo intenta seguir — es una restricción que no puede violar.

La diferencia conceptual clave:

EnfoqueNaturalezaFiabilidad
"Responde en JSON" en el promptPetición — el modelo intenta cumplir~95-98%
JSON mode (opción de API antigua)Garantiza JSON sintácticamente válido, pero no tu estructuraJSON válido, campos no garantizados
Structured Outputs con schemaRestricción — la generación no puede desviarse100% conforme al schema

Cómo funciona internamente

El mecanismo se llama constrained decoding (decodificación restringida), y es elegante porque reutiliza todo lo que ya sabes del pipeline.

Recuerda: en cada paso, el modelo produce logits para los ~100,000 tokens del vocabulario, softmax los convierte en probabilidades, y el sampling elige uno. El constrained decoding inserta un filtro antes del sampling:

Schema: urgencia solo puede ser "alta" | "media" | "baja"
 
Texto generado hasta ahora:  {"urgencia": "

El motor compila el schema a una gramática: ¿qué tokens
pueden venir AHORA sin violar el schema?

Válidos en este punto: tokens que empiecen "alta", "media", "baja"

A TODOS los demás tokens se les asigna probabilidad 0
(sus logits se fijan en -infinito antes del softmax)

El sampling solo puede elegir entre lo permitido

Es decir: no se le "pide" al modelo que cumpla — se le amputan las opciones inválidas en cada paso de generación. Aunque el token más probable según el modelo fuera "muy (para escribir "muy alta"), ese token tiene probabilidad 0 y es literalmente imposible que salga. Por eso la garantía es del 100%: no depende de la obediencia del modelo, sino de una máscara mecánica sobre el vocabulario.

Un matiz honesto que separa el marketing de la realidad: la garantía es sintáctica, no semántica. El schema garantiza que urgencia será exactamente "alta", "media" o "baja" — no garantiza que la clasificación sea correcta. Un ticket urgente mal clasificado como "baja" cumple el schema perfectamente. La estructura se garantiza con constrained decoding; la calidad del contenido se evalúa con los métodos del Módulo 9.

Ejemplos reales

En Python, la forma moderna es definir la estructura con Pydantic (la librería estándar de validación de datos en Python — el equivalente conceptual de Zod en TypeScript) y dejar que el SDK genere el JSON Schema por ti:

from pydantic import BaseModel
from typing import Literal
from openai import OpenAI
 
class TicketAnalisis(BaseModel):
    urgencia: Literal["alta", "media", "baja"]
    categoria: Literal["facturacion", "tecnico", "ventas", "otro"]
    requiere_humano: bool
    resumen: str
 
client = OpenAI()
 
resp = client.chat.completions.parse(          # .parse, no .create
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": "Analiza tickets de soporte."},
        {"role": "user", "content": "¡Llevan 3 meses cobrándome doble y nadie responde!"},
    ],
    response_format=TicketAnalisis,            # ← el schema como restricción
)
 
ticket = resp.choices[0].message.parsed        # objeto TicketAnalisis tipado
print(ticket.urgencia)                         # "alta" — garantizado uno de los tres

Lo que recibes no es un string que hay que parsear con try/except, sino un objeto tipado, con autocompletado en tu IDE y validado. El mismo patrón existe en TypeScript con Zod:

import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
 
const TicketAnalisis = z.object({
  urgencia: z.enum(["alta", "media", "baja"]),
  categoria: z.enum(["facturacion", "tecnico", "ventas", "otro"]),
  requiere_humano: z.boolean(),
  resumen: z.string(),
});
 
const resp = await client.chat.completions.parse({
  model: "gpt-4.1",
  messages: [/* ... */],
  response_format: zodResponseFormat(TicketAnalisis, "ticket"),
});

Ejemplos empresariales donde esto es la pieza central: extracción de datos de facturas o contratos hacia un ERP (los campos van directo a la base de datos — no puede haber un campo sorpresa), routing de correos entrantes (la salida alimenta un switch en el código), generación de filtros de búsqueda ("muéstrame las ventas de Q3 en el norte" se convierte en {"periodo": "2026-Q3", "region": "norte"} que va directo al SQL), y — conectando con la sección anterior — los propios tool calls, que por debajo usan exactamente este mecanismo: cuando el modelo genera los argumentos de get_weather, el proveedor aplica constrained decoding contra el schema de parámetros que definiste. Function calling y structured outputs son el mismo motor con dos interfaces.

Errores comunes

Parsear texto libre con regex habiendo structured outputs. Se ve código en producción extrayendo JSON de respuestas con expresiones regulares y limpiando backticks de Markdown a mano. Es frágil y ya innecesario. Si la salida la consume código, usa el mecanismo con garantías.

Meter la lógica en el prompt y dejar el schema laxo. Un campo resumen: str acepta cualquier cosa, incluyendo un resumen de 4,000 tokens. Los schemas admiten restricciones (maxLength, enums, rangos numéricos) — úsalas. Cada restricción en el schema es una instrucción del prompt que ya no puede desobedecerse.

Olvidar que el contenido puede seguir mal. El error inverso al anterior: confiar en que "cumple el schema" significa "es correcto". El schema no evita alucinaciones — un modelo puede extraer con total confianza un número de factura que no existe en el documento, dentro de un JSON impecable. Para campos críticos, valida contra la fuente (¿ese número aparece en el texto original?) o marca umbrales de confianza.

Schemas gigantes de 60 campos anidados. La precisión del contenido se degrada con schemas enormes, igual que con 40 tools. Si una extracción necesita 60 campos, divídela en varias llamadas por secciones del documento — más barato de debuggear y más preciso.

Buenas prácticas

Un AI Engineer profesional define los schemas como contratos compartidos: el mismo modelo Pydantic/Zod que restringe al LLM valida en el resto del pipeline, de modo que hay una sola fuente de verdad de la estructura. Usa Literal/enum para todo campo con valores cerrados (es la restricción más barata y la que más errores elimina), descripciones en los campos del schema cuando el nombre no baste (el modelo las lee — son parte del prompt, igual que las descripciones de tools), y temperature 0 en extracción y clasificación, como se estableció en la sección de sampling. Y para decisiones binarias del pipeline, prefiere un campo booleano explícito (requiere_humano: bool) a interpretar texto libre después.

Ejercicio práctico (~15 min)

Construye un clasificador de correos con salida garantizada:

  1. Define un modelo Pydantic CorreoAnalisis con: remitente_tipo (Literal: "cliente", "proveedor", "interno", "spam"), accion (Literal: "responder_hoy", "responder_semana", "archivar", "escalar"), sentimiento (Literal: "positivo", "neutro", "negativo") y resumen_una_linea (str).
  2. Pásale 3-4 correos de prueba con .parse() y temperature=0.
  3. El experimento interesante: toma un correo ambiguo (un proveedor quejándose amablemente) y ejecútalo 5 veces. Con temperature 0 y schema deberías ver estabilidad casi total. Sube a temperature 1 y repite: la estructura seguirá siendo perfecta (el schema lo garantiza) pero verás las clasificaciones cambiar entre ejecuciones. Es la demostración empírica de la frase clave de esta sección: la garantía es sintáctica, no semántica.
Pregunta de reflexión: ¿por qué constrained decoding puede garantizar el 100% de conformidad estructural, mientras que ninguna instrucción en el prompt — por perfecta que sea — puede lograrlo?

Porque operan en capas distintas del pipeline. Una instrucción en el prompt actúa sobre las probabilidades: hace que los tokens correctos sean más probables, pero la distribución sigue asignando probabilidad no nula a desviaciones — y con suficientes ejecuciones, lo improbable ocurre. El constrained decoding actúa sobre el espacio de opciones: fija en menos infinito los logits de todo token que violaría el schema antes del softmax, de modo que la probabilidad de desviación no es baja — es exactamente cero. Es la diferencia entre pedirle a alguien que no salga del camino y poner muros a los lados. Corolario: por eso mismo, el mecanismo solo puede garantizar lo que una gramática puede expresar (estructura, tipos, enums), no la veracidad del contenido — los muros definen el camino, no la calidad del paso.

Proyecto del módulo: Chat con memoria

Construir desde cero un chat de terminal que mantenga conversaciones largas sin reventar el context window ni el presupuesto. Es el proyecto perfecto para cerrar el módulo porque obliga a usar casi todo lo visto: el modelo stateless, el historial que se reenvía completo, el conteo de tokens, el costo cuadrático y el campo usage. Al terminarlo habrás implementado a mano lo que Semantic Kernel y LangChain entregan empaquetado en los Módulos 3 y 4 — y por eso entenderás qué hacen por dentro.

Tiempo estimado: 45-60 minutos. Requisitos: Python 3.10+ y una API key (OpenAI o Azure OpenAI).

La arquitectura:

┌────────────────────────────────────────────────────────┐
│                    CHAT CON MEMORIA                    │
│                                                        │
│  input del usuario                                     │
│       ↓                                                │
│  historial.append(mensaje)                             │
│       ↓                                                │
│  ¿historial mayor que el presupuesto de tokens?        │
│       ├── no  → seguir                                 │
│       └── sí  → ESTRATEGIA DE MEMORIA                  │
│                 (ventana deslizante o resumen)         │
│       ↓                                                │
│  llamada al modelo [system + historial gestionado]     │
│       ↓                                                │
│  historial.append(respuesta)                           │
│       ↓                                                │
│  mostrar respuesta + telemetría (tokens, costo)        │
│       └──────────── loop ──────────────                │
└────────────────────────────────────────────────────────┘

Instrucciones paso a paso

1. El esqueleto: chat que "recuerda" reenviando todo.

# pip install openai tiktoken
from openai import OpenAI
 
client = OpenAI()
MODEL = "gpt-4.1-mini"   # barato para experimentar
 
system = {"role": "system", "content": "Eres un asistente útil y conciso."}
historial = []           # aquí vive la "memoria"
 
while True:
    user_input = input("\nTú: ")
    if user_input == "/salir":
        break
    historial.append({"role": "user", "content": user_input})
 
    resp = client.chat.completions.create(
        model=MODEL,
        messages=[system] + historial,   # ← TODO el historial, cada vez
        temperature=0.7,
    )
    msg = resp.choices[0].message
    historial.append({"role": "assistant", "content": msg.content})
    print(f"\nAsistente: {msg.content}")

Pruébalo: dile tu nombre, habla de otra cosa durante 3 turnos, y pregúntale cómo te llamas. "Recuerda" — pero ya sabes que no hay magia: el nombre viaja en messages en cada llamada.

2. Haz visible lo invisible: telemetría. Después de cada respuesta, imprime el campo usage que devuelve la API:

u = resp.usage
# precios de gpt-4.1-mini por millón de tokens; verifica los actuales
costo = (u.prompt_tokens * 0.40 + u.completion_tokens * 1.60) / 1_000_000
print(f"   [entrada: {u.prompt_tokens} tok | salida: {u.completion_tokens} tok | ~${costo:.6f}]")

Conversa 10 turnos y observa cómo prompt_tokens crece sin parar aunque tus mensajes sean cortos. Estás viendo el costo cuadrático en vivo. Este paso es el corazón pedagógico del proyecto: no sigas hasta haberlo visto.

3. Cuenta tokens tú mismo. Necesitas medir el historial antes de llamar a la API:

import tiktoken
enc = tiktoken.get_encoding("o200k_base")
 
def contar_tokens(mensajes) -> int:
    # Aproximación suficiente: contenido + ~4 tokens de overhead por mensaje
    return sum(len(enc.encode(m["content"])) + 4 for m in mensajes)

4. Estrategia A — ventana deslizante (sliding window). Define un presupuesto y, si el historial lo excede, descarta los mensajes más viejos:

PRESUPUESTO = 2_000   # tokens máximos de historial (bajo a propósito, para forzarlo)
 
def aplicar_ventana(historial):
    while contar_tokens(historial) > PRESUPUESTO and len(historial) > 2:
        historial.pop(0)   # elimina el mensaje más antiguo
    return historial

Llámala antes de cada request. Detalle importante: elimina siempre desde el principio y en pares coherentes (que no quede una respuesta del asistente sin su pregunta), y nunca elimines el system prompt — por eso se mantiene fuera de historial.

Prueba: di tu nombre en el turno 1, conversa largo (pega párrafos grandes para acelerar), y pregunta tu nombre cuando la ventana ya haya descartado el turno 1. Ahora no lo sabe. Acabas de ver la limitación de la estrategia: la memoria es literal — lo que sale de la ventana, deja de existir.

5. Estrategia B — resumen (summarization). En lugar de tirar los mensajes viejos, comprímelos: cuando el historial exceda el presupuesto, toma la mitad más antigua y pídele al propio modelo que la resuma, sustituyéndola por un solo mensaje:

def comprimir_historial(historial):
    mitad = len(historial) // 2
    viejos, recientes = historial[:mitad], historial[mitad:]
    resumen = client.chat.completions.create(
        model=MODEL,
        temperature=0,   # extracción fiel, no creatividad
        messages=[
            {"role": "system", "content": "Resume esta conversación preservando: nombres, datos personales, decisiones tomadas y temas pendientes. Sé denso y factual."},
            {"role": "user", "content": str(viejos)},
        ],
    ).choices[0].message.content
    return [{"role": "user", "content": f"[Resumen de la conversación previa: {resumen}]"}] + recientes

Repite la prueba del nombre: ahora sí lo recuerda, porque el resumen lo preservó — pagando una llamada extra al modelo y aceptando pérdida de detalle. Ningún almuerzo es gratis: la ventana deslizante es barata pero olvida todo; el resumen preserva lo esencial pero cuesta llamadas y pierde matices. Los sistemas reales suelen combinar ambas (resumen de lo viejo + ventana literal de lo reciente), que es exactamente lo que puedes hacer como extensión.

6. Compara. Ejecuta la misma conversación larga con las dos estrategias y anota: tokens de entrada por turno, costo total y qué "recuerda" cada versión al final. Tres números y una conclusión — eso es una evaluación, tu primer contacto informal con el Módulo 9.

Extensiones opcionales

  • Persistencia: guarda historial en un JSON al salir y cárgalo al arrancar. Descubrirás que la "memoria entre sesiones" es solo serialización — no hay nada especial.
  • Memoria semántica (avanzado): guarda cada turno con su embedding y, en cada pregunta, inyecta al contexto solo los 3 turnos pasados más similares a la pregunta actual. Es un mini-RAG sobre tu propia conversación, y el puente directo al Módulo 7.
  • Structured outputs: haz que el resumen del paso 5 use un schema (hechos_clave: list[str], temas_pendientes: list[str]) en lugar de texto libre. Más controlable y más fácil de inspeccionar.
Pregunta de reflexión del proyecto: los productos de chat comerciales "recuerdan" conversaciones de meses. Con lo que acabas de construir, ¿qué combinación de estrategias sospechas que usan por debajo?

Alguna variante de lo que acabas de implementar, en capas: ventana literal de los mensajes recientes, resúmenes progresivos de lo antiguo, y memoria semántica — hechos extraídos de conversaciones pasadas, guardados con embeddings y recuperados por similitud cuando son relevantes (por eso a veces "recuerdan" un detalle de hace semanas pero olvidan otro de ayer: la recuperación es probabilística, no total). La lección de fondo: la "memoria" de un LLM no es una capacidad del modelo — es ingeniería de gestión de contexto alrededor de un componente stateless. Esa ingeniería es tu trabajo.

Resumen del módulo

Lo indispensable, en nueve líneas:

  • Un LLM hace una sola cosa: predecir el siguiente token más plausible, en bucle (autoregresión). Todo lo demás emerge de ahí.
  • El pipeline de cada predicción: tokenizer → embeddings → transformer (atención) → logits → softmax → sampling.
  • Las alucinaciones son plausibilidad sin verdad. No se arreglan con temperature; se atacan con grounding (RAG, tools).
  • El modelo es stateless: la "memoria" es ingeniería tuya — reenvío de historial con presupuesto de tokens y estrategias de truncado o resumen.
  • Los tokens son la unidad de costo, latencia y límites. La salida cuesta más que la entrada porque generar es secuencial (decode) y leer es paralelo (prefill).
  • El context window es un presupuesto compartido entrada+salida, y que algo quepa no significa que el modelo lo use bien (lost in the middle). Contexto pequeño y relevante supera a contexto gigante y ruidoso.
  • La temperature controla variabilidad, no calidad: baja para decisiones y extracción, media para redacción, alta solo para creatividad.
  • Los embeddings convierten significado en vectores comparables: la base de la búsqueda semántica, la memoria de agentes y RAG.
  • Function calling: el modelo pide, tu código ejecuta; un agente es un LLM en un bucle con tools. Structured outputs: constrained decoding garantiza la estructura al 100% — garantía sintáctica, no semántica.

¿Te sirvió esta lección?

El curso es gratis y así seguirá. Un café ayuda a que salga el siguiente módulo.

Invítame un café

¿Quieres llevar esto a tu negocio?

En Burnisoft convertimos estas ideas en apps, web y automatizaciones a la medida. Cuéntanos tu reto, sin compromiso.

Contáctanos