Optimizar costos de llamadas a LLM: prompt caching, modelos pequeños y batch

1. Descripción general

Cuando una funcionalidad que usa LLM pasa de demo a producción, el costo suele convertirse en el primer problema a resolver. La factura aumenta según el número de tokens, y los tokens aumentan según el número de usuarios, la longitud del prompt y el número de llamadas del agent.

Este artículo presenta tres técnicas comunes para reducir costos: prompt caching, enrutamiento a modelos pequeños y procesamiento batch. Cada técnica tiene ejemplos en Java 21 / Spring Boot 3.x. Después de leer, sabrás cómo organizar prompts para aprovechar la caché, cómo elegir modelo según el tipo de tarea, y dónde usar batch. El artículo también señala cuándo no se debe aplicar cada técnica.

2. De dónde provienen los costos de LLM

La mayoría de los proveedores cobran por tokens y los dividen en tres grupos:

  • Input tokens: system prompt, historial de conversación, documentos RAG, definición de tools.
  • Cached input tokens: la parte del input leída nuevamente desde caché, normalmente más barata que el input regular.
  • Output tokens: el contenido generado por el modelo, normalmente más caro que el input.

En muchas aplicaciones, el input representa la mayor parte del costo. La razón es que cada request envía de nuevo el mismo system prompt largo, el mismo conjunto de tools, el mismo fragmento de documento. Modelamos el costo como sigue:

public record TokenUsage(long inputTokens, long cachedInputTokens, long outputTokens) {}

public record ModelPricing(double inputPerMillion, double cachedInputPerMillion, double outputPerMillion) {

    public double cost(TokenUsage u) {
        long uncached = u.inputTokens() - u.cachedInputTokens();
        return (uncached * inputPerMillion
                + u.cachedInputTokens() * cachedInputPerMillion
                + u.outputTokens() * outputPerMillion) / 1_000_000.0;
    }

    public static void main(String[] args) {
        // Precio ilustrativo en unidades ficticias, NO son precios reales de ningún proveedor
        var pricing = new ModelPricing(10, 1, 30);
        System.out.printf("Sin caché: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
        System.out.printf("Con caché:    %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
    }
}

TokenUsage contiene el número de tokens que el proveedor devuelve en cada response. cost() separa la parte de input regular de la parte del input obtenida de caché, luego multiplica por el precio unitario correspondiente. Con el conjunto de precios ficticios anterior, el resultado es:

Sin caché: 0.215
Con caché:    0.053

Los números aquí son solo ilustrativos. Lo que debe aprenderse es la estructura: cuando la mayor parte del prompt se repite entre requests, la caché impacta directamente en el gasto más grande. Debes incluir los precios reales del proveedor en la configuración y calcular el costo de cada request en los logs.

3. Prompt caching

3.1. Concepto

El prompt caching es un mecanismo del lado del proveedor. La primera vez que se procesa un prompt, el proveedor guarda el estado ya calculado para la parte inicial (prefix) de ese prompt. Los requests posteriores que tengan un prefix idéntico carácter por carácter releerán este estado, por lo que será más barato y normalmente también más rápido.

Tres características que debes recordar:

  • La caché coincide según prefix. Solo basta que un carácter sea diferente al inicio del prompt para que toda la parte siguiente no pueda usar caché.
  • La caché tiene tiempo de vida corto y normalmente requiere que el prefix sea lo suficientemente largo. El umbral específico depende del proveedor.
  • Algunos proveedores cachean automáticamente. Otros te obligan a marcar el punto de corte de caché en la request.

3.2. Organizar prompt: parte estática primero, parte dinámica después

El principio más importante es poner contenido estable al inicio y contenido que cambia al final:

import java.time.LocalDate;
import java.util.List;

public record ChatMessage(String role, String content) {}

public final class SupportPromptBuilder {

    private final String instructions;
    private final String productCatalog;

    public SupportPromptBuilder(String instructions, String productCatalog) {
        this.instructions = instructions;
        this.productCatalog = productCatalog;
    }

    public List<ChatMessage> build(String customerQuestion, LocalDate today) {
        return List.of(
                new ChatMessage("system", instructions),                          // estático
                new ChatMessage("system", "Catálogo de productos:\n" + productCatalog), // estático
                new ChatMessage("user", "Hoy: " + today + "\nPregunta: " + customerQuestion)); // dinámico
    }
}

Las instrucciones y el catálogo de productos no cambian entre clientes, por lo que están al inicio de la lista. La fecha actual y la pregunta cambian con cada request, por lo que están en el último mensaje.

Un error común es escribir "Eres un asistente. Ahora son " + LocalDateTime.now() justo en la primera línea del system prompt. El timestamp cambia en cada request, por lo que la caché nunca dará positivo. Algunos errores similares son más difíciles de detectar:

  • Serializar la definición de tools desde un HashMap, haciendo que el orden de las claves sea inestable.
  • Insertar el nombre del usuario o ID de request en el system prompt.
  • Clasificar documentos RAG en orden aleatorio entre llamadas.

Puedes escribir una prueba para mantener el prefix estable cuando el código cambia:

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class SupportPromptBuilderTest {

    private final SupportPromptBuilder builder =
            new SupportPromptBuilder("Eres un empleado de atención al cliente de una tienda.", "Camiseta, Pantalones vaqueros, Zapatos");

    @Test
    void prefixIsIdenticalAcrossDifferentRequests() {
        var first = builder.build("¿Hay talla M?", LocalDate.of(2024, 1, 1));
        var second = builder.build("¿Cuánto cuesta el envío?", LocalDate.of(2024, 1, 2));

        assertThat(first.subList(0, 2)).isEqualTo(second.subList(0, 2));
        assertThat(first.get(2)).isNotEqualTo(second.get(2));
    }
}

La prueba compara los dos primeros mensajes de dos requests diferentes. Si en el futuro alguien inserta datos dinámicos en la parte estática, la prueba informará del error de inmediato.

3.3. Marcar puntos de caché explícitamente

Con proveedores que lo requieren, la request generalmente tiene un campo que especifica “cache hasta aquí”. Por ejemplo, el cuerpo JSON puede verse así. El nombre del campo y su ubicación dependen de la API de cada proveedor:

{
  "model": "your-model",
  "system": [
    { "type": "text", "text": "Eres un empleado de atención al cliente..." },
    { "type": "text", "text": "Catálogo de productos: ...", "cache_control": { "type": "ephemeral" } }
  ],
  "messages": [ { "role": "user", "content": "¿Hay talla M?" } ]
}

La marca de caché se coloca al final del último bloque estático. Todo el contenido antes de ese punto es un candidato para ser cacheado.

3.4. Medir la tasa de acierto de caché

La response normalmente devuelve el número de tokens leídos desde caché. Debes registrar este número, ya que sin la métrica no sabrás si la caché está funcionando:

public static double cacheHitRatio(TokenUsage u) {
    return u.inputTokens() == 0 ? 0 : (double) u.cachedInputTokens() / u.inputTokens();
}

Envía esta tasa a Micrometer o regístrala como métrica. Cuando la tasa cae repentinamente a 0 después de un deploy, es casi seguro que alguien ha cambiado el prefix.

4. Cachear respuestas del lado de la aplicación

El prompt caching solo reduce el precio de la parte de entrada. Si la misma pregunta se repite, puedes evitar completamente la llamada a LLM. Añade las siguientes dependencias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
spring.cache.cache-names=faq-answers
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=6h
public interface LlmClient {
    String complete(String model, String prompt);
}

@Service
public class FaqAnswerService {

    private final LlmClient llmClient;

    public FaqAnswerService(LlmClient llmClient) {
        this.llmClient = llmClient;
    }

    @Cacheable(cacheNames = "faq-answers", key = "#root.target.normalize(#question)")
    public String answer(String question) {
        return llmClient.complete("small-model", "Responde brevemente la pregunta de FAQ: " + question);
    }

    public String normalize(String question) {
        return question.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
    }
}

Necesitas añadir @EnableCaching en una clase @Configuration. La clave de la caché es la pregunta normalizada: elimina espacios en blanco extra y convierte a minúsculas. Gracias a esto, “¿Cuánto cuesta el envío?” y “ ¿CUÁNTO CUESTA EL ENVÍO? “ comparten el mismo resultado.

Nota: @Cacheable funciona a través de un proxy. Si otro método en la misma clase llama a answer() directamente, la caché será omitida. No cachees respuestas con datos personalizados, como el estado del pedido de un cliente específico, usando una clave que no contenga el identificador del usuario.

5. Enrutamiento a modelos pequeños

Muchas tareas no necesitan el modelo más poderoso: clasificar tickets, extraer campos de emails, resumir brevemente, validar formato. Los modelos pequeños son normalmente más baratos y significativamente más rápidos. Esta es la razón por la que el “model routing” está atrayendo la atención de muchos equipos.

La forma práctica es cascade: llamar primero al modelo pequeño, verificar la salida, y solo cambiar al modelo grande cuando la salida no sea válida.

import java.util.Set;

public class TicketClassifier {

    private static final Set<String> LABELS = Set.of("BILLING", "SHIPPING", "TECHNICAL", "OTHER");

    private final LlmClient llm;
    private final String smallModel;
    private final String largeModel;

    public TicketClassifier(LlmClient llm, String smallModel, String largeModel) {
        this.llm = llm;
        this.smallModel = smallModel;
        this.largeModel = largeModel;
    }

    public String classify(String ticket) {
        String prompt = "Clasifica el ticket en una etiqueta de " + LABELS + ". Solo devuelve la etiqueta.\nTicket: " + ticket;

        String label = llm.complete(smallModel, prompt).strip();
        if (LABELS.contains(label)) {
            return label;
        }
        return llm.complete(largeModel, prompt).strip();
    }
}

El modelo pequeño procesa primero. Si devuelve una etiqueta válida en el conjunto permitido, usamos ese resultado directamente. Si no, por ejemplo si el modelo devuelve una respuesta vaga, volvemos a llamar con el modelo grande. El paso de validación debe ser determinista: parsear JSON, comparar enum, validar schema. No preguntes al modelo pequeño si está “seguro”.

Prueba con un falso LlmClient:

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;

class TicketClassifierTest {

    @Test
    void escalatesToLargeModelWhenSmallModelReturnsInvalidLabel() {
        List<String> calledModels = new ArrayList<>();
        LlmClient fake = (model, prompt) -> {
            calledModels.add(model);
            return model.equals("small-model") ? "Probablemente sea de pago?" : "BILLING";
        };

        var classifier = new TicketClassifier(fake, "small-model", "large-model");

        assertThat(classifier.classify("Me cobraron dos veces")).isEqualTo("BILLING");
        assertThat(calledModels).containsExactly("small-model", "large-model");
    }

    @Test
    void staysOnSmallModelWhenOutputIsValid() {
        List<String> calledModels = new ArrayList<>();
        LlmClient fake = (model, prompt) -> {
            calledModels.add(model);
            return "SHIPPING";
        };

        new TicketClassifier(fake, "small-model", "large-model").classify("El paquete aún no ha llegado");

        assertThat(calledModels).containsExactly("small-model");
    }
}

Debes poner el nombre del modelo en la configuración en lugar de codificarlo directamente. De esta forma puedes cambiar el modelo sin necesidad de recompilar:

llm:
  models:
    small: small-model
    large: large-model

También debes monitorear la tasa de escalada. Si la mayoría de requests aún tienen que subir al modelo grande, el cascade te está haciendo gastar más dinero porque cada request se llama dos veces.

6. Batch

6.1. Agrupar múltiples elementos en una request

Cuando necesitas analizar muchos elementos cortos, por ejemplo 50 reseñas de productos, puedes agruparlos en un prompt. La parte de instrucciones se envía una sola vez en lugar de 50 veces:

public record Review(String id, String text) {}

public static String buildSentimentPrompt(List<Review> reviews) {
    var sb = new StringBuilder("""
            Para cada reseña, devuelve una línea JSON: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
            """);
    reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
    return sb.toString();
}

Debes mantener cada grupo en un tamaño razonable. Un grupo demasiado grande hace que el modelo tienda a omitir elementos. Además, un error de parseo afectará a todo el grupo. Siempre verifica que cada id de entrada esté presente en la salida, y reintenta los elementos que falten.

6.2. API Batch asincrónica

Muchos proveedores ofrecen API Batch. Envías un archivo con múltiples requests, y el proveedor los procesa durante un período de tiempo más largo, a cambio de un precio normalmente más bajo que las llamadas síncronas. El formato común es JSONL, una request por línea y con custom_id para emparejar resultados. La estructura exacta depende del proveedor:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public static String toJsonl(List<Review> reviews, String model, ObjectMapper mapper) {
    return reviews.stream()
            .map(r -> Map.of(
                    "custom_id", "review-" + r.id(),
                    "body", Map.of(
                            "model", model,
                            "messages", List.of(Map.of("role", "user",
                                    "content", "Clasifica el sentimiento: " + r.text())))))
            .map(line -> {
                try {
                    return mapper.writeValueAsString(line);
                } catch (Exception e) {
                    throw new IllegalStateException(e);
                }
            })
            .collect(Collectors.joining("\n"));
}

Cada reseña se convierte en una línea JSON independiente. El custom_id te ayuda a saber qué resultado pertenece a qué reseña, ya que no se garantiza el orden de devolución. La salida se ve así:

{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"Clasifica el sentimiento: Envío rápido"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"Clasifica el sentimiento: La camiseta llegó rota"}]}}

El orden de las claves en JSON puede ser diferente porque Map.of no preserva el orden. Esto no afecta a la API Batch. El procedimiento generalmente consta de cuatro pasos: un job @Scheduled por la noche crea el archivo, lo sube y crea el batch, guarda el ID del batch en la base de datos, luego otro job periódicamente verifica el estado y descarga los resultados.

7. Cuándo no usar

  • Prompt caching apenas es beneficioso cuando el prompt es corto, cuando el prefix cambia por usuario, o cuando el tráfico es tan escaso que la caché expira antes de la siguiente request. Con proveedores que cobran por escribir en caché, marcar contenido que nunca se vuelve a leer aumentará tu costo.
  • Response cache no es apropiado cuando la respuesta depende de datos que cambian constantemente, como inventario o precios, cuando necesita personalización, o cuando los usuarios esperan respuestas variadas.
  • Modelo pequeño no es apropiado para tareas que requieren razonamiento de múltiples pasos, escritura o revisión de código complejo, o decisiones con grandes consecuencias (legal, médica, financiera) cuando no tienes forma de verificar la salida de manera determinista. No cambies de modelo solo por intuición. Ejecuta primero en un conjunto de datos de evaluación.
  • Batch no se puede usar para flujos que necesitan respuesta inmediata como chat o autocompletado. Agrupar múltiples elementos tampoco es apropiado cuando cada elemento necesita un contexto largo separado, o cuando un error en un elemento no debe afectar a los demás.

Nota: Mide antes de optimizar. Registra TokenUsage, el modelo usado y la funcionalidad llamada para cada request. Normalmente solo unos pocos endpoints consumen la mayor parte del costo, y ese es el lugar por donde empezar.

8. Conclusión

El costo de LLM proviene principalmente de tokens que se repiten y del uso de modelos más poderosos de lo necesario. Cada técnica en este artículo apunta a una fuente de desperdicio:

  • Prompt caching: coloca la parte estática al inicio, mantén el prefix estable y mide la tasa de acierto de caché.
  • Response cache: omite la llamada a LLM para preguntas repetidas.
  • Cascade modelo pequeño → grande: valida la salida de manera determinista y solo escala cuando sea necesario.
  • Batch: agrupa elementos para compartir las instrucciones, y usa la API Batch para trabajo que no necesita resultados inmediatos.

Estas técnicas se pueden combinar entre sí. Por ejemplo, un job de clasificación nocturno puede usar un modelo pequeño, enviarse a través de la API Batch, con un prompt que tiene un prefix estable. Lo importante es que midas el costo en cada request. Con métricas, sabrás qué cambios son realmente efectivos.

llmaispring-bootcost-optimizationtech-trends