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

Llamar modelos desde código ​

docs.scrimba.com

En el capítulo anterior armaste una lista de mensajes. En este capítulo vas a enviar esa lista a un modelo real desde Python y a recibir una respuesta. Ese viaje de ida y vuelta es el ciclo completo más pequeño de trabajar con estos modelos, y casi toda funcionalidad que escribas más adelante se apoya en él.

Escribes un prompt, tu código lo envía a algún lado, y regresa texto. Da la sensación de que el modelo vive dentro de tu programa. No es así, y entender dónde se ejecuta realmente explica el costo, la velocidad y las rarezas que vas a encontrar.

Así que imagina lo que ocurre físicamente. El modelo es un enorme conjunto de parámetros que vive en los servidores del proveedor, en hardware que nunca vas a ver. Cuando lo llamas, tu código envía una solicitud web común a través de internet con tus mensajes dentro.

El proveedor corre el ciclo de predicción en su máquina y te devuelve los tokens generados. Llamar a un modelo es alquilar unos instantes en la computadora de otra persona. Ese marco explica muchas cosas: la latencia es el modelo generando tokens uno por uno del otro lado, el costo es lo que ellos cobran por ese cómputo, y todo el proceso es una llamada de red, con todo lo que eso implica.

Los ejemplos usan la librería oficial de OpenAI. Otros proveedores difieren en los detalles, pero la forma es casi igual en todos lados: envías una lista de mensajes, recibes un mensaje de vuelta. Esa uniformidad es tu seguro contra quedar atado a un proveedor, ya que el mismo código puede apuntar a un modelo abierto que hospedes tú o que alquiles en otro lugar, muchas veces con solo cambiar la URL base (Modelos abiertos y cerrados cubre esa decisión).

Llamas a una función de la librería, pasan unos segundos, y regresa texto. La librería hace que se sienta local, pero nada del modelo está en tu máquina.

Lo importante es entender qué envuelve esa función. Tu código empaqueta la solicitud como JSON, abre una conexión HTTPS con el proveedor, y espera mientras su hardware corre el ciclo de predicción y transmite los tokens de vuelta. El SDK (la librería de software del proveedor, como openai) es una capa fina de conveniencia sobre una sola solicitud POST.

Saber que el formato subyacente es JSON plano da resultado en la práctica. Cuando una llamada se comporta de forma extraña, puedes inspeccionar la solicitud y la respuesta exactas. Cuando quieres una funcionalidad que el SDK todavía no expone, puedes enviar el campo a mano.

Que todo esto sea una llamada de red es lo que determina el costo, la latencia y el manejo de errores del resto de este capítulo. Los ejemplos usan OpenAI, pero la forma de la solicitud, una lista de mensajes que entra, un mensaje que sale, es casi idéntica entre proveedores.

Una llamada a un modelo se parece a una llamada a función pero se comporta como un procedimiento remoto sobre infraestructura poco confiable. De ese desajuste vienen los problemas en producción, así que conviene tener clara la imagen real desde el principio.

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

Dos consecuencias marcan el resto del capítulo. Primero, esto es una llamada de red facturada por token, así que el costo, la latencia, la idempotencia y el manejo de fallas son decisiones de diseño, no algo que agregas después como parche. Segundo, el SDK y los nombres de sus campos son la superficie más volátil de la que dependes, así que la jugada duradera es aislar las partes específicas del proveedor detrás de tu propia frontera. Los ejemplos usan OpenAI; la forma de la solicitud es casi universal, pero el envoltorio alrededor es exactamente lo que no quieres que se esparza por todo 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 única línea que cambias para usar otro modelo

response = client.chat.completions.create(
    model=MODEL,
    messages=[
        {"role": "system", "content": "You are a concise assistant. Answer in one sentence."},
        {"role": "user", "content": "Why is the sky blue?"},
    ],
)

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

Fíjate que el nombre del modelo vive en una sola constante. En un campo donde cada pocos meses aparece un modelo mejor y más barato, eso es deliberado. Mantén el nombre del modelo en un solo lugar para que cambiar de modelo sea una modificación de una sola línea. Esta es la regla del cambio constante en la práctica: mantén los detalles volátiles donde puedas encontrarlos, no dispersos por todo tu código.

