LLM運用の可観測性を実装する:OpenTelemetryでつくるPrompt/Token/Latency監視の実践

LLMアプリは「動く」だけでは本番品質になりません。運用を始めると、次のような問題が必ず発生します。

  • 昨日まで 1.2 秒だった応答が突然 4 秒台になる
  • コストが月末に急増したが、どの機能が原因かわからない
  • 回答品質が落ちたと言われるが、どのプロンプト変更が影響したか追えない
  • リトライ回数や外部API待ちの偏りが可視化されていない

この課題を解く鍵が「可観測性(Observability)」です。本記事では OpenTelemetry を軸に、LLM アプリの監視をゼロから構築する実装を、実際に運用で使える粒度で説明します。

なぜ APM だけでは LLM を見切れないのか

従来の Web アプリ監視(CPU、HTTP レイテンシ、エラーレート)だけでは、LLM 特有の故障点が見えません。理由は、LLM の品質とコストが「入力テキスト」と「推論設定」に強く依存するためです。

少なくとも次の軸が必要です。

  1. Prompt 可視化: システム/ユーザー/ツール呼び出しの構成
  2. Token 可視化: input/output token、モデル別単価、キャッシュヒット率
  3. 推論経路可視化: retrieval → rerank → generation の各ステップ時間
  4. 品質シグナル: hallucination 率、参照文書一致率、ユーザー評価

つまり、HTTP 1 本のログでは不十分で、トレース単位で LLM 実行を分解する必要があります。

アーキテクチャの全体像

最初に、実装対象を次の構成とします。

  • API: FastAPI
  • LLM: OpenAI / Azure OpenAI(抽象化)
  • RAG: pgvector + reranker
  • Observability: OpenTelemetry SDK + OTLP Exporter + Grafana Tempo/Loki/Prometheus

処理フローは次の通りです。

  1. リクエスト受信時に trace_id を生成
  2. Retrieval、Rerank、Generate をそれぞれ span 化
  3. 各 span に token、model、temperature、cache_hit を attribute として記録
  4. 失敗時は exception をイベントとして保存
  5. レスポンス時にコスト推定を metrics として送信

ステップ1:OpenTelemetryの初期設定

まずは Python で最小セットを導入します。

1
uv add opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-instrumentation-fastapi

次に初期化コードを用意します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# observability.py
from opentelemetry import trace, metrics
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource

resource = Resource.create({
    "service.name": "tech-blog-autopilot-api",
    "service.version": "1.3.0",
    "deployment.environment": "production",
})

provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True))
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("llm-pipeline")

ここで重要なのは、service.name を固定することです。デプロイごとに揺れるとダッシュボードが分断され、比較分析ができません。

ステップ2:LLM処理を span で分割する

実運用では「遅い」の原因が retrieval なのか generation なのかで対応が変わります。そこで、処理を細かく span 化します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from opentelemetry import trace
tracer = trace.get_tracer("llm-pipeline")

def generate_answer(query: str, user_id: str):
    with tracer.start_as_current_span("rag.pipeline") as root:
        root.set_attribute("user.id", user_id)
        root.set_attribute("feature", "support-chat")

        with tracer.start_as_current_span("rag.retrieve") as span_retrieve:
            docs = retrieve_docs(query)
            span_retrieve.set_attribute("retrieved.count", len(docs))

        with tracer.start_as_current_span("rag.rerank") as span_rerank:
            ranked = rerank_docs(query, docs)
            span_rerank.set_attribute("rerank.top_k", 5)

        with tracer.start_as_current_span("llm.generate") as span_gen:
            response = call_llm(query, ranked)
            span_gen.set_attribute("llm.model", response.model)
            span_gen.set_attribute("llm.input_tokens", response.usage.input_tokens)
            span_gen.set_attribute("llm.output_tokens", response.usage.output_tokens)
            span_gen.set_attribute("llm.temperature", 0.2)

        return response.text

