Observability für LLM-Anwendungen: Monitoring von Prompts, Kosten und Qualität

1. Übersicht

Wenn Sie eine LLM-Funktion (Large Language Model) in Production gehen, sind die ersten Fragen normalerweise: „Wie viel kostet das diesen Monat?“, „Warum war die Antwort gestern falsch?”, „Ist der neue Prompt besser als der alte?“. Standard-HTTP-Metriken beantworten diese Fragen nicht.

Dieser Artikel erklärt das Konzept der Observability für LLM-Anwendungen und warum dieses Thema Aufmerksamkeit erhält. Anschließend werden Sie einen Decorator um den LLM-Client in Spring Boot 3 bauen, um Traces, Tokens, Kosten und Qualitätssignale zu erfassen. Der letzte Abschnitt behandelt häufige Fehler und Fälle, in denen Sie nicht zu weit gehen sollten.

2. Warum LLM Observability braucht

Eine normale REST-API ist deterministisch. Gleiche Input erzeugt gleiche Output. Sie müssen nur Latenz, Fehlerrate und Durchsatz überwachen. LLM-Aufrufe unterscheiden sich in mehreren Punkten:

  • Nicht deterministisch: Derselbe Prompt kann unterschiedliche Ergebnisse liefern. Der häufigste Fehler ist nicht HTTP 500, sondern ein HTTP 200 mit einer falschen Antwort.
  • Kosten pro Token: Die Rechnung hängt von der Länge des Prompts und der Antwort ab, nicht von der Anzahl der Requests. Ein aufgeblähtes Prompt-Template kann die Kosten vervielfachen, ohne dass es jemand bemerkt.
  • Große Latenzschwankungen: Die Antwortzeit ist proportional zur Anzahl der generierten Tokens. Der Durchschnittswert ist praktisch bedeutungslos. Sie müssen die Verteilung (p95, p99) betrachten.
  • Prompt ist Code: Eine Änderung im System-Prompt ist ein „Deploy”. Sie müssen wissen, welcher Request mit welcher Prompt-Version läuft.

Observability für LLM dreht sich daher normalerweise um drei Säulen:

  1. Tracing von Prompt/Response: Welcher Request, welches Model, welche Prompt-Version, wie lange dauerte es, warum endete es.
  2. Kosten: Input/Output-Tokens, Umrechnung in Geldeinheiten, Aufteilung nach Funktion.
  3. Qualität: Benutzerfeedback, automatisierte Tests, Offline-Bewertung.

Die OpenTelemetry-Community erstellt auch eine Namenskonvention für GenAI-Attribute (Präfix gen_ai.*). Diese Konvention wird weiterhin entwickelt. In diesem Artikel werden Sie diese Namenskonvention verwenden, um die spätere Integration zu erleichtern.

3. Projektsetup

Fügen Sie Actuator, Prometheus-Registry und die OpenTelemetry-Bridge für Tracing hinzu. Da die Versionen von Spring Boot BOM verwaltet werden, müssen Sie sie nicht explizit angeben:

<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>

Konfigurieren Sie Endpoints und Trace-Destination:

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

Hier bedeutet probability: 0.1, dass nur 10% der Requests als Traces gesendet werden. Metriken werden aber zu 100% gezählt. Dieser Unterschied ist wichtig, wenn Sie Kosten in Abschnitt 5 berechnen.

4. LLM-Client mit Observation umhüllen

Sie definieren eine kleine Abstraktion, unabhängig vom Anbieter:

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 trägt die Token-Zahlen mit sich. Die meisten Provider-APIs geben ein usage-Feld in der Response zurück, Sie müssen es nur auf diesen Record abbilden. Verwenden Sie die vom Provider zurückgegebenen Zahlen statt zu schätzen.

Jetzt der Decorator mit Observability:

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;
        });
    }
}

Wichtige Punkte:

  • Observation.createNotStarted("llm.request", ...) erstellt eine Observation. Bei Vorhandensein von DefaultMeterObservationHandler (von Spring Boot selbst konfiguriert) erzeugt diese Observation einen Timer llm.request. Mit Tracing-Bridge wird zusätzlich ein Span erzeugt.
  • lowCardinalityKeyValue fügt Tags zu sowohl Metriken als auch Spans hinzu. Verwenden Sie nur für Werte mit endlichen Mengen wie Modellnamen, Prompt-Versionen, Funktionsnamen.
  • highCardinalityKeyValue fügt nur zu Spans hinzu. Die Tokenzahl jedes Requests gehört hier hin. Wenn Sie es zu Metriken hinzufügen, erzeugt jeder unterschiedliche Wert eine neue Zeitreihe.
  • observe(...) ruft automatisch Start/Stop auf. Bei Exceptions wird der Fehler in der Observation erfasst.
  • Die beiden Counter llm.tokens und llm.cost.usd werden für alle Requests gezählt, unabhängig vom Sampling des Tracings.

