Otimizando custos de chamadas LLM: prompt caching, modelos pequenos e batch

1. Visão geral

Quando um recurso que usa LLM vai de um protótipo para produção, o custo geralmente se torna o primeiro problema a resolver. A fatura aumenta com o número de tokens, e tokens aumentam com o número de usuários, comprimento do prompt e quantidade de chamadas do agente.

Este artigo apresenta três técnicas comuns para reduzir custos: prompt caching, roteamento para modelos menores e processamento em batch. Cada técnica tem exemplos em Java 21 / Spring Boot 3.x. Após ler, você saberá como organizar prompts para aproveitar o cache, como escolher modelos de acordo com a tarefa, e quando usar batch. O artigo também aponta quando não deve-se aplicar cada técnica.

2. De onde vêm os custos de LLM

A maioria dos provedores cobra por token e divide em três grupos:

  • Input tokens: system prompt, histórico de conversa, documentos RAG, definições de ferramentas.
  • Cached input tokens: parte do input lida novamente do cache, geralmente mais barata que input normal.
  • Output tokens: conteúdo gerado pelo modelo, geralmente mais caro que input.

Em muitas aplicações, input consome a maior parte do custo. A razão é que cada request envia novamente o mesmo system prompt longo, o mesmo conjunto de ferramentas, o mesmo documento. Modelamos o custo assim:

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) {
        // Preços ilustrativos em unidades fictícias, NÃO são preços reais de nenhum provedor
        var pricing = new ModelPricing(10, 1, 30);
        System.out.printf("Sem cache: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
        System.out.printf("Com cache:    %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
    }
}

TokenUsage contém o número de tokens que o provedor retorna em cada response. cost() separa a parte do input normal da parte do input obtida do cache, depois multiplica pela unidade de preço correspondente. Com o conjunto de preços fictício acima, o resultado é:

Sem cache: 0.215
Com cache:    0.053

Os números aqui são apenas para ilustrar. O ponto é a estrutura: quando a maior parte do prompt se repete entre requests, o cache impacta diretamente na conta maior. Você deve inserir os preços reais do seu provedor na configuração e calcular o custo para cada request nos logs.

3. Prompt caching

3.1. Conceito

Prompt caching é um mecanismo no lado do provedor. Quando processa um prompt pela primeira vez, o provedor salva o estado já calculado para a parte inicial (prefix) desse prompt. Requisições subsequentes com prefix exatamente igual caractere por caractere lerão novamente esse estado, portanto será mais barato e geralmente também mais rápido.

Três características que você precisa lembrar:

  • O cache corresponde por prefix. Basta um caractere diferente no início do prompt para que toda a parte posterior não possa usar cache.
  • O cache tem tempo de vida curto e geralmente exige prefix suficientemente longo. O limite exato depende do provedor.
  • Alguns provedores fazem cache automaticamente. Outros exigem que você marque o ponto de corte do cache no request.

3.2. Organizando o prompt: parte estática primeiro, parte dinâmica depois

O princípio mais importante é colocar conteúdo estável no início e conteúdo que muda no 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 produtos:\n" + productCatalog), // estático
                new ChatMessage("user", "Hoje: " + today + "\nPergunta: " + customerQuestion)); // dinâmico
    }
}

As instruções e o catálogo de produtos não mudam entre clientes, então ficam no início da lista. A data atual e a pergunta mudam de acordo com cada request, então ficam na mensagem final.

Um erro comum é escrever "Você é um assistente. Agora é " + LocalDateTime.now() bem na primeira linha do system prompt. O timestamp muda a cada request, então o cache nunca bate. Alguns erros semelhantes são mais difíceis de detectar:

  • Serializar definições de ferramenta a partir de um HashMap, fazendo a ordem das chaves ficar instável.
  • Inserir nome de usuário ou ID de request no system prompt.
  • Ordenar documentos RAG em ordem aleatória entre chamadas.

Você pode escrever um teste para manter o prefix estável quando o código muda:

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("Você é um funcionário de suporte da loja.", "Camisetas, Calças jeans, Sapatos");

    @Test
    void prefixIsIdenticalAcrossDifferentRequests() {
        var first = builder.build("Ainda tem tamanho M?", LocalDate.of(2024, 1, 1));
        var second = builder.build("Quanto custa o frete?", LocalDate.of(2024, 1, 2));

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

O teste compara as duas primeiras mensagens de dois requests diferentes. Se depois alguém colocar dados dinâmicos na parte estática, o teste vai reportar o erro imediatamente.

3.3. Marcando o ponto de cache explicitamente

Com provedores que exigem marcação, o request geralmente tem um campo indicando “cache até aqui”. Por exemplo, um corpo JSON pode parecer assim. O nome do campo e a posição dependem da API de cada provedor:

{
  "model": "seu-modelo",
  "system": [
    { "type": "text", "text": "Você é um funcionário de suporte..." },
    { "type": "text", "text": "Catálogo de produtos: ...", "cache_control": { "type": "ephemeral" } }
  ],
  "messages": [ { "role": "user", "content": "Ainda tem tamanho M?" } ]
}

O marcador de ponto é colocado no final do último bloco estático. Todo conteúdo anterior ao marcador é candidato para cache.

3.4. Medindo a taxa de acerto do cache

A response geralmente retorna o número de tokens lidos do cache. Você deve registrar esse número, pois sem os dados você não sabe se o cache está funcionando:

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

Envie essa taxa para o Micrometer ou registre como métrica. Quando a taxa cai subitamente para 0 após um deploy, é quase certo que alguém alterou o prefix.

4. Cache de respostas no lado da aplicação

O prompt caching reduz apenas o preço do input. Se a mesma pergunta for feita repetidamente, você pode evitar completamente a chamada LLM. Adicione as seguintes dependências:

<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", "Responda brevemente a pergunta de FAQ: " + question);
    }

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

Você precisa adicionar @EnableCaching a uma classe @Configuration. A chave do cache é a pergunta normalizada: espaços desnecessários removidos e convertidos para minúsculas. Assim, “Qual é o frete?” e “ qual EH o FRETE? “ usam o mesmo resultado.

Nota: @Cacheable funciona através de proxy. Se outro método na mesma classe chamar answer() diretamente, o cache será ignorado. Não faça cache de respostas com dados personalizados, como status de pedido de um cliente específico, usando uma chave que não inclua o identificador do usuário.

5. Roteamento para modelos menores

Muitas tarefas não precisam do modelo mais poderoso: classificação de tickets, extração de campos de emails, resumos curtos, validação de formato. Modelos menores geralmente são significativamente mais baratos e rápidos. Esta é a razão pela qual “model routing” está recebendo muita atenção dos times.

A abordagem prática é cascade: chamar o modelo pequeno primeiro, verificar a saída, e só escalar para o modelo grande quando a saída não é 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 = "Classifique o ticket em um dos rótulos " + LABELS + ". Retorne apenas o rótulo.\nTicket: " + ticket;

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

O modelo pequeno processa primeiro. Se retornar exatamente um rótulo do conjunto permitido, usamos esse resultado logo. Caso contrário, por exemplo, o modelo retorna uma resposta longa, chamamos novamente com o modelo grande. A verificação deve ser determinística: parse de JSON, correspondência de enum, validação de schema. Não pergunte ao modelo pequeno se ele está “confiante”.

Teste com um LlmClient falso:

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") ? "Pode ser pagamento?" : "BILLING";
        };

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

        assertThat(classifier.classify("Fui cobrado duas vezes")).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("Meu pedido não chegou");

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

Você deve colocar nomes de modelos na configuração em vez de hardcodar. Assim você pode trocar o modelo sem recompilar:

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

Você também deve monitorar a taxa de escalonamento. Se a maioria dos requests ainda precisar ir para o modelo grande, o cascade está fazendo você gastar mais porque cada request é chamado duas vezes.

6. Batch

6.1. Agrupando vários itens em um request

