LLMアプリケーション向けObservability: プロンプト、コスト、品質の監視

1. 概要

LLM(大言語モデル)を使った機能を本番環境にデプロイすると、最初に浮かぶ質問は通常「今月のコストはいくら?」「昨日の回答が間違っていたのはなぜ?」「新しいプロンプトは古いプロンプトより良い?」といったものです。通常のHTTPメトリクスではこのような質問に答えられません。

この記事ではLLMアプリケーション向けのobservabilityという概念を説明し、なぜこのトピックが注目されているのかを解説します。その後、Spring Boot 3でLLM clientの周りにデコレータレイヤを構築し、トレース、トークン、コスト、品質シグナルを記録する方法を示します。最後のセクションでは一般的な落とし穴と過度な計測を避けるべきケースについて説明します。

2. LLMが独自のobservabilityを必要とする理由

通常のREST APIは決定的です。同じ入力であれば同じ出力が得られます。レイテンシ、エラー率、スループットを監視するだけで十分です。LLMの呼び出しは複数の点で異なります:

  • 非決定的: 同じプロンプトでも異なる結果が得られる可能性があります。最も一般的なエラーはHTTP 500ではなく、HTTP 200とともに間違った回答が返ることです。
  • トークンによる価格設定: 請求額はリクエスト数ではなく、プロンプトと回答の長さに依存します。膨らんだプロンプトテンプレートは何度も気づかないうちにコストを乗算する可能性があります。
  • 大きなレイテンシ変動: 応答時間は生成されるトークン数に比例します。平均値はほぼ無意味です。p95、p99などの分布を見る必要があります。
  • プロンプトはコード: システムプロンプト内の文を1つ変更することも1回の「デプロイ」です。どのリクエストがどのバージョンのプロンプトで実行されているかを知る必要があります。

したがって、LLMのobservabilityは通常3つの柱の周りで展開します:

  1. プロンプト/レスポンスのトレース: どのリクエスト、どのモデル、どのプロンプトバージョン、どのくらいかかったか、どのような理由で終了したか。
  2. コスト: 入出力トークン、金銭に換算、機能別の分類。
  3. 品質: ユーザーフィードバック、自動チェック、オフライン評価。

OpenTelemetryコミュニティもGenAI向けの属性命名規約(接頭辞gen_ai.*)を構築中です。この規約は引き続き進化しています。本記事では、今後の統合を容易にするため、その命名規約を採用します。

3. プロジェクトの設定

Actuator、Prometheus registry、OpenTelemetry tracing bridgeを追加します。バージョンはSpring Boot BOMで管理されているため、宣言する必要はありません:

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

エンドポイントとトレース送信先を設定します:

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

ここでprobability: 0.1は10%のリクエストのみがトレース送信されることを意味します。メトリクスは依然として100%カウントされます。この違いは第5節で費用計算を行う場合に重要です。

4. ObservationでLLM clientをラップする

ベンダーに依存しない小さなabstractionを定義します:

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はトークン数を持っています。ほとんどのプロバイダーAPIはレスポンスでusageフィールドを返すため、このレコードにマップするだけです。トークンを自分で推定するのではなく、プロバイダーが返した数値を使用してください。

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

主なポイント:

  • Observation.createNotStarted("llm.request", ...)はobservationを作成します。DefaultMeterObservationHandlerがある場合(Spring Bootが自動設定)、このobservationはllm.requestというタイマーを生成します。tracingブリッジがある場合、それはさらにspanを生成します。
  • lowCardinalityKeyValueはメトリクスとspan両方にタグを付与します。モデル名、プロンプトバージョン、機能名など値の集合が有限の場合のみ使用してください。
  • highCardinalityKeyValueはspanのみにタグを付与します。各リクエストのトークン数はここに適しています。これをメトリクスに追加すると、値が異なるたびに新しいタイムシリーズが作成されます。
  • observe(...)は自動的にstart/stopを呼び出します。例外がある場合、観察に対してエラーを記録します。
  • 2つのカウンターllm.tokensとllm.cost.usdはすべてのリクエストに対してカウントされ、tracingのサンプリングには依存しません。

Beanを登録します:

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

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // 実際のAPI呼び出しを行うclient
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

/actuator/prometheusでは以下のような系列が表示されます:

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

注記: Spring AIを使用している場合、このフレームワークはchat modelに対するobservationをすでに備えています。上記のメカニズムを理解すると、これらのメトリクスを正しく読み取り、featureタグやprompt.versionなどの不足している部分を補うことができます。

5. トークンからコストを計算する

価格表はプロバイダーと時間とともに変わるため、ハードコードしないでください。これを設定に出しましょう:

@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   # 例示用の価格。実際のプロバイダー価格表に置き換えてください
      output-per-million: 1.50

計算クラス:

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; // 警告をログ出力すべき: モデルに価格が未設定
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

例の価格表で1,200トークンの入力と300トークンの出力の場合、結果は:

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

このカウンターはfeatureとmodel別に、リアルタイムに近い推定コストを示します。これにより「support-chatの1時間のコストがXを超える」などのアラートを設定できます。公式請求書はまだプロバイダーから来ます。定期的に照合してズレを検出してください。例えば、キャッシュされたトークンやバッチ割引など、シンプルな公式では考慮されていない場合があります。

