Skip to content
This page has been auto-translated and may contain errors.View in English

Llamar modelos desde código

docs.scrimba.com

El capítulo anterior construyó una lista de mensajes. Este capítulo envía esa lista a un modelo real desde Python y obtiene una respuesta. Ese viaje de ida y vuelta es el bucle completo más pequeño al construir con estos modelos, y casi cada función que escribirás se sitúa encima de él.

Escribes una indicación, tu código la envía a algún lugar, y vuelve texto. Parece que el modelo vive en tu programa. No es así, y ver dónde corre realmente explica el costo, la velocidad, y las particularidades que encontrarás.

Entonces imagina qué está sucediendo físicamente. El modelo es un enorme conjunto de parámetros sentados en los servidores del proveedor, en hardware que nunca verás. Cuando lo llamas, tu código envía una solicitud web ordinaria por internet llevando tus mensajes.

El proveedor ejecuta el bucle de predicción en su máquina y envía los tokens generados de vuelta a ti. Una llamada a modelo es alquilar algunos momentos en la computadora de otra persona. Ese encuadre tiene sentido para mucho: la latencia es el modelo generando tokens uno a uno en su extremo, el costo es que te cobren por ese cómputo, y todo es una llamada de red, con todo lo que eso implica.

Los ejemplos usan la librería oficial de OpenAI. Otros proveedores difieren en detalles, pero la forma es casi la misma en todos lados: envías una lista de mensajes, obtienes un mensaje de vuelta. Esa similitud es tu seguro contra el bloqueo de proveedor, ya que el mismo código puede apuntar a un modelo abierto que alojas o alquilas en otro lugar, a menudo con solo un cambio de URL base (Modelos abiertos y cerrados cubre esa opción).

Llamas a una función de librería, pasan unos segundos, y vuelve texto. La librería lo hace parecer local, pero nada del modelo está en tu máquina.

El movimiento a entender es qué envuelve esa función. Tu código empaqueta la solicitud como JSON, abre una conexión HTTPS al proveedor, y espera mientras su hardware ejecuta el bucle de predicción y envía tokens en streaming. El SDK (la librería de software del proveedor, como openai) es una capa de conveniencia delgada sobre una solicitud POST.

Saber que el formato de cable debajo es JSON puro paga dividendos en la práctica. Cuando una llamada se comporta de manera extraña, puedes inspeccionar la solicitud y respuesta exactas. Cuando quieres una función que el SDK aún no ha expuesto, puedes enviar el campo manualmente.

Todo esto siendo una llamada de red es lo que impulsa el costo, latencia, y manejo de errores para el resto de este capítulo. Los ejemplos usan OpenAI, pero la forma de solicitud, una lista de mensajes adentro, un mensaje afuera, es casi idéntica entre proveedores.

Una llamada a modelo parece una llamada a función y se comporta como un procedimiento remoto sobre infraestructura inestable. Ese desajuste es de donde vienen los problemas de producción, así que vale la pena mantener la imagen real desde el inicio.

Debajo del SDK, cada llamada es un POST HTTPS: un cuerpo JSON sube, el proveedor ejecuta inferencia en su hardware, y tokens vuelven, opcionalmente en streaming sobre la conexión abierta. El SDK (la librería cliente del proveedor) te compra autenticación, reintentos, tipado, y parsing de streaming, y te cuesta una capa de abstracción sobre cambios que no controlas. Trátalo como un envoltorio que puedes ver a través, no una caja negra.

Dos consecuencias establecen el resto del capítulo. Primero, esto es una llamada de red facturada por token, así que costo, latencia, idempotencia, y manejo de fallos son preocupaciones de diseño, no afterthoughts que pegas después. Segundo, el SDK y sus nombres de campo son la superficie más volátil en la que dependes, así que el movimiento duradero es aislar las partes específicas del proveedor detrás de tu propio límite. Los ejemplos usan OpenAI; la forma de solicitud es cercana a universal, pero el envoltorio alrededor es exactamente lo que no quieres propagándose por tu código.

La solicitud

Instala la librería con pip install openai, crea un cliente, y llámalo. El cliente lee tu clave de API desde una variable de entorno, así que la clave nunca aparece en tu código.

python
from openai import OpenAI

client = OpenAI()  # lee OPENAI_API_KEY del entorno

MODEL = "gpt-4o-mini"  # la línea que cambias para intercambiar modelos

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "Eres un asistente conciso. Responde en una sola oración."},
        {"role": "user", "content": "¿Por qué es azul el cielo?"},
    ],
)

Dos piezas son requeridas: model, el nombre del modelo que quieres ejecutar, y messages, la lista de mensajes etiquetados por rol del capítulo anterior. Todo lo demás tiene un valor por defecto.

