Arquitectura y setup

"Antes de escribir la primera línea, entendé qué estás construyendo. Un chatbot es 20% modelo y 80% lo que vos armás alrededor."

Qué vas a aprender en este capítulo

Cuando chateás con Claude o ChatGPT, se siente como hablar con algo que te recuerda, que tiene personalidad, que a veces busca información o ejecuta acciones. Nada de eso vive dentro del modelo. El modelo es una función: texto entra, texto sale. Todo lo demás — la memoria, la personalidad, las acciones, el conocimiento del negocio — lo construye el programador. O sea, vos.

En este capítulo vas a:

1.1 Qué es realmente un chatbot moderno

💡 Intuición

Pensá en la mejor empleada de La Esquina, Marta. Cuando un cliente le escribe por WhatsApp, Marta:

  1. Sabe quién es y cómo hablar — es cordial, conoce el negocio, no inventa precios (eso será el system prompt).
  2. Recuerda la conversación — si el cliente dijo "quiero 3 de queso" hace dos mensajes, no le pregunta de nuevo (el historial).
  3. Puede hacer cosas — consulta la pizarra de precios, anota el pedido en el cuaderno (las herramientas).
  4. Conoce las reglas del negocio — sabe la política de entregas y qué hacer con un reclamo (el conocimiento, que daremos vía RAG).

El LLM por sí solo no tiene ninguna de las cuatro. Es un motor de lenguaje potentísimo pero amnésico, sin identidad y sin manos. Un chatbot es ese motor más las cuatro piezas, que vos programás.

Este es el plano del sistema completo que vas a tener funcionando al final del libro. No te preocupés si todavía no entendés cada caja — para eso están los capítulos:

Mermaid