Bean registrieren:

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

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // Client der echten API
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

Unter /actuator/prometheus sehen Sie Serien wie die folgenden:

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

Hinweis: Falls Sie Spring AI verwenden, bietet dieses Framework bereits Observations für Chat-Modelle. Das Verständnis des obigen Mechanismus hilft Ihnen, diese Metriken richtig zu interpretieren und fehlende Elemente wie feature oder prompt.version Tags zu ergänzen.

5. Kosten pro Token berechnen

Preistabellen unterscheiden sich je nach Anbieter und Zeit, daher hartcodieren Sie sie nicht. Lagern Sie sie in die Konfiguration aus:

@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   # Beispielpreise, bitte durch Ihre tatsächlichen Preise ersetzen
      output-per-million: 1.50

Berechnungsklasse:

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; // sollte Warnung protokollieren: model hat keinen Preis
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

Mit 1.200 Input-Tokens und 300 Output-Tokens nach der Beispielpreistabelle ist das Ergebnis:

(1200 × 0.50 + 300 × 1.50) / 1_000_000 = 0.00105

Dieser Counter zeigt Ihnen die geschätzten Kosten nahezu in Echtzeit, aufgeteilt nach feature und model. So können Sie Alerts setzen, z.B. „Kosten von support-chat in 1 Stunde überschreiten Grenzwert X”. Die offizielle Rechnung kommt weiterhin vom Anbieter. Vergleichen Sie regelmäßig, um Abweichungen zu erkennen, z.B. wenn es gecachte Tokens oder Batch-Rabatte gibt, die die einfache Formel nicht berücksichtigt.

6. Sichere Prompt-Protokollierung

Wenn Sie eine falsche Antwort debuggen, möchten Sie normalerweise den vollständigen Prompt sehen. Aber der Prompt enthält oft Benutzerdaten wie E-Mail, Telefonnummer oder Bestellinhalte. Daher müssen Sie zwei Dinge tun: sensible Informationen maskieren und Sampling durchführen.

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]");
    }
}

Mit der Eingabe "Liên hệ an@shop.vn hoặc 0912345678" gibt die Funktion redact zurück:

Liên hệ [EMAIL] hoặc [PHONE]

Bei Verwendung von Tracing put Micrometer automatisch traceId in das MDC. Dadurch wird diese Log-Zeile mit dem entsprechenden Span verknüpft. Regex ist nur ein minimales Schutzmittel. Für wirklich sensible Daten sollten Sie erwägen, den Prompt nicht zu speichern oder ihn in einem separaten Speicher mit Zugriffskontrolle und Löschzeitrahmen zu speichern.

7. Qualitätsmessung

Qualität ist die schwierigste Säule, da es keinen „Status-Code” für eine richtige Antwort gibt. In der Praxis kombiniert man drei Signalquellen.

7.1. Billiger automatisierter Check

Einige schlechte Zeichen können sofort im Code erkannt werden: abgeschnittene Antwort (finishReason ist length), leere Ausgabe oder nicht analysierbare Ausgabe, wenn Sie JSON anfordert.

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();
    }
}

Das Tag prompt.version ermöglicht es Ihnen, Fehlerquoten zwischen v2 und v3 direkt im Dashboard nach der Prompt-Änderung zu vergleichen.

7.2. Benutzerfeedback

Der 👍/👎-Button ist ein billiges und wertvolles Signal. Senden Sie den traceId mit, um zur richtigen Request zurück zu verfolgen:

@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();
        // Speichern Sie fb.traceId() in DB zur späteren Abfrage
        return ResponseEntity.accepted().build();
    }
}

traceId wird nicht zu Metric-Tags hinzugefügt, da sein Wert unbegrenzt ist. Es wird nur in der DB gespeichert.

7.3. Offline-Bewertung

