0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本番のLLMを観測する — トレース·コスト·品質ドリフトの監視

0
Posted at

導入

前回はCIで回帰を止める話だった。デプロイ前に壊れているものを出さない、が主題だった。今回はその逆側 — デプロイ後、静かに劣化していく方をどう捕まえるか、について書く。

先月、あるプロジェクトでこんなことがあった。オフライン評価は緑、CIの回帰テストも緑、ステージングでも問題なし。それでも本番投入から2週間ほど経った頃、サポート経由で「最近、返答が長すぎて読みづらい」というフィードバックが数件届いた。ログを掘ってみると、平均出力トークンが導入初日の1.6倍に膨らんでいた。プロンプトは一切変えていない。モデルも同じmodel名のまま。何が起きたのかを追う過程で、「本番のLLMを観測する」ことの意味を改めて考えた。

本番LLMの観測は、普通のAPIの観測と何が違うか

結論から言えば、外形的な話 — レイテンシ、エラー率、スループット — は普通のAPIと変わらない。P95 latencyのアラート、5xxのアラート、この辺はいつも通り。違うのは、次の3つが加わる点だ。

  • トークンとコスト (呼び出しごとにお金が動く)

  • 品質 (200 OKで返ってきても中身が劣化していることがある)

  • 非決定性 (同じ入力で違う出力。エラーではなく「なんか違う」)

この3つを普通のObservabilityスタックにどう乗せるかが、本記事の中心になる。

OpenTelemetry GenAI Semantic Conventions

2026年時点で、LLM観測のデファクトはOpenTelemetryのGenAI Semantic Conventionsだ。年初にStableに昇格して以来、Langfuse、Phoenix、Uptrace、Jaeger — 主要なバックエンドはこの形式を受け入れる。乗り換えを想定した時、独自SDKに閉じないというのは意外と大きい。

属性名はgen_ai.*プレフィクスで統一されている。gen_ai.systemgen_ai.request.modelgen_ai.usage.input_tokensgen_ai.usage.output_tokensgen_ai.response.finish_reasonsあたりが基本セット。span kindは、単発のLLMクライアント呼び出し、agentの1ターン、tool呼び出し、の3種を分けて記録する。

最低限のPython実装はこう書ける。

import os
from openai import OpenAI
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
1回だけ初期化
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
)
tracer = trace.get_tracer(name)
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def chat_with_trace(user_id: str, feature: str, messages: list, model: str = "gpt-4o-mini"):
with tracer.start_as_current_span("chat.completion") as span:
# GenAI Semantic Conventions に準拠
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.model", model)
# ビジネス属性 (集計軸として重要)
span.set_attribute("app.user_id", user_id)
span.set_attribute("app.feature", feature)
    try:
        resp = client.chat.completions.create(model=model, messages=messages)
        span.set_attribute("gen_ai.response.model", resp.model)
        span.set_attribute("gen_ai.response.id", resp.id)
        span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
        span.set_attribute("gen_ai.usage.output_tokens", resp.usage.completion_tokens)
        span.set_attribute(
            "gen_ai.response.finish_reasons",
            [c.finish_reason for c in resp.choices],
        )
        return resp.choices[0].message.content
    except Exception as e:
        span.record_exception(e)
        span.set_status(trace.StatusCode.ERROR)
        raise

ポイントは、gen_ai.*と別にapp.user_idapp.featureのような自分たちのビジネス属性を必ず入れることだ。あとで「featureごとのコスト」「userごとの異常な使い方」を見たくなる時が必ず来る。span kindやmodel名だけで集計するのは限界が早い。

overheadを気にする人がいるかもしれないが、実測では呼び出しごとに1ms未満だった。LLMのAPI自体が100ms〜30秒のレンジで動いているので、無視できる。

本番稼働中のLLMを監視するダッシュボードの画面イメージ

コストの監視 — 「気づいた時には遅い」の典型

LLMコストが吹き飛ぶ時、原因はだいたい決まっている。筆者が過去に遭遇したのは次の4つだ。

  1. リトライループ (429を食らってバックオフなしで再試行)

  2. agentのtool呼び出しループ (終了判定が甘くて同じtoolを叩き続ける)

  3. セッションのコンテキスト累積 (会話履歴を全部プロンプトに乗せ続ける実装)

  4. プロンプトのbloat (誰かがsystem promptに例文を10個追加、レビューなし)

これらは月末の請求書を見て初めて分かる、では遅い。日次、できれば時間単位で気づける構造がいる。

3層のアラート