flowchart TB U["👤 Cliente
(navegador / consola)"] -->|mensaje| APP["Tu aplicación
(Python — Flask en cap. 5)"]

subgraph APP_INT["Lo que construís VOS"]
    SP["System prompt<br/>(personalidad + reglas)<br/>cap. 2"]
    H["Historial de mensajes<br/>(la 'memoria')<br/>cap. 2"]
    T["Herramientas<br/>consultar_menu, registrar_pedido...<br/>cap. 3"]
    K["Conocimiento (RAG)<br/>políticas + FAQ indexadas<br/>cap. 4"]
end

APP --> SP
APP --> H
APP --> K
APP -->|"request: system + historial<br/>+ tools + contexto"| API["API de Claude<br/>(claude-opus-4-8)"]
API -->|"texto o tool_use"| APP
APP <-->|ejecutar| T
APP -->|respuesta| U

La flecha clave es la del medio: en cada turno, tu aplicación arma un request con TODO (system prompt, historial completo, herramientas disponibles, contexto recuperado) y la API devuelve la siguiente respuesta. La API no guarda nada entre llamadas.

📐 Fundamento

La API es stateless

Stateless significa que el servidor no guarda estado entre requests. Cada llamada a la API de Claude es independiente y autocontenida: el modelo solo "sabe" lo que viene en ese request. Cuando la respuesta sale, el servidor olvida que exististe.

Consecuencias directas para tu código:

  1. La memoria es tu problema. Si querés que el bot recuerde que el cliente pidió 3 pupusas de queso, tenés que guardar ese intercambio en una lista y reenviarlo completo en la siguiente llamada. El "chat con memoria" es una ilusión que tu código fabrica (capítulo 2).
  2. Pagás por la memoria. Cada token del historial que reenviás cuenta como entrada y se factura. Conversaciones largas = requests cada vez más caros. Por eso existen el recorte de historial (cap. 2) y el prompt caching (cap. 5).
  3. Escalar es fácil. Como el servidor no guarda nada, dos requests tuyos pueden caer en máquinas distintas de Anthropic sin problema. Y tu propia app puede correr en varias instancias, siempre que el historial viva en un lugar compartido.

El formato del estado que reenviás es la lista messages: una alternancia de turnos user y assistant, donde el primero siempre es user:

messages = [
    {"role": "user", "content": "¿Tienen pupusas de ayote?"},
    {"role": "assistant", "content": "¡Sí! La de ayote cuesta 0.85..."},
    {"role": "user", "content": "Dame 3 entonces"},   # ← el turno nuevo
]

Sin las dos primeras entradas, el modelo no tiene idea de qué son "3". Esa lista es la conversación.

📜 Historia

Los chatbots de los 2000-2010 (los de "presione 1 para saldo") eran árboles de decisión escritos a mano: miles de reglas si el usuario dice X, respondé Y. Frágiles, carísimos de mantener, e inútiles ante cualquier frase no prevista. La revolución de 2022-2023 (ChatGPT, Claude) no fue inventar el chatbot — fue reemplazar el árbol de reglas por un LLM que entiende lenguaje. Lo curioso: la arquitectura alrededor (estado, acciones, conocimiento) sigue siendo trabajo de ingeniería clásica. Por eso este libro existe.

1.2 El caso de negocio: el bot de La Esquina

Doña Carmen, dueña de La Esquina, tiene tres sucursales en San Miguel y un problema: entre las 11:30 y la 1:00 el teléfono no para, y las muchachas no dan abasto anotando pedidos mientras atienden mesas. Te contrató para construir un bot que atienda el chat del sitio web. Estos son los requisitos, levantados en una reunión con ella:

# Requisito ¿Cómo lo resolvemos? Capítulo
R1 Responder preguntas sobre el menú y precios sin inventar Herramienta consultar_menu sobre datos reales 3
R2 Calcular el total de un pedido con precios exactos Herramienta calcular_pedido (el LLM no hace aritmética confiable) 3
R3 Registrar pedidos con nombre del cliente y sucursal Herramienta registrar_pedido 3
R4 Saber horarios por sucursal (y si está abierta ahora) Herramienta verificar_horario 3
R5 Conocer políticas: entregas, pagos, reclamos, encargos grandes RAG sobre el documento de políticas 4
R6 Tono cordial salvadoreño, nunca grosero, no salirse del tema System prompt 2
R7 Recordar lo que el cliente dijo en la conversación Historial 2
R8 Funcionar en el navegador, rápido y fluido Flask + streaming + SSE 5
R9 Costar menos de $25 al mes en API Control de costos, caching, elección de modelo 5

Fijate en algo: casi ningún requisito se resuelve "con el modelo". El modelo es el mismo en todos. Lo que cambia es lo que construís alrededor. Guardá esta tabla — vamos a volver a ella al final de cada capítulo para marcar qué ya cumplimos.

1.3 Los modelos y sus precios

📐 Fundamento

Anthropic ofrece varios modelos. Difieren en capacidad, ventana de contexto (cuánto texto aceptan por request) y precio. Los precios se cobran por millón de tokens (MTok), separando entrada (lo que mandás) y salida (lo que el modelo genera):

Modelo ID exacto Contexto Entrada / MTok Salida / MTok
Claude Fable 5 claude-fable-5 1M $10 $50
Claude Opus 4.8 claude-opus-4-8 1M $5 $25
Claude Sonnet 4.6 claude-sonnet-4-6 1M $3 $15
Claude Haiku 4.5 claude-haiku-4-5 200K $1 $5

(Precios verificados en junio 2026 — confirmalos en la documentación oficial antes de presupuestar.)

En este libro usamos Claude Opus 4.8 (claude-opus-4-8), el modelo recomendado por defecto: el mejor balance entre capacidad y precio para un bot que tiene que entender pedidos enredados, usar herramientas con criterio y no equivocarse con el dinero de los clientes. En el capítulo 5 discutimos cuándo conviene bajar a Sonnet o Haiku.

¿Qué es un token? La unidad en la que el modelo procesa texto: fragmentos de palabras. En español, una palabra ≈ 1.3 tokens. "Quiero tres pupusas de queso con curtido" ≈ 10 tokens. No adivines conteos: la API tiene un endpoint exacto, client.messages.count_tokens(...), que usaremos más abajo. (Nunca uses tiktoken para Claude — es el tokenizador de OpenAI y cuenta mal.)

💡 Intuición

Para dimensionar: un mensaje típico del bot de La Esquina son ~500 tokens de entrada (system prompt + historial corto + pregunta) y ~150 de salida. Con Opus 4.8:

  • Entrada: 500 × $5 / 1,000,000 = $0.0025
  • Salida: 150 × $25 / 1,000,000 = $0.00375
  • Total: ~$0.006 por mensaje. Más de 150 mensajes por dólar.

El costo no te va a doler por mensaje — te va a doler por acumulación de historial y por volumen. De eso nos ocupamos en los capítulos 2 y 5.

1.4 Setup del entorno

Vamos a dejar todo listo. Necesitás Python 3.10 o superior (python3 --version para verificar).

Paso 1: carpeta y entorno virtual

mkdir bot-la-esquina
cd bot-la-esquina
python3 -m venv .venv
source .venv/bin/activate        # En Windows: .venv\Scripts\activate

El entorno virtual aísla las dependencias del proyecto. Si el prompt de tu terminal ahora muestra (.venv), vas bien.

Paso 2: instalar dependencias

pip install anthropic python-dotenv

Paso 3: obtener y guardar la API key

  1. Creá una cuenta en la consola de Anthropic (platform.claude.com).
  2. Cargá un mínimo de crédito ($5 alcanza de sobra para todo este libro).
  3. Generá una API key. Es un string largo que empieza con sk-ant-.... Tratala como una contraseña: quien la tenga puede gastar tu crédito.

Guardala en un archivo llamado .env en la raíz del proyecto:

# Archivo: .env
ANTHROPIC_API_KEY=sk-ant-tu-key-aqui

Y creá inmediatamente un .gitignore para que jamás llegue a un repositorio:

# Archivo: .gitignore
.env
.venv/
__pycache__/
*.pyc
conversaciones/
pedidos.json

⚠️ Trampa común

La API key en el código. El error más caro que podés cometer en este libro:

client = anthropic.Anthropic(api_key="sk-ant-abc123...")   # ❌ NUNCA

Ese archivo termina en GitHub, un bot la encuentra en minutos (hay scrapers escaneando repos públicos 24/7), y tu crédito se esfuma esa misma noche. Pasa constantemente, también a gente con experiencia.

La regla es una sola: la key vive en una variable de entorno, jamás en el código ni en el repositorio. El SDK lee ANTHROPIC_API_KEY del entorno automáticamente, así que anthropic.Anthropic() sin argumentos es todo lo que necesitás. Si una key se te filtra, revocala de inmediato en la consola.

1.5 Hola, Claude — tu primera llamada, línea por línea

Creá 01_hola_claude.py:

"""Primera llamada a la API de Claude — el 'hola mundo' del libro."""
from dotenv import load_dotenv
import anthropic

load_dotenv()  # carga ANTHROPIC_API_KEY desde .env al entorno

client = anthropic.Anthropic()  # lee la key del entorno; NUNCA la pongas acá

respuesta = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system="Sos el asistente de La Esquina, una pupusería de San Miguel, El Salvador.",
    messages=[
        {"role": "user", "content": "¡Hola! ¿Qué venden ustedes?"},
    ],
)

