導入
前回は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.system、gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_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_idとapp.featureのような自分たちのビジネス属性を必ず入れることだ。あとで「featureごとのコスト」「userごとの異常な使い方」を見たくなる時が必ず来る。span kindやmodel名だけで集計するのは限界が早い。
overheadを気にする人がいるかもしれないが、実測では呼び出しごとに1ms未満だった。LLMのAPI自体が100ms〜30秒のレンジで動いているので、無視できる。

コストの監視 — 「気づいた時には遅い」の典型
LLMコストが吹き飛ぶ時、原因はだいたい決まっている。筆者が過去に遭遇したのは次の4つだ。
リトライループ (429を食らってバックオフなしで再試行)
agentのtool呼び出しループ (終了判定が甘くて同じtoolを叩き続ける)
セッションのコンテキスト累積 (会話履歴を全部プロンプトに乗せ続ける実装)
プロンプトの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 の業務応用を担当している。