Skip to content
Published on

LLMOpsプラットフォーム構築ガイド:モデルデプロイ、モニタリング、A/Bテスト

Authors
  • Name
    Twitter
LLMOps Platform

はじめに

LLM(Large Language Model)が本番環境に急速に普及する中、従来のMLOpsだけでは解決できない新たな運用課題が登場しました。モデル学習よりもプロンプトエンジニアリングが中心となり、定量的メトリクスよりも生成品質の評価が重要になり、トークン単位のコスト管理とハルシネーション防止のためのガードレールが必須となっています。

Gartnerによると、2026年までに企業の生成AI導入の50%以上が運用の未成熟さにより失敗すると予測されています。これはLLM技術自体の問題ではなく、LLMを安定的に運用するためのプラットフォームアーキテクチャの不在に起因しています。

この記事では、LLMOpsプラットフォームの全体アーキテクチャを解説します。vLLM/TGIベースのモデルサービングからトークン使用量モニタリング、プロンプトバージョン管理、A/Bテストフレームワーク、NeMo Guardrails統合、コスト最適化まで、本番LLM運用に必要なすべてをコードとともに構築します。

LLMOps vs MLOps:何が違うのか

従来のMLOpsが「学習-デプロイ-モニタリング」パイプラインの自動化に注力していたのに対し、LLMOpsは根本的に異なるパラダイムから出発します。MLOpsは再現可能な予測のためのシステムであり、LLMOpsは確率的生成のためのシステムです。

項目MLOpsLLMOps
コア活動モデル学習/再学習プロンプトエンジニアリング/ファインチューニング
コスト構造学習コスト中心推論コスト中心(トークン課金)
評価方法Accuracy, F1, RMSEBLEU, ROUGE, LLM-as-Judge
データパイプラインFeature Store, ETLRAG、ベクトルDB、チャンキングパイプライン
バージョン管理モデルアーティファクトプロンプトテンプレート + モデル + パラメータ
モニタリングデータドリフト、性能指標トークン使用量、レイテンシ、品質、ハルシネーション
デプロイサイクル週/月単位の再学習プロンプト変更は分単位で可能
安全対策入力バリデーションガードレール、コンテンツフィルタリング、PII検出

LLMOpsのアーキテクチャは従来のMLOpsに比べてより多くのコンポーネントを必要とします。モデルサーバーの前にアプリケーションゲートウェイが配置され、プロンプトルーティング、ベクトルDB検索、ツール呼び出し、キャッシングレイヤーをオーケストレーションします。

ユーザーリクエスト
    |
    v
+-------------------------------------------+
|         Application Gateway               |
|  +----------+ +--------+ +-----------+    |
|  | プロンプト| | ベクトル| | ガード    |    |
|  | ルーター  | | DB     | | レール    |    |
|  |          | | (RAG)  | | エンジン  |    |
|  +----------+ +--------+ +-----------+    |
|  +----------+ +--------+ +-----------+    |
|  | キャッシュ| | A/B    | | トークン  |    |
|  | レイヤー  | | ルーター| | メータリング|   |
|  +----------+ +--------+ +-----------+    |
+-------------------+-----------------------+
                    |
    +---------------+---------------+
    v               v               v
+--------+    +--------+    +------------+
| vLLM   |    |  TGI   |    | TensorRT-  |
| Server |    | Server |    | LLM Server |
+--------+    +--------+    +------------+

モデルサービングアーキテクチャ

LLMサービングフレームワーク比較

LLMサービングに特化した主要フレームワークの比較です。

フレームワークコア技術GPU要件スループットレイテンシモデル互換性運用難易度
vLLMPagedAttentionCUDA GPU高い中程度HuggingFace全体低い
TGIFlash AttentionCUDA GPU高い低い (v3)HuggingFace全体低い
TensorRT-LLMCUDAグラフ最適化NVIDIA専用最高最低変換が必要高い
Triton + vLLMアンサンブルパイプラインCUDA GPU高い中程度マルチモデル中程度

vLLMはPagedAttentionを通じてKVキャッシュを仮想メモリのようにページ単位で管理し、GPUメモリの断片化を最小化します。同じVRAMでより多くの同時リクエストを処理でき、混合長ワークロードでTGIに比べて10~30%高いスループットを示します。

TGI v3は長文プロンプト(200K+トークン)処理でvLLMに比べて最大13倍高速な性能を発揮し、Hugging Faceエコシステムとの緊密な統合が強みです。

TensorRT-LLMはH100ハードウェアでCUDAグラフ最適化とフューズドカーネルを通じてvLLM/TGIに比べて20~40%高い生のスループットを達成しますが、モデル変換プロセスとNVIDIAハードウェアへの依存が必要です。

vLLMデプロイ設定

Kubernetes環境でvLLMをデプロイする実践的な設定です。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama3-70b
  namespace: llm-serving
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-llama3-70b
  template:
    metadata:
      labels:
        app: vllm-llama3-70b
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.7.3
          args:
            - '--model'
            - 'meta-llama/Llama-3.3-70B-Instruct'
            - '--tensor-parallel-size'
            - '4'
            - '--max-model-len'
            - '8192'
            - '--gpu-memory-utilization'
            - '0.90'
            - '--enable-chunked-prefill'
            - '--max-num-batched-tokens'
            - '32768'
            - '--port'
            - '8000'
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 4
            requests:
              nvidia.com/gpu: 4
              memory: '64Gi'
              cpu: '16'
          env:
            - name: HUGGING_FACE_HUB_TOKEN
              valueFrom:
                secretKeyRef:
                  name: hf-secret
                  key: token
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 180
            periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-llama3-70b-svc
  namespace: llm-serving
spec:
  selector:
    app: vllm-llama3-70b
  ports:
    - port: 8000
      targetPort: 8000
  type: ClusterIP

主要パラメータの説明:

  • tensor-parallel-size: 4:70Bモデルを4つのGPUに分散して推論します。
  • gpu-memory-utilization: 0.90:GPUメモリの90%をKVキャッシュに割り当て、同時リクエスト数を最大化します。
  • enable-chunked-prefill:プリフィルとデコーディングをインターリーブしてTTFT(Time To First Token)を削減します。
  • max-num-batched-tokens: 32768:バッチあたりの最大トークン数で、スループットとレイテンシのバランスを取ります。

モニタリング戦略

LLMモニタリングは従来のMLモニタリングと異なり、3つの次元を同時に追跡する必要があります:パフォーマンス(Performance)コスト(Cost)品質(Quality)

コアメトリクス体系

パフォーマンスメトリクス      コストメトリクス          品質メトリクス
+-- TTFT (Time To            +-- 入力トークン数        +-- 応答関連性スコア
|   First Token)             +-- 出力トークン数        +-- ハルシネーション率
+-- TPOT (Time Per           +-- リクエストあたりコスト +-- ガードレール違反率
|   Output Token)            +-- モデル別コスト比較    +-- ユーザーフィードバック
+-- 総生成時間               +-- キャッシュヒット率    |   (thumbs up/down)
+-- リクエストスループット   +--/月別コスト推移    +-- LLM-as-Judgeスコア
|   (RPS)
+-- GPU使用率
+-- キュー待機時間

Prometheusメトリクス収集の実装

vLLMが公開するメトリクスをPrometheusで収集し、カスタムビジネスメトリクスを追加するPythonミドルウェアの例です。

import time
import tiktoken
from prometheus_client import (
    Counter, Histogram, Gauge, start_http_server
)
from functools import wraps

# パフォーマンスメトリクス
REQUEST_LATENCY = Histogram(
    "llm_request_latency_seconds",
    "LLMリクエストレイテンシ",
    ["model", "endpoint"],
    buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0],
)
TTFT_LATENCY = Histogram(
    "llm_ttft_seconds",
    "Time To First Token",
    ["model"],
    buckets=[0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0],
)

# コストメトリクス
TOKEN_COUNTER = Counter(
    "llm_tokens_total",
    "総トークン使用量",
    ["model", "direction"],  # direction: input/output
)
REQUEST_COST = Counter(
    "llm_request_cost_dollars",
    "リクエスト別コスト (USD)",
    ["model"],
)