print(respuesta.content[0].text)

Corrélo:

python 01_hola_claude.py

Vas a recibir algo como: "¡Hola! Bienvenido a La Esquina 😊 Somos una pupusería..." — el modelo ya adoptó el rol, solo con esa línea de system prompt.

📐 Fundamento

Desarmemos el programa pieza por pieza:

  • load_dotenv() — lee el archivo .env y vuelca sus variables al entorno del proceso. Sin esto, el SDK no encuentra la key (salvo que la hayas exportado en la terminal).

  • anthropic.Anthropic() — construye el cliente. Sin argumentos, busca ANTHROPIC_API_KEY en el entorno. El cliente maneja por vos la conexión HTTPS, los reintentos automáticos ante errores temporales y la serialización JSON.

  • client.messages.create(...) — la llamada al endpoint Messages, el corazón de toda la API. Sus parámetros:

    • model — el ID exacto del modelo. Sin sufijos de fecha, tal cual la tabla de la sección 1.3.
    • max_tokens — tope de tokens de salida. Es obligatorio y es tu freno de mano económico: el modelo nunca generará más que esto. Si la respuesta lo alcanza, queda cortada (lo detectás con stop_reason, ya vemos).
    • system — el system prompt: instrucciones de rol que el modelo trata con más autoridad que los mensajes del usuario. Acá vive la personalidad del bot (capítulo 2 entero sobre esto).
    • messages — la conversación: lista de turnos {"role": ..., "content": ...}. Roles válidos: user y assistant, alternados, empezando por user. En este primer programa hay un solo turno.
  • respuesta — un objeto Message con varios campos que importan:

    • respuesta.content — lista de bloques de contenido. Para una respuesta de texto simple es un solo bloque con .type == "text" y .text adentro. ¿Por qué una lista y no un string? Porque en el capítulo 3 el modelo va a devolver bloques tool_use mezclados con texto — el formato ya está preparado para eso.
    • respuesta.stop_reason — por qué terminó de generar: "end_turn" (terminó naturalmente), "max_tokens" (lo cortó tu tope — la respuesta está incompleta), "tool_use" (quiere ejecutar una herramienta, cap. 3) o "refusal" (declinó responder por seguridad).
    • respuesta.usage — el medidor de la luz: input_tokens y output_tokens consumidos en esta llamada. Lo usamos ya mismo para calcular costos.

