Optimiser le coût des appels LLM: prompt caching, petits modèles et batch

1. Tổng quan

Quand une fonctionnalité utilisant un LLM passe de la démo à la production, le coût devient généralement le premier problème à résoudre. La facture augmente avec le nombre de tokens, et les tokens augmentent avec le nombre d’utilisateurs, la longueur du prompt et le nombre d’appels de l’agent.

Cet article présente trois techniques courantes pour réduire les coûts: prompt caching, routage vers des petits modèles et traitement par batch. Chaque technique inclut un exemple en Java 21 / Spring Boot 3.x. Après la lecture, vous saurez comment organiser votre prompt pour tirer parti du cache, comment choisir un modèle selon le type de tâche, et où utiliser le batch. L’article indique aussi quand ne pas appliquer chaque technique.

2. Chi phí LLM đến từ đâu

La plupart des fournisseurs facturent selon le nombre de tokens et les divisent en trois catégories:

  • Input tokens: system prompt, historique de conversation, documents RAG, définitions d’outils.
  • Cached input tokens: portion input relue depuis le cache, généralement moins chère que les tokens input normaux.
  • Output tokens: contenu généré par le modèle, généralement plus cher que les tokens input.

Dans de nombreuses applications, les tokens input constituent la majorité du coût. La raison est que chaque requête renvoie le même long system prompt, le même ensemble d’outils, le même fragment de document. Nous modélisons les coûts comme suit:

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) {
        // Prix illustratifs en unités supposées, PAS les vrais prix d'aucun fournisseur
        var pricing = new ModelPricing(10, 1, 30);
        System.out.printf("Sans cache: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
        System.out.printf("Avec cache:    %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
    }
}

TokenUsage contient le nombre de tokens que le fournisseur retourne à chaque réponse. cost() sépare les tokens input ordinaires et les tokens input du cache, puis multiplie par les prix correspondants. Avec les prix supposés ci-dessus, le résultat est:

Sans cache: 0.215
Avec cache:    0.053

Les chiffres ici sont uniquement à titre illustratif. Ce qu’il faut comprendre, c’est la structure: quand une grande partie du prompt se répète entre les requêtes, le cache affecte directement le coût le plus important. Vous devriez mettre les vrais prix du fournisseur dans votre configuration et calculer le coût de chaque requête dans les logs.

3. Prompt caching

3.1. Khái niệm

Le prompt caching est un mécanisme côté fournisseur. La première fois qu’un prompt est traité, le fournisseur sauvegarde l’état calculé pour la partie initiale (prefix) de ce prompt. Les requêtes suivantes avec un prefix identique au caractère près relisent cet état, ce qui est moins cher et généralement plus rapide.

Trois caractéristiques à retenir:

  • Le cache correspond au prefix. Un seul caractère différent au début du prompt suffit à rendre tout ce qui suit non cachable.
  • Le cache a une durée de vie courte et généralement un prefix minimum requis. Le seuil exact dépend du fournisseur.
  • Certains fournisseurs cachent automatiquement. D’autres vous obligent à marquer le point de coupe du cache dans la requête.

3.2. Sắp xếp prompt: phần tĩnh trước, phần động sau

Le principe le plus important est de placer le contenu stable en début et le contenu changeant à la fin:

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),                          // statique
                new ChatMessage("system", "Danh mục sản phẩm:\n" + productCatalog), // statique
                new ChatMessage("user", "Hôm nay: " + today + "\nCâu hỏi: " + customerQuestion)); // dynamique
    }
}

Les instructions et le catalogue des produits ne changent pas entre les clients, donc ils se trouvent en début de liste. La date actuelle et la question changent avec chaque requête, donc ils se trouvent dans le dernier message.

Une erreur courante est d’écrire "Vous êtes un assistant. Il est maintenant " + LocalDateTime.now() dès la première ligne du system prompt. L’horodatage change à chaque requête, donc le cache ne correspondra jamais. Certaines erreurs similaires sont plus difficiles à détecter:

  • Sérialiser les définitions d’outils à partir d’une HashMap, ce qui rend l’ordre des clés instable.
  • Insérer le nom d’utilisateur ou l’ID de requête dans le system prompt.
  • Trier les documents RAG dans un ordre aléatoire entre les appels.

Vous pouvez écrire un test pour garder le prefix stable quand le code change:

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("Vous êtes un agent de support client pour le magasin.", "T-shirts, Jeans, Chaussures");

    @Test
    void prefixIsIdenticalAcrossDifferentRequests() {
        var first = builder.build("Vous avez la taille M?", LocalDate.of(2024, 1, 1));
        var second = builder.build("Quel est le frais de port?", LocalDate.of(2024, 1, 2));

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

Le test compare les deux premiers messages de deux requêtes différentes. Si quelqu’un insère des données dynamiques dans la partie statique, le test signalera une erreur.

3.3. Đánh dấu điểm cache tường minh

Avec un fournisseur qui demande un marquage, la requête a généralement un champ spécifiant “cache jusqu’ici”. Par exemple, le corps JSON pourrait ressembler à ceci. Le nom du champ et sa position dépendent de l’API de chaque fournisseur:

{
  "model": "your-model",
  "system": [
    { "type": "text", "text": "Vous êtes un agent de support..." },
    { "type": "text", "text": "Catalogue des produits: ...", "cache_control": { "type": "ephemeral" } }
  ],
  "messages": [ { "role": "user", "content": "Vous avez la taille M?" } ]
}

Le marqueur est placé à la fin du dernier bloc statique. Tout ce qui précède ce point est candidat au cache.

3.4. Đo tỉ lệ cache hit

La réponse retourne généralement le nombre de tokens lus depuis le cache. Vous devriez enregistrer ce chiffre, car sans données, vous ne saurez pas si le cache fonctionne:

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

Poussez ce ratio vers Micrometer ou dans les logs comme une métrique. Si le ratio chute soudainement à 0 après un déploiement, c’est presque certainement que quelqu’un a modifié le prefix.

4. Cache câu trả lời ở phía ứng dụng

Le prompt caching ne réduit que le prix des tokens input. Si la même question se pose à plusieurs reprises, vous pouvez complètement sauter l’appel LLM. Ajoutez les dépendances suivantes:

<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", "Trả lời ngắn gọn câu hỏi FAQ: " + question);
    }

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

Vous devez ajouter @EnableCaching à une classe @Configuration. La clé du cache est la question normalisée: supprimer les espaces en excès et convertir en minuscules. Ainsi, “Quel est le coût d’expédition?” et “ quel est le COÛT d’expédition? “ utilisent le même résultat.

Note: @Cacheable fonctionne via un proxy. Si une autre méthode de la même classe appelle answer() directement, le cache sera contourné. Ne cachez pas les réponses contenant des données personnalisées, comme l’état de la commande d’un client spécifique, avec une clé qui ne contient pas l’identifiant de l’utilisateur.

5. Định tuyến sang model nhỏ

De nombreuses tâches ne nécessitent pas le modèle le plus puissant: classification des tickets, extraction de champs d’e-mails, résumés courts, vérification du format. Les petits modèles sont généralement beaucoup moins chers et plus rapides. C’est pourquoi le “model routing” attire l’attention de nombreuses équipes.

L’approche pratique est le cascading: appeler d’abord le petit modèle, vérifier la sortie, et seulement passer au grand modèle si la sortie n’est pas valide.

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 = "Classez le ticket dans l'une des étiquettes " + LABELS + ". Retournez seulement l'étiquette.\nTicket: " + ticket;

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

Le petit modèle traite d’abord. S’il retourne exactement une étiquette dans l’ensemble autorisé, on utilise le résultat immédiatement. Sinon, par exemple si le modèle répond de manière divaguante, on rappelle avec le grand modèle. L’étape de vérification doit être déterministe: parser JSON, correspondre à enum, valider le schéma. Ne demandez pas au petit modèle s’il est “confiant”.

Test bằng một fake 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") ? "Peut-être facturation?" : "BILLING";
        };

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

        assertThat(classifier.classify("J'ai été débité deux fois")).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("Le produit n'est pas arrivé");

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

Vous devriez mettre les noms de modèle dans la configuration au lieu de les coder en dur. De cette façon, vous pouvez changer de modèle sans reconstruire:

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

Vous devriez également surveiller le taux d’escalade. Si la plupart des requêtes doivent toujours aller au grand modèle, le cascading vous coûte plus cher car chaque requête est appelée deux fois.

6. Batch

6.1. Gộp nhiều item vào một request

Quand vous avez besoin d’analyser de nombreux petits éléments, par exemple 50 avis de produits, vous pouvez les combiner dans un prompt. La partie instructions n’est envoyée qu’une seule fois au lieu de 50 fois:

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

public static String buildSentimentPrompt(List<Review> reviews) {
    var sb = new StringBuilder("""
            Pour chaque avis, retournez une ligne JSON: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
            """);
    reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
    return sb.toString();
}

Vous devriez garder chaque groupe à une taille raisonnable. Les groupes trop grands rendent le modèle susceptible de manquer des éléments. De plus, une erreur d’analyse cassera tout le groupe. Vérifiez toujours que tous les id d’entrée se trouvent dans la sortie, et réessayez séparément les éléments manquants.

6.2. Batch API bất đồng bộ

De nombreux fournisseurs offrent une Batch API. Vous envoyez un fichier contenant plusieurs requêtes, et le fournisseur les traite sur une période plus longue, en échange d’un prix généralement inférieur aux appels synchrones. Le format courant est JSONL, une requête par ligne avec un custom_id pour associer les résultats. La structure exacte dépend du fournisseur:

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

Chaque avis devient une ligne JSON indépendante. Le custom_id vous aide à savoir quel résultat appartient à quel avis, car l’ordre de retour n’est pas garanti. La sortie ressemble à ceci:

{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"Classez le sentiment: Livraison rapide"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"Classez le sentiment: Le t-shirt est déchiré"}]}}

L’ordre des clés dans JSON peut être différent car Map.of ne conserve pas l’ordre. Cela n’affecte pas la Batch API. Le processus comprend généralement quatre étapes: un travail @Scheduled la nuit crée le fichier, télécharge et crée un batch, enregistre l’ID du batch dans la base de données, puis un autre travail vérifie périodiquement le statut et télécharge les résultats.

7. Khi nào không nên dùng

  • Prompt caching a peu d’avantages quand le prompt est court, quand le prefix change pour chaque utilisateur, ou quand le trafic est si faible que le cache expire avant la requête suivante. Avec un fournisseur qui facture à chaque fois qu’il écrit le cache, marquer le cache pour du contenu qui n’est jamais relù augmentera les coûts.
  • Response cache ne convient pas quand la réponse dépend de données qui changent constamment comme l’inventaire ou les prix, quand la personnalisation est nécessaire, ou quand les utilisateurs attendent des réponses variées.
  • Les petits modèles ne conviennent pas aux tâches nécessitant un raisonnement multi-étapes, l’écriture ou l’examen de code complexe, ou les décisions ayant de grandes conséquences (légales, médicales, financières) quand vous n’avez pas de moyen de vérifier la sortie de manière déterministe. Ne changez pas de modèle uniquement sur un sentiment. Exécutez d’abord sur un ensemble de données d’évaluation.
  • Batch ne peut pas être utilisé pour tous les flux nécessitant une réponse immédiate comme le chat ou l’autocomplétion. Combiner plusieurs éléments ne convient pas non plus quand les éléments nécessitent un long contexte séparé, ou quand une erreur sur un élément ne doit pas affecter les autres.

Note: Mesurez avant d’optimiser. Enregistrez TokenUsage, le modèle utilisé et la feature appelée pour chaque requête. Généralement, seuls quelques points de terminaison représentent la majorité des coûts, et c’est là où vous devriez commencer.

8. Kết luận

Les coûts LLM proviennent principalement des tokens répétés et de l’utilisation de modèles plus puissants que nécessaire. Chaque technique dans cet article vise une source de gaspillage:

  • Prompt caching: placer la partie statique en début, garder le prefix stable et mesurer le taux de cache hit.
  • Response cache: ignorer l’appel LLM pour les questions répétées.
  • Cascade petit modèle → grand modèle: vérifier la sortie de manière déterministe et ne faire l’escalade que si nécessaire.
  • Batch: combiner les éléments pour partager la partie instructions, et utiliser l’API Batch pour les travaux n’ayant pas besoin d’un résultat immédiat.

Ces techniques peuvent être combinées ensemble. Par exemple, un travail de classification la nuit peut utiliser un petit modèle, envoyer via l’API Batch, avec un prompt ayant un prefix stable. L’important est que vous puissiez mesurer le coût de chaque requête. Avec des données, vous savez seulement quel changement est vraiment efficace.

llmaispring-bootcost-optimizationtech-trends