Ir al contenido
wannacode

Llamar a un modelo a través de una API

En resumen: Llamar a un modelo de lenguaje a través de una API es enviar una solicitud con un modelo, un prompt de sistema y mensajes con roles; la respuesta trae el texto, el motivo de la parada y la cantidad de tokens.

Problema

El asistente sale ante los clientes

Hasta ahora los modelos eran pequeños y vivían dentro del programa. El asistente de soporte tiene que responder a clientes reales, y para eso hace falta un modelo grande de un proveedor. El programa le consulta a través de una API: envía una solicitud por la red y espera una respuesta.

El primer día de las rebajas los clientes ven «Error 429» en lugar de una respuesta. A otro cliente el asistente le pide el número de pedido que acaba de dar.

¿Cómo es una solicitud, qué devuelve el modelo y qué haces cuando la respuesta es un error?

La idea

Solicitud y respuesta

Llamar a un modelo es una solicitud HTTP con JSON. La solicitud lleva:

  • model: qué modelo;
  • max_tokens: de cuántos tokens como máximo puede ser la respuesta;
  • system: el prompt de sistema, la instrucción para el rol y el tono;
  • messages: los mensajes con sus roles, user (el cliente) y assistant (las respuestas anteriores del modelo).

La respuesta lleva:

  • content: una lista de bloques con un type; el texto está en un bloque text;
  • stop_reason: por qué se detuvo el modelo, end_turn (terminó) o max_tokens (llegó al techo);
  • usage: cuántos tokens entraron (input_tokens) y cuántos salieron (output_tokens). Pagas por ellos.

La API no tiene memoria. Cada solicitud es independiente y lleva todo lo que el modelo tiene que ver, como en la lección sobre la ventana de contexto. Las API de distintos proveedores tienen la misma forma: mensajes con roles, tokens, herramientas.

Míralo

Un cliente falso con respuestas grabadas

Los programas de la lección no llaman a un modelo real: su comprobación no tiene red, y la respuesta real es distinta cada vez (la lección sobre los límites). Por eso, en lugar del cliente del proveedor hay un sustituto con la misma forma: recibe model, max_tokens, system y messages y devuelve content, stop_reason y usage. Las respuestas están grabadas de antemano y se eligen por una palabra del último mensaje. Los tokens se cuentan a grandes rasgos, 4 por cada 3 palabras.

class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// un sustituto del cliente real: respuestas grabadas, sin red
class FakeClient {
constructor(failures = []) {
this.failures = [...failures];
}
create({ model, max_tokens, system, messages }) {
if (this.failures.length > 0) {
const status = this.failures.shift();
throw new ApiError(status, reason(status));
}
let text = answer(messages);
let stop_reason = "end_turn";
if (tokens(text) > max_tokens) {
// max_tokens: la respuesta se corta a la mitad
text = text.split(" ").slice(0, Math.floor((max_tokens * 3) / 4)).join(" ");
stop_reason = "max_tokens";
}
let input_tokens = tokens(system);
for (const m of messages) {
input_tokens += tokens(m.content);
}
const usage = { input_tokens, output_tokens: tokens(text) };
return { content: [{ type: "text", text }], stop_reason, usage };
}
}
// la respuesta grabada: se elige por una palabra del último mensaje
function answer(messages) {
const last = messages[messages.length - 1].content;
const hasOrder = messages.some((m) => m.content.includes("1042"));
if (last.includes("devol")) return "Puedes devolver el artículo hasta 14 días después de recibirlo.";
if (last.includes("lleg")) {
return hasOrder ? "El pedido 1042 está con el mensajero y llega mañana antes de las 18:00." : "¿Cuál es tu número de pedido?";
}
return "¿En qué puedo ayudar: estado del pedido, entrega o devoluciones?";
}
// el texto del error según su código
function reason(status) {
const reasons = {
400: "solicitud no válida",
401: "clave no válida",
429: "demasiadas solicitudes",
};
return reasons[status] ?? "error del servidor";
}
// a grandes rasgos: 4 tokens por cada 3 palabras (el número real lo da el tokenizador)
function tokens(text) {
const words = text.trim().split(/\s+/).length;
return Math.floor((words * 4 + 2) / 3);
}

Una llamada, y luego lo mismo con max_tokens 8:

// los bloques de texto, luego por qué se detuvo y cuántos tokens
function show(response) {
for (const block of response.content) {
if (block.type === "text") console.log(block.text);
}
const u = response.usage;
console.log(`stop_reason: ${response.stop_reason}, input_tokens: ${u.input_tokens}, output_tokens: ${u.output_tokens}`);
}
const client = new FakeClient();
const system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";
const messages = [{ role: "user", content: "¿Cuándo llegará el pedido 1042?" }];
let response = client.create({ model: MODEL, max_tokens: 1024, system, messages });
show(response);
response = client.create({ model: MODEL, max_tokens: 8, system, messages });
show(response);
Salida
El pedido 1042 está con el mensajero y llega mañana antes de las 18:00.
stop_reason: end_turn, input_tokens: 23, output_tokens: 19
El pedido 1042 está con el
stop_reason: max_tokens, input_tokens: 23, output_tokens: 8

Con 8 tokens la respuesta se corta a mitad de la frase, y stop_reason es max_tokens. El programa tiene que comprobarlo: una respuesta cortada que ve el cliente parece un error de la tienda.

Míralo

Ejecútalo en tu computadora: la llamada real

La misma llamada a un modelo real. No es uno de los programas comprobados y aquí no se ejecuta: necesitas una cuenta en un proveedor y una clave en el entorno, y cada llamada cuesta dinero.

Por la red va JSON. La solicitud:

solicitud
{
"model": "<nombre del modelo>",
"max_tokens": 1024,
"system": "Eres un asistente de soporte de una tienda en línea. Responde brevemente.",
"messages": [
{
"role": "user",
"content": "¿Cuándo llegará el pedido 1042?"
}
]
}

Y la respuesta (los números aquí son del cliente falso):