JunoLa solicitud Una llamada solo necesita dos cosas: model y messages. El nombre del modelo debe vivir en una sola constante, porque cada pocos meses aparece uno más barato o mejor y no quieres andar buscándolo. A mí me costó aprenderlo por las malas, después de que renombraron un modelo y lo encontré pegado en nueve archivos distintos.

Se requieren dos campos, model y messages, y todo lo demás tiene un valor por defecto. Que el nombre del modelo esté en una constante no es solo cuestión de estilo: es el único detalle volátil que quieres tener en un solo lugar, porque un modelo más barato o mejor aparece con la frecuencia suficiente como para que tenerlo escrito a mano en varios archivos convierta un simple cambio en una búsqueda tediosa.

Ahora mira un nivel más abajo. Esa llamada a create se serializa como un cuerpo JSON y se envía con POST. Explicado sin rodeos, el formato en crudo es la solicitud literal que recibe el proveedor:

python
import httpx, os

# la misma solicitud que envía el SDK, hecha 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": "You are a concise assistant."},
            {"role": "user", "content": "Why is the sky blue?"},
        ],
    },
    timeout=20,
)
data = resp.json()  # la misma forma que el SDK convierte en objetos

En código real vas a usar el SDK: se encarga de la autenticación, los reintentos y el tipado por ti. Pero ver que el cuerpo es un simple diccionario hace todo lo demás más concreto. La URL y los nombres de los campos aquí son de OpenAI, y el endpoint exacto y las claves varían según el proveedor, pero la operación en sí, JSON que entra, JSON que sale, es igual en todos lados.

JunoLa solicitud Los campos requeridos son model y messages, nada más. La llamada create del SDK es, por debajo, un único POST en JSON, así que cuando algo se ve raro puedes bajar al nivel de la solicitud en crudo e inspeccionar el cuerpo literal. Mantén el nombre del modelo en una constante: es la parte que cambia con frecuencia, y quieres tenerla 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 de la solicitud y el SDK es la dependencia más volátil que lo rodea.

La llamada create se serializa como un POST en JSON. Eso importa porque significa que la solicitud es información que tú controlas, no magia que hace el SDK. Puedes construir el cuerpo, registrarlo en logs, compararlo entre ejecuciones, o enviarlo a mano cuando el SDK todavía no soporta una funcionalidad nueva del proveedor. Conocer el formato en crudo es lo que te permite depurar una llamada que la abstracción te está ocultando.

El hábito en producción es enrutar cada llamada a través de una única función angosta propia:

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

Ese punto de control es donde cambiar de modelo se vuelve una modificación de una línea, y donde el cambio constante propio del proveedor queda contenido en lugar de filtrarse a cada punto donde se hace una llamada. La ruta del endpoint y los nombres de los campos que se muestran son de OpenAI; otro proveedor renombra la ruta y algunas claves, así que el valor de tener esa frontera es que el renombrado toque un solo archivo, no todo el código base. Fija también la versión del modelo: que "latest" se mueva bajo tus pies en silencio es el mismo riesgo de regresión que se cubre en Cómo funcionan los LLM, ahora dentro del cuerpo de tu solicitud.

JunoLa solicitud Solo se requieren model y messages, y ambos merecen vivir detrás de una frontera que controles. Enruta cada llamada a través de una sola función: ahí es donde cambiar de modelo es una línea, donde va el logging, y donde el cambio constante del proveedor se detiene antes de llegar a quien te llama. Fija también la versión del modelo, porque que "latest" se mueva bajo tus pies es una regresión que no desplegaste y que no puedes ver venir.

Leer la respuesta ​

La respuesta llega envuelta en cierta estructura. El texto que quieres es solo un camino dentro de esa estructura, pero vale la pena conocer el resto, porque te dice qué pasó.

python
choice = response.choices[0]

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

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

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

Y usage reporta los tokens que gastó la llamada, separados en el prompt que enviaste y la respuesta que escribió. Ese objeto usage es tu factura, detallada. Cada llamada te dice exactamente cuánto costó en tokens, que es el número que hay que vigilar mientras construyes.

JunoLeer la respuesta El texto de la respuesta está en response.choices[0].message.content, pero hay dos campos vecinos que también importan. finish_reason te dice por qué se detuvo: "stop" significa que terminó, "length" significa que llegó a tu límite de tokens y se cortó. Y response.usage es tu factura en tokens, con el prompt y la respuesta separados, así que nunca tienes que adivinar cuánto costó una llamada.

Tres campos llevan la información clave. choices[0].message.content es el texto. finish_reason explica por qué se detuvo el modelo: "stop" es un final limpio, "length" significa que llegó a tu límite de tokens y se truncó, así que revisarlo es cómo detectas en código una respuesta cortada en lugar de enviar media respuesta. Y usage es el conteo de tokens, con el prompt y la respuesta separados.

La jugada que da resultado aquí es convertir usage en dinero real. El objeto usage son datos de facturación por llamada, así que puedes calcular el costo al instante:

python
# los precios del proveedor son por token; verifica las tarifas vigentes, cambian
PROMPT_RATE = 0.15 / 1_000_000      # dólares por token de prompt
COMPLETION_RATE = 0.60 / 1_000_000  # dólares por token de respuesta

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

El prompt y la respuesta suelen tener precios distintos, y los tokens de respuesta suelen costar más, así que la separación importa cuando optimizas. Registra el costo por llamada y podrás ver qué funcionalidades son costosas antes de que te lo diga la factura mensual. Los nombres de los campos aquí son de OpenAI; otros proveedores exponen los mismos conteos con claves un poco distintas, así que fíjate en la forma, no en el nombre exacto del atributo.

JunoLeer la respuesta Lee tres campos: content para el texto, finish_reason para detectar en código un truncado "length", y usage para los tokens. Convierte usage en dólares al instante, con precios separados para prompt y respuesta, y regístralo por llamada. Vas a notar la funcionalidad cara mucho antes de que te lo diga la factura.

La respuesta lleva tres campos sobre los que actúas: content, finish_reason y usage. El que más gente se salta y luego lamenta es finish_reason. Un valor "length" significa que el modelo llegó a tu max_tokens y la salida quedó truncada, muchas veces como JSON malformado o una oración a medio terminar con la que después tu parser se atasca. Trata cualquier valor distinto de "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 estar conectado a tu observabilidad, no impreso en pantalla y olvidado. Los tokens de prompt y de respuesta se cobran por separado, la respuesta suele ser más cara, así que registra ambos por llamada, etiquetados por funcionalidad:

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 marcha, el costo por funcionalidad y por usuario se vuelve una consulta, no una suposición, y un prompt descontrolado que silenciosamente triplicó su tamaño aparece como una métrica en lugar de un cargo sorpresa. Presta atención a la asimetría: los tokens de entrada son baratos y se procesan en paralelo, los de salida son la parte cara y secuencial que se cubre en Cómo funcionan los LLM, así que una funcionalidad que genera respuestas largas cuesta y tarda más de lo que sugiere el tamaño de su prompt. Los nombres de los atributos son de OpenAI y cambian según el proveedor, así que registra los conteos detrás de tu propia frontera en lugar de esparcir response.usage.prompt_tokens por toda la aplicación.

JunoLeer la respuesta Trata cualquier finish_reason que no sea "stop" como una llamada fallida: un truncado "length" le entrega a tu parser JSON roto y vas a terminar rastreando el bug más adelante. Conecta usage a tus logs etiquetado por funcionalidad, con el prompt y la respuesta separados, para que el costo por funcionalidad sea una consulta y un prompt que se infla sea una métrica, no una sorpresa en la factura. Los tokens de salida son la parte cara y secuencial, así que una funcionalidad habladora cuesta más de lo que su prompt sugiere.

Los parámetros que vale la pena conocer ​

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

temperature es el control de aleatoriedad de Cómo funcionan los LLM, hecho concreto. Valores bajos (cerca de 0) mantienen al modelo apegado a sus opciones más probables: enfocado y repetible, lo que quieres para extracción y clasificación. Valores más altos (entre 0.7 y 1) le permiten elegir tokens menos probables: más variado y creativo, y más propenso a desviarse. Solo estás ajustando cuán audaz es el muestreo del modelo, nada más.