この分割で、「retrieval が中央値 70ms → 280ms に悪化」「特定モデルだけ output token が急増」など、運用判断に直結する情報が取得できます。

ステップ3:コストをメトリクス化する

運用現場で最も効くのは、推定コストをリアルタイムに可視化することです。モデル単価表をコードに持ち、1リクエストごとに計算して metrics に送ります。

1
2
3
4
5
6
7
8
MODEL_PRICE = {
    "gpt-4.1-mini": {"in": 0.0000003, "out": 0.0000012},
    "gpt-4.1": {"in": 0.000003, "out": 0.000012},
}

def estimate_cost(model: str, in_tokens: int, out_tokens: int) -> float:
    p = MODEL_PRICE[model]
    return in_tokens * p["in"] + out_tokens * p["out"]

推奨は次の3指標です。

  • llm_cost_usd_total(counter)
  • llm_tokens_input_total / llm_tokens_output_total(counter)
  • llm_latency_ms(histogram)

これを feature、tenant、model のラベルで集計すると、予算統制が一気に楽になります。

ステップ4:品質低下を検知する仕組みを入れる

レイテンシとコストだけでは不十分です。品質監視を最低限でも導入します。

4-1. 自動評価ジョブ

夜間バッチで固定データセット(100問程度)を流し、次を記録します。

  • 正答率(正解文との semantic similarity)
  • 出典一致率(回答が引用した文書IDの妥当性)
  • 禁止事項違反率(PII、コンプラNG)

4-2. 本番フィードバック

UI で 👍 / 👎 を取り、trace_id と紐づけます。こうすると「悪評の大半が temperature=0.9 の実験フラグ経由」など、根因分析が可能です。

ステップ5:運用で効くダッシュボードを作る

実際に使われるダッシュボードは、項目を欲張らない方が強いです。最初は次の 6 つに絞ってください。

  1. P50/P95 レイテンシ(全体 + モデル別)
  2. リクエスト数とエラー率(HTTP + LLM例外)
  3. 日次コスト(全体 + feature別)
  4. input/output token 推移
  5. retrieval 件数と空振り率
  6. ユーザー評価(👍率)

特に P95 とコストは同一画面に置くのがポイントです。高速化で品質が落ちた、または品質改善でコストが跳ねた、というトレードオフが即時に見えます。

よくある失敗と回避策

失敗1:Prompt全文を生で保存して個人情報を漏らす

対策は、PII マスキングを export 前に必ず実行することです。メール、電話番号、住所は正規表現だけでなく、NER ベースで二重防御すると安全です。

失敗2:span属性の命名がバラバラ

llm.input_tokensinput_token_count が混在すると集計不能になります。命名規約をリポジトリに固定し、CI で lint してください。

失敗3:高カーディナリティ地獄

user_id をそのままメトリクスラベルに入れると TSDB が破綻します。ユーザー軸は trace/log に置き、metrics は tenant や plan 程度に抑えます。

導入ロードマップ(2週間)

  • Day 1-2: FastAPI + LLM呼び出しに trace 埋め込み
  • Day 3-4: token/cost メトリクス送信
  • Day 5-6: Grafana ダッシュボード構築
  • Day 7-9: しきい値アラート設計(P95、error、cost)
  • Day 10-12: 品質評価バッチ導入
  • Day 13-14: インシデント演習(意図的劣化を検知できるか)

2週間で「見える化」は十分達成できます。完璧を目指すより、まず計測可能にすることが重要です。

まとめ

LLM運用で本当に困るのは、失敗そのものではなく「失敗の理由が見えない」状態です。OpenTelemetry を使って retrieval、generation、token、cost、品質を一貫して観測できるようにすると、改善サイクルが回り始めます。

可観測性は守りではなく、開発速度を上げるための攻めの基盤です。まずは span を3つに分けるところから始めてください。それだけで、LLM運用の景色が大きく変わります。