Nota que el nombre del modelo vive en una sola constante. En un campo donde un modelo mejor y más barato llega cada pocos meses, eso es deliberado. Mantén el nombre del modelo en un solo lugar así que intercambiar modelos es un cambio de una línea. Esta es la regla de cambio en la práctica: mantén los detalles volátiles específicos donde puedas encontrarlos, no dispersos por tu código.

JunoLa solicitud Una llamada necesita solo dos cosas: model y messages. El nombre del modelo pertenece a una sola constante, porque un modelo más barato o mejor aparece lo suficientemente frecuente que codificarlo en todos lados convierte un intercambio en una búsqueda. Me tomó un tiempo aprender eso de la manera difícil, después de que un modelo fue renombrado y lo encontré pegado en nueve archivos.

Dos campos son requeridos, model y messages, y todo lo demás tiene un valor por defecto. El nombre del modelo en una constante no es un punto de estilo: es el detalle volátil que quieres en un solo lugar, porque un modelo más barato o mejor llega lo suficientemente frecuente que codificarlo en todos lados convierte un intercambio en una búsqueda.

Ahora mira un nivel abajo. Esa llamada create se serializa a un cuerpo JSON y POSTea. Expresado, el formato de cable es la solicitud literal que el proveedor recibe:

python
import httpx, os

