Antigravity で組んだエージェントを本番に出して数週間、Grafana のダッシュボードはずっと緑でした。合計コストはなだらか、P95 レイテンシも閾値内、エラーレートはほぼゼロ。それでも月末の請求は見積もりの 1.6 倍で、ある機能の回答だけが目に見えて雑になっていました。
合計値は、平均の中に問題を溶かしてしまいます。個人開発で複数の機能を一つの API キーに相乗りさせていると、この「溶けてしまう」性質が特に効いてきます。ここでは、合計の裏で進む二種類のずれ — 請求のずれと品質のずれ — を観測で捕まえるための計装を、実際に組み直した順に書き残します。
合計を見ても、誰がコストを食べているかは分からない
最初に作った監視は、モデル別のトークン数とコストを足し上げるだけのものでした。これは「いくら使ったか」には答えますが、「どこで増えたか」には答えません。本番では、コストは機能ごと・テナントごとに大きく偏ります。要約機能が全体の 6 割を食べていたり、特定の一社が平均の 20 倍を消費していたりします。合計のグラフはそれを平らに均してしまいます。
そこで計装の単位を、モデルではなく「機能 × テナント × プロンプト版」に変えました。OpenTelemetry のスパンとメトリクスに、この三つを必ず属性として乗せます。属性さえ揃っていれば、後から PromQL でどの切り口にも展開できます。
# llm_telemetry.py
import time
import anthropic
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.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
_tp = TracerProvider()
_tp.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317")))
trace.set_tracer_provider(_tp)
metrics.set_meter_provider(MeterProvider(metric_readers=[
PeriodicExportingMetricReader(
OTLPMetricExporter(endpoint="http://otel-collector:4317"),
export_interval_millis=30_000,
)
]))
tracer = trace.get_tracer("llm-app")
meter = metrics.get_meter("llm-app")
tokens = meter.create_counter("llm.tokens", description="tokens by direction")
cost = meter.create_counter("llm.cost_usd", unit="USD", description="API cost in USD")
latency = meter.create_histogram("llm.latency_ms", unit="ms", description="end-to-end latency")
errors = meter.create_counter("llm.errors", description="error count by type")
# 価格は $/1M tokens。モデル更新のたびに必ず見直す(後述)
MODEL_PRICES = {
"claude-sonnet-4-6": {"in": 3.0, "out": 15.0},
"claude-haiku-4-5-20251001": {"in": 0.25, "out": 1.25},
"claude-opus-4-8": {"in": 5.0, "out": 25.0},
}
class TelemetryClient:
"""機能・テナント・プロンプト版で属性付けする計装ラッパー"""
def __init__(self):
self._client = anthropic.Anthropic()
def call(self, *, model, messages, feature, tenant, prompt_version,
max_tokens=2048, system=None, role="product"):
# role="product" は本番応答、role="eval" は品質評価用(費用を分離する鍵)
attrs = {
"feature": feature, "tenant": tenant,
"prompt_version": prompt_version, "model": model, "role": role,
}
with tracer.start_as_current_span("llm.call") as span:
for k, v in attrs.items():
span.set_attribute(k, v)
t0 = time.monotonic()
try:
kwargs = {"model": model, "max_tokens": max_tokens, "messages": messages}
if system:
kwargs["system"] = system
res = self._client.messages.create(**kwargs)
ms = (time.monotonic() - t0) * 1000
tin, tout = res.usage.input_tokens, res.usage.output_tokens
price = MODEL_PRICES.get(model, {"in": 3.0, "out": 15.0})
usd = tin / 1e6 * price["in"] + tout / 1e6 * price["out"]
tokens.add(tin, {**attrs, "direction": "in"})
tokens.add(tout, {**attrs, "direction": "out"})
cost.add(usd, attrs)
latency.record(ms, attrs)
span.set_attribute("llm.cost_usd", usd)
span.set_attribute("llm.latency_ms", ms)
return {"text": res.content[0].text, "cost_usd": usd,
"latency_ms": ms, "in": tin, "out": tout}
except Exception as e:
errors.add(1, {**attrs, "error_type": type(e).__name__})
span.record_exception(e)
span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
raiseここで prompt_version を属性に含めているのは、後の品質追跡で効いてきます。プロンプトを変えた瞬間からコストと品質がどう動いたかを、版の境目で切れるようにしておきたいからです。バージョン文字列はプロンプトテンプレートのハッシュでも、手で振る chat-v7 のような短い識別子でも構いません。大事なのは「いつ何を変えたか」がメトリクスの軸に残ることです。
機能別・テナント別にコストを展開するクエリは、属性が揃っていれば素直に書けます。
# 機能ごとの直近1時間のコスト(どの機能が食べているか)
sum(increase(llm_cost_usd_total{role="product"}[1h])) by (feature)
# テナント別の上位消費(特定の一社の暴走を見つける)
topk(5, sum(increase(llm_cost_usd_total[24h])) by (tenant))評価モデルの費用が本番費用に紛れ込む
品質を自動評価するために LLM-as-a-Judge を回すと、評価それ自体が API を叩きます。最初これを区別していなかったので、本番コストのグラフに評価分が上乗せされ、「本番が高い」のか「評価を回しすぎている」のかが分からなくなりました。role="eval" を属性で分けたのはこのためです。評価は安いモデルで回し、PromQL では role="product" で本番費用だけを見ます。
評価は全リクエストにかけるとそれだけで費用が膨らむので、巡回サンプリングにしました。毎分すべてを採点するのではなく、機能ごとに一定割合だけを抜き取って評価します。これで評価費用を本番費用の数パーセントに抑えつつ、傾向の変化は十分に拾えます。
# quality_sampler.py
import json, random
from dataclasses import dataclass
@dataclass
class Score:
relevance: float
grounded: float # 参照情報に基づいているか(RAG の場合)
overall: float
note: str
class QualitySampler:
"""巡回サンプリングで品質を採点し、プロンプト版ごとに集計する"""
def __init__(self, telemetry, sample_rate=0.05, eval_model="claude-haiku-4-5-20251001"):
self.t = telemetry
self.rate = sample_rate
self.eval_model = eval_model
def maybe_score(self, *, question, answer, context, feature, tenant, prompt_version):
if random.random() > self.rate:
return None # サンプル対象外
ctx = f"\n\n参照情報:\n{context}" if context else ""
prompt = (
"次の回答を JSON のみで採点してください。"
"各項目は 0.0〜1.0。\n"
f"質問: {question}{ctx}\n\n回答: {answer}\n\n"
'{"relevance": 適切さ, "grounded": 参照情報への忠実さ, '
'"overall": 総合, "note": "一文の所見"}'
)
res = self.t.call(
model=self.eval_model,
messages=[{"role": "user", "content": prompt}],
feature=feature, tenant=tenant, prompt_version=prompt_version,
role="eval", max_tokens=300,
)
data = json.loads(res["text"])
score = Score(**data)
# メトリクスにも品質を流し、版ごとに追えるようにする
from opentelemetry import metrics
gauge = metrics.get_meter("llm-app").create_histogram("llm.quality_overall")
gauge.record(score.overall, {"feature": feature, "prompt_version": prompt_version})
return score採点結果を prompt_version で集計すると、品質劣化が「いつから」始まったかが版の境目に現れます。これが、次の一番厄介なずれを捕まえる土台になります。