# 品質メトリクス
QUALITY_SCORE = Histogram(
    "llm_quality_score",
    "LLM応答品質スコア",
    ["model", "evaluator"],
    buckets=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
)
GUARDRAIL_VIOLATIONS = Counter(
    "llm_guardrail_violations_total",
    "ガードレール違反回数",
    ["model", "violation_type"],
)

# モデル別トークン単価 (USD per 1K tokens)
PRICING = {
    "llama-3.3-70b": {"input": 0.00059, "output": 0.00079},
    "gpt-4o": {"input": 0.0025, "output": 0.01},
    "claude-sonnet": {"input": 0.003, "output": 0.015},
}


class LLMMetricsCollector:
    def __init__(self, model_name: str):
        self.model_name = model_name
        self.encoder = tiktoken.get_encoding("cl100k_base")

    def record_request(self, prompt: str, response: str,
                       latency: float, ttft: float):
        input_tokens = len(self.encoder.encode(prompt))
        output_tokens = len(self.encoder.encode(response))

        # パフォーマンス記録
        REQUEST_LATENCY.labels(
            model=self.model_name, endpoint="/v1/chat/completions"
        ).observe(latency)
        TTFT_LATENCY.labels(model=self.model_name).observe(ttft)

        # トークン使用量記録
        TOKEN_COUNTER.labels(
            model=self.model_name, direction="input"
        ).inc(input_tokens)
        TOKEN_COUNTER.labels(
            model=self.model_name, direction="output"
        ).inc(output_tokens)

        # コスト計算と記録
        pricing = PRICING.get(self.model_name, PRICING["llama-3.3-70b"])
        cost = (
            input_tokens / 1000 * pricing["input"]
            + output_tokens / 1000 * pricing["output"]
        )
        REQUEST_COST.labels(model=self.model_name).inc(cost)

    def record_quality(self, score: float, evaluator: str = "auto"):
        QUALITY_SCORE.labels(
            model=self.model_name, evaluator=evaluator
        ).observe(score)

    def record_guardrail_violation(self, violation_type: str):
        GUARDRAIL_VIOLATIONS.labels(
            model=self.model_name, violation_type=violation_type
        ).inc()


if __name__ == "__main__":
    start_http_server(9090)
    collector = LLMMetricsCollector("llama-3.3-70b")

Grafanaダッシュボードの主要パネル

Prometheusメトリクスを基に構成するGrafanaダッシュボードのコアPromQLクエリです。

# P99レイテンシ(5分ウィンドウ)
histogram_quantile(0.99, rate(llm_request_latency_seconds_bucket[5m]))

# 分あたりトークン消費量
rate(llm_tokens_total[1m])

# 時間あたりコスト推移
rate(llm_request_cost_dollars[1h]) * 3600

# ガードレール違反率
rate(llm_guardrail_violations_total[5m]) / rate(llm_request_latency_seconds_count[5m])

# 中央値品質スコア
histogram_quantile(0.5, rate(llm_quality_score_bucket[1h]))

プロンプトバージョン管理

LLMOpsにおいてプロンプトは、従来のMLOpsにおけるモデルアーティファクトに相当するコア資産です。プロンプトテンプレート、モデルバージョン、生成パラメータ(temperature, top_pなど)を一括でバージョン管理することで、再現可能なデプロイと細かなロールバックが可能になります。

import json
import hashlib
from datetime import datetime
from dataclasses import dataclass, field, asdict
from typing import Optional


@dataclass
class PromptVersion:
    name: str
    template: str
    model: str
    temperature: float = 0.7
    top_p: float = 0.9
    max_tokens: int = 2048
    system_prompt: str = ""
    version: str = ""
    created_at: str = field(
        default_factory=lambda: datetime.utcnow().isoformat()
    )

    def __post_init__(self):
        if not self.version:
            content = f"{self.template}{self.model}{self.temperature}"
            self.version = hashlib.sha256(
                content.encode()
            ).hexdigest()[:8]

    def to_dict(self) -> dict:
        return asdict(self)