# la misma solicitud que el SDK envía, a mano
resp = httpx.post(
    "https://api.openai.com/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
    json={
        "model": "gpt-4o-mini",
        "messages": [
            {"role": "system", "content": "Eres un asistente conciso."},
            {"role": "user", "content": "¿Por qué es azul el cielo?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # la misma forma que el SDK parsea en objetos

Recurrirás al SDK en código real: maneja autenticación, reintentos, y tipado para ti. Pero ver que el cuerpo es un dict puro hace el resto concreto. La URL y nombres de campo aquí son de OpenAI, y el endpoint exacto y claves varían por proveedor, pero el movimiento, JSON adentro, JSON afuera, es el mismo en todos lados.

JunoLa solicitud Los campos requeridos son model y messages, nada más. La llamada create del SDK es un POST JSON bajo el capó, así que cuando algo se ve mal puedes bajar al cable e inspeccionar el cuerpo literal. Mantén el nombre del modelo en una constante: es la parte que cambia, y quieres que esté en un solo lugar.

Los campos requeridos son model y messages. La pregunta interesante no es qué son sino dónde viven en tu código, porque model es el valor más volátil en la solicitud y el SDK es la dependencia más volátil alrededor.

La llamada create se serializa a un POST JSON. Eso importa porque significa que la solicitud es datos que posees, no magia que el SDK ejecuta. Puedes construir el cuerpo, registrarlo, hacer diff entre ejecuciones, o enviarlo a mano cuando el SDK se retrasa con una nueva función del proveedor. Saber el formato de cable es lo que te deja depurar una llamada que la abstracción te está ocultando.

El hábito de producción es encaminar cada llamada a través de una única función estrecha de la tuya:

python
def complete(messages, *, model=DEFAULT_MODEL, **params):
    # un punto de estrangulamiento: intercambia proveedores, agrega logging, cambia reintentos aquí
    return client.chat.completions.create(model=model, messages=messages, **params)

Ese punto de estrangulamiento es donde un intercambio de modelo se convierte en un cambio de una línea y donde el cambio específico del proveedor se mantiene contenido en lugar de filtrarse en cada llamador. La ruta del endpoint y nombres de campos mostrados son de OpenAI; otro proveedor renombra la ruta y algunas claves, así que el valor del límite es que el renombramiento toca un archivo, no todo el código base. Fija también la versión del modelo: que "latest" se mueva silenciosamente debajo de ti es el mismo riesgo de regresión cubierto en Cómo funcionan los LLMs, ahora en tu cuerpo de solicitud.

JunoLa solicitud Solo model y messages son requeridos, y ambos merecen vivir detrás de un límite que controlas. Encamina cada llamada a través de una función: es donde un intercambio de modelo es una línea, donde va el logging, y donde el cambio del proveedor se detiene antes de llegar a tus llamadores. Fija la versión del modelo también, porque que "latest" se mueva debajo de ti es una regresión que no desplegaste y no puedes ver.

Leyendo la respuesta

La respuesta vuelve envuelta en alguna estructura. El texto que quieres está un camino dentro, pero el resto de la estructura vale la pena conocer, porque te dice qué sucedió.

python
choice = response.choices[0]

print(choice.message.content)   # el texto de respuesta
print(choice.finish_reason)     # "stop"  -> el modelo terminó por su propia cuenta
print(response.usage)           # Usage(prompt_tokens=24, completion_tokens=18, total_tokens=42)

Tres cosas allí importan. message.content es el texto, sentado dentro de choices[0] porque la API puede devolver más de una opción, aunque casi siempre pides una y lees la primera.

finish_reason te dice por qué se detuvo el modelo. "stop" significa que terminó su pensamiento, mientras "length" significa que golpeó tu límite de tokens y fue cortado a mitad de respuesta. Verificar ese campo es cómo detectas una respuesta truncada en código en lugar de a simple vista.

Y usage reporta los tokens que la llamada gastó, dividida en el indicativo que enviaste y la conclusión que escribió. Ese objeto usage es tu factura, desglosada. Cada llamada te dice exactamente qué costó en tokens, que es el número a vigilar mientras construyes.

JunoLeyendo la respuesta El texto de respuesta está en response.choices[0].message.content, pero dos vecinos importan también. finish_reason te dice por qué se detuvo: "stop" significa hecho, "length" significa que golpeó tu límite de tokens y fue cortado. Y response.usage es tu factura en tokens, indicativo y conclusión divididos, así que nunca tienes que adivinar qué costó una llamada.

Tres campos llevan la señal. choices[0].message.content es el texto. finish_reason es por qué se detuvo el modelo: "stop" es un fin limpio, "length" significa que golpeó tu límite de tokens y fue truncado, así que verificarlo es cómo atrapas una respuesta cortada en código en lugar de enviar media respuesta. Y usage es el conteo de tokens, indicativo y conclusión separados.

El movimiento que paga dividendos aquí es convertir usage en dinero real. El objeto usage es datos de facturación por llamada, así que puedes calcular costo al instante:

python
# los precios del proveedor son por token; verifica las tarifas actuales, cambian
PROMPT_RATE = 0.15 / 1_000_000      # dólares por token de indicativo
COMPLETION_RATE = 0.60 / 1_000_000  # dólares por token de conclusión

u = response.usage
cost = u.prompt_tokens * PROMPT_RATE + u.completion_tokens * COMPLETION_RATE
print(f"{u.total_tokens} tokens, ${cost:.6f}")

Indicativo y conclusión usualmente se facturan diferente, y los tokens de conclusión frecuentemente cuestan más, así que el desglose importa cuando optimizas. Registra el costo por llamada y puedes ver qué características son costosas antes de que la factura mensual lo diga. Los nombres de campo aquí son de OpenAI; otros proveedores exponen los mismos conteos bajo claves ligeramente diferentes, así que lee la forma, no el atributo exacto.

JunoLeyendo la respuesta Lee tres campos: content para el texto, finish_reason para atrapar un truncamiento "length" en código, y usage para tokens. Convierte usage en dólares al instante, indicativo y conclusión facturados separadamente, y regístralo por llamada. Verás la característica costosa mucho antes de que la factura lo haga.

La respuesta lleva tres campos en los que actúas: content, finish_reason, y usage. El que los constructores saltan y lamentan es finish_reason. Un valor "length" significa que el modelo golpeó tu max_tokens y la salida está truncada, frecuentemente como JSON malformado o una oración medio terminada que tu parser descendente luego choca. Trata cualquier valor que no sea "stop" como una llamada fallida y manéjalo, no asumas que content está completo.

El objeto usage es tu telemetría de economía unitaria, y merece ser cableada en observabilidad, no impresa y olvidada. Los tokens de indicativo y conclusión se facturan separadamente, conclusión usualmente más alta, así que emite ambos por llamada etiquetados por característica:

python
u = response.usage
log.info("llm_call", feature="invoice_extract", model=MODEL,
         prompt_tokens=u.prompt_tokens, completion_tokens=u.completion_tokens,
         finish_reason=response.choices[0].finish_reason)

Con eso en su lugar, el costo por característica y por usuario se convierte en una consulta, no una adivinanza, y un indicativo desbocado que silenciosamente se triplicó en tamaño se muestra como una métrica en lugar de un cargo sorpresa. Observa la asimetría: los tokens de entrada son baratos y paralelos al procesamiento, los tokens de salida son la parte cara, serial cubierta en Cómo funcionan los LLMs, así que una característica que emite respuestas largas cuesta y se retrasa más de lo que su tamaño de indicativo sugiere. Los nombres de atributo son de OpenAI y se desplazan por proveedor, así que registra los conteos detrás de tu propio límite en lugar de esparcir response.usage.prompt_tokens por toda la aplicación.

JunoLeyendo la respuesta Trata cualquier finish_reason que no sea "stop" como una llamada fallida: un truncamiento "length" entrega a tu parser JSON roto y perseguirás el bug descendente. Cables usage en tus logs etiquetados por característica, indicativo y conclusión divididos, así que el costo por característica es una consulta y un indicativo inflado es una métrica, no una sorpresa de facturación. Los tokens de salida son la parte cara serial, así que una característica charladora cuesta más de lo que sugiere su indicativo.

Los parámetros que valen la pena

Más allá de model y messages, dos configuraciones opcionales surgen constantemente.

temperature es el dial de aleatoriedad de Cómo funcionan los LLMs, hecho concreto. Valores bajos (cerca de 0) mantienen el modelo cerca de sus mejores selecciones: enfocado y repetible, lo que quieres para extracción y clasificación. Valores más altos (alrededor de 0.7 a 1) lo dejan alcanzar tokens menos probables: más variado y creativo, y más propenso a desviarse. Estás afinando qué tan atrevidamente el modelo muestrea, nada más.

max_tokens limita cuántos tokens la respuesta puede ejecutar. Te protege de una respuesta desbocada y una factura desbocada. Fija max_tokens demasiado bajo y el modelo se corta, que es exactamente la razón "length" del fin de la sección anterior, así que deja margen real.

python
response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    temperature=0.2,  # enfocado y consistente
    max_tokens=300,   # limita la longitud de respuesta
)
JunoLos parámetros que valen la penatemperature afina qué tan atrevidamente el modelo muestrea: bajo para respuestas enfocadas y repetibles, más alto para variadas y creativas. max_tokens limita la longitud de respuesta para proteger tu factura, pero fijado demasiado bajo dispara el truncamiento "length", así que deja margen. Para extracción y clasificación, alcanza siempre una temperatura baja.

Dos parámetros llevan la mayoría del peso: temperature y max_tokens. La mecánica está en Cómo funcionan los LLMs; el movimiento aquí es emparejar el valor a la tarea en lugar de confiar en cualquier default que se envíe.

Piensa en temperature como una configuración por tarea, no una global. Elige de qué la tarea necesita:

python
TEMPERATURE_BY_TASK = {
    "extraction": 0.0,      # una respuesta correcta, la quieres cada vez
    "classification": 0.0,  # elige una etiqueta, no se quiere creatividad
    "summary": 0.3,         # mayormente fiel, un poco de libertad de phrasing
    "brainstorm": 0.9,      # la variedad es el punto completo
}

Para cualquier cosa que analices después, datos estructurados, una categoría, un número sacado de un documento, ve a 0 y deja de razonar sobre aleatoriedad. Para redacción e ideación donde la variedad es el valor, ve alto.

max_tokens es un techo, no un objetivo: limita gasto y respuestas desbocadas, pero fijado por debajo de qué la tarea necesita y obtienes el truncamiento "length" de la sección anterior, que es peor que una factura ligeramente mayor. Tamaña al respuesta legítima más larga más margen. Estos dos nombres de campo son estables entre proveedores; algunos renombran max_tokens, así que confirma la clave cuando cambias.

JunoLos parámetros que valen la pena Fija temperature por tarea, no una vez globalmente: 0 para cualquier cosa que analices después, alto para redacción donde la variedad es el punto. max_tokens es un techo de seguridad, así que tamaña al respuesta real más larga más margen; fijado demasiado apretado y cambias un pequeño ahorro de factura por una respuesta truncada. Deja de confiar en el default que el SDK envía.

temperature y max_tokens parecen diales y se comportan como decisiones de política con costo y confiabilidad adjuntos. La profundidad aquí está en cómo interactúan con todo lo demás que despliegas.

Trata temperature como parte de tu contrato de confiabilidad. Para cualquier salida que analices o almacenes, corre a 0 así que la varianza impulsada por muestreo de Cómo funcionan los LLMs es tan pequeña como el proveedor permite. No es perfectamente determinístico incluso a 0, así que no construyas caché de coincidencia exacta o pruebas byte-idénticas encima de ello, pero es el piso que quieres para trabajo estructurado. Reserva temperaturas más altas para superficies donde la variedad es el producto, y trata esa varianza como algo que tus evals miden a través de ejecuciones, no una sola pasada que ojeaste.

max_tokens es una palanca de latencia y costo, no solo una red de seguridad. Los tokens de salida son la parte serial, cara de la generación, así que limitar la longitud limita tanto tu peor factura de caso como tu peor espera.

La trampa es tamaña por ojo: demasiado apretado trunca respuestas válidas en el fallo "length" que entrega a tu parser salida rota, demasiado suelto deja un bucle degenerado ejecutar costo y latencia antes de que algo lo detenga. Tamaña a la respuesta legítima más larga más margen, por tarea, y registra cuando golpeas el límite así que una característica que mantiene truncando se vuelve visible. Ambos nombres de parámetro son de OpenAI y un par de proveedores los renombran, así que fíjalos detrás de la función límite en lugar de en cada sitio de llamada.

JunoLos parámetros que valen la penatemperature es un contrato de confiabilidad: 0 para cualquier cosa que analices, más alto solo donde la varianza es el producto, y nunca caché de coincidencia exacta encima de 0 porque no es byte-determinístico. max_tokens limita tu peor costo y latencia, así que tamaña a la respuesta real más larga más margen y registra cuando la golpeas, porque una característica que mantiene truncando es un bug ocultándose como un parámetro. Fija ambos detrás de tu límite, no en cada sitio de llamada.

Streaming

Por defecto esperas a que toda la respuesta termine de generarse, luego llega a la vez. Como el modelo produce tokens uno a la vez, una respuesta larga significa una espera larga mirando nada.

El streaming envía cada token abajo a ti el momento en que es generado, así que el texto aparece de inmediato y se llena en vivo, la manera que aplicaciones de chat muestran una respuesta escribiéndose a sí misma.

python
stream = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    stream=True,
)

for chunk in stream:
    piece = chunk.choices[0].delta.content
    if piece:
        print(piece, end="", flush=True)

El tiempo total de generación es el mismo de cualquier forma; el modelo no es más rápido. El streaming solo cambia cuándo ves los tokens, llevándolos a la superficie mientras son producidos en lugar de mantenerlos a raya hasta el fin.

Lo manejas iterando sobre chunks e imprimiendo cada delta de texto. El tradeoff es un poco más de código por una espera mucho mejor. El streaming cambia cuándo ves tokens, no qué tan rápido llegan.

JunoStreaming El modelo genera tokens uno a la vez, así que por defecto una respuesta larga es una larga espera silenciosa. El streaming reenvía cada token el momento que llega, así que la respuesta se llena en vivo, que es por qué los chats se sienten responsivos. Mismo tiempo total, solo ves los tokens antes iterando sobre chunks e imprimiendo cada delta.

El streaming reenvía cada token cuando el modelo lo produce, así que el usuario ve texto inmediatamente en lugar de esperar la respuesta completa. El tiempo total de generación es sin cambios; lo que baja es tiempo al primer token, que es la mayoría de lo que "se siente rápido" significa.

La versión limpia maneja dos cosas que el bucle juguete salta: chunks sin texto, y acumular la respuesta completa mientras la muestras.

python
stream = client.chat.completions.create(model=MODEL, messages=messages, stream=True)

full = []
for chunk in stream:
    delta = chunk.choices[0].delta
    piece = delta.content
    if piece is None:          # chunks de rol-solo y finales no llevan texto
        continue
    full.append(piece)
    print(piece, end="", flush=True)

reply = "".join(full)          # el mensaje completo, para historial o almacenamiento

La verificación de delta vacío importa: el primer chunk frecuentemente lleva el rol y sin texto, y el chunk final puede llevar un finish_reason con contenido vacío. Salta el None y evitas imprimir "None" en tu salida.

Acumular en full te da el mensaje completo para añadir al historial, ya que el streaming muestra texto pero no lo almacena para ti. Un tradeoff a saber: una llamada en streaming no devuelve un bloque usage por defecto, así que si necesitas conteos de tokens los pides explícitamente o los cuentas tú mismo. El streaming existe en todos los proveedores, pero la forma de chunk, delta, content, dónde finish_reason se coloca, es específica del proveedor.

JunoStreaming El streaming baja tiempo al primer token, no tiempo total. Guarda el `delta` vacío: el primer chunk frecuentemente es rol-solo y el final lleva finish_reason sin texto, así que salta None o imprimes "None" en la respuesta. Acumula los pedazos tú mismo para el historial, y recuerda una llamada en streaming salta usage a menos que la pidas.

El streaming cambia una respuesta más simple por un mejor perfil de latencia: bajas tiempo al primer token, pero renuncias al objeto de respuesta única limpia y asumes armar estado mientras los chunks llegan. Vale la pena para cualquier cosa que un humano mira, rara vez vale la pena para una llamada de backend que parseás completa.

Los detalles que muerden en producción están en los límites de chunk. El primer chunk es típicamente rol-solo, el chunk final lleva finish_reason con contenido vacío, y debes acumular texto tú mismo porque nada te entrega el mensaje ensamblado:

python
full, finish = [], None
for chunk in stream:
    choice = chunk.choices[0]
    if choice.delta.content:
        full.append(choice.delta.content)
    if choice.finish_reason:        # llega en el último chunk
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # truncación sigue sucediendo mid-stream
    handle_truncated(reply)

Tres realidades de producción:

  • finish_reason todavía llega al fin de un stream, así que el truncamiento "length" no desaparece cuando haces stream, solo lo notas después, y lo verificas antes de confiar en el texto ensamblado.
  • Un stream puede romperse mid-flight: la conexión cae después de diez tokens de cuarenta, dejándote una respuesta parcial, así que llamadas en streaming necesitan su propio manejo de fallo parcial y son incómodas de reintentar limpiamente.
  • Las respuestas en streaming usualmente omiten usage, así que pasas un flag para incluirlo o tokenizas el texto ensamblado tú mismo para rastreo de costo.

El framing SSE y esquema de chunk son específicos del proveedor, así que mantén el parsing de stream detrás del mismo límite que el resto, y deja que los llamadores vean tu resultado ensamblado, no chunks crudos.

JunoStreaming El streaming compra tiempo al primer token y cuesta el objeto de respuesta limpio, así que úsalo donde un humano está mirando y sáltalo para parsing de backend. El chunk final todavía lleva finish_reason, así que un truncamiento "length" es real mid-stream, verifica antes de confiar en el texto. Y un stream puede morir después de diez tokens de cuarenta, así que maneja la respuesta parcial y mantén el parsing de chunk específico del proveedor detrás de tu límite.

Cada llamada es independiente

Este es el comportamiento más importante en el capítulo, y sigue directamente de la lección de context window. La API es stateless: cada solicitud está completamente por sí sola. El servidor del proveedor no recuerda tu llamada anterior. Ejecuta la predicción en exactamente los mensajes que enviaste, devuelve el resultado, y mantiene nada sobre el intercambio para próxima vez.

Así que la conversación no se almacena en ningún lugar por el modelo o la API. Vive en tu código. Si quieres que el turno dos sepa qué pasó en el turno uno, mantienes la lista de mensajes en ejecución tú mismo y envías el todo de nuevo. Un diálogo ida y vuelta es una ilusión que creas al reenviar un historial siempre creciente en cada llamada.

Eso es también por qué una conversación larga cuesta más por mensaje a lo largo del tiempo: cada llamada reenvía todos los turnos anteriores como tokens de entrada, así que el indicativo mantiene creciendo incluso cuando la pregunta nueva del usuario es corta.

python
history = [
    {"role": "system", "content": "Eres un asistente de viajes amigable. Mantén respuestas cortas."},
]

def chat(user_text):
    history.append({"role": "user", "content": user_text})

    # el HISTORIAL COMPLETO sube cada vez; el servidor no recuerda nada
    response = client.chat.completions.create(model=MODEL, messages=history)
    reply = response.choices[0].message.content

    history.append({"role": "assistant", "content": reply})  # mantén la respuesta para el próximo turno
    return reply

chat("Tengo un fin de semana en Ciudad de México. ¿Qué debo ver?")
chat("¿Cuál de esos es mejor para niños?")  # solo funciona porque el historial lleva el turno uno

La segunda pregunta tiene sentido solo porque la primera pregunta y su respuesta todavía están en history y son enviadas junto. Eres tú la memoria. Esta idea, que ensamblas y reenenvías el contexto completo cada vez, subyace conversación, y después subyace uso de herramientas, recuperación, y agentes también. Son todas variaciones en decidir qué va en esa lista de mensajes antes de cada llamada independiente.

JunoCada llamada es independiente La API es stateless: cada solicitud ejecuta en exactamente los mensajes que envías, y el servidor olvida el intercambio el momento que responde. Una conversación vive en tu código, no en el modelo, así que mantienes la lista de mensajes creciente y reenenvías todo cada turno. Por eso chats largos cuestan más a lo largo del tiempo, cada llamada reenvía cada turno anterior como tokens de entrada.

La API es stateless: el servidor ejecuta en exactamente los mensajes que envías y mantiene nada después. La conversación vive en tu código, y reenenvías el historial completo cada turno para fingir continuidad, que es por qué un chat largo cuesta más por mensaje incluso cuando la nueva pregunta es corta.

Ese crecimiento es un problema que manejas, no uno que puedas ignorar, porque el historial eventualmente sobrepasa la context window. El primer movimiento es una ventana deslizante: mantén el mensaje del sistema más los turnos más recientes.

python
def trim(history, keep_recent=10):
    system = history[:1]               # siempre mantén el mensaje del sistema
    recent = history[1:][-keep_recent:]  # solo turnos nuevos
    return system + recent

Esto es barato y limita tu costo, pero silenciosamente cae turnos más viejos, así que el modelo olvida hechos de temprano en la conversación que el usuario todavía espera que sepa. Cuando eso importa, el próximo paso es resumir los turnos caídos en un mensaje corto en lugar de eliminarlos de plano. Cuál escojas depende de la característica: un bot de soporte que referencia el problema original necesita el resumen, un Q y A rápido está bien con la ventana. La falta de estado en sí es universal en los proveedores; solo el formato de mensaje alrededor varía.

JunoCada llamada es independiente Stateless significa el servidor olvida después de cada respuesta, así que reenenvías el historial completo y lo pagas mientras crece. Recorta con una ventana deslizante de turnos recientes más el mensaje del sistema para limitar costo, pero sabe que silenciosamente cae hechos tempranos que el usuario todavía espera que recuerdes. Cuando eso muerde, resume los turnos caídos en lugar de eliminarlos.

La API es stateless: posees la conversación y la reenenvías cada llamada. La versión ingenua, añade para siempre y envía el lote, falla dos formas a escala: eventualmente sobrepasa la context window, y hace cada turno más caro y más lento que el último, ya que la entrada crece sin límite.

Así que la gestión del historial es un problema de presupuesto con tres estrategias estándar, cada una fallando a su propia forma:

  • Una ventana deslizante mantiene los N turnos más recientes: barato y limitado, pero silenciosamente cae hechos tempranos que el usuario todavía referencia.
  • Resumición comprime turnos viejos en un resumen en ejecución: sostiene más historial por token, pero pierde detalle y puede hornear sus propios errores en cada turno posterior.
  • Recuperación almacena turnos externamente y tira de vuelta solo los relevantes: escala a historiales largos, pero depende de tu índice devolviendo los pedazos correctos y añade una búsqueda.

Elige por caso de uso e instrumántalo, porque cada uno eventualmente surfaceará el contexto incorrecto.

python
def build_messages(system, history, user_msg, budget_tokens=6000):
    msgs = [system]
    running = count_tokens(system) + count_tokens(user_msg)
    for turn in reversed(history):           # más nuevo primero
        t = count_tokens(turn)
        if running + t > budget_tokens:
            break                             # turnos más viejos caen del presupuesto
        msgs.insert(1, turn)
        running += t
    msgs.append(user_msg)
    return msgs

Presupuesta por conteo de tokens, no conteo de turnos, ya que los turnos varían salvajemente en tamaño y una ventana de conteo de turnos explota tu presupuesto de contexto el momento que alguien pega un documento. La estrategia es independiente del proveedor; el tokenizador que cuentas con no, así que cuenta con el del modelo mismo. Este es el mismo trabajo de construcción de contexto que prompting y RAG construyen encima, ahora en la conversación en sí.

JunoCada llamada es independiente Stateless significa la gestión del historial es tu problema de presupuesto, y "añade para siempre" sobrepasa la ventana e infla costo de cada turno. Elige una estrategia por caso de uso: una ventana deslizante es barata pero cae hechos tempranos, resumición sostiene más pero hornea sus propios errores, recuperación escala pero depende de tu índice. Presupuesta por conteo de tokens no conteo de turnos, porque un documento pegado explota una ventana de conteo de turnos instantáneamente.

Cuando las cosas van mal

Una llamada a modelo es una llamada de red a servidores de otra persona, así que falla en todas las formas que las llamadas de red fallan. Diseña para ello desde el inicio.

  • Errores y límites de tasa. Los proveedores limitan cuántas solicitudes puedes enviar en una ventana. Golpea el límite y la llamada falla con un error de límite de tasa. La respuesta estándar es esperar y reintentar, alargando la espera después de cada fallo (esto se llama backoff) así que no martilleas un servicio ocupado.
  • Timeouts. Una llamada puede colgarse. Fija un timeout así que una solicitud lenta no congela tu aplicación.
  • Las claves se quedan en el servidor. Tu clave de API es una contraseña que gasta tu dinero. Nunca pongas la clave de API en código de navegador. Cualquiera puede abrir herramientas de desarrollador y leerla. El navegador habla a tu backend, y tu backend, teniendo la clave, habla al modelo.
  • Asume que cualquier respuesta puede estar equivocada. Incluso una llamada exitosa puede devolver una alucinación o salida malformada, así que los próximos capítulos son sobre hacer el resultado algo en lo que tu código pueda confiar.
python
def ask_model(messages):
    try:
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            timeout=20,  # rinde después de 20 segundos
        )
        return response.choices[0].message.content
    except Exception as err:
        print("Model call failed:", err)
        return "Lo siento, algo salió mal. Por favor intenta de nuevo."
JunoCuando las cosas van mal Una llamada a modelo es una llamada de red a servidores de otra persona, así que espera límites de tasa, timeouts, y fallos planos, y envuelve cada llamada en manejo de errores con reintento y backoff. Mantén tu clave de API en el servidor, nunca en el navegador, porque gasta dinero real y las herramientas de desarrollador la leerán. Y trata incluso una respuesta exitosa como posiblemente equivocada, que los próximos capítulos te ayudan a manejar.

Una llamada a modelo falla la forma que las llamadas de red fallan: límites de tasa, timeouts, errores de servidor transitorios. El movimiento es atrapar los fallos específicos y reintentar los que valen la pena, en lugar de envolver todo en un except desnudo que oculta bugs reales.

Atrapa excepciones tipadas y aplica backoff, esperando más tiempo después de cada fallo así que dejas de martillar un servicio ocupado:

python
import time
from openai import RateLimitError, APITimeoutError, APIError

def ask_model(messages, retries=3):
    for attempt in range(retries):
        try:
            r = client.chat.completions.create(model=MODEL, messages=messages, timeout=20)
            return r.choices[0].message.content
        except (RateLimitError, APITimeoutError, APIError) as err:
            if attempt == retries - 1:
                raise
            wait = 2 ** attempt          # 1s, 2s, 4s: backoff exponencial
            print(f"retry {attempt + 1} after {err}")
            time.sleep(wait)

Los tipos de excepción te dicen qué hacer: RateLimitError y APITimeoutError son transitorios y valen la pena reintentar, mientras una solicitud mala (mensajes malformados, modelo desconocido) es tu bug y debería fallar fuerte, no reintentar. Duplicar la espera cada intento te mantiene de acumular en un servicio que ya está luchando. El SDK reintenta algunos fallos para ti, así que verifica sus defaults antes de añadir tu propia capa.

Mantén la clave del lado del servidor, y trata una respuesta exitosa como posiblemente equivocada, que los próximos capítulos abordan. Los nombres de clase de excepción aquí son de OpenAI; otros SDKs lanzan los suyos, así que mapéalos en tu límite.

JunoCuando las cosas van mal Atrapa excepciones tipadas, no un `except` desnudo: `RateLimitError` y `APITimeoutError` son transitorios y valen la pena un reintento, una solicitud mala es tu bug y debería fallar fuerte. Backoff exponencial, duplicando la espera, así que dejas de martillar un servicio que lucha. Verifica si el SDK ya reintenta antes de añadir tu propia capa, y mantén la clave del lado del servidor.

El manejo de fallo es donde esto deja de ser una llamada a función y se convierte en trabajo de sistemas distribuidos. La llamada falla de formas conocidas, límites de tasa, timeouts, errores 5xx transitorios, y la parte insegura no es el reintento, es reintentar sin pensar en qué cuesta un reintento.

La trampa es idempotencia (si ejecutar una operación dos veces tiene el mismo efecto que ejecutarla una vez). Un reintento ingenuo después de un timeout puede facturar el doble: la primera solicitud puede haberse completado y generado tokens del lado del proveedor, la respuesta nunca te alcanzó, así que tu reintento paga por una segunda generación completa. Para una llamada de tipo lectura que es dinero desperdiciado, para cualquier cosa con un efecto secundario es una acción duplicada. Donde el proveedor soporta una clave de idempotencia, envía una así que un reintento es reconocido como la misma solicitud; donde no, haz la operación alrededor segura de repetir.

python
import random, time
from openai import RateLimitError, APITimeoutError, APIError

RETRYABLE = (RateLimitError, APITimeoutError, APIError)

def complete(messages, *, retries=3, **params):
    for attempt in range(retries):
        try:
            return client.chat.completions.create(messages=messages, model=MODEL, **params)
        except RETRYABLE as err:
            if attempt == retries - 1:
                raise
            time.sleep((2 ** attempt) + random.random())  # backoff + jitter

Tres más realidades de producción:

  • Jitter, la fracción aleatoria añadida a cada espera, detiene una flota de clientes de reintentar en sincronía y re-picos el límite de tasa (el problema thundering-herd).
  • Reuso de conexión importa bajo carga: crea un cliente y comparte, así que las conexiones son agrupadas en lugar de pagar un apretón TLS fresco por llamada, y ejecuta llamadas independientes concurrentemente en lugar de en un bucle lento serial.
  • Clasifica antes de reintentar: un RateLimitError es transitorio, un error de solicitud malformada es tu bug y reintentar lo desperdicia tiempo y dinero en una llamada que nunca va a tener éxito.

Los tipos de excepción y el mecanismo de idempotencia son específicos del proveedor, así que esta política completa pertenece detrás de la función límite, donde mapeas cada error del proveedor a tu decisión de reintento una sola vez. La clave se queda del lado del servidor, y una respuesta exitosa todavía es desconfiada hasta validada, que output estructurado y el capítulo de seguridad manejan.

JunoCuando las cosas van mal El reintento es la parte peligrosa: un reintento ingenuo después de un timeout puede facturar el doble, porque la primera llamada puede haber generado tokens que nunca recibiste, así que usa una clave de idempotencia o haz la operación segura de repetir. Añade jitter a tu backoff así que una flota de clientes no reintenta en sincronía y re-picos el límite, reusa un cliente agrupado, y ejecuta llamadas independientes concurrentemente. Clasifica primero, reintenta errores transitorios y falla fuerte en tus propias solicitudes malas, y mantén la política completa detrás de un límite así que los quirks de cada proveedor mapan a tu decisión de reintento en un lugar.