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:
- Tracing von Prompt/Response: Welcher Request, welches Model, welche Prompt-Version, wie lange dauerte es, warum endete es.
- Kosten: Input/Output-Tokens, Umrechnung in Geldeinheiten, Aufteilung nach Funktion.
- 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 vonDefaultMeterObservationHandler(von Spring Boot selbst konfiguriert) erzeugt diese Observation einen Timerllm.request. Mit Tracing-Bridge wird zusätzlich ein Span erzeugt.lowCardinalityKeyValuefügt Tags zu sowohl Metriken als auch Spans hinzu. Verwenden Sie nur für Werte mit endlichen Mengen wie Modellnamen, Prompt-Versionen, Funktionsnamen.highCardinalityKeyValuefü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.tokensundllm.cost.usdwerden 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
featureoderprompt.versionTags 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.versionTags. 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.