max_tokens limita cuántos tokens puede tener la respuesta. Te protege de una respuesta descontrolada y de una factura descontrolada. Si fijas max_tokens demasiado bajo, el modelo se corta a mitad de camino, que es exactamente el motivo de finalización "length" 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 la respuesta
)
JunoLos parámetros que vale la pena conocertemperature ajusta cuán audaz es el muestreo del modelo: bajo para respuestas enfocadas y repetibles, alto para respuestas variadas y creativas. max_tokens limita la longitud de la respuesta para proteger tu factura, pero si lo fijas demasiado bajo dispara el corte "length", así que deja margen. Para extracción y clasificación, elige siempre una temperatura baja.

Dos parámetros llevan la mayor parte del peso: temperature y max_tokens. La mecánica está en Cómo funcionan los LLM; la jugada aquí es ajustar el valor a la tarea en lugar de confiar en lo que sea que venga por defecto.

Piensa en temperature como una configuración por tarea, no una configuración global. Elígela según lo que necesite la tarea:

python
TEMPERATURE_BY_TASK = {
    "extraction": 0.0,      # una sola respuesta correcta, la quieres siempre igual
    "classification": 0.0,  # elegir una etiqueta, no se quiere creatividad
    "summary": 0.3,         # sobre todo fiel, con algo de libertad de redacción
    "brainstorm": 0.9,      # la variedad es todo el punto
}

Para cualquier cosa que vayas a parsear después, datos estructurados, una categoría, un número extraído de un documento, usa 0 y deja de pensar en la aleatoriedad. Para redacción e ideación donde la variedad es el valor, usa un número alto.

max_tokens es un techo, no una meta: limita el gasto y las respuestas descontroladas, pero si lo fijas por debajo de lo que necesita la tarea, obtienes el truncado "length" de la sección anterior, que es peor que una factura un poco más alta. Ajústalo al tamaño de la respuesta legítima más larga, con margen. Estos dos nombres de campo son estables entre proveedores; algunos renombran max_tokens, así que confirma la clave cuando cambies de proveedor.

JunoLos parámetros que vale la pena conocer Fija temperature por tarea, no una sola vez de forma global: 0 para todo lo que vayas a parsear después, alto para redacción donde la variedad es el punto. max_tokens es un techo de seguridad, así que ajústalo al tamaño de la respuesta real más larga con margen; si lo fijas demasiado ajustado, cambias un pequeño ahorro en la factura por una respuesta truncada. Deja de confiar en el valor por defecto que trae el SDK.

temperature y max_tokens parecen simples controles, pero se comportan como decisiones de política con costo y confiabilidad asociados. La profundidad aquí está en cómo interactúan con todo lo demás que construyes.

Trata temperature como parte de tu contrato de confiabilidad. Para cualquier salida que parsees o almacenes, usa 0 para que la varianza inducida por el muestreo de Cómo funcionan los LLM sea lo más pequeña que el proveedor permita. No es perfectamente determinista ni siquiera en 0, así que no construyas caché de coincidencia exacta ni pruebas de igualdad byte a byte sobre eso, pero es el piso que quieres para trabajo estructurado. Reserva temperaturas más altas para las superficies donde la variedad es el producto, y trata esa varianza como algo que tus evaluaciones miden a través de varias ejecuciones, no algo que revisas de un vistazo en una sola pasada.

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

La trampa es dimensionarlo a ojo: demasiado ajustado trunca respuestas válidas en el fallo "length" que le entrega a tu parser una salida rota, demasiado holgado permite que un ciclo degenerado inflate costo y latencia antes de que algo lo detenga. Ajústalo al tamaño de la respuesta legítima más larga más un margen, por tarea, y registra en logs cuando llegues al límite para que una funcionalidad que se trunca constantemente se vuelva visible. Ambos nombres de parámetro son de OpenAI y un par de proveedores los renombran, así que configúralos detrás de la función frontera en lugar de en cada punto de llamada.

