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) yassistant(las respuestas anteriores del modelo).
La respuesta lleva:
content: una lista de bloques con untype; el texto está en un bloquetext;stop_reason: por qué se detuvo el modelo,end_turn(terminó) omax_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.
record Message(String role, String content) {}
record Block(String type, String text) {}
record Usage(int inputTokens, int outputTokens) {}
record Response(List<Block> content, String stopReason, Usage usage) {}
class ApiException extends RuntimeException { final int status;
ApiException(int status, String message) { super(message); this.status = status; }}// un sustituto del cliente real: respuestas grabadas, sin redclass FakeClient { private final Deque<Integer> failures;
FakeClient(List<Integer> failures) { this.failures = new ArrayDeque<>(failures); }
Response create(String model, int maxTokens, String system, List<Message> messages) { if (!failures.isEmpty()) { int status = failures.poll(); throw new ApiException(status, reason(status)); } String text = answer(messages); String stopReason = "end_turn"; if (tokens(text) > maxTokens) { // max_tokens: la respuesta se corta a la mitad String[] words = text.split(" "); text = String.join(" ", Arrays.copyOf(words, maxTokens * 3 / 4)); stopReason = "max_tokens"; } int input = tokens(system); for (Message m : messages) { input += tokens(m.content()); } Usage usage = new Usage(input, tokens(text)); return new Response(List.of(new Block("text", text)), stopReason, usage); }
// la respuesta grabada: se elige por una palabra del último mensaje private static String answer(List<Message> messages) { String last = messages.get(messages.size() - 1).content(); boolean hasOrder = messages.stream().anyMatch(m -> m.content().contains("1042")); if (last.contains("devol")) return "Puedes devolver el artículo hasta 14 días después de recibirlo."; if (last.contains("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 private static String reason(int status) { return switch (status) { case 400 -> "solicitud no válida"; case 401 -> "clave no válida"; case 429 -> "demasiadas solicitudes"; default -> "error del servidor"; }; }
// a grandes rasgos: 4 tokens por cada 3 palabras (el número real lo da el tokenizador) static int tokens(String text) { int words = text.trim().split("\\s+").length; return (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 tokensstatic void show(Response response) { for (Block block : response.content()) { if (block.type().equals("text")) System.out.println(block.text()); } Usage u = response.usage(); System.out.printf("stop_reason: %s, input_tokens: %d, output_tokens: %d%n", response.stopReason(), u.inputTokens(), u.outputTokens());}FakeClient client = new FakeClient(List.of());String system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";List<Message> messages = List.of(new Message("user", "¿Cuándo llegará el pedido 1042?"));
Response response = client.create(MODEL, 1024, system, messages);show(response);
response = client.create(MODEL, 8, system, messages);show(response);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: 19El pedido 1042 está con elstop_reason: max_tokens, input_tokens: 23, output_tokens: 8Con 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:
{ "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):
{ "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.
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;
String key = System.getenv("LLM_API_KEY");String body = """ { "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?"} ] } """;HttpRequest request = HttpRequest.newBuilder(URI.create("https://<proveedor>/messages")) .header("Authorization", "Bearer " + key) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build();HttpResponse<String> response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString());System.out.println(response.statusCode());System.out.println(response.body()); // JSON: content, stop_reason, usageLos 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.
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.
String key = System.getenv("LLM_API_KEY");if (key == null || key.isBlank()) { System.out.println("No hay clave: define LLM_API_KEY en el entorno.");} else { System.out.println("La clave se carga del entorno. No la imprimas ni la escribas en el código.");}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.
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 respuestastatic String ask(FakeClient client, String system, List<Message> history, String text) { history.add(new Message("user", text)); Response r = client.create(MODEL, 1024, system, history); String reply = r.content().get(0).text(); history.add(new Message("assistant", reply)); return reply;}FakeClient client = new FakeClient(List.of());String system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";List<Message> history = new ArrayList<>();for (String text : List.of("Hola, tengo el pedido 1042.", "¿Cuándo llegará?")) { System.out.println("cliente: " + text); System.out.println("asistente: " + ask(client, system, history, text));}
Response alone = client.create(MODEL, 1024, system, List.of(new Message("user", "¿Cuándo llegará?")));System.out.println("solo la pregunta, sin el historial: " + alone.content().get(0).text());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.
El historial se guarda pero no se envía: la solicitud lleva solo el último mensaje. El modelo no recuerda las solicitudes anteriores, así que ve una pregunta sin número. Hay que enviar todo el historial.
Pruébalo
Completa
¿Con qué rol entra la respuesta del modelo en el historial?
static String ask(FakeClient client, String system,
List<Message> history, String text) {
history.add(new Message("user", text));
Response r = client.create(MODEL, 1024, system, history);
String reply = r.content().get(0).text();
history.add(new Message(, reply));
return reply;
}Las respuestas del modelo tienen el rol "assistant", y los mensajes del cliente "user". Así, en la siguiente solicitud el modelo sabe qué dijo él y qué dijo el cliente.
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 duplicastatic Response createWithRetry(FakeClient client, String system, List<Message> messages) { int maxAttempts = 4; int delay = 1; for (int attempt = 1; ; attempt++) { try { return client.create(MODEL, 1024, system, messages); } catch (ApiException e) { boolean last = attempt == maxAttempts; if (last || !retryable(e.status)) throw e; System.out.printf( " intento %d: %d, espero %d s%n", attempt, e.status, delay); // un programa real espera aquí: // Thread.sleep(delay * 1000L) delay *= 2; } }}
// temporales: 408, 409, 429 y 5xxstatic boolean retryable(int status) { return status == 408 || status == 409 || status == 429 || status >= 500;}Paso a paso, en palabras
- La solicitud: el modelo, el prompt de sistema y los mensajes con sus roles. Aquí hay uno, del cliente (user).
- El primer intento devuelve 429: «demasiadas solicitudes». Un error temporal.
- 429 está entre los temporales, así que el programa espera 1 s y lo intenta de nuevo.
- El segundo intento: 500, «error del servidor». También temporal.
- La espera se duplica: 2 s. El servidor sobrecargado respira.
- El tercer intento pasa.
- La respuesta: un bloque de texto, stop_reason end_turn y usage: 23 tokens de entrada y 19 de salida.
- 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 duplicastatic Response createWithRetry(FakeClient client, String system, List<Message> messages) { int maxAttempts = 4; int delay = 1; for (int attempt = 1; ; attempt++) { try { return client.create(MODEL, 1024, system, messages); } catch (ApiException e) { boolean last = attempt == maxAttempts; if (last || !retryable(e.status)) throw e; System.out.printf( " intento %d: %d, espero %d s%n", attempt, e.status, delay); // un programa real espera aquí: // Thread.sleep(delay * 1000L) delay *= 2; } }}
// temporales: 408, 409, 429 y 5xxstatic boolean retryable(int 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.
String system = "Eres un asistente de soporte de una tienda en línea. Responde brevemente.";List<Message> messages = List.of(new Message("user", "¿Cuándo llegará el pedido 1042?"));List<List<Integer>> scenarios = List.of( List.of(429, 500), List.of(401), List.of(429, 429, 429, 429));for (List<Integer> failures : scenarios) { System.out.println("escenario " + failures + ":"); try { Response r = createWithRetry(new FakeClient(failures), system, messages); System.out.println(" respuesta: " + r.content().get(0).text()); } catch (ApiException e) { System.out.println(" me rindo: " + e.status + " " + e.getMessage()); }}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álidaescenario [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 solicitudesLos 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?
FakeClient client = new FakeClient(List.of(429, 400));try { Response r = createWithRetry(client, system, messages); System.out.println("respuesta: " + r.content().get(0).text());} catch (ApiException e) { System.out.println("me rindo: " + e.status);}429 es temporal: el programa imprime el primer intento, espera 1 s y lo intenta de nuevo. 400 es permanente: retryable(400) es false, el error sale de createWithRetry de inmediato, sin una segunda línea sobre la espera, y el programa imprime que se rindió con el código 400. Nunca llega a una 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.
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
createWithRetryun parámetro paramax_tokens. Sistop_reasonesmax_tokens, devuelve la respuesta con «…» al final y registra una advertencia. Pruébalo con 8.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: El texto en trozos: tokens · Predecir el siguiente token · La ventana de contexto · Los límites del modelo
- Continúa en: El prompt es código · Salida estructurada: JSON según un esquema · Uso de herramientas: el modelo pide, el programa actúa · Evaluación: un conjunto de pruebas para el asistente
- Ejercicios sobre el tema: Aplicaciones con LLM