respuesta
{
"content": [
{
"type": "text",
"text": "El pedido 1042 está con el mensajero y llega mañana antes de las 18:00."
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 23,
"output_tokens": 19
}
}

Con el cliente HTTP estándar del lenguaje es una sola solicitud POST. La dirección, el nombre del modelo y la cabecera con la clave son marcadores: los reales están en la documentación del proveedor. La clave se lee de la variable LLM_API_KEY.

const response = await fetch("https://<proveedor>/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "<nombre del modelo>",
max_tokens: 1024,
system: "Eres un asistente de soporte de una tienda en línea. Responde brevemente.",
messages: [{ role: "user", content: "¿Cuándo llegará el pedido 1042?" }],
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reply = await response.json();
for (const block of reply.content) {
if (block.type === "text") console.log(block.text);
}

Los proveedores también ofrecen bibliotecas oficiales (SDK) para Java, C#, Python y JavaScript con la misma forma: mensajes con roles, tokens, herramientas. Los nombres exactos de las clases y los métodos están en su documentación. await: la llamada va por la red y es asíncrona.

La idea

La clave está en el entorno, no en el código

La clave de la API es como una contraseña de la cuenta: quien la tiene gasta de ella. En el código llega a git, a cada compañero y a veces a un repositorio público. Por eso se lee de una variable de entorno, aquí LLM_API_KEY.

const key = process.env.LLM_API_KEY ?? "";
if (key.trim() === "") {
console.log("No hay clave: define LLM_API_KEY en el entorno.");
} else {
console.log("La clave se carga del entorno. No la imprimas ni la escribas en el código.");
}
Salida
No hay clave: define LLM_API_KEY en el entorno.

El programa dice si la clave está, pero nunca la imprime. El archivo con los secretos locales (por ejemplo .env) está en .gitignore.

Cómo se guardan los secretos en un proyecto real está en la lección sobre secretos.

Míralo

Una API sin memoria

ask añade la pregunta al historial, envía todo el historial y guarda la respuesta con el rol assistant. Al final, la misma pregunta enviada sola:

// añade la pregunta, envía todo el historial y guarda la respuesta
function ask(client, system, history, text) {
history.push({ role: "user", content: text });
const r = client.create({ model: MODEL, max_tokens: 1024, system, messages: history });
const reply = r.content[0].text;
history.push({ role: "assistant", content: reply });
return reply;
}
const client = new FakeClient();
const system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";
const history = [];
for (const text of ["Hola, tengo el pedido 1042.", "¿Cuándo llegará?"]) {
console.log(`cliente: ${text}`);
console.log(`asistente: ${ask(client, system, history, text)}`);
}
const alone = client.create({
model: MODEL,
max_tokens: 1024,
system,
messages: [{ role: "user", content: "¿Cuándo llegará?" }],
});
console.log(`solo la pregunta, sin el historial: ${alone.content[0].text}`);
Salida
cliente: Hola, tengo el pedido 1042.
asistente: ¿En qué puedo ayudar: estado del pedido, entrega o devoluciones?
cliente: ¿Cuándo llegará?
asistente: El pedido 1042 está con el mensajero y llega mañana antes de las 18:00.
solo la pregunta, sin el historial: ¿Cuál es tu número de pedido?

Con el historial el modelo ve el número 1042 del primer mensaje. Sin él, lo pide. La memoria de la conversación está en el programa, no en el modelo, y el historial crece y se paga en cada turno.

Pruébalo

Encuentra el error

Un compañero escribe ask. En la segunda pregunta el asistente vuelve a pedir el número de pedido. ¿En qué línea está el error?

Elige la línea con el error.

Pruébalo

Completa

¿Con qué rol entra la respuesta del modelo en el historial?

function ask(client, system, history, text) {
  history.push({ role: "user", content: text });
  const r = client.create({ model: MODEL, max_tokens: 1024, system, messages: history });
  const reply = r.content[0].text;
  history.push({ role: , content: reply });
  return reply;
}

La idea

Los errores de la API

La red y el servidor del proveedor fallan como cualquier servicio externo. Los errores llegan con un código HTTP y se dividen en dos grupos:

  • Temporales: 429 (demasiadas solicitudes, se alcanzó un límite), 500 y el resto de los 5xx (el servidor tiene un problema o está sobrecargado), 408 y 409. Un momento después la misma solicitud puede pasar. Estos vale la pena reintentarlos.
  • Permanentes: 400 (solicitud mala), 401 (clave no válida), 404. Un reintento da lo mismo. El programa los comunica de inmediato.

Un reintento espera entre los intentos, y la espera se duplica: 1, 2, 4 segundos. Un problema corto pasa rápido, y un servidor sobrecargado no recibe un torrente de solicitudes nuevas. Esto es el backoff exponencial. Tras un número fijo de intentos el programa se rinde.

Míralo

Reintentos con una espera que se duplica

// repite los errores temporales, la espera se duplica
function createWithRetry(client, system, messages) {
const maxAttempts = 4;
let delay = 1;
for (let attempt = 1; ; attempt++) {
try {
return client.create({
model: MODEL,
max_tokens: 1024,
system,
messages,
});
} catch (e) {
const last = attempt === maxAttempts;
if (last || !retryable(e.status)) throw e;
console.log(` intento ${attempt}: ${e.status},`,
`espero ${delay} s`);
// un programa real espera aquí:
// await sleep(delay * 1000)
delay *= 2;
}
}
}
// temporales: 408, 409, 429 y 5xx
function retryable(status) {
return status === 408 || status === 409
|| status === 429 || status >= 500;
}
Paso a paso, en palabras
  1. La solicitud: el modelo, el prompt de sistema y los mensajes con sus roles. Aquí hay uno, del cliente (user).
  2. El primer intento devuelve 429: «demasiadas solicitudes». Un error temporal.
  3. 429 está entre los temporales, así que el programa espera 1 s y lo intenta de nuevo.
  4. El segundo intento: 500, «error del servidor». También temporal.
  5. La espera se duplica: 2 s. El servidor sobrecargado respira.
  6. El tercer intento pasa.
  7. La respuesta: un bloque de texto, stop_reason end_turn y usage: 23 tokens de entrada y 19 de salida.
  8. 401 y 400 no se repiten: esperar no arregla una clave incorrecta. Tras 4 intentos el programa se rinde.
// repite los errores temporales, la espera se duplica
function createWithRetry(client, system, messages) {
const maxAttempts = 4;
let delay = 1;
for (let attempt = 1; ; attempt++) {
try {
return client.create({
model: MODEL,
max_tokens: 1024,
system,
messages,
});
} catch (e) {
const last = attempt === maxAttempts;
if (last || !retryable(e.status)) throw e;
console.log(` intento ${attempt}: ${e.status},`,
`espero ${delay} s`);
// un programa real espera aquí:
// await sleep(delay * 1000)
delay *= 2;
}
}
}
// temporales: 408, 409, 429 y 5xx
function retryable(status) {
return status === 408 || status === 409
|| status === 429 || status >= 500;
}

Tres escenarios: 429 y 500, luego una respuesta; 401, sin reintento; cuatro veces 429, y tras el cuarto intento se rinde. La espera solo se imprime, para que el programa se ejecute enseguida.

const system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";
const messages = [{ role: "user", content: "¿Cuándo llegará el pedido 1042?" }];
const scenarios = [[429, 500], [401], [429, 429, 429, 429]];
for (const failures of scenarios) {
console.log(`escenario [${failures.join(", ")}]:`);
try {
const r = createWithRetry(new FakeClient(failures), system, messages);
console.log(` respuesta: ${r.content[0].text}`);
} catch (e) {
console.log(` me rindo: ${e.status} ${e.message}`);
}
}
Salida
escenario [429, 500]:
intento 1: 429, espero 1 s
intento 2: 500, espero 2 s
respuesta: El pedido 1042 está con el mensajero y llega mañana antes de las 18:00.
escenario [401]:
me rindo: 401 clave no válida
escenario [429, 429, 429, 429]:
intento 1: 429, espero 1 s
intento 2: 429, espero 2 s
intento 3: 429, espero 4 s
me rindo: 429 demasiadas solicitudes

Los SDK oficiales de los proveedores suelen hacer esto por dentro: reintentan ellos mismos los errores temporales, con una espera creciente. Lee la documentación y no pongas un bucle propio encima: los intentos se multiplican. Escribes tu propio bucle cuando llamas a la API directamente por HTTP, como en el bloque «Ejecútalo en tu computadora».

Los timeouts, los reintentos con jitter y el circuit breaker están en la lección sobre resiliencia.

Pruébalo

Predice la salida

El cliente falso devuelve primero 429 y luego 400. system y messages son como arriba. ¿Qué imprimirá el programa?

const client = new FakeClient([429, 400]);
try {
const r = createWithRetry(client, system, messages);
console.log(`respuesta: ${r.content[0].text}`);
} catch (e) {
console.log(`me rindo: ${e.status}`);
}
Respuesta

La idea

Lo que ve el cliente

Los reintentos no siempre ayudan. Cuando también falla el último intento, el cliente no debe ver «ApiException: 503». El programa captura el error y dice algo amable y verdadero: «Ahora mismo no puedo responder. Te paso con un compañero.» El error va al registro para el equipo.

Más precisamente, para curiosos

Jitter. Cuando miles de programas fallan a la vez y esperan exactamente 1, 2, 4 segundos, vuelven a intentarlo a la vez. A la espera se le añade una parte aleatoria (jitter) para repartirlos.

Timeout. La solicitud también puede quedarse colgada, así que cada llamada por la red tiene un techo de tiempo para la respuesta. Una respuesta larga es lenta, por eso max_tokens protege el tiempo y el dinero a la vez. Más sobre lentitud y costo está en la lección sobre costo y latencia.

Límites. 429 significa que alcanzaste el límite de solicitudes o de tokens por minuto. Un reintento ayuda en un pico. Si lo alcanzas todo el tiempo, la solución son menos solicitudes, solicitudes más cortas o un límite más alto, no más reintentos.

Lo que viene. El prompt de sistema es el primer paso hacia el prompt como código. Una respuesta que el programa lee, y no solo muestra, es salida estructurada, y un modelo que pide datos al programa por su cuenta es uso de herramientas. El texto de un usuario o de un documento que intenta cambiar las instrucciones (prompt injection) está en el módulo sobre agentes, más adelante en el curso.

Ejercicio

Una respuesta o una negativa amable

Resuelve la tarea en tu editor.

Escribe askSupport(client, system, question): llama a createWithRetry y devuelve el texto de la respuesta, y si también falla el último intento (o el error es permanente), devuelve «Ahora mismo no puedo responder. Te paso con un compañero.». Ejecútala con la pregunta «¿Puedo devolver la chaqueta?» y cuatro escenarios del cliente falso: sin errores; cuatro veces 503; 401; un 429.

Salida
escenario []:
Puedes devolver el artículo hasta 14 días después de recibirlo.
escenario [503, 503, 503, 503]:
intento 1: 503, espero 1 s
intento 2: 503, espero 2 s
intento 3: 503, espero 4 s
Ahora mismo no puedo responder. Te paso con un compañero.
escenario [401]:
Ahora mismo no puedo responder. Te paso con un compañero.
escenario [429]:
intento 1: 429, espero 1 s
Puedes devolver el artículo hasta 14 días después de recibirlo.

Más tareas

  • Un registro para el equipo. En una negativa, imprime una línea aparte con el código y el texto del error antes de la respuesta amable. El cliente ve solo la amable.
  • La respuesta cortada. Añade a createWithRetry un parámetro para max_tokens. Si stop_reason es max_tokens, devuelve la respuesta con «…» al final y registra una advertencia. Pruébalo con 8.

Test

Ponte a prueba

1. El cliente escribe «Hola, tengo el pedido 1042.» y luego «¿Cuándo llegará?». En la segunda solicitud el programa envía solo la pregunta nueva. ¿Qué pasa?
2. La respuesta llega con stop_reason: max_tokens. ¿Qué significa?
3. ¿Qué errores vale la pena reintentar?
4. ¿Por qué se duplica la espera entre los intentos (1, 2, 4 s)?
5. ¿Dónde va la clave de la API?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·