JunoLos parámetros que vale la pena conocertemperature es un contrato de confiabilidad: 0 para todo lo que parsees, alto solo donde la varianza es el producto, y nunca hagas caché de coincidencia exacta sobre 0 porque no es determinista byte a byte. max_tokens acota tu peor costo y latencia, así que ajústalo al tamaño de la respuesta real más larga con margen y registra en logs cuando lo alcances, porque una funcionalidad que se trunca constantemente es un bug disfrazado de parámetro. Configura ambos detrás de tu frontera, no en cada punto de llamada.

Streaming ​

Por defecto esperas a que termine de generarse toda la respuesta, y entonces llega de golpe. Como el modelo produce los tokens de uno en uno, una respuesta larga significa una espera larga mirando la pantalla sin nada.

Streaming te envía cada token en el momento en que se genera, así que el texto aparece de inmediato y se va completando en vivo, de la misma forma en que las apps de chat muestran una respuesta "escribiéndose sola".

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 en ambos casos; el modelo no se vuelve más rápido. El streaming solo cambia cuándo ves los tokens, mostrándolos a medida que se producen en lugar de retenerlos hasta el final.

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

JunoStreaming El modelo genera los tokens de uno en uno, así que por defecto una respuesta larga es una espera larga y silenciosa. El streaming reenvía cada token en el momento en que llega, así que la respuesta se va completando en vivo, y por eso las apps de chat se sienten receptivas. El tiempo total es el mismo, solo ves los tokens antes iterando sobre los fragmentos e imprimiendo cada delta.

El streaming reenvía cada token a medida que el modelo lo produce, así que el usuario ve texto de inmediato en lugar de esperar la respuesta completa. El tiempo total de generación no cambia; lo que baja es el tiempo hasta el primer token, que es la mayor parte de lo que significa "sentirse rápido".

La versión limpia maneja dos cosas que el bucle de juguete se salta: fragmentos 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:          # el fragmento de rol y el final no llevan texto
        continue
    full.append(piece)
    print(piece, end="", flush=True)

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

La verificación de delta vacío importa: el primer fragmento suele llevar el rol y ningún texto, y el fragmento final puede llevar un finish_reason con contenido vacío. Si te saltas el None, evitas imprimir "None" en tu salida.

Acumular en full te da el mensaje completo para agregarlo al historial, ya que el streaming muestra el texto pero no lo guarda por ti. Un detalle que hay que conocer: una llamada con streaming no devuelve un bloque usage por defecto, así que si necesitas el conteo de tokens tienes que pedirlo explícitamente o contarlo tú mismo. El streaming existe en todos los proveedores, pero la forma del fragmento, delta, content, dónde aparece finish_reason, es específica de cada proveedor.

JunoStreaming El streaming reduce el tiempo hasta el primer token, no el tiempo total. Protégete del delta vacío: el primer fragmento suele ser solo de rol y el final lleva finish_reason sin texto, así que salta el None o vas a imprimir "None" en la respuesta. Acumula los fragmentos tú mismo para el historial, y recuerda que una llamada con streaming se salta usage a menos que lo pidas.

El streaming cambia una respuesta más simple por un mejor perfil de latencia: reduces el tiempo hasta el primer token, pero pierdes el objeto de respuesta único y limpio y asumes la tarea de armar el estado a medida que llegan los fragmentos. Vale la pena para cualquier cosa que observe una persona, casi nunca vale la pena para una llamada de backend que parseas completa.

Los detalles que muerden en producción están en los límites de los fragmentos. El primer fragmento suele llevar solo el rol, el fragmento final lleva finish_reason con contenido vacío, y tienes que acumular el texto tú mismo porque nada te entrega el mensaje ya 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 fragmento
        finish = choice.finish_reason
reply = "".join(full)
if finish == "length":              # el truncado todavía puede pasar a mitad del stream
    handle_truncated(reply)

Tres realidades de producción:

  • finish_reason sigue llegando al final de un stream, así que el truncado "length" no desaparece cuando usas streaming, solo lo notas más tarde, y hay que revisarlo antes de confiar en el texto ya ensamblado.
  • Un stream puede romperse a mitad de camino: la conexión se cae después de diez tokens de cuarenta, dejándote una respuesta parcial, así que las llamadas con streaming necesitan su propio manejo de fallas parciales y son incómodas de reintentar de forma limpia.
  • Las respuestas con streaming suelen omitir usage, así que pasas una bandera para incluirlo, o tokenizas el texto ensamblado tú mismo para el seguimiento de costos.