筆者の運用では、コストのアラートは3層に分けている。

1つ目は予算に対する使用率。月初からの累計コストが月次予算の50%、80%、100%で発火する定番のやつ。これは「使いすぎ」を捕らえる。

2つ目はスパイク検知。過去7日間の同じ時間帯を基準に、そこから2倍を超えたら発火する。深夜3時のトラフィックが昼と同じ、というような明らかにおかしい状態を拾う。

3つ目は単発の異常。1リクエストが想定より遥かに多いトークンを使ったケース。リトライループやagent暴走の初動を捕まえる。

2つ目のスパイク検知は、SQLで書くとこんな形になる。Langfuseの内部テーブルでも自前のPostgresでも、構造はほぼ同じだ。

-- 直近1時間のコストが、過去7日の同時間帯平均の2倍を超えたfeatureを抽出
WITH recent AS (
SELECT
feature,
SUM(cost_usd) AS cost_recent
FROM llm_calls
WHERE created_at >= NOW() - INTERVAL '1 hour'
GROUP BY feature
),
baseline AS (
SELECT
feature,
AVG(hourly_cost) AS cost_baseline
FROM (
SELECT
feature,
date_trunc('hour', created_at) AS h,
SUM(cost_usd) AS hourly_cost
FROM llm_calls
WHERE created_at >= NOW() - INTERVAL '7 days'
AND created_at < NOW() - INTERVAL '1 hour'
AND EXTRACT(hour FROM created_at) = EXTRACT(hour FROM NOW())
GROUP BY feature, h
) t
GROUP BY feature
)
SELECT
r.feature,
r.cost_recent,
b.cost_baseline,
r.cost_recent / NULLIF(b.cost_baseline, 0) AS ratio
FROM recent r
JOIN baseline b USING (feature)
WHERE b.cost_baseline > 0.5  -- 元々小さすぎるfeatureはノイズになるので除外
AND r.cost_recent / b.cost_baseline > 2.0;

最後のb.cost_baseline > 0.5のガードは、書き始めた頃は入れていなかった。結果、月に数百円しか使わないマイナーfeatureがちょっと動いただけで「10倍!」と鳴り続けて、アラートが完全にノイズになった。今は「金額として意味がある水準」でフィルタしている。厳密には割合と絶対値の両方を見るべきだが、実務ではこれで十分機能している。

アラートに「動ける情報」を乗せる

「コスト2倍です」だけ届いても人は動けない。最低限、アラートに次の情報を乗せる。

  • 影響を受けているfeature (と、それを見るダッシュボードのURL)

  • 直近の関連デプロイ (これが原因の第一容疑者)

  • 異常なリクエストのtrace ID (span 1本まで飛べる導線)

これがあると、Slackで通知を受けた担当者が3クリックで根本原因に到達できる。無いと、まず質問から始まる。

品質ドリフト — 200 OKで返ってくる劣化

これが一番難しい。エラーは出ていない、レイテンシも正常、コストも問題なし。それでも出力が徐々に悪くなっている、というやつ。

プロンプトを変えていないのに劣化する主な要因は、次のあたりだ。

  • モデルプロバイダ側の暗黙のアップデート (「同じmodel名」でも中身が入れ替わることがある)

  • 入力分布の変化 (ユーザーの使い方が変わった、季節性、新機能リリースで流入層が変わった)

  • RAGなら、参照コーパスの変化 (社内文書が増えた、古いものが混じった)

  • コンテキスト長の増加 (会話が長くなるにつれて指示追従が落ちる)

これらは、オフライン評価では気づけない。オフライン評価は「壊れたものを出さない」ためのものだ。「出した後の静かな劣化」を見るのは、オンライン評価の仕事になる。

オンライン評価の骨組み

本番トラフィックの一部をサンプリングして、初回に扱ったLLM-as-judgeを非同期に走らせる、というのが実務的にはよく機能する。全リクエストを判定するのはコスト的に厳しいので、サンプリングレートを機能ごとに変える。

import random
import asyncio
from datetime import datetime
featureごとのサンプリング率 (重要度と単価のバランス)
SAMPLE_RATES = {
"customer_support_reply": 0.20,  # ユーザーに直接届く。厚めに見る
"internal_summary": 0.05,        # 社内利用。薄めでOK
"code_review_hint": 0.10,
}
async def maybe_evaluate(trace_id: str, feature: str, prompt: str, response: str):
rate = SAMPLE_RATES.get(feature, 0.02)
if random.random() > rate:
return
# 本番のレスポンスは既にユーザーに返っている。judgeは非同期でよい
score = await judge_llm.score(
    prompt=prompt,
    response=response,
    rubric_id=f"rubric.{feature}",  # featureごとのrubric
)