class PromptRegistry:
    """プロンプトバージョン管理レジストリ"""

    def __init__(self, storage_backend="redis"):
        self.storage_backend = storage_backend
        self.prompts: dict[str, list[PromptVersion]] = {}

    def register(self, prompt: PromptVersion) -> str:
        if prompt.name not in self.prompts:
            self.prompts[prompt.name] = []
        self.prompts[prompt.name].append(prompt)
        return prompt.version

    def get_latest(self, name: str) -> Optional[PromptVersion]:
        versions = self.prompts.get(name, [])
        return versions[-1] if versions else None

    def get_version(self, name: str, version: str
                    ) -> Optional[PromptVersion]:
        versions = self.prompts.get(name, [])
        for v in versions:
            if v.version == version:
                return v
        return None

    def rollback(self, name: str, version: str) -> bool:
        target = self.get_version(name, version)
        if target:
            self.prompts[name].append(
                PromptVersion(
                    name=target.name,
                    template=target.template,
                    model=target.model,
                    temperature=target.temperature,
                    top_p=target.top_p,
                    max_tokens=target.max_tokens,
                    system_prompt=target.system_prompt,
                )
            )
            return True
        return False


# 使用例
registry = PromptRegistry()
v1 = PromptVersion(
    name="customer-support",
    template="お客様のお問い合わせに丁寧にお答えください。\n\nお問い合わせ: {query}",
    model="llama-3.3-70b",
    temperature=0.3,
    system_prompt="あなたはプロのカスタマーサポートエージェントです。",
)
registry.register(v1)

A/Bテストフレームワーク

LLMのA/Bテストは従来のWebのA/Bテストとは根本的に異なります。クリック率のような単純なメトリクスではなく、生成品質という多次元的な評価が必要であり、確率的な出力特性により同一入力に異なる応答が返される可能性があるため、より多くのサンプルが必要です。

A/Bテストルーターの実装

import random
import hashlib
from dataclasses import dataclass
from typing import Any


@dataclass
class ABVariant:
    name: str
    prompt_version: str
    model: str
    weight: float  # トラフィック比率 (0.0 ~ 1.0)
    parameters: dict = None


class LLMABRouter:
    """LLM A/Bテストトラフィックルーター"""

    def __init__(self, experiment_name: str):
        self.experiment_name = experiment_name
        self.variants: list[ABVariant] = []

    def add_variant(self, variant: ABVariant):
        self.variants.append(variant)

    def route(self, user_id: str) -> ABVariant:
        """ユーザーIDベースの決定的ルーティング
        (同一ユーザーは同一variantを取得)"""
        hash_input = f"{self.experiment_name}:{user_id}"
        hash_value = int(
            hashlib.md5(hash_input.encode()).hexdigest(), 16
        )
        normalized = (hash_value % 10000) / 10000.0

        cumulative = 0.0
        for variant in self.variants:
            cumulative += variant.weight
            if normalized < cumulative:
                return variant
        return self.variants[-1]

    def validate_weights(self) -> bool:
        total = sum(v.weight for v in self.variants)
        return abs(total - 1.0) < 0.001


# 実験設定例
experiment = LLMABRouter("customer-support-v2-test")
experiment.add_variant(ABVariant(
    name="control",
    prompt_version="v1-abc123",
    model="llama-3.3-70b",
    weight=0.7,
    parameters={"temperature": 0.3},
))
experiment.add_variant(ABVariant(
    name="treatment",
    prompt_version="v2-def456",
    model="llama-3.3-70b",
    weight=0.3,
    parameters={"temperature": 0.5},
))

# ユーザー別ルーティング
variant = experiment.route(user_id="user-12345")
print(f"Assigned variant: {variant.name}")

統計的有意性の判定

LLM A/Bテストにおける統計的有意性を判定するコアロジックです。

import numpy as np
from scipy import stats