El formato SSE y el esquema de los fragmentos son específicos de cada proveedor, así que mantén el parseo del stream detrás de la misma frontera que el resto, y deja que quien te llama vea tu resultado ya ensamblado, no fragmentos crudos.

JunoStreaming El streaming te da tiempo hasta el primer token a cambio del objeto de respuesta limpio, así que úsalo donde una persona está mirando y sáltalo para el parseo de backend. El fragmento final sigue llevando finish_reason, así que un truncado "length" es real a mitad del stream, revísalo 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 parseo de fragmentos específico del proveedor detrás de tu frontera.

Cada llamada es independiente ​

Este es el comportamiento más importante del capítulo, y se deriva directamente de la lección de la ventana de contexto. La API no guarda estado: cada solicitud se sostiene completamente por sí sola. El servidor del proveedor no recuerda tu llamada anterior. Corre la predicción exactamente sobre los mensajes que enviaste, devuelve el resultado, y no guarda nada del intercambio para la próxima vez.

Así que la conversación no se almacena en ningún lado por parte del modelo ni de la API. Vive en tu código. Si quieres que el segundo turno sepa qué pasó en el primero, tú mismo mantienes la lista creciente de mensajes y la envías completa otra vez. Un ida y vuelta de chat es una ilusión que tú creas reenviando un historial cada vez más grande en cada llamada.

Esa es también la razón por la que una conversación larga cuesta más por mensaje con el tiempo: cada llamada reenvía todos los turnos anteriores como tokens de entrada, así que el prompt sigue creciendo aunque la pregunta nueva del usuario sea corta.

python
history = [
    {"role": "system", "content": "You are a friendly travel assistant. Keep answers short."},
]

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

    # se envía TODO el historial 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})  # guarda la respuesta para el siguiente turno
    return reply

chat("Tengo un fin de semana en Lima. ¿Qué debería ver?")
chat("¿Cuál de esas opciones es mejor para niños?")  # solo funciona porque history lleva el primer turno

La segunda pregunta tiene sentido solo porque la primera pregunta y su respuesta siguen en history y se envían junto con ella. Tú eres la memoria. Esta idea, que armas y reenvías el contexto completo cada vez, es la base de la conversación, y más adelante también es la base del uso de herramientas, la recuperación de información y los agentes. Todos son variaciones sobre decidir qué entra en esa lista de mensajes antes de cada llamada independiente.

JunoCada llamada es independiente La API no guarda estado: cada solicitud se ejecuta exactamente sobre los mensajes que envías, y el servidor olvida el intercambio en el momento en que responde. Una conversación vive en tu código, no en el modelo, así que tú mantienes la lista de mensajes creciente y la reenvías completa cada turno. Por eso los chats largos cuestan más con el tiempo: cada llamada reenvía todos los turnos anteriores como tokens de entrada.

La API no guarda estado: el servidor corre exactamente sobre los mensajes que envías y no guarda nada después. La conversación vive en tu código, y reenvías todo el historial en cada turno para simular continuidad, que es por lo que un chat largo cuesta más por mensaje incluso cuando la pregunta nueva es corta.

Ese crecimiento es un problema que hay que gestionar, no algo que puedas ignorar, porque el historial eventualmente supera la ventana de contexto. La primera jugada es una ventana deslizante: conservar el mensaje de sistema más los turnos más recientes.

python
def trim(history, keep_recent=10):
    system = history[:1]               # siempre conserva el mensaje de sistema
    recent = history[1:][-keep_recent:]  # solo los turnos más nuevos
    return system + recent

Esto es barato y acota tu costo, pero descarta silenciosamente los turnos más antiguos, así que el modelo olvida datos del principio de la conversación que el usuario todavía espera que recuerde. Cuando eso importa, el siguiente paso es resumir los turnos descartados en un mensaje corto en lugar de eliminarlos por completo. Cuál elegir depende de la funcionalidad: un bot de soporte que hace referencia al problema original necesita el resumen, un par de preguntas y respuestas rápidas puede vivir bien con la ventana. La ausencia de estado en sí es universal entre proveedores; solo varía el formato de mensajes alrededor de ella.

