La integración de 03-01 funciona, pero tiene dos debilidades que separan un prototipo de un sistema usable. La primera la nota cualquier empleado de Nubelia en su primera pregunta: DocuBot se queda en silencio varios segundos y suelta la respuesta de golpe. La segunda la notará el equipo el peor día posible: cuando la API devuelva un 429 por límite de peticiones o un error de servidor, el bot simplemente explotará con un traceback. En esta lección resolvemos ambas: streaming para que la respuesta fluya según se genera, y una estrategia completa de errores, reintentos con backoff y degradación elegante para que DocuBot falle bien cuando algo falle — porque algo, tarde o temprano, fallará.
Contenido
- Por qué streaming: la percepción de latencia
- Consumir un stream en Python
- Streaming y salidas JSON: validar al final
- Taxonomía de errores de la API
- Reintentos con backoff exponencial y jitter
- Timeouts, idempotencia y qué NO reintentar
- Circuit breaker: dejar de insistir (concepto)
- Degradación elegante en DocuBot
Por qué streaming: la percepción de latencia
Un LLM genera la respuesta token a token, pero la llamada de 03-01 espera a que todos los tokens existan antes de devolver nada. Si la respuesta tarda 6 segundos en generarse, el usuario mira una pantalla vacía 6 segundos.
El streaming cambia el contrato: el servidor envía cada fragmento en cuanto lo genera y el cliente lo consume sobre la marcha. El tiempo total es el mismo (los tokens se generan al mismo ritmo), pero la percepción es radicalmente distinta:
| Métrica | Sin streaming | Con streaming |
|---|---|---|
| Tiempo hasta el primer texto visible | 6 s (todo o nada) | ~0,5–1,5 s (primer token) |
| Tiempo hasta la respuesta completa | 6 s | 6 s (igual) |
| Sensación del usuario | "Está colgado" | "Está escribiendo" |
| Posibilidad de abortar una respuesta que va mal | No | Sí, a mitad |
Para el chat de DocuBot, streaming es obligatorio: un agente de soporte con un cliente al teléfono no tolera mirar una ruleta. La regla general: interfaz conversacional → streaming; proceso por lotes (clasificar 500 tickets de noche) → sin streaming, porque allí nadie mira la pantalla.
Hay un segundo motivo, más técnico: en respuestas largas, mantener una conexión HTTP abierta esperando la respuesta completa acaba chocando con timeouts de red intermedios. El goteo constante del streaming mantiene la conexión viva.
Consumir un stream en Python
El SDK expone un context manager que itera fragmentos de texto. La forma más directa:
# streaming.py
from cliente import client, MODELO, extraer_texto
from prompts import SYSTEM_DOCUBOT, prompt_respuesta
def stream_respuesta(pregunta: str, documentacion: str) -> str:
"""Imprime la respuesta según se genera y devuelve el texto completo."""
with client.messages.stream(
model=MODELO,
max_tokens=1024,
system=SYSTEM_DOCUBOT,
messages=[{"role": "user", "content": prompt_respuesta(pregunta, documentacion)}],
) as stream:
fragmentos = []
for texto in stream.text_stream:
print(texto, end="", flush=True) # mostrar al usuario YA
fragmentos.append(texto) # y acumular para procesar después
# Al agotar el stream podemos pedir el mensaje final completo,
# con usage y stop_reason como en una llamada normal.
final = stream.get_final_message()
print() # salto de línea al terminar
print(f"[tokens salida: {final.usage.output_tokens}, parada: {final.stop_reason}]")
return "".join(fragmentos)Puntos clave del código:
client.messages.stream(...)acepta los mismos parámetros quemessages.create(); solo cambia cómo se consume la respuesta.stream.text_streames un iterador de fragmentos de texto. Cada iteración entrega uno o pocos tokens;flush=Truefuerza a que se pinten inmediatamente en consola (sin él, Python puede retener la salida en un buffer y arruinar el efecto).- Acumulamos los fragmentos en una lista y los unimos al final: mostrar y procesar son dos necesidades distintas y el patrón "imprime y acumula" cubre ambas.
stream.get_final_message()devuelve, cuando el stream ha terminado, el mismo objeto de respuesta que ya conoces de 03-01:usage,stop_reason,content. El streaming no te hace perder la telemetría.
Por debajo, el proveedor envía eventos tipados (inicio de mensaje, inicio de bloque, delta de texto, fin de bloque, fin de mensaje) sobre una conexión de eventos de servidor (SSE). text_stream es el atajo cómodo que filtra solo los deltas de texto; si algún día necesitas los eventos crudos (por ejemplo, para distinguir tipos de bloque), el mismo stream permite iterarlos uno a uno. El concepto es idéntico en cualquier proveedor: OpenAI y Gemini también emiten fragmentos incrementales que se consumen en un bucle.
Streaming y salidas JSON: validar al final
Aquí aparece una limitación práctica importante para DocuBot: nuestro contrato de 02-03 exige un JSON completo, y un JSON parcial no es parseable. {"categoria": "facturacion", "respu no se puede validar; solo el documento entero.
La consecuencia de diseño:
- Puedes mostrar los fragmentos según llegan (percepción de latencia).
- Debes validar solo al final, con el texto completo acumulado y el parser de 02-03.
import json
from prompts import RESPUESTA_FALLBACK
from validacion import limpiar_json, validar_contrato
def stream_docubot(pregunta: str, documentacion: str) -> dict:
texto = stream_respuesta(pregunta, documentacion) # muestra en vivo y acumula
try:
return validar_contrato(json.loads(limpiar_json(texto)))
except (json.JSONDecodeError, TypeError):
return RESPUESTA_FALLBACKEsto crea una tensión de experiencia de usuario: si la salida es el JSON crudo, el usuario vería llaves y comillas fluyendo por pantalla. Las soluciones habituales, de más simple a más elaborada:
- No hacer streaming en las rutas JSON y reservarlo para respuestas de texto libre. Simple y suficiente para muchos casos.
- Streaming interno, render al final: consumes el stream (la conexión se mantiene viva, puedes medir el primer token) pero solo muestras al usuario un indicador de progreso hasta validar.
- Extraer el campo
respuestaen vivo con un parser incremental que detecta cuándo está "dentro" del valor de ese campo y emite solo eso. Es frágil y rara vez compensa; menciónalo en una revisión de diseño antes de intentarlo.
Para DocuBot adoptamos la opción 2: es la que mantiene el contrato intacto sin renunciar a la conexión en vivo.
Taxonomía de errores de la API
Toda API de LLM devuelve errores HTTP con la misma lógica de fondo: 4xx = el problema es tuyo, 5xx = el problema es suyo. Los que verás en la práctica:
| Código | Nombre | Causa típica | ¿Reintentar? |
|---|---|---|---|
| 400 | Petición inválida | Parámetro mal formado; entrada que excede la ventana de contexto | ❌ No: fallará igual. Corrige la petición (o recorta el contexto, 03-03) |
| 401 | Autenticación | Clave ausente, mal escrita o revocada | ❌ No: revisa la variable de entorno |
| 403 | Permisos | La clave no tiene acceso a ese modelo o función | ❌ No: revisa el plan/permisos |
| 404 | No encontrado | Identificador de modelo inexistente (errata) | ❌ No: corrige el nombre |
| 413 | Petición demasiado grande | Cuerpo de la petición excede límites | ❌ No: reduce la entrada |
| 429 | Límite de peticiones | Demasiadas peticiones o tokens por minuto | ✅ Sí, con espera (respetando retry-after si llega) |
| 500 | Error interno | Fallo transitorio del proveedor | ✅ Sí, con backoff |
| 529 | Sobrecarga | El servicio está saturado (equivalente a "vuelve en un rato") | ✅ Sí, con backoff más generoso |
Y una fila más que no es HTTP: los errores de conexión (DNS, timeout de red, conexión cortada a mitad de stream). No hay código porque la petición no llegó o no volvió; también son reintenables.
El SDK traduce estos códigos a excepciones tipadas, lo que permite un try/except legible en vez de comparar números:
import anthropic
try:
respuesta = client.messages.create(...)
except anthropic.BadRequestError as e: # 400: contexto excedido, parámetro inválido
print(f"Petición inválida: {e.message}")
except anthropic.AuthenticationError: # 401
print("Clave de API inválida: revisa ANTHROPIC_API_KEY")
except anthropic.RateLimitError as e: # 429
espera = int(e.response.headers.get("retry-after", "60"))
print(f"Límite alcanzado; el servidor sugiere esperar {espera}s")
except anthropic.APIStatusError as e: # resto de códigos HTTP
if e.status_code >= 500:
print(f"Error del proveedor ({e.status_code}): reintenable")
else:
print(f"Error de cliente ({e.status_code}): {e.message}")
except anthropic.APIConnectionError: # la red, no la API
print("Error de red: reintenable")Nota el orden: de la excepción más específica a la más general. RateLimitError antes que APIStatusError, porque la primera es subtipo conceptual de la segunda y un except genérico colocado antes se la tragaría. Este patrón de jerarquía de excepciones existe en todos los SDKs de todos los proveedores con nombres distintos; la taxonomía 4xx/5xx/red es universal.
El 400 por contexto excedido merece mención aparte: hoy es improbable (enviamos poca cosa), pero en 03-03, cuando acumulemos historial de conversación, se convertirá en un riesgo estructural. Recuérdalo.
Reintentos con backoff exponencial y jitter
Ante un 429 o un 5xx, reintentar inmediatamente es contraproducente: si el servidor está saturado, mil clientes reintentando a la vez lo saturan más. La estrategia estándar tiene dos ingredientes:
- Backoff exponencial: esperar cada vez más entre intentos (1s, 2s, 4s, 8s...). Da tiempo a que el problema transitorio se resuelva.
- Jitter (aleatoriedad): sumar un componente aleatorio a cada espera. Sin él, todos los clientes que fallaron a la vez reintentan a la vez, creando olas sincronizadas de tráfico (el problema del "rebaño atronador").
Implementación completa para DocuBot:
# reintentos.py
import random
import time
import anthropic
from cliente import client
def llamar_con_reintentos(
max_reintentos: int = 4,
espera_base: float = 1.0,
espera_maxima: float = 30.0,
**kwargs,
):
"""Envía una petición a la API con backoff exponencial + jitter.
Reintenta SOLO errores transitorios (429, 5xx, red). Los errores
de cliente (400, 401, 403, 404...) se propagan inmediatamente.
"""
ultima_excepcion = None
for intento in range(max_reintentos):
try:
return client.messages.create(**kwargs)
except anthropic.RateLimitError as e:
ultima_excepcion = e
# Si el servidor dicta cuánto esperar, obedecer
retry_after = e.response.headers.get("retry-after")
if retry_after is not None:
espera = min(float(retry_after), espera_maxima)
else:
espera = None # calcular con backoff abajo
except anthropic.APIStatusError as e:
if e.status_code < 500:
raise # 4xx distinto de 429: reintentar no lo arreglará
ultima_excepcion = e
espera = None
except anthropic.APIConnectionError as e:
ultima_excepcion = e
espera = None
if espera is None:
# backoff exponencial: base * 2^intento, acotado, + jitter
espera = min(espera_base * (2 ** intento), espera_maxima)
espera += random.uniform(0, espera_base)
print(f"[intento {intento + 1}/{max_reintentos} fallido; espero {espera:.1f}s]")
time.sleep(espera)
# Agotados los intentos: propagar el último error para que el caller decida
raise ultima_excepcionDecisiones de diseño que debes poder justificar:
- Solo se reintenta lo transitorio. Un 401 reintentado 4 veces son 4 fracasos idénticos más lentos. El
raisetemprano en los 4xx no-429 es tan importante como el bucle. retry-aftermanda. Cuando el servidor te dice cuánto esperar, tu heurística sobra.- La espera está acotada (
espera_maxima): sin tope, el intento 6 esperaría 32s, el 8 más de 2 minutos, y el usuario se habrá ido. - El último error se propaga. La función de reintentos no decide qué mostrar al usuario; eso es de la capa de degradación (última sección).
Importante: los SDKs oficiales ya traen reintentos automáticos configurables — en el de Anthropic, anthropic.Anthropic(max_retries=4) reintenta 429/5xx/red con backoff por ti, y otros proveedores ofrecen lo equivalente. En producción, usa primero el del SDK y escribe el tuyo solo si necesitas un comportamiento que aquel no cubre (logging propio, métricas, políticas por tipo de consulta). Lo hemos implementado a mano porque entender qué hace el SDK por dentro es lo que te permitirá configurarlo con criterio y depurarlo cuando algo no cuadre.
Timeouts, idempotencia y qué NO reintentar
Timeouts. Un timeout limita cuánto esperas una respuesta antes de rendirte. Sin él, una conexión colgada bloquea tu proceso indefinidamente. El cliente permite configurarlo globalmente o por llamada:
client = anthropic.Anthropic(timeout=30.0) # segundos, para todas las llamadas # o puntualmente, sin tocar el cliente global: client.with_options(timeout=10.0).messages.create(...)
Criterio: el timeout debe ser coherente con el caso de uso. El clasificador (20 tokens de salida) no debería tardar más de unos segundos: timeout corto. Una respuesta larga con streaming puede legítimamente tardar más: timeout más generoso o streaming, que reparte la espera. Ten en cuenta que un timeout agotado suele contarse como error reintenable, así que el tiempo total en el peor caso es aproximadamente timeout × (reintentos + 1) — dimensiona ambos juntos.
Idempotencia. Una operación es idempotente si ejecutarla dos veces produce el mismo efecto que una. Las llamadas de DocuBot (pregunta → respuesta) lo son: reintentar es seguro, como mucho pagas dos veces la generación. Pero cuidado con el patrón general: si tu llamada al LLM está acoplada a un efecto secundario ("genera el texto Y envía el correo", "responde Y descuenta crédito al cliente"), un reintento puede duplicar el efecto. Regla de diseño: separa la generación (reintenable) del efecto secundario (ejecutar una sola vez, después de validar). En el módulo 5, cuando el LLM llame a herramientas que modifican estado, esta regla será crítica.
Qué no reintentar, resumido:
- Errores 4xx que no sean 429 (fallarán igual).
- Peticiones cuyo contenido violó una política del proveedor (fallará igual y ensucia tus métricas).
- Operaciones no idempotentes sin protección (riesgo de efecto duplicado).
- Y no reintentar infinitamente: todo bucle de reintentos necesita un límite y una salida hacia la degradación.
Circuit breaker: dejar de insistir
Los reintentos resuelven fallos puntuales. Pero si el proveedor lleva 10 minutos caído, cada petición de cada usuario recorrerá su ciclo completo de 4 intentos con esperas — multiplicando latencia y tráfico inútil justo cuando el sistema ajeno está peor.
El patrón circuit breaker (cortacircuitos) añade memoria compartida sobre la salud del servicio. Conceptualmente, tres estados:
- Cerrado (normal): las peticiones pasan. Se cuentan los fallos recientes.
- Abierto: tras superar un umbral de fallos (p. ej. 5 fallos en 60 segundos), el circuito "salta": durante un tiempo de enfriamiento, las peticiones ni se intentan — van directas a la respuesta degradada. Fallar rápido es el objetivo.
- Semiabierto: agotado el enfriamiento, se deja pasar una petición de prueba. Si funciona, el circuito se cierra (vuelta a la normalidad); si falla, se reabre.
No lo implementaremos aquí (bibliotecas como pybreaker lo traen hecho, y la observabilidad que lo alimenta se ve en 06-04); lo que debes retener es la división de responsabilidades: reintentos para el fallo puntual, circuit breaker para la caída sostenida. Un sistema maduro tiene ambos.
Degradación elegante en DocuBot
Todo lo anterior converge en una pregunta de producto: ¿qué ve el empleado de Nubelia cuando, pese a todo, la llamada falla? Un traceback es inaceptable; un silencio, casi peor. La respuesta ya la diseñamos en 02-03: RESPUESTA_FALLBACK, un objeto que cumple el contrato JSON con valores conservadores. Ahora le damos su papel definitivo como último eslabón de la cadena:
# docubot.py — la capa que une todas las piezas del módulo
import json
import anthropic
from cliente import MODELO, extraer_texto
from reintentos import llamar_con_reintentos
from prompts import SYSTEM_DOCUBOT, prompt_respuesta, RESPUESTA_FALLBACK
from validacion import limpiar_json, validar_contrato
def responder(pregunta: str, documentacion: str) -> dict:
"""Pipeline completo: llamada con reintentos -> validación -> fallback.
Pase lo que pase, SIEMPRE devuelve un dict que cumple el contrato.
"""
try:
respuesta = llamar_con_reintentos(
model=MODELO,
max_tokens=1024,
system=SYSTEM_DOCUBOT,
messages=[{"role": "user", "content": prompt_respuesta(pregunta, documentacion)}],
)
except anthropic.APIError:
# Reintentos agotados o error no reintenable: degradar, no explotar.
# (En 06-04 añadiremos aquí registro y métricas.)
return RESPUESTA_FALLBACK
if respuesta.stop_reason == "max_tokens":
return RESPUESTA_FALLBACK
try:
return validar_contrato(json.loads(limpiar_json(extraer_texto(respuesta))))
except (json.JSONDecodeError, TypeError):
return RESPUESTA_FALLBACKLa garantía que ofrece esta función es la que define un sistema robusto: su tipo de retorno no depende de que el mundo funcione. Red caída, proveedor saturado, JSON corrupto, respuesta cortada — el caller siempre recibe un contrato válido, y la interfaz siempre tiene algo digno que mostrar ("Ahora mismo no puedo consultar la documentación; inténtalo de nuevo en unos minutos"). Los detalles de cuántas veces se degradó y por qué pertenecen a la observabilidad (06-04); el que nunca explote pertenece a esta lección.
Errores Comunes y Consejos
- Reintentar los 400. Es el error de novato más caro: un bucle reintentando una petición inválida 4 veces con esperas de 30s convierte un bug de un segundo en minutos de latencia. 4xx (salvo 429) → corregir, no insistir.
- Backoff sin jitter. Funciona en tu prueba con un solo cliente y fracasa en producción con cien: todos reintentan sincronizados. El
random.uniformno es decorativo. - Ignorar
retry-after. El servidor te está diciendo exactamente cuánto esperar; tu exponencial es una suposición. - Validar JSON fragmento a fragmento en streaming. Un JSON parcial nunca parsea; acumula y valida al final. Si ves
JSONDecodeErrorconstantes en la ruta de streaming, casi seguro es esto. - Olvidar
flush=Trueal imprimir el stream: el buffer de salida retiene los fragmentos y el "efecto máquina de escribir" desaparece, con lo que el streaming pierde su razón de ser visual. - Duplicar reintentos (los del SDK activados Y los tuyos alrededor): 4 × 4 = 16 intentos reales. Decide una capa y desactiva o limita la otra (
max_retries=0si el control lo lleva tu función). - Consejo: provoca los errores en desarrollo, no esperes a producción. Una clave falsa te da el 401;
max_tokens=10te da el corte; untimeout=0.001te da el error de conexión. Cada rama delexceptdebe haberse ejecutado al menos una vez ante tus ojos. - Consejo: registra siempre el identificador de petición (
respuesta.ido el de la excepción) al reportar errores al proveedor: es lo que les permite rastrear tu caso.
Ejercicios
Ejercicio 1
Escribe stream_con_metricas(pregunta, documentacion) que consuma el stream y devuelva un diccionario con: texto (completo), t_primer_token (segundos desde el envío hasta el primer fragmento) y t_total (segundos hasta el final). Usa time.perf_counter(). Ejecuta la misma pregunta con y sin streaming y compara qué métrica mejora y cuál no.
Ejercicio 2
Modifica llamar_con_reintentos() para que acepte un parámetro al_fallar (callable opcional) que se invoque en cada intento fallido con (intento, excepcion, espera). Úsalo para imprimir una traza legible. Después provoca un 401 con una clave falsa y comprueba que no se reintenta (la traza no debe aparecer).
Ejercicio 3
Diseña (en pseudocódigo o Python) un circuit breaker mínimo para DocuBot: clase Cortacircuitos con umbral_fallos=5, ventana_segundos=60 y enfriamiento_segundos=120, y métodos permitir() -> bool, registrar_exito() y registrar_fallo(). Intégralo conceptualmente en responder(): ¿dónde se consulta y dónde se registra?
Soluciones
Solución 1:
import time
def stream_con_metricas(pregunta, documentacion):
inicio = time.perf_counter()
t_primer_token = None
fragmentos = []
with client.messages.stream(
model=MODELO, max_tokens=1024, system=SYSTEM_DOCUBOT,
messages=[{"role": "user", "content": prompt_respuesta(pregunta, documentacion)}],
) as stream:
for texto in stream.text_stream:
if t_primer_token is None:
t_primer_token = time.perf_counter() - inicio
fragmentos.append(texto)
return {
"texto": "".join(fragmentos),
"t_primer_token": t_primer_token,
"t_total": time.perf_counter() - inicio,
}Observarás que t_total es similar con y sin streaming, pero t_primer_token es una fracción pequeña de él. Esa diferencia es la mejora de percepción — y en 03-04 la llamaremos por su nombre (TTFT) y la mediremos sistemáticamente.
Solución 2:
def llamar_con_reintentos(max_reintentos=4, espera_base=1.0,
espera_maxima=30.0, al_fallar=None, **kwargs):
ultima_excepcion = None
for intento in range(max_reintentos):
try:
return client.messages.create(**kwargs)
except anthropic.RateLimitError as e:
ultima_excepcion = e
except anthropic.APIStatusError as e:
if e.status_code < 500:
raise # aquí muere el 401: sin reintento
ultima_excepcion = e
except anthropic.APIConnectionError as e:
ultima_excepcion = e
espera = min(espera_base * (2 ** intento), espera_maxima)
espera += random.uniform(0, espera_base)
if al_fallar:
al_fallar(intento + 1, ultima_excepcion, espera)
time.sleep(espera)
raise ultima_excepcion
# Uso:
# llamar_con_reintentos(al_fallar=lambda i, e, s: print(f"[{i}] {type(e).__name__}, espero {s:.1f}s"), ...)Con la clave falsa, AuthenticationError (que hereda del error de estado con código < 500 en la práctica del SDK; si tu versión la expone como clase propia, añade su except con raise) se propaga en el primer intento: cero trazas, cero esperas. Exactamente el comportamiento correcto.
Solución 3:
import time
class Cortacircuitos:
def __init__(self, umbral_fallos=5, ventana_segundos=60, enfriamiento_segundos=120):
self.umbral = umbral_fallos
self.ventana = ventana_segundos
self.enfriamiento = enfriamiento_segundos
self.fallos = [] # timestamps de fallos recientes
self.abierto_hasta = 0.0 # instante en que termina el enfriamiento
def permitir(self) -> bool:
return time.monotonic() >= self.abierto_hasta
def registrar_exito(self):
self.fallos.clear()
def registrar_fallo(self):
ahora = time.monotonic()
self.fallos = [t for t in self.fallos if ahora - t < self.ventana]
self.fallos.append(ahora)
if len(self.fallos) >= self.umbral:
self.abierto_hasta = ahora + self.enfriamiento
self.fallos.clear()Integración en responder(): se consulta al principio (if not breaker.permitir(): return RESPUESTA_FALLBACK — fallar rápido, sin tocar la red) y se registra al final de la llamada (éxito tras el create, fallo en el except). El estado semiabierto surge solo: pasado el enfriamiento, permitir() vuelve a dar True y la siguiente petición actúa de prueba.
Conclusión
DocuBot ya se comporta como un sistema y no como un script: el streaming hace visible la generación desde el primer token (validando el JSON solo al final, con el parser de 02-03 intacto), la taxonomía de errores separa lo que se corrige (4xx) de lo que se espera (429, 5xx, red), los reintentos con backoff exponencial y jitter absorben los fallos puntuales —sabiendo que el SDK trae los suyos configurables—, los timeouts y la idempotencia marcan los límites de lo reintenable, el circuit breaker queda apuntado para las caídas sostenidas, y RESPUESTA_FALLBACK garantiza que, pase lo que pase, el empleado de Nubelia recibe una respuesta digna y no un traceback. Pero nuestro DocuBot sigue teniendo la memoria de un pez: cada llamada empieza de cero, y si un agente pregunta "¿y eso cómo se configura?", el bot no sabe qué es "eso". En la próxima lección atacamos la conversación: por qué los LLMs no recuerdan nada entre llamadas, cómo se construye la ilusión de memoria enviando el historial completo, y qué estrategias existen para que ese historial no devore la ventana de contexto ni el presupuesto.
Curso de IA Generativa y LLMs para Desarrolladores
Módulo 1: Fundamentos de la IA generativa
- Qué es la IA generativa y por qué importa a los desarrolladores
- Cómo funciona un LLM: tokens, embeddings y atención
- Panorama de modelos y proveedores
- Limitaciones y riesgos: alucinaciones, contexto y costes
Módulo 2: Prompt engineering
- Anatomía de un prompt: roles, instrucciones y contexto
- Técnicas de prompting: few-shot, cadena de razonamiento y plantillas
- Salidas estructuradas: JSON y control de formato
- Iteración y evaluación de prompts
Módulo 3: Integración de LLMs en aplicaciones
- Primera integración con la API de un LLM
- Streaming, errores y reintentos
- Conversaciones y gestión del contexto
- Costes, latencia y caching
Módulo 4: RAG - Generación aumentada por recuperación
- Embeddings y búsqueda semántica
- Bases de datos vectoriales
- Ingesta y chunking de documentos
- Construcción de un pipeline RAG completo
- Evaluación de sistemas RAG
Módulo 5: Function calling y agentes
- Function calling: conectar el LLM con tu código
- De LLM a agente: el bucle de razonamiento y acción
- Frameworks de orquestación