def calculate_ab_significance(
    control_scores: list[float],
    treatment_scores: list[float],
    alpha: float = 0.05,
    min_samples: int = 100,
) -> dict:
    """A/Bテスト結果の統計的有意性を判定"""

    if (len(control_scores) < min_samples
            or len(treatment_scores) < min_samples):
        return {
            "status": "insufficient_samples",
            "control_n": len(control_scores),
            "treatment_n": len(treatment_scores),
            "min_required": min_samples,
        }

    control_mean = np.mean(control_scores)
    treatment_mean = np.mean(treatment_scores)
    lift = (treatment_mean - control_mean) / control_mean

    # Welchのt検定(等分散の仮定不要)
    t_stat, p_value = stats.ttest_ind(
        control_scores, treatment_scores, equal_var=False
    )

    # 効果量(Cohen's d)
    pooled_std = np.sqrt(
        (np.std(control_scores) ** 2 + np.std(treatment_scores) ** 2)
        / 2
    )
    cohens_d = (
        (treatment_mean - control_mean) / pooled_std
        if pooled_std > 0 else 0
    )

    return {
        "status": "significant" if p_value < alpha else "not_significant",
        "control_mean": round(control_mean, 4),
        "treatment_mean": round(treatment_mean, 4),
        "lift": round(lift * 100, 2),
        "p_value": round(p_value, 6),
        "cohens_d": round(cohens_d, 4),
        "recommendation": (
            "DEPLOY treatment"
            if p_value < alpha and lift > 0
            else "KEEP control"
        ),
    }

ガードレール統合

本番LLMにおいてガードレールは選択ではなく必須です。NVIDIA NeMo Guardrailsを活用すれば、入出力フィルタリング、トピック逸脱防止、PII検出、ハルシネーションチェックを宣言的に構成できます。

NeMo Guardrails設定

# config.yml - NeMo Guardrails設定
models:
  - type: main
    engine: vllm
    parameters:
      base_url: 'http://vllm-llama3-70b-svc:8000/v1'
      model_name: 'meta-llama/Llama-3.3-70B-Instruct'

rails:
  input:
    flows:
      - self check input # 入力有害性チェック
      - check jailbreak # 脱獄試行検出
      - mask pii # PIIマスキング

  output:
    flows:
      - self check output # 出力有害性チェック
      - check hallucination # ハルシネーション検出
      - check topic relevance # トピック関連性確認

  config:
    enable_multi_step_generation: true
    lowest_temperature: 0.1
    enable_rails_exceptions: true

instructions:
  - type: general
    content: |
      以下のガイドラインを必ず遵守してください:
      1. 確認されていない事実は推測であると明示
      2. 個人情報が含まれた応答は禁止
      3. 医療/法律/金融アドバイスは専門家への相談を推奨
      4. 暴力的または有害なコンテンツの生成を禁止

sample_conversation: |
  user "こんにちは、助けが必要です。"
    express greeting
  bot express greeting and offer help
    "こんにちは!何かお手伝いできることはありますか?"

ガードレールミドルウェア統合

from nemoguardrails import RailsConfig, LLMRails


class GuardrailMiddleware:
    """LLMガードレールミドルウェア"""

    def __init__(self, config_path: str):
        config = RailsConfig.from_path(config_path)
        self.rails = LLMRails(config)

    async def process(self, user_message: str,
                      context: dict = None) -> dict:
        try:
            response = await self.rails.generate_async(
                messages=[{"role": "user", "content": user_message}]
            )
            return {
                "status": "success",
                "response": response["content"],
                "guardrail_actions": response.get(
                    "log", {}
                ).get("activated_rails", []),
            }
        except Exception as e:
            return {
                "status": "blocked",
                "reason": str(e),
                "response": "リクエストを処理できません。"
                            "別の質問をお試しください。",
            }

コスト最適化

LLM運用においてコストはトークン使用量に比例するため、体系的なコスト最適化戦略が必要です。

コスト削減戦略

  1. セマンティックキャッシング:類似した質問への応答をベクトル類似度ベースでキャッシングし、重複推論を防止します。一般的に20~40%のコスト削減効果があります。

  2. プロンプト圧縮:不要なトークンを削除し、核心情報のみを伝達して入力トークンを削減します。LLMLinguaのようなツールを活用すれば、プロンプトを50%以上圧縮できます。

  3. モデルルーティング:クエリの難易度に応じて軽量モデル(7B)と大型モデル(70B)を自動ルーティングします。簡単な質問を軽量モデルで処理すれば、コストを80%以上削減できます。

  4. KVキャッシュ最適化:vLLMのprefix caching機能を活用して、システムプロンプトや共通コンテキストのKVキャッシュを再利用します。