JunoCada llamada es independiente No guardar estado significa que el servidor olvida después de cada respuesta, así que reenvías todo el historial y pagas por eso a medida que crece. Recorta con una ventana deslizante de los turnos recientes más el mensaje de sistema para acotar el costo, pero ten en cuenta que descarta silenciosamente hechos tempranos que el usuario todavía espera que recuerdes. Cuando eso pesa, resume los turnos descartados en lugar de eliminarlos.

La API no guarda estado: tú eres dueño de la conversación y la reenvías en cada llamada. La versión ingenua, agregar mensajes para siempre y enviar todo el lote, falla de dos formas a gran escala: eventualmente desborda la ventana de contexto, y hace que cada turno sea más caro y más lento que el anterior, 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 con su propia forma de fallar:

  • Una ventana deslizante conserva los N turnos más recientes: barata y acotada, pero descarta silenciosamente hechos tempranos a los que el usuario todavía hace referencia.
  • El resumen comprime los turnos antiguos en un resumen corrido: guarda más historial por token, pero pierde detalle y puede incrustar sus propios errores en cada turno posterior.
  • La recuperación de información almacena los turnos de forma externa y solo trae de vuelta los relevantes: escala a historiales largos, pero depende de que tu índice devuelva las piezas correctas, y agrega una búsqueda adicional.

Elige según el caso de uso e instruméntalo, porque cada estrategia eventualmente va a mostrar el contexto equivocado.

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):           # el más nuevo primero
        t = count_tokens(turn)
        if running + t > budget_tokens:
            break                             # los turnos más antiguos quedan fuera del presupuesto
        msgs.insert(1, turn)
        running += t
    msgs.append(user_msg)
    return msgs

Presupuesta por conteo de tokens, no por conteo de turnos, ya que los turnos varían enormemente en tamaño y una ventana por conteo de turnos revienta tu presupuesto de contexto en el momento en que alguien pega un documento entero. La estrategia es independiente del proveedor; el tokenizador con el que cuentas no lo es, así que cuenta con el propio del modelo. Este es el mismo trabajo de construcción de contexto sobre el que se apoyan prompting y RAG, ahora aplicado a la conversación misma.

JunoCada llamada es independiente No guardar estado significa que la gestión del historial es tu propio problema de presupuesto, y "agregar para siempre" desborda la ventana e inflige un costo mayor en cada turno. Elige una estrategia según el caso de uso: una ventana deslizante es barata pero descarta hechos tempranos, el resumen guarda más pero incrusta sus propios errores, la recuperación escala pero depende de tu índice. Presupuesta por conteo de tokens, no por conteo de turnos, porque un solo documento pegado revienta al instante una ventana por conteo de turnos.

Cuando las cosas salen mal ​

Llamar a un modelo es una llamada de red a los servidores de otra persona, así que falla de todas las formas en que fallan las llamadas de red. Diséñalo así desde el principio.

  • Errores y límites de tasa. Los proveedores limitan cuántas solicitudes puedes enviar en una ventana de tiempo. Si llegas al límite, 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) para no golpear un servicio ya ocupado.
  • Tiempos de espera agotados. Una llamada puede quedarse colgada. Configura un tiempo límite para que una solicitud lenta no congele 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 las herramientas de desarrollador y leerla. El navegador habla con tu backend, y tu backend, que tiene la clave, habla con el modelo.
  • Asume que cualquier respuesta puede estar equivocada. Incluso una llamada exitosa puede devolver una alucinación o una salida malformada, así que los siguientes capítulos tratan de convertir el resultado en 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,  # abandona después de 20 segundos
        )
        return response.choices[0].message.content
    except Exception as err:
        print("Model call failed:", err)
        return "Sorry, something went wrong. Please try again."
JunoCuando las cosas salen mal Llamar a un modelo es una llamada de red a los servidores de otra persona, así que espera límites de tasa, tiempos de espera y fallos simples, 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 van a leer. Y trata incluso una respuesta exitosa como potencialmente equivocada, algo que los siguientes capítulos te ayudan a manejar.