Bei großen Änderungen wie Model-Wechsel oder Neuschreiben des System-Prompts sollten Sie einen Satz von Beispielfragen mit erwarteten Antworten haben. Führen Sie diesen in CI oder zeitgesteuert aus. Die Bewertung kann Matching, Rules oder ein anderes LLM als Schiedsrichter verwenden. Ergebnisse sollten nach prompt.version zum Vergleichen erfasst werden. Dieser Teil liegt außerhalb des Code-Umfangs dieses Artikels, ergänzt aber die oben genannten Runtime-Metriken.

8. Tests für Observability schreiben

Sie können den Decorator testen, ohne die echte API aufzurufen. Sie brauchen nur SimpleMeterRegistry und einen Fake-Client:

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("Xin chào", 1200, 300, "stop");

        var client = new ObservedLlmClient(fake, observations, meters, new CostCalculator(pricing));
        client.complete(new LlmRequest("model-a", "v3", "support-chat", "Chào bạn"));

        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));
    }
}

Dieser Test bestätigt drei Dinge: Der Timer llm.request wird genau einmal erfasst, Tokens werden nach type aufgeteilt, und Kosten passen zur Berechnung in Abschnitt 5. Wenn später jemand den Metrik-Namen ändert oder die Preisformel bricht, schlägt der Test fehl und das Dashboard verliert nicht „stumm” Daten.

9. Häufige Fehler und wann nicht zu weit gehen

Einige häufige Fehler:

  • Unbegrenzte Werte in Metric-Tags einfügen: userId, traceId, Prompt-Inhalt oder spezifische Token-Zahlen. Diese lassen die Anzahl der Zeitreihen explodieren und können das Metriken-System zum Absturz bringen. Diese Werte gehören zu Spans oder Logs.
  • Kosten aus gesampelten Traces berechnen: Wenn nur 10% der Requests Traces haben, ist die aus Traces addierte Gesamtkostensumme falsch. Verwenden Sie Counter wie in Abschnitt 4.
  • Gesamte Prompts zeitlich unbegrenzt speichern: Datenschutzrisiken und Speicherkosten. Maskieren Sie sensible Daten, nehmen Sie Sampling vor und setzen Sie Löschzeitrahmen.
  • Prompt-Versionen nicht taggen: Wenn die Qualität sinkt, wissen Sie nicht, ob die Ursache ein Prompt-Wechsel, Model-Wechsel oder Eingabedatenänderung ist.
  • Nur auf Durchschnittslatenz achten: Bei LLM sollten Sie p95/p99 anschauen und die Zeit zum ersten Token separat überwachen, wenn Sie Streaming verwenden.

Hinweis: Nicht jedes Projekt braucht alle oben genannten Teile. Bei einem internen Prototyp mit einigen Dutzend Requests pro Tag und kleinerem Budget reicht es aus, die Nutzung aus der Response zu protokollieren und die Rechnung des Anbieters zu prüfen. Tracing-System, Prompt-Repository und Bewertungs-Pipeline sind dann nur zusätzliche Verwaltungsarbeit. Beginnen Sie mit Token/Kosten-Countern und prompt.version Tags. Erweitern Sie die restlichen Teile, wenn Durchsatz, Kosten oder Qualitätsanforderungen dies wirklich erfordern.

Wenn Ihre Organisation bereits eine spezialisierte Plattform für LLM-Observability nutzt, schreiben Sie nicht alles neu. Die Decorator-Schicht in diesem Artikel ist immer noch nützlich zur Standardisierung von Tags, aber Speicherung und Visualisierung sollten dem vorhandenen Tool überlassen werden.

10. Fazit

Observability für LLM-Anwendungen erweitert die drei vertrauten Säulen (Metriken, Traces, Logs), um drei neue Fragen zu beantworten: Welcher Prompt läuft, wie viel kostet es und ist die Antwort gut?

In diesem Artikel haben Sie den LLM-Client mit Micrometer Observation umhüllt, um Timer und Spans mit gen_ai.* Tags zu erhalten. Sie zählen Tokens und rechnen sie mit der Preis-Konfiguration in Kosten um, protokollieren Prompts mit maskierten sensiblen Daten und erfassen Qualitätssignale aus automatisierten Tests und Benutzerfeedback. Die sinnvolle Anwendungsreihenfolge ist: Kosten und Prompt-Versionen zuerst, dann Tracing und Qualität, dann nach echtem Bedarf erweitern.

llmobservabilitymicrometeropentelemetryspring-boot