6. プロンプトを安全にログに記録する

間違った回答をデバッグするとき、通常はプロンプト全体を見たいとします。しかし、プロンプトはメールアドレス、電話番号、注文内容などのユーザーデータを含むことがあります。したがって、2つのことを実行する必要があります: 機密情報をマスクしサンプリングすることです。

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

入力"連絡先 an@shop.vn または 0912345678"の場合、redact関数は以下を返します:

連絡先 [EMAIL] または [PHONE]

トレース使用時、Micrometerは自動的にtraceIdをMDCに追加します。これにより、このログ行を対応するspanにリンクできます。正規表現は最小限の保護層に過ぎません。本当に機密性の高いデータの場合は、プロンプトを保存しないか、アクセス制御と削除期限を持つ専用のリポジトリに保存することを検討してください。

7. 品質を測定する

品質は「正しい回答」の「ステータスコード」がないため、最も難しい柱です。実際には、3つのシグナル源を組み合わせます。

7.1. 低コストの自動チェック

コード内ですぐに検出できるいくつかの悪いサイン: 回答が切り詰められる(finishReasonがlength)、出力が空、または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();
    }
}

prompt.versionタグを使用すると、プロンプト変更後、ダッシュボード上でv2とv3間のエラー率を直接比較できます。

7.2. ユーザーフィードバック

👍/👎ボタンは安価で価値のあるシグナルです。対応するリクエストにトレースバックするため、traceIdを一緒に送信してください:

@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();
        // fb.traceId()をDBに保存して後で検索
        return ResponseEntity.accepted().build();
    }
}

traceIdは値が無限であるため、メトリクスタグに含めないでください。DBにのみ保存されます。

7.3. オフライン評価

モデル変更やシステムプロンプトの書き直しなどの大きな変更の場合は、期待される回答を伴うサンプル質問のセットが必要です。これをCI内で実行するか、スケジュールに従って実行します。スコアリング方法は、マッチング、ルール、または別のLLMが審査官の役割を果たすことができます。結果は比較するためprompt.versionに従って記録すべきです。このセクションは記事のコード範囲外ですが、上記の実行時メトリクスを補完します。

8. observability部分のテストを書く

実際のAPI呼び出しなしでデコレータをテストできます。SimpleMeterRegistryと偽の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));
    }
}

このテストは3つのことを確認します: llm.requestタイマーが正確に1回記録される、トークンがtypeで分離される、コストが第5節の計算と一致する。将来的に誰かがメトリクス名を変更したり、価格計算式を破壊した場合、テストはfailし、ダッシュボードは無言でデータが失われることはありません。

9. 一般的なエラーと過度な計測を避けるべき場合

一般的なエラー:

  • メトリクスタグに無限の値を入力する: userId、traceId、プロンプト内容、または特定のトークン数。これらはタイムシリーズの数を爆発させ、メトリクスシステムをクラッシュさせる可能性があります。これらの値はspanまたはlogに属するべきです。
  • サンプリング済みトレースからのコスト計算: リクエストの10%のみがトレースされている場合、トレースから合計コストを加算すると間違います。第4節のようにcounterを使用してください。
  • プロンプト全体を無期限に保存する: プライバシーリスクとストレージコスト。機密データをマスクし、サンプリングして、削除期限を設定してください。
  • プロンプトバージョンを添付しない: 品質が低下した場合、原因がプロンプト変更、モデル変更、または入力データの変更であるかどうかわかりません。
  • 平均レイテンシだけを見る: LLMの場合、p95/p99を見て、ストリーミングを使用している場合は最初のトークンまでの時間を別途追跡してください。

注記: すべてのプロジェクトが上記の完全な機能を必要とするわけではありません。内部プロトタイプ、1日数十件のリクエスト、小予算の場合、レスポンスからのusageログとプロバイダーの請求書を見るだけで十分です。その場合、トレース、プロンプトリポジトリ、評価パイプラインシステムは運用上のオーバーヘッドを追加するだけです。トークン/コストカウンターとprompt.versionタグで開始してください。他の部分は、トラフィック、コスト、または品質要件が実際に要求する場合に拡張してください。

さらに、組織がLLM observability専用のプラットフォームをすでに使用している場合は、すべてを再実装しないでください。この記事のデコレータレイヤはタグの標準化に有用ですが、ストレージと表示部分は既存のツールに委譲すべきです。

10. まとめ

LLMアプリケーション向けのobservabilityは、おなじみの3つの柱(メトリクス、トレース、ログ)を拡張して、3つの新しい質問に答えます: どのプロンプトが実行されているか、どのくらいのコストがかかるか、回答は良いか。

本記事では、Micrometer Observationでllm clientをラップして、gen_ai.*タグ付きのタイマーとspanを取得しました。価格設定を通じてトークンをカウントして費用に換算し、機密データをマスクしてプロンプトをログに記録し、自動チェックとユーザーフィードバックから品質シグナルを収集します。合理的な適用順序は: コストとプロンプトバージョンを最初に、トレースと品質は後に、その後実際のニーズに応じて段階的に拡張することです。

llmobservabilitymicrometeropentelemetryspring-boot