ANTIGRAVITY LABEN
記事一覧/AIツール
AIツール/2026-06-17上級

Antigravity の LLM アプリは、ダッシュボードが緑のまま請求と品質がずれていく — 観測の計装メモ

LLM アプリの監視は、合計のコストとレイテンシだけ見ていると静かなずれを見逃します。機能・テナント・プロンプト版で属性付けし、品質劣化とコスト急増を早期に捕まえる計装の設計メモです。

llmops2observability13opentelemetry2costquality2antigravity367production55

プレミアム記事

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 で集計すると、品質劣化が「いつから」始まったかが版の境目に現れます。これが、次の一番厄介なずれを捕まえる土台になります。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
機能・テナント・プロンプト版で属性付けし、合計値の裏に隠れたコスト偏りを切り出す計装パターン
既定モデルが勝手に上がる時代に、品質劣化を数値で捕まえる巡回サンプリング評価の設計
評価モデルの費用が本番費用に紛れ込む罠と、コスト台帳でテナント単位の急増を止める実装
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

連携・プラグイン2026-03-27
Antigravity × OpenTelemetry: AI駆動オブザーバビリティパイプライン構築 — トレース・メトリクス・ログを統合監視する実践ガイド
Antigravity のAIエージェントを活用して OpenTelemetry ベースのオブザーバビリティパイプラインを構築する上級ガイド。分散トレーシング、メトリクス収集、ログ集約を統合し、異常検知と自動修復を実現します。
AIツール2026-04-21
プロンプトは「資産」です — Antigravityで作る本番級プロンプト管理基盤:バージョニング・A/Bテスト・品質評価の実装パターン
プロンプトをコードとして扱い、バージョン管理・A/Bテスト・品質評価を自動化する本番基盤の実装ガイド。AntigravityのAIエージェントで安全にプロンプトを改善し続けるための設計パターンを、動作するコードとともに解説します。
AIツール2026-04-19
Gemma 4 × Antigravity でローカルLLMを本番環境で動かす——セットアップから安定運用まで
Gemma 4をAntigravityと組み合わせてローカル環境で本番運用するための完全ガイド。Ollamaセットアップ・パフォーマンスチューニング・API連携・よくあるトラブルまで実装例つきで解説。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →