Observability pour applications LLM: suivi des prompts, coûts et qualité

1. Aperçu

Quand vous mettez en production une fonctionnalité utilisant un LLM (Large Language Model), les premières questions sont généralement : “Combien ça a coûté ce mois-ci ?”, “Pourquoi la réponse hier était-elle fausse ?”, “Le nouveau prompt est-il meilleur que l’ancien ?” Les metrics HTTP classiques ne répondent pas à ces questions.

Cet article explique le concept d’observabilité pour les applications LLM et pourquoi ce sujet retient l’attention. Ensuite, vous construirez une couche décorateur autour du client LLM dans Spring Boot 3 pour enregistrer les traces, tokens, coûts et signaux de qualité. La dernière partie aborde les erreurs courantes et les cas où ne pas en faire trop.

2. Pourquoi les LLM ont besoin d’une observabilité spécifique

Une API REST ordinaire est déterministe. Même entrée donne même sortie. Vous avez seulement besoin de surveiller la latence, le taux d’erreur et le débit. Les appels LLM diffèrent sur plusieurs points :

  • Non-déterministe : un même prompt peut donner des résultats différents. L’erreur la plus courante n’est pas une HTTP 500 mais un HTTP 200 avec une réponse incorrecte.
  • Coût calculé par token : la facturation dépend de la longueur du prompt et de la réponse, pas du nombre de requêtes. Un template de prompt surdimensionné peut multiplier les coûts sans que personne ne s’en aperçoive.
  • Latence très variable : le temps de réponse est proportionnel au nombre de tokens générés. La moyenne est presque insignifiante. Vous devez regarder la distribution (p95, p99).
  • Le prompt est du code : changer une phrase du system prompt est aussi un “déploiement”. Vous devez savoir quel request a exécuté quelle version de prompt.

Donc l’observabilité pour LLM tourne généralement autour de trois piliers :

  1. Traçage prompt/réponse : quel request, quel model, quelle version de prompt, combien de temps, pour quelle raison ça s’est terminé.
  2. Coûts : tokens entrés/sortis, conversion en monnaie, répartition par fonctionnalité.
  3. Qualité : feedback utilisateurs, vérifications automatisées, évaluation hors ligne.

La communauté OpenTelemetry construit aussi un ensemble de conventions de noms d’attributs pour GenAI (préfixe gen_ai.*). Cette convention évolue encore. Dans cet article, vous en emprunterez la convention de noms pour faciliter l’intégration future.

3. Configuration du projet

Ajoutez l’Actuator, le registre Prometheus et le pont OpenTelemetry pour le traçage. Les versions étant gérées par le BOM Spring Boot, vous n’avez pas besoin de les déclarer :

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configurez l’endpoint et la destination des traces :

management:
  endpoints:
    web:
      exposure:
        include: health,prometheus
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces

Ici, probability: 0.1 signifie que seulement 10% des requests envoient une trace. Les metrics sont toujours comptabilisés à 100%. Cette distinction est importante quand vous calculez les coûts à la section 5.

4. Encapsuler le client LLM avec Observation

Vous définissez une abstraction légère, indépendante du fournisseur :

public record LlmRequest(String model, String promptVersion, String feature, String prompt) {}

public record LlmResponse(String content, int inputTokens, int outputTokens, String finishReason) {}

public interface LlmClient {
    LlmResponse complete(LlmRequest request);
}

LlmResponse porte le nombre de tokens. La plupart des APIs des fournisseurs retournent un champ usage dans la réponse, donc vous n’avez qu’à la mapper sur ce record. Utilisez les chiffres que le fournisseur retourne plutôt que d’estimer les tokens vous-même.

Ensuite vient le décorateur qui ajoute l’observabilité :

public class ObservedLlmClient implements LlmClient {

    private final LlmClient delegate;
    private final ObservationRegistry observations;
    private final MeterRegistry meters;
    private final CostCalculator costs;

    public ObservedLlmClient(LlmClient delegate, ObservationRegistry observations,
                             MeterRegistry meters, CostCalculator costs) {
        this.delegate = delegate;
        this.observations = observations;
        this.meters = meters;
        this.costs = costs;
    }

    @Override
    public LlmResponse complete(LlmRequest req) {
        Observation obs = Observation.createNotStarted("llm.request", observations)
                .lowCardinalityKeyValue("gen_ai.request.model", req.model())
                .lowCardinalityKeyValue("prompt.version", req.promptVersion())
                .lowCardinalityKeyValue("feature", req.feature());

        return obs.observe(() -> {
            LlmResponse res = delegate.complete(req);
            obs.lowCardinalityKeyValue("gen_ai.response.finish_reason", res.finishReason());
            obs.highCardinalityKeyValue("gen_ai.usage.input_tokens", String.valueOf(res.inputTokens()));
            obs.highCardinalityKeyValue("gen_ai.usage.output_tokens", String.valueOf(res.outputTokens()));

            Tags tags = Tags.of("model", req.model(), "feature", req.feature());
            meters.counter("llm.tokens", tags.and("type", "input")).increment(res.inputTokens());
            meters.counter("llm.tokens", tags.and("type", "output")).increment(res.outputTokens());
            meters.counter("llm.cost.usd", tags).increment(costs.cost(req.model(), res).doubleValue());
            return res;
        });
    }
}

Les points clés :

  • Observation.createNotStarted("llm.request", ...) crée une observation. Quand un DefaultMeterObservationHandler est présent (Spring Boot le configure automatiquement), cette observation génère un timer llm.request. Quand le pont de traçage est présent, il génère aussi un span.
  • lowCardinalityKeyValue attache une étiquette à la fois à la metric ET au span. À utiliser seulement pour des valeurs avec un ensemble fini comme le nom du model, la version du prompt, le nom de la fonctionnalité.
  • highCardinalityKeyValue attache une étiquette seulement au span. Le nombre de tokens de chaque request est approprié ici. Si vous le mettez dans la metric, chaque valeur différente créerait une nouvelle time series.
  • observe(...) appelle automatiquement start/stop. S’il y a une exception, elle est enregistrée dans l’observation.
  • Les deux compteurs llm.tokens et llm.cost.usd sont comptabilisés pour chaque request, indépendamment du sampling du traçage.

Inscrivez le bean :

@Configuration
@EnableConfigurationProperties(LlmPricingProperties.class)
class LlmConfig {

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // votre client qui appelle l'API réelle
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

Sur /actuator/prometheus, vous verrez des séries comme :

llm_request_seconds_count{feature="support-chat",gen_ai_request_model="model-a",prompt_version="v3",...} 42
llm_tokens_total{feature="support-chat",model="model-a",type="input"} 50400
llm_cost_usd_total{feature="support-chat",model="model-a"} 0.0441

Note : Si vous utilisez Spring AI, ce framework a déjà une observation intégrée pour le chat model. Comprendre le mécanisme ci-dessus vous aide à lire correctement ces metrics et à compléter ce qui manque, par exemple la tag feature ou prompt.version.

5. Calculer les coûts à partir des tokens

La tarification change selon le fournisseur et avec le temps, ne la hard-codez pas. Mettez-la en configuration :

@ConfigurationProperties(prefix = "llm")
public record LlmPricingProperties(Map<String, ModelPrice> pricing) {
    public record ModelPrice(BigDecimal inputPerMillion, BigDecimal outputPerMillion) {}
}
llm:
  pricing:
    model-a:
      input-per-million: 0.50   # prix illustratif, remplacez par votre tarification réelle
      output-per-million: 1.50

La classe de calcul :

public class CostCalculator {

    private static final BigDecimal MILLION = BigDecimal.valueOf(1_000_000);
    private final Map<String, LlmPricingProperties.ModelPrice> pricing;

    public CostCalculator(Map<String, LlmPricingProperties.ModelPrice> pricing) {
        this.pricing = pricing;
    }

    public BigDecimal cost(String model, LlmResponse res) {
        var price = pricing.get(model);
        if (price == null) {
            return BigDecimal.ZERO; // devrait émettre une alerte : model n'a pas de prix
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

Avec 1 200 tokens en entrée et 300 tokens en sortie selon la tarification illustrative, le résultat est :

(1200 × 0,50 + 300 × 1,50) / 1 000 000 = 0,00105

Ce compteur vous donne le coût estimé en temps quasi-réel, ventilé par feature et model. Vous pouvez configurer des alertes, par exemple “le coût du support-chat en 1 heure dépasse le seuil X”. La facture officielle vient toujours du fournisseur. Vérifiez régulièrement pour détecter des écarts, par exemple quand il y a des tokens en cache ou une remise en masse que la formule simple ne prend pas en compte.

6. Journaliser les prompts de façon sécurisée

Quand vous debuggez une mauvaise réponse, vous voulez souvent voir le prompt complet. Mais un prompt contient souvent des données sensibles de l’utilisateur comme des emails, numéros de téléphone ou contenu de commande. Vous devez donc faire deux choses : masquer les informations sensibles et faire du sampling.

public class PromptLogger {

    private static final Logger log = LoggerFactory.getLogger(PromptLogger.class);
    private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.]+");
    private static final Pattern PHONE = Pattern.compile("\\b0\\d{9}\\b");

    private final double sampleRate;

    public PromptLogger(double sampleRate) {
        this.sampleRate = sampleRate;
    }

    public void log(LlmRequest req, LlmResponse res) {
        if (ThreadLocalRandom.current().nextDouble() >= sampleRate) {
            return;
        }
        log.info("llm.exchange feature={} promptVersion={} prompt=\"{}\" response=\"{}\"",
                req.feature(), req.promptVersion(), redact(req.prompt()), redact(res.content()));
    }

    static String redact(String text) {
        String noEmail = EMAIL.matcher(text).replaceAll("[EMAIL]");
        return PHONE.matcher(noEmail).replaceAll("[PHONE]");
    }
}

Avec l’entrée "Contactez-nous à an@shop.fr ou 0912345678", la fonction redact retourne :

Contactez-nous à [EMAIL] ou [PHONE]

Quand vous utilisez le traçage, Micrometer met automatiquement le traceId dans le MDC. Grâce à ça, cette ligne de log se connecte au span correspondant. La regex n’est qu’une couche de protection minimale. Avec des données vraiment sensibles, envisagez de ne pas stocker le prompt, ou de le stocker dans un référentiel séparé avec un contrôle d’accès et une politique de suppression après expiration.

7. Mesurer la qualité

La qualité est le pilier le plus difficile car il n’y a pas de “status code” pour une bonne réponse. En pratique, les gens combinent trois sources de signaux.

7.1. Vérifications automatisées bon marché

Certains mauvais signes peuvent être détectés immédiatement dans le code : réponse tronquée (finishReason est length), output vide, ou output non-parsable quand vous demandez du JSON.

public class QualityChecks {

    private final MeterRegistry meters;

    public QualityChecks(MeterRegistry meters) {
        this.meters = meters;
    }

    public void inspect(LlmRequest req, LlmResponse res) {
        if ("length".equals(res.finishReason())) {
            count(req, "truncated");
        }
        if (res.content() == null || res.content().isBlank()) {
            count(req, "empty");
        }
    }

    private void count(LlmRequest req, String issue) {
        meters.counter("llm.quality.issue",
                "feature", req.feature(), "prompt.version", req.promptVersion(), "issue", issue)
              .increment();
    }
}

La tag prompt.version vous permet de comparer le taux d’erreur entre v2 et v3 directement sur le tableau de bord après un changement de prompt.

7.2. Feedback utilisateurs

Un bouton 👍/👎 est un signal bon marché et précieux. Envoyez le traceId avec pour pouvoir retrouver le request exact :

@RestController
@RequestMapping("/api/feedback")
class FeedbackController {

    private final MeterRegistry meters;

    FeedbackController(MeterRegistry meters) {
        this.meters = meters;
    }

    record Feedback(String traceId, String feature, String promptVersion, boolean helpful) {}

    @PostMapping
    ResponseEntity<Void> submit(@RequestBody Feedback fb) {
        meters.counter("llm.feedback", "feature", fb.feature(),
                "prompt.version", fb.promptVersion(),
                "helpful", String.valueOf(fb.helpful())).increment();
        // sauvegardez fb.traceId() en DB pour consultation ultérieure
        return ResponseEntity.accepted().build();
    }
}

Le traceId ne va pas dans les tags de la metric car sa valeur est infinie. Il va seulement dans la DB.

7.3. Évaluation hors ligne

Pour les changements majeurs comme un changement de model ou une réécriture du system prompt, vous devriez avoir un ensemble de questions d’exemple avec les réponses attendues. Exécutez cet ensemble en CI ou selon un calendrier. La façon de scorer peut utiliser une correspondance, des règles ou un autre LLM qui joue juge. Les résultats doivent être enregistrés par prompt.version pour comparaison. Cette partie sort du cadre du code de l’article, mais elle complète les metrics runtime ci-dessus.

8. Écrire des tests pour l’observabilité

Vous pouvez tester le décorateur sans appeler l’API réelle. Il suffit d’un SimpleMeterRegistry et d’un client fictif :

class ObservedLlmClientTest {

    @Test
    void recordsLatencyTokensAndCost() {
        SimpleMeterRegistry meters = new SimpleMeterRegistry();
        ObservationRegistry observations = ObservationRegistry.create();
        observations.observationConfig()
                .observationHandler(new DefaultMeterObservationHandler(meters));

        var pricing = Map.of("model-a", new LlmPricingProperties.ModelPrice(
                new BigDecimal("0.50"), new BigDecimal("1.50")));
        LlmClient fake = req -> new LlmResponse("Bonjour", 1200, 300, "stop");

        var client = new ObservedLlmClient(fake, observations, meters, new CostCalculator(pricing));
        client.complete(new LlmRequest("model-a", "v3", "support-chat", "Salut"));

        assertThat(meters.get("llm.request").timer().count()).isEqualTo(1);
        assertThat(meters.get("llm.tokens").tag("type", "input").counter().count())
                .isEqualTo(1200);
        assertThat(meters.get("llm.tokens").tag("type", "output").counter().count())
                .isEqualTo(300);
        assertThat(meters.get("llm.cost.usd").counter().count())
                .isCloseTo(0.00105, within(1e-9));
    }
}

Ce test confirme trois choses : le timer llm.request est enregistré exactement une fois, les tokens sont ventilés par type, et le coût correspond au calcul de la section 5. Si quelqu’un change le nom d’une metric ou casse la formule de prix, le test échouera et votre dashboard ne restera pas silencieusement sans données.

9. Erreurs courantes et quand ne pas en faire trop

Quelques erreurs courantes :

  • Mettre des valeurs infinies dans les tags de la metric : userId, traceId, contenu du prompt, ou nombre de tokens spécifique. Ils font exploser le nombre de time series et peuvent écraser le système de metrics. Ces valeurs appartiennent au span ou au log.
  • Calculer les coûts à partir de traces échantillonnées : si seuls 10% des requests ont une trace, le coût total additionné des traces sera faux. Utilisez un compteur comme à la section 4.
  • Stocker l’intégralité du prompt indéfiniment : risque de confidentialité et coût de stockage. Masquez les données sensibles, faites du sampling et fixez une date d’expiration pour la suppression.
  • Ne pas annoter la version du prompt : quand la qualité baisse, vous ne saurez pas si c’est à cause d’un changement de prompt, de model ou de données d’entrée.
  • Ne regarder que la latence moyenne : avec les LLM, regardez p95/p99 et suivez séparément le temps du premier token si vous utilisez du streaming.

Note : Pas tous les projets ont besoin de toutes les parties ci-dessus. Pour un prototype interne avec quelques dizaines de requests par jour, petit budget, alors juste tracer l’usage depuis la réponse et regarder la facture du fournisseur suffit. Le traçage distribué, le stockage de prompts et le pipeline d’évaluation ne font qu’ajouter de la complexité opérationnelle. Commencez par un compteur de tokens/coûts et une tag prompt.version. Développez le reste quand le trafic, les coûts ou les exigences de qualité vraiment l’exigent.

De plus, si votre organisation utilise déjà une plateforme spécialisée dans l’observabilité des LLM, ne réinventez pas la roue. Le décorateur de cet article reste utile pour normaliser les tags, mais la partie stockage et affichage devrait être confiée à l’outil existant.

10. Conclusion

L’observabilité pour les applications LLM élargit les trois piliers familiers (metric, trace, log) pour répondre à trois nouvelles questions : quel prompt tourne, combien ça coûte, et la réponse est-elle bonne ?

Dans cet article, vous avez encapsulé le client LLM avec une Observation Micrometer pour avoir un timer et un span avec des tags gen_ai.*. Vous comptabilisez les tokens et les convertissez en coûts via une configuration de prix, vous journalisez les prompts en masquant les données sensibles, et vous collectez des signaux de qualité à partir de vérifications automatisées et du feedback utilisateur. L’ordre raisonnable d’application est : coûts et version de prompt d’abord, traçage et qualité après, puis élargissez graduellement selon les besoins réels.

llmobservabilitymicrometeropentelemetryspring-boot