Quando precisa analisar muitos itens curtos, por exemplo 50 avaliações de produtos, você pode agrupá-los em um prompt. A parte de instruções é enviada apenas uma vez em vez de 50 vezes:

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

public static String buildSentimentPrompt(List<Review> reviews) {
    var sb = new StringBuilder("""
            Para cada avaliação, retorne uma linha JSON: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
            """);
    reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
    return sb.toString();
}

Você deve manter cada grupo em tamanho razoável. Grupos muito grandes fazem o modelo tender a perder itens. Além disso, um erro de parse danificará todo o grupo. Sempre verifique se todos os id de entrada estão presentes na saída, e refaça individualmente os itens que faltam.

6.2. Batch API assíncrono

Muitos provedores fornecem Batch API. Você envia um arquivo contendo vários requests, e o provedor processa durante um período mais longo, em troca de um preço geralmente mais baixo do que chamadas síncronas. Um formato comum é JSONL, uma linha por request com custom_id para corresponder resultados. A estrutura exata depende do provedor:

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", "Classifique o sentimento: " + r.text())))))
            .map(line -> {
                try {
                    return mapper.writeValueAsString(line);
                } catch (Exception e) {
                    throw new IllegalStateException(e);
                }
            })
            .collect(Collectors.joining("\n"));
}

Cada avaliação vira uma linha JSON independente. custom_id ajuda você a saber qual resultado pertence a qual avaliação, pois a ordem de retorno não é garantida. A saída fica assim:

{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"Classifique o sentimento: Entrega rápida"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"Classifique o sentimento: Camisa rasgada"}]}}

A ordem das chaves em JSON pode variar porque Map.of não mantém ordem. Isso não afeta a Batch API. O processo geralmente tem quatro etapas: um job @Scheduled à noite cria o arquivo, upload e cria o batch, salva o ID do batch no banco de dados, depois outro job verifica periodicamente o status e baixa os resultados.

7. Quando não usar

  • Prompt caching quase não ajuda quando o prompt é curto, quando o prefix muda por usuário, ou quando o tráfego é tão esparso que o cache expira antes do próximo request. Com provedores que cobram para escrever cache, marcar cache para conteúdo nunca lido novamente aumentará o custo.
  • Response cache não é apropriado quando a resposta depende de dados em constante mudança como inventário ou preço, quando requer personalização, ou quando o usuário espera respostas variadas.
  • Modelos menores não são apropriados para tarefas que exigem raciocínio em múltiplos passos, escrita ou revisão de código complexo, ou decisões com consequências maiores (legal, médica, financeira) quando você não tem uma forma determinística de validar a saída. Não troque modelo apenas por sensação. Execute em um conjunto de dados de avaliação primeiro.
  • Batch não funciona para fluxos que precisam de resposta imediata como chat ou autocomplete. Agrupar vários itens também não é apropriado quando cada item precisa de contexto longo separado, ou quando um item com erro não deve afetar outros.

Nota: Meça antes de otimizar. Registre TokenUsage, modelo usado e recurso chamador para cada request. Geralmente apenas alguns endpoints consomem a maior parte dos custos, e é aí que você deveria começar.

8. Conclusão

O custo de LLM vem principalmente de tokens repetidos e do uso de modelos mais poderosos que o necessário. Cada técnica deste artigo visa uma fonte de desperdício:

  • Prompt caching: coloque a parte estática no início, mantenha o prefix estável e meça a taxa de acerto do cache.
  • Response cache: evite chamadas LLM para perguntas repetidas.
  • Cascade de modelo pequeno → grande: valide a saída de forma determinística e escalone apenas quando necessário.
  • Batch: agrupe itens para compartilhar a parte de instruções e use Batch API para trabalhos que não precisam de resultados imediatos.

Essas técnicas podem ser combinadas. Por exemplo, um job de classificação noturno pode usar um modelo pequeno, enviar via Batch API, com um prompt que tem um prefix estável. O importante é que você meça o custo por cada request. Com dados, você saberá qual mudança realmente faz diferença.

llmaispring-bootcost-optimizationtech-trends