Hasta ahora, todo lo que DocuBot "ha respondido" lo hemos escrito nosotros sobre el papel: prompts diseñados en el módulo 2 y respuestas esperadas contra las que evaluar. En esta lección el sistema cobra vida: prepararemos un entorno Python limpio, haremos la primera llamada real a la API de un LLM enviando los roles system/user que diseñamos en 02-01, diseccionaremos la respuesta que devuelve el proveedor (contenido, motivo de parada, consumo de tokens) y conectaremos el parser JSON defensivo de 02-03 a una respuesta de verdad. Ejemplificaremos con el SDK de Anthropic, pero los conceptos —mensajes con roles, límite de salida, objeto de uso— son equivalentes en cualquier proveedor, y cerraremos con una tabla que lo demuestra.
Contenido
- Del papel a la API: qué vamos a construir
- Preparar el entorno: venv, SDK y clave de API
- La primera llamada: system + user con los artefactos de
prompts.py - Anatomía de la respuesta: contenido,
stop_reasonyusage - Parámetros principales:
model,max_tokensytemperature - Conectar el parser JSON de 02-03 a una respuesta real
- Equivalencia de conceptos entre proveedores
Del papel a la API: qué vamos a construir
En 01-03 tomamos una decisión de arquitectura que ahora se materializa: API gestionada, modelo de gama media y temperature baja. Eso significa que no vamos a instalar ni servir ningún modelo; vamos a enviar peticiones HTTP a un proveedor y recibir respuestas. El SDK del proveedor se encarga del transporte, la autenticación y la serialización, y nosotros nos concentramos en lo que ya tenemos: SYSTEM_DOCUBOT, las plantillas de prompts.py (versión 1.2) y el contrato JSON con su validación defensiva.
El objetivo de esta lección es un módulo cliente.py que, dado una pregunta y un bloque de documentación, devuelva el diccionario validado {"categoria", "respuesta", "fuentes", "confianza"}. Es la primera pieza "viva" de DocuBot.
Preparar el entorno: venv, SDK y clave de API
Trabajaremos en un entorno virtual para aislar dependencias del resto del sistema:
# Crear y activar el entorno virtual python -m venv .venv # Linux / macOS source .venv/bin/activate # Windows (PowerShell) .venv\Scripts\Activate.ps1 # Instalar el SDK del proveedor (ejemplo: Anthropic) pip install anthropic # Congelar dependencias para reproducibilidad pip freeze > requirements.txt
La clave de API nunca se escribe en el código ni se sube al repositorio. Se define como variable de entorno, que es donde el SDK la busca automáticamente:
# Linux / macOS export ANTHROPIC_API_KEY="tu-clave-aqui" # Windows (PowerShell) $env:ANTHROPIC_API_KEY = "tu-clave-aqui"
Buenas prácticas desde el primer día:
- Variable de entorno o gestor de secretos, nunca una cadena en el código. Una clave filtrada en un commit es un incidente de seguridad (y de facturación: quien la tenga gasta tu cuota).
- Añade
.venv/y cualquier fichero.enva.gitignore. - Usa una clave distinta para desarrollo y para producción; así puedes revocar una sin afectar a la otra.
La primera llamada: system + user
Creamos cliente.py reutilizando los artefactos del módulo 2 tal cual. Fíjate en que el system prompt viaja en un parámetro propio, separado de la lista de mensajes — exactamente la separación de roles que estudiamos en 02-01:
# cliente.py
import os
import anthropic
from prompts import SYSTEM_DOCUBOT, prompt_respuesta, PROMPT_VERSION
# El identificador exacto del modelo depende del proveedor y del momento;
# lo leemos de una variable de entorno con un placeholder como valor por defecto.
MODELO = os.environ.get("DOCUBOT_MODELO", "claude-...")
# El cliente lee ANTHROPIC_API_KEY del entorno: no pasamos la clave a mano.
client = anthropic.Anthropic()
def preguntar_docubot_bruto(pregunta: str, documentacion: str):
"""Envía una pregunta a la API y devuelve la respuesta completa (sin procesar)."""
return client.messages.create(
model=MODELO,
max_tokens=1024,
temperature=0.2, # baja: respuestas consistentes (decisión de 01-03)
system=SYSTEM_DOCUBOT, # el system prompt v1.2 diseñado en 02-01
messages=[
{
"role": "user",
"content": prompt_respuesta(pregunta, documentacion),
}
],
)Desglose línea a línea:
anthropic.Anthropic()crea el cliente. No le pasamos la clave: la resuelve desdeANTHROPIC_API_KEY. Si no existe, la llamada fallará con un error de autenticación — mejor eso que una clave escrita en el fichero.model=MODELOindica qué modelo responde. Usamos un placeholder tipo"claude-..."porque los identificadores concretos cambian con el tiempo; en tu proyecto fija el que elegiste en 01-03.system=SYSTEM_DOCUBOTenvía la identidad, las reglas anti-alucinación y el tono de DocuBot. En este proveedor es un parámetro de primer nivel; en otros va como un mensaje más con rolsystem(lo veremos en la tabla final).messageses la lista de turnos de conversación. De momento un único turnouser, cuyo contenido generaprompt_respuesta(): la pregunta del empleado más el bloque<documentacion>que exige la regla 2 del system prompt.max_tokens=1024es el tope de tokens de salida: la respuesta nunca será más larga que eso. Es obligatorio en esta API y es tu primer freno de coste.
Probamos con datos ficticios de Nubelia:
if __name__ == "__main__":
documentacion = """## Exportar un proyecto
Para exportar un proyecto a CSV: Proyecto > Ajustes > Exportar > CSV.
La exportación incluye tareas, responsables y fechas. Disponible en
los planes Pro y Business."""
pregunta = "¿Cómo exporto mi proyecto a CSV?"
respuesta = preguntar_docubot_bruto(pregunta, documentacion)
print(respuesta)Si todo está bien configurado, al ejecutar python cliente.py verás por primera vez una respuesta generada por el modelo con tu system prompt. Merece la pena pararse un segundo: las reglas que escribiste en 02-01 están gobernando, ahora mismo, la salida de un modelo real.
Anatomía de la respuesta
El objeto que devuelve la API contiene mucho más que el texto. Sus campos importantes:
respuesta = preguntar_docubot_bruto(pregunta, documentacion)
# 1. El contenido: una LISTA de bloques, no un string directo
for bloque in respuesta.content:
if bloque.type == "text":
print(bloque.text)
# 2. Por qué dejó de generar
print(respuesta.stop_reason) # p. ej. "end_turn"
# 3. Cuántos tokens ha consumido (¡esto es la factura!)
print(respuesta.usage.input_tokens) # tokens de entrada (system + user)
print(respuesta.usage.output_tokens) # tokens generados
# 4. Metadatos
print(respuesta.model) # el modelo que respondió realmente
print(respuesta.id) # identificador único de la peticiónTres detalles que conviene interiorizar:
contentes una lista de bloques. Hoy solo contiene bloques de texto, pero el mismo campo transporta otros tipos (llamadas a herramientas en el módulo 5, por ejemplo). Acostúmbrate desde ya a filtrar porbloque.type == "text"en vez de acceder acontent[0].texta ciegas.stop_reasonte dice por qué terminó la generación. Los valores principales:
stop_reason |
Significado | Qué hacer |
|---|---|---|
end_turn |
El modelo terminó de forma natural | Caso normal, procesar la respuesta |
max_tokens |
Se agotó el tope de salida | La respuesta está cortada: súbelo o pide salidas más breves |
stop_sequence |
Apareció una secuencia de parada definida por ti | Procesar hasta ese punto |
tool_use |
El modelo quiere llamar a una herramienta | Lo veremos en el módulo 5 |
Para DocuBot, max_tokens es especialmente peligroso: un JSON cortado a la mitad nunca validará. Comprobar stop_reason antes de parsear evita perder tiempo depurando "JSON inválido" cuando el problema real era el tope de salida.
usagees tu contador de coste. Entrada y salida se facturan por separado y a precios distintos. En 03-04 construiremos la estimación de costes sobre estos dos números; de momento, imprímelos en cada prueba para ir desarrollando intuición de cuánto "pesa" cada llamada.
Una función auxiliar que usaremos en todo el curso:
def extraer_texto(respuesta) -> str:
"""Concatena los bloques de texto de una respuesta de la API."""
return "".join(
bloque.text for bloque in respuesta.content if bloque.type == "text"
)Parámetros principales
Los tres parámetros que ajustarás constantemente:
| Parámetro | Qué controla | Guía para DocuBot |
|---|---|---|
model |
Qué modelo responde (capacidad, velocidad, precio) | Gama media para respuestas; en 03-04 veremos que el clasificador puede usar uno menor |
max_tokens |
Tope duro de tokens de salida | 1024 sobra para respuestas de <150 palabras + JSON; es freno de coste y de descontrol |
temperature |
Aleatoriedad del muestreo (0 = muy determinista, 1 = muy variado) | Baja (0.0–0.3): DocuBot debe ser consistente, no creativo |
Sobre temperature, dos matices honestos:
- Temperature baja no es determinismo total. Como vimos en 01-04 (no determinismo), dos llamadas idénticas pueden diferir ligeramente incluso a temperature 0. Reduce la varianza; no la elimina.
- Algunos modelos recientes de algunos proveedores fijan el muestreo internamente y rechazan el parámetro
temperaturecon un error de petición inválida. Consulta la documentación del modelo concreto que uses; si lo rechaza, simplemente omite el parámetro y controla el estilo mediante el prompt.
Conectar el parser JSON de 02-03 a una respuesta real
En 02-03 diseñamos el contrato {"categoria", "respuesta", "fuentes", "confianza"} y la validación defensiva (limpiar_json, json.loads con try/except, defaults conservadores, RESPUESTA_FALLBACK). Todo aquello se escribió contra respuestas hipotéticas; ahora lo enchufamos a la salida real:
import json
from prompts import RESPUESTA_FALLBACK
from validacion import limpiar_json, validar_contrato # el módulo escrito en 02-03
def preguntar_docubot(pregunta: str, documentacion: str) -> dict:
"""Llamada completa: API -> texto -> JSON validado con el contrato de 02-03."""
respuesta = preguntar_docubot_bruto(pregunta, documentacion)
# Un JSON cortado por max_tokens jamás parseará: detectarlo pronto y claro.
if respuesta.stop_reason == "max_tokens":
return RESPUESTA_FALLBACK
texto = extraer_texto(respuesta)
try:
datos = json.loads(limpiar_json(texto)) # quita ```json ... ``` y ruido
except (json.JSONDecodeError, TypeError):
return RESPUESTA_FALLBACK
# Defaults conservadores para campos ausentes o inválidos (02-03)
return validar_contrato(datos)La primera vez que ejecutes esto contra el modelo real es probable que descubras cosas que el papel no mostraba: respuestas envueltas en ```json, un campo confianza con un valor fuera del enumerado, una lista fuentes que llega como string. Exactamente para eso construimos la validación defensiva — y por eso limpiar_json y los defaults no eran paranoia, sino ingeniería.
Salida estructurada nativa. Como anunciamos en 02-03, los proveedores ofrecen modos que garantizan que la salida cumple un esquema JSON: en Anthropic se declara un esquema en la petición (output_config con json_schema), en OpenAI existe el equivalente con response_format/esquemas estrictos y en Gemini con response_schema. Cuando tu proveedor y modelo lo soporten, actívalo: convierte el "casi siempre es JSON válido" en "siempre lo es". Aun así, mantén la validación defensiva: el esquema garantiza la forma, no el contenido (una confianza: "alta" en una respuesta inventada sigue siendo un problema de evaluación, no de formato), y tu código seguirá siendo portable entre proveedores.
Equivalencia de conceptos entre proveedores
Todo lo anterior existe, con otros nombres, en cualquier proveedor serio. A nivel de conceptos (no de cifras ni de identificadores concretos):
| Concepto | Anthropic | OpenAI | Google Gemini |
|---|---|---|---|
| SDK Python | anthropic |
openai |
google-genai |
| Clave de API | ANTHROPIC_API_KEY (env) |
OPENAI_API_KEY (env) |
GOOGLE_API_KEY (env) |
| System prompt | Parámetro system separado |
Mensaje con rol system/developer en la lista |
system_instruction en la configuración |
| Turnos de conversación | messages con roles user/assistant |
messages con roles user/assistant |
contents con roles user/model |
| Tope de salida | max_tokens |
Parámetro de límite de tokens de salida | max_output_tokens |
| Consumo de tokens | usage.input_tokens / usage.output_tokens |
Objeto usage (prompt / completion) |
usage_metadata |
| Motivo de parada | stop_reason |
finish_reason |
finish_reason |
| Salida estructurada | Esquema JSON en la petición | response_format / esquemas estrictos |
response_schema |
La lección de esta tabla: aprende los conceptos, no los nombres. Si mañana Nubelia cambia de proveedor, el 90% de tu código (plantillas, validación, evaluación, gestión de errores) no cambia; solo la fina capa que habla con el SDK. Por eso concentramos toda la interacción con la API en cliente.py — un único fichero que reescribir si llega ese día.
Lo que no hemos tocado hoy, deliberadamente: la respuesta llega de golpe tras varios segundos (streaming, 03-02), si la red falla la excepción explota sin control (errores y reintentos, 03-02), cada llamada empieza de cero sin recordar nada (conversaciones, 03-03) y no sabemos cuánto cuesta esto al mes (costes, 03-04).
Errores Comunes y Consejos
- Hardcodear la clave de API "solo para probar". Ese "solo para probar" acaba en un commit. Variable de entorno desde el minuto uno, sin excepciones.
- Acceder a
respuesta.content[0].textsin comprobar el tipo de bloque. Funciona hoy; se rompe en cuanto el modelo devuelva otro tipo de bloque. Usa siempre el filtrado porbloque.type. - Ignorar
stop_reason. El síntoma típico: "el parser JSON falla aleatoriamente". La causa real: respuestas cortadas pormax_tokens. Comprueba el motivo de parada antes de parsear. - Olvidar que
max_tokenslimita la salida, no la entrada. La entrada está limitada por la ventana de contexto del modelo (01-04); son límites distintos con errores distintos. - Reinventar el parser en cada script. Los artefactos de 02-03 (
limpiar_json,validar_contrato,RESPUESTA_FALLBACK) son módulos importables. Un único punto de validación, un único sitio que corregir. - Consejo: imprime
usageen cada prueba durante el desarrollo. En dos días tendrás una intuición sólida de cuántos tokens consume cada tipo de pregunta — y 03-04 te resultará mucho más natural. - Consejo: ejecuta contra la API el conjunto de evaluación de 02-04 (o una muestra de 10 preguntas) en cuanto tengas
preguntar_docubot()funcionando. Es tu primera evaluación con respuestas reales, y la base de comparación para todo lo que optimices después.
Ejercicios
Ejercicio 1
Escribe una función diagnostico_llamada(respuesta) que reciba el objeto de respuesta de la API y devuelva un diccionario con: texto (contenido concatenado), cortada (booleano: True si stop_reason == "max_tokens"), tokens_entrada, tokens_salida y modelo. Pruébala con una llamada real y con max_tokens=20 para forzar el corte.
Ejercicio 2
Modifica preguntar_docubot_bruto() para aceptar un parámetro opcional temperature (por defecto 0.2) y haz 3 llamadas con la misma pregunta a temperature 0.0 y otras 3 a 0.9. Compara las salidas: ¿se cumple lo que estudiamos en 02-04 sobre la necesidad de varias ejecuciones por caso al comparar?
Ejercicio 3
El clasificador de soporte de 02-02 (prompt_clasificacion() con EJEMPLOS_CLASIFICACION) todavía no ha tocado la API. Escribe clasificar_consulta(texto: str) -> str que lo envíe con max_tokens=20 y temperature 0.0, y devuelva una de las 6 categorías (facturacion, permisos, integraciones, api, incidencias, otros). Si la salida del modelo no coincide exactamente con ninguna categoría, devuelve "otros" (default conservador, como en 02-03).
Soluciones
Solución 1:
def diagnostico_llamada(respuesta) -> dict:
return {
"texto": "".join(
b.text for b in respuesta.content if b.type == "text"
),
"cortada": respuesta.stop_reason == "max_tokens",
"tokens_entrada": respuesta.usage.input_tokens,
"tokens_salida": respuesta.usage.output_tokens,
"modelo": respuesta.model,
}Con max_tokens=20 verás cortada: True y un texto interrumpido a media frase: exactamente el caso que preguntar_docubot() intercepta antes de parsear.
Solución 2:
def preguntar_docubot_bruto(pregunta, documentacion, temperature=0.2):
return client.messages.create(
model=MODELO,
max_tokens=1024,
temperature=temperature,
system=SYSTEM_DOCUBOT,
messages=[{"role": "user", "content": prompt_respuesta(pregunta, documentacion)}],
)
for t in (0.0, 0.9):
print(f"--- temperature={t} ---")
for i in range(3):
r = preguntar_docubot_bruto(pregunta, documentacion, temperature=t)
print(f"[{i+1}]", extraer_texto(r)[:120])A 0.0 las tres salidas serán casi idénticas (casi: no determinismo de 01-04); a 0.9 variarán visiblemente. Conclusión práctica: comparar prompts con una sola ejecución por caso es ruido, no medición — por eso 02-04 fijó 3 ejecuciones por caso.
Solución 3:
from prompts import prompt_clasificacion
CATEGORIAS = {"facturacion", "permisos", "integraciones", "api", "incidencias", "otros"}
def clasificar_consulta(texto: str) -> str:
respuesta = client.messages.create(
model=MODELO,
max_tokens=20,
temperature=0.0,
messages=[{"role": "user", "content": prompt_clasificacion(texto)}],
)
salida = extraer_texto(respuesta).strip().lower()
return salida if salida in CATEGORIAS else "otros"Dos decisiones heredadas del módulo 2: temperature 0.0 porque clasificar exige consistencia, y default "otros" porque ante la duda es mejor una categoría genérica que una equivocada con apariencia de segura.
Conclusión
DocuBot ya no es papel: tienes un entorno reproducible con el SDK instalado y la clave fuera del código, una primera llamada real que envía el SYSTEM_DOCUBOT de 02-01 y las plantillas de prompts.py, y el criterio para leer lo que vuelve — el contenido como lista de bloques, stop_reason como centinela (especialmente max_tokens antes de parsear) y usage como contador de la factura. El parser defensivo de 02-03 procesa ahora respuestas de verdad, reforzado con los modos de salida estructurada nativos cuando el proveedor los ofrezca, y la tabla de equivalencias te garantiza que nada de esto te ata a un proveedor concreto. Pero nuestra integración es ingenua en un aspecto que el usuario nota al instante y en otro que notará el peor día del año: la respuesta tarda varios segundos en aparecer de golpe, y si la API devuelve un error 429 o un 500, DocuBot simplemente explota. En la próxima lección resolvemos ambos: streaming para que las palabras fluyan según se generan, y una estrategia seria de errores y reintentos para que DocuBot se degrade con elegancia en vez de romperse.
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