⚠️ Trampa común

Parámetros de muestreo que ya no existen. Si venís de tutoriales viejos (o de la API de OpenAI), vas a querer pasar temperature, top_p o top_k para controlar la "creatividad". En Claude Opus 4.8 esos parámetros fueron eliminados y devuelven error 400. El comportamiento se guía con el prompt, no con perillas de muestreo. Tampoco existe el prefill (pre-llenar el turno del assistant para forzar el formato): si necesitás controlar el formato de salida, pedilo en el system prompt. Y si algún día necesitás razonamiento extendido para problemas difíciles, el parámetro es thinking={"type": "adaptive"} — no lo vamos a necesitar para el bot.

1.6 ¿Cuánto costó ese mensaje?

Un bot en producción sin medición de costos es un cheque en blanco. Vamos a construir desde el día uno el hábito de medir. Creá 02_costo.py:

"""Calcular el costo exacto de cada llamada usando response.usage."""
from dotenv import load_dotenv
import anthropic

load_dotenv()
client = anthropic.Anthropic()

# Precios de claude-opus-4-8 por MILLON de tokens (junio 2026)
PRECIO_ENTRADA = 5.00   # USD por 1M tokens de entrada
PRECIO_SALIDA = 25.00   # USD por 1M tokens de salida


def costo_usd(usage) -> float:
    """Convierte el usage de una respuesta en dolares."""
    entrada = usage.input_tokens * PRECIO_ENTRADA / 1_000_000
    salida = usage.output_tokens * PRECIO_SALIDA / 1_000_000
    return entrada + salida


respuesta = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    system="Sos el asistente de La Esquina, una pupusería de San Miguel.",
    messages=[
        {"role": "user", "content": "¿Qué es una pupusa? Explicámelo en 2 oraciones."},
    ],
)

print(respuesta.content[0].text)
print("-" * 50)
print(f"stop_reason:    {respuesta.stop_reason}")
print(f"Tokens entrada: {respuesta.usage.input_tokens}")
print(f"Tokens salida:  {respuesta.usage.output_tokens}")
print(f"Costo:          {costo_usd(respuesta.usage):.6f} USD")

Salida típica:

Una pupusa es una tortilla gruesa de maíz rellena de queso, chicharrón o
frijoles, asada en comal. Es el platillo nacional de El Salvador y se sirve
con curtido y salsa de tomate.
--------------------------------------------------
stop_reason:    end_turn
Tokens entrada: 41
Tokens salida:  68
Costo:          0.001905 USD

Menos de un quinto de centavo. Pero ese número va a crecer cuando el historial crezca — y por eso lo medimos siempre.

🛠️ En la práctica