Una llamada a un modelo falla de la misma forma en que fallan las llamadas de red: límites de tasa, tiempos de espera, errores transitorios del servidor. La jugada es capturar los fallos específicos y reintentar los que valen la pena, en lugar de envolver todo en un except genérico que oculta bugs reales.

Captura excepciones tipadas y aplica backoff, esperando más tiempo después de cada fallo para no seguir golpeando un servicio ya 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 vale la pena reintentarlos, mientras que una solicitud mal formada (mensajes malformados, modelo desconocido) es tu propio error y debería fallar de forma ruidosa, no reintentarse. Duplicar la espera en cada intento evita que sigas amontonando carga sobre un servicio que ya está sufriendo. El SDK ya reintenta algunos fallos por ti, así que revisa sus valores por defecto antes de agregar tu propia capa.

Mantén la clave del lado del servidor, y trata una respuesta exitosa como potencialmente equivocada, algo que abordan los siguientes capítulos. Los nombres de las clases de excepción aquí son de OpenAI; otros SDK lanzan las suyas propias, así que mapéalas en tu frontera.

JunoCuando las cosas salen mal Captura excepciones tipadas, no un except genérico: RateLimitError y APITimeoutError son transitorios y vale la pena reintentarlos, una solicitud mal formada es tu propio error y debería fallar de forma ruidosa. Aplica backoff exponencial, duplicando la espera, para no seguir golpeando un servicio que ya está sufriendo. Revisa si el SDK ya reintenta antes de agregar tu propia capa, y mantén la clave del lado del servidor.

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

La trampa es la idempotencia (si ejecutar una operación dos veces tiene el mismo efecto que ejecutarla una sola vez). Un reintento ingenuo después de un tiempo de espera agotado puede cobrarte dos veces: la primera solicitud puede haberse completado y generado tokens del lado del proveedor, la respuesta nunca te llegó, así que tu reintento paga por una segunda generación completa. Para una llamada de solo lectura eso es dinero desperdiciado, para cualquier cosa con efecto secundario es una acción duplicada. Donde el proveedor soporte una clave de idempotencia, envíala para que un reintento se reconozca como la misma solicitud; donde no la soporte, haz que la operación que la rodea sea 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 realidades más de producción:

  • El jitter, la fracción aleatoria que se agrega a cada espera, evita que toda una flota de clientes reintente al mismo ritmo y vuelva a disparar el límite de tasa (el problema de la estampida).
  • La reutilización de conexiones importa bajo carga: crea un solo cliente y compártelo, para que las conexiones se agrupen en lugar de pagar un nuevo handshake TLS por cada llamada, y ejecuta llamadas independientes de forma concurrente en lugar de en un bucle serial lento.
  • Clasifica antes de reintentar: un RateLimitError es transitorio, un error de solicitud malformada es tu propio bug y reintentarlo desperdicia tiempo y dinero en una llamada que nunca va a tener éxito.

Las clases de excepción y el mecanismo de idempotencia son específicos de cada proveedor, así que toda esta política pertenece detrás de la función frontera, donde mapeas los errores de cada proveedor a tu propia decisión de reintento una sola vez. La clave se queda del lado del servidor, y una respuesta exitosa sigue sin ser confiable hasta que se valida, algo que manejan salida estructurada y el capítulo de seguridad.

JunoCuando las cosas salen mal El reintento es la parte peligrosa: un reintento ingenuo después de un tiempo de espera agotado puede cobrarte dos veces, porque la primera llamada puede haber generado tokens que nunca recibiste, así que usa una clave de idempotencia o haz que la operación sea segura de repetir. Agrega jitter a tu backoff para que toda una flota de clientes no reintente al mismo ritmo y vuelva a disparar el límite, reutiliza un cliente con pool de conexiones, y ejecuta llamadas independientes de forma concurrente. Clasifica primero, reintenta errores transitorios y falla de forma ruidosa ante tus propias solicitudes mal formadas, y mantén toda la política detrás de una sola frontera para que las rarezas de cada proveedor se mapeen a tu decisión de reintento en un solo lugar.