# モデルルーティング例:クエリ複雑度に基づく自動ルーティング
class ModelRouter:
    def __init__(self):
        self.complexity_threshold = 0.6
        self.models = {
            "simple": {
                "name": "llama-3.2-8b",
                "endpoint": "http://vllm-8b:8000/v1",
                "cost_per_1k": 0.00010,
            },
            "complex": {
                "name": "llama-3.3-70b",
                "endpoint": "http://vllm-70b:8000/v1",
                "cost_per_1k": 0.00079,
            },
        }

    def classify_complexity(self, query: str) -> float:
        """クエリ複雑度を0~1のスコアで評価"""
        indicators = [
            len(query) > 500,          # 長い質問
            "比較" in query,            # 比較分析要求
            "分析" in query,            # 分析要求
            "コード" in query,          # コード生成
            query.count("?") > 2,       # 複数質問
        ]
        return sum(indicators) / len(indicators)

    def route(self, query: str) -> dict:
        complexity = self.classify_complexity(query)
        if complexity >= self.complexity_threshold:
            return self.models["complex"]
        return self.models["simple"]

失敗事例と教訓

事例1:モデルサービングOOM(Out of Memory)

70Bモデルを4x A100 80GBにデプロイしましたが、max-model-lenを32768に設定したため、同時リクエスト増加時にOOMが発生しました。

原因:KVキャッシュはシーケンス長に比例してメモリを消費します。32K長で同時リクエスト50件が入ると、KVキャッシュだけで300GB以上のメモリが必要になります。

解決max-model-lenを8192に削減し、gpu-memory-utilizationを0.90に設定した後、長い入力は別のインスタンスにルーティングしました。

事例2:プロンプト回帰(Prompt Regression)

カスタマーサポートのプロンプトを「より親切に」修正した結果、技術サポートの精度が30%低下しました。

原因:プロンプト変更をA/Bテストなしで全トラフィックに即座にデプロイしました。「親切さ」の強調が正確な技術用語の使用を抑制する副作用を引き起こしました。

解決:すべてのプロンプト変更は必ず10%トラフィックでカナリアデプロイし、精度/関連性/有用性の3つの品質メトリクスをすべてパスしてから全面デプロイするポリシーを確立しました。

事例3:A/Bテストの統計的エラー

500件のサンプルでA/Bテストを終了してtreatmentのデプロイを決定しましたが、その後パフォーマンスがcontrolを下回りました。

原因:LLMの確率的特性により分散が大きく、500件では統計的検出力(power)が不足していました。また、週末/平日のトラフィックパターンの違いが考慮されていませんでした。

解決:最小サンプルサイズを1000件以上に設定し、最低1週間以上の実験を実施して時間帯効果を相殺するようにしました。Cohen's dベースの効果量も併せて確認します。

運用チェックリスト

デプロイ前

  • モデルサービングフレームワークのhealthcheckエンドポイントが正常に応答することを確認
  • GPUメモリ使用率が95%を超えないことを確認
  • プロンプトバージョンがレジストリに登録されていることを確認
  • ガードレール設定が最新のポリシーを反映していることを確認
  • ロールバックプロンプトバージョンが明確に指定されていることを確認

デプロイ中

  • カナリアトラフィック比率を10%から開始して段階的に増加
  • TTFT、TPOT、総レイテンシの3つのパフォーマンスメトリクスをリアルタイムでモニタリング
  • ガードレール違反率がベースラインに比べて急増していないことを確認
  • トークン使用量とコストが予算範囲内であることを確認

デプロイ後

  • A/Bテスト結果の統計的有意性を確認してから全面デプロイを決定
  • 品質メトリクス(関連性、精度、有用性)ダッシュボードを週単位でレビュー
  • 月次コストレポートを生成して予算対実績を追跡
  • ユーザーフィードバックデータを収集してプロンプト改善に反映
  • ガードレールログを分析して新たなリスクパターンをポリシーに追加

参考資料