Contar tokens ANTES de mandar. A veces querés saber el tamaño de un prompt sin pagar por una respuesta (por ejemplo, para decidir si recortar el historial). El endpoint count_tokens cuenta gratis:

conteo = client.messages.count_tokens(
    model="claude-opus-4-8",
    system="Sos el asistente de La Esquina, una pupusería de San Miguel.",
    messages=[{"role": "user", "content": "¿Qué es una pupusa?"}],
)
print(conteo.input_tokens)   # p. ej. 38

Dos reglas de oro del equipo que mantiene bots en producción:

  1. Logueá usage en cada llamada desde el primer día. Cuando el costo mensual te sorprenda (te va a sorprender), vas a tener los datos para saber por qué.
  2. El conteo es por modelo. El mismo texto puede tokenizar distinto en modelos distintos. Contá siempre con el modelo que vas a usar.

1.7 Manejo de errores desde el día uno

La red falla, los servidores se saturan, los límites de tasa existen. El SDK trae excepciones tipadas y reintenta automáticamente los errores temporales (429 y 5xx) con backoff exponencial — pero tu código igual debe estar preparado para cuando los reintentos se agotan. Creá 03_errores.py:

"""Llamada con manejo de errores tipado — la plantilla que usaremos siempre."""
from dotenv import load_dotenv
import anthropic

load_dotenv()
client = anthropic.Anthropic()


def preguntar(texto: str) -> str:
    """Hace una pregunta al modelo y devuelve el texto, manejando errores."""
    try:
        respuesta = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=1024,
            system="Sos el asistente de La Esquina, pupusería de San Miguel.",
            messages=[{"role": "user", "content": texto}],
        )
        return respuesta.content[0].text
    except anthropic.RateLimitError:
        # 429: demasiados requests. El SDK ya reintento; si llegamos aca, esperar.
        return "⏳ Estamos recibiendo muchos mensajes. Probá de nuevo en un minuto."
    except anthropic.APIConnectionError:
        # No se pudo conectar: red caida, DNS, timeout.
        return "🔌 No hay conexión con el servidor. Revisá tu internet."
    except anthropic.APIStatusError as e:
        # Cualquier otro error HTTP. 529 = API sobrecargada (temporal).
        if e.status_code == 529:
            return "🔥 El servicio está sobrecargado. Reintentá en unos segundos."
        return f"⚠️ Error del API ({e.status_code}): {e.message}"


if __name__ == "__main__":
    print(preguntar("¿A qué hora abren?"))

El orden de los except importa: de lo más específico (RateLimitError) a lo más general (APIStatusError). Esta plantilla — try, excepciones tipadas, mensajes amables al usuario — la vamos a arrastrar a todos los capítulos.

Resumen visual

Concepto Qué es Quién lo provee
LLM Motor de lenguaje: texto entra, texto sale Anthropic (API)
System prompt Personalidad y reglas del bot Vos (cap. 2)
Historial (messages) La "memoria": lista user/assistant que reenviás completa Vos (cap. 2)
Herramientas Acciones que el bot puede pedir ejecutar Vos (cap. 3)
Conocimiento (RAG) Documentos del negocio inyectados al prompt Vos (cap. 4)
max_tokens Tope de salida — freno económico obligatorio Vos, en cada llamada
usage Tokens consumidos → costo exacto La respuesta del API
stop_reason Por qué terminó: end_turn, max_tokens, tool_use, refusal La respuesta del API

Estado del proyecto — requisitos de La Esquina cumplidos: ninguno todavía 😅, pero ya tenés el entorno seguro, sabés llamar a la API, leer la respuesta y medir el costo. La base de todo lo que viene.

Ejercicios

✏️ Ejercicio 1 — Tu primer diálogo a mano

Modificá 01_hola_claude.py para que messages contenga tres turnos escritos a mano: un user que pregunta si hay pupusas de chicharrón, un assistant que responde que sí, y un user final que dice "dame 2 entonces". Corrélo. ¿La respuesta demuestra que el modelo "entendió" los turnos anteriores? ¿Qué pasa si borrás los dos primeros turnos y dejás solo "dame 2 entonces"?

✅ Solución
messages=[
    {"role": "user", "content": "¿Tienen pupusas de chicharrón?"},
    {"role": "assistant", "content": "¡Claro! Las de chicharrón son de las más pedidas."},
    {"role": "user", "content": "Dame 2 entonces."},
]

Con los tres turnos, el modelo responde confirmando 2 pupusas de chicharrón: usó el contexto. Si dejás solo "dame 2 entonces", el modelo no sabe 2 de qué — típicamente pregunta "¿2 de qué te gustaría?". Acabás de demostrar empíricamente qué significa stateless: el modelo solo sabe lo que va en messages. La "memoria" del capítulo 2 será exactamente esto, automatizado.

✏️ Ejercicio 2 — El freno de mano

Pedile al modelo "Contame la historia de las pupusas con todo detalle" con max_tokens=50. Imprimí stop_reason y la respuesta. ¿Qué observás? ¿Por qué max_tokens es un parámetro de seguridad económica y no solo un detalle técnico?

✅ Solución

La respuesta queda cortada a media frase y stop_reason es "max_tokens" en vez de "end_turn". El modelo habría generado quizás 800 tokens; tu tope lo frenó en 50.

Es seguridad económica porque la salida es lo caro ($25/MTok vs $5/MTok en Opus 4.8 — 5× más). Sin tope, un prompt malicioso o un bug ("repetí esto 1000 veces") puede generar miles de tokens por request. En producción, siempre poné el max_tokens más bajo que tu caso de uso tolere, y revisá stop_reason para detectar respuestas truncadas.

✏️ Ejercicio 3 — Comparador de precios

Escribí comparar_costos.py: una función que reciba input_tokens y output_tokens y devuelva el costo en los cuatro modelos de la tabla 1.3. Usala para estimar el costo mensual del bot de La Esquina con: 2,000 conversaciones/mes, 8 mensajes por conversación, ~600 tokens de entrada y ~150 de salida por mensaje. ¿Cuál cumple el requisito R9 (< $25/mes)?

✅ Solución
PRECIOS = {  # (entrada, salida) USD por MTok
    "claude-fable-5": (10, 50),
    "claude-opus-4-8": (5, 25),
    "claude-sonnet-4-6": (3, 15),
    "claude-haiku-4-5": (1, 5),
}

def costo(modelo, t_in, t_out):
    e, s = PRECIOS[modelo]
    return t_in * e / 1e6 + t_out * s / 1e6

msgs = 2000 * 8                      # 16,000 mensajes/mes
for m in PRECIOS:
    mensual = msgs * costo(m, 600, 150)
    print(f"{m:20s} ${mensual:8.2f}/mes")

Resultados aproximados: Fable 5 → $216, Opus 4.8 → $108, Sonnet 4.6 → $64.80, Haiku 4.5 → $21.60. Solo Haiku cumple R9 a este volumen y con este tamaño de prompt. ¿Significa que hay que usar Haiku? No necesariamente: en el capítulo 5 vas a ver que el prompt caching reduce el costo de entrada ~10× en la parte cacheada, lo que cambia la cuenta por completo. Moraleja: el costo se diseña, no solo se elige.

✏️ Ejercicio 4 — Detective de errores

Provocá a propósito dos errores y anotá qué excepción lanza el SDK: (a) usá un modelo inexistente como "claude-opus-99"; (b) renombrá temporalmente tu .env y corré el programa. ¿Qué tipo de excepción aparece en cada caso? ¿Cuál de los dos reintentaría el SDK automáticamente?

✅ Solución

(a) anthropic.NotFoundError (HTTP 404) — el modelo no existe. (b) anthropic.AuthenticationError (HTTP 401) — no hay key.

Ninguno de los dos se reintenta: son errores permanentes (4xx, excepto 429); reintentar no los arregla. El SDK solo reintenta automáticamente 429 (rate limit) y errores 5xx/529 (problemas temporales del servidor). Esta distinción — errores permanentes vs temporales — es central para el manejo robusto del capítulo 5.

Para profundizar