# 保存 — 集計は別プロセスがまとめて回す
await db.execute(
    """INSERT INTO online_eval
       (trace_id, feature, score, judged_at) VALUES ($1, $2, $3, $4)""",
    trace_id, feature, score, datetime.utcnow(),
)

ここで使うjudgeは、初回に組んだものと同じで構わない。むしろ、オフラインで検証したjudgeがそのまま本番モニタリングに載る、というのがこの流れの一番のうまみだと思っている。judgeの信頼性はオフラインで測ったcorrelationで担保されているので、本番でも「まあこれくらい信じてよい」の目安がある。

スコアが下がってきた時の見方は2つある。日次のスコア中央値のトレンドを追うのが1つ。もう1つは、低スコア (rubric 5点満点で2以下、など) の件数を追う。中央値は鈍いが、低スコア件数は敏感に反応する。両方見ておいた方がよい。

ユーザーフィードバックを併走させる

judgeだけでは足りない場面もある。「合ってはいるが冷たい」「合っているが長い」のような、rubricに載せにくい微妙な劣化はユーザーの👍/👎の方が早く教えてくれることが多い。UIに軽いフィードバック導線を用意して、それをそのままtraceにひもづけて保存しておくと、あとで「👎がついたリクエストのspanを全部見る」ができる。この地味な作業が、意外と多くを教えてくれる。

ダッシュボードは3枚あれば十分

ツールを選ぶ前に、何を可視化したいか決めた方がいい。筆者が最終的に落ち着いたのは3枚構成だ。

1枚目:全体のヘルス。P50/P95 latency、エラー率、requests per minute、日次コストの累計。オンコールが最初に開く画面。

2枚目:featureとmodel別のブレイクダウン。featureごとのコスト、model別のtoken分布、feature × modelのマトリクス。ここで「どこにお金が流れているか」が分かる。

3枚目:品質。オンライン評価スコアのトレンド、👍/👎の割合、低スコア件数、rubric別のスコア分布。週次のレビュー時にここを開く。

これ以上は、作った時点では美しいが誰も見なくなる。3枚を毎日誰かが見る方が、20枚を月1回見るよりずっと効く、という当たり前の話でもある。

ツール選定はワークロード次第

OTel準拠のバックエンドは選択肢が多い。乱暴に括ると次のような使い分けになる。

  • Langfuse — 本番運用のモニタリングとpromptバージョン管理が得意。Postgres + ClickHouse + Redisで構成するので、そこそこの規模の運用が前提。self-host可能、MITライセンス

  • Arize Phoenix — 開発時のtracing、eval、RAGのデバッグが強い。pip install一発で立ち上がる手軽さ。ライセンスはELv2 (自分たちで使うのはOK、hosted-as-serviceは制限あり)

  • Grafana + Tempo + Loki — 既存のGrafanaスタックがあるなら、無理にLLM専用ツールを増やさずここに寄せるのも十分アリ

成熟したチームだと「開発と評価はPhoenix、本番モニタリングはLangfuse」の二段構えを見かける。両方OTelを喋るので、instrumentation側は共通で済む。この分業はうまくいっているのを何度か見た。

3回書いてきて、ここで一旦区切る

1回目でオフライン評価とLLM-as-judgeの話をした。2回目でCIに載せる回帰テストと評価ゲート。今回で本番の観測。振り返ると、扱ったのは全部「LLMの出力を測る話」だった。プロンプトを書く話でもモデルを選ぶ話でもない、地味な足回りの話。

この3つが揃うと、LLMを組み込んだシステムは「作って終わり」ではなく「動かし続けられるもの」になる。逆に言えば、この3つのどれかが欠けている状態で本番運用しているなら、遅かれ早かれどこかで焦ることになると思っている。筆者もそうやって焦りながら覚えた。

ここに書いた設計や閾値は、あくまで筆者の現場での落とし所だ。トラフィック規模、コスト許容、品質基準、いずれもプロジェクトごとに違うので、読んだ人の現場で少しでも応用してもらえたら十分だ。あと、ここに載せなかった細かいハマりどころ (judgeのtemperature、rubricの言語、trace保存のretention設計、など) は、また別の機会に断片的に書くかもしれない。

筆者は 5years+ で AI/LLM の業務応用を担当している。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?