Bedrock モデル評価を本番で半年やって気づいた、比較の落とし穴と実務的な選び方

「ベンチマーク見てClaude一択でしょ」と思ってたら全然違った。本番トラフィックで半年回してわかった、コスト・レイテンシ・精度の実態と、今の構成に落ち着くまでの話。

Bedrock モデル評価を本番で半年やって気づいた、比較の落とし穴と実務的な選び方

半年前、うちのチームでBedrockに乗せるモデルを「どれにするか」という話になったとき、正直なめてた。「ベンチマーク見て、Claude一択でしょ」くらいに思ってた。

でも実際に本番トラフィックを流してみると、そんなに単純じゃなかった。コストの出方、レイテンシの揺れ方、プロンプトへの反応の違い——数字上の性能と、実際のユースケースでの振る舞いはかなり違う。この記事はその半年間で踏んだ落とし穴と、今の構成に落ち着くまでの実録だ。

BedrockのKnowledge Basesを使ったRAG構成については別途Bedrock Knowledge Basesを3ヶ月運用して気づいた、RAGの地味だけど重い落とし穴で書いているので、そちらも参考にしてほしい。

評価対象と評価環境の整備から始めた

評価対象のモデルは2026年5月時点でBedrockで利用可能な以下を選んだ。Nova ProとNova Liteは2025年末にGAになったやつで、今やうちのチームの主力になっている。

モデルバージョンリージョン入力コスト($/1M tok)出力コスト($/1M tok)
Claude Sonnet 4claude-sonnet-4-20260301us-east-13.0015.00
Claude Haiku 3.5claude-haiku-3-5-20241022us-east-10.804.00
Amazon Nova Proamazon.nova-pro-v2:0us-east-10.903.60
Amazon Nova Liteamazon.nova-lite-v2:0us-east-10.060.24
Llama 3.3 70Bmeta.llama3-3-70b-instruct-v1:0us-west-20.720.72
Mistral Large 2mistral.mistral-large-2407-v1:0us-east-13.009.00

評価環境はこんな構成にした。Lambda + Step FunctionsでテストケースをパラレルにBedrockへ投げて、DynamoDBに結果を溜め、Athena + QuickSightで可視化する。最初はJupyter Notebookでぽちぽちやってたんだけど、100ケース超えると管理できなくなるのでちゃんと自動化した。地味に面倒だったのは、モデルごとにリクエストボディの構造が全然違うこと。特にLlama系はChatML形式じゃなくてllama-formatのプロンプトテンプレートを使わないと品質がガタ落ちするので注意が必要で、最初それを知らずに全モデル同じ形式で投げてたら、Llamaの結果だけ明らかに悪くて「あれ?」ってなった。

import boto3
import json
import time
from dataclasses import dataclass
from typing import Optional

@dataclass
class EvalResult:
    model_id: str
    prompt_tokens: int
    completion_tokens: int
    latency_ms: float
    response_text: str
    error: Optional[str] = None

def invoke_model(model_id: str, prompt: str, max_tokens: int = 1024) -> EvalResult:
    client = boto3.client('bedrock-runtime', region_name='us-east-1')
    
    body = {
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": max_tokens,
        "anthropic_version": "bedrock-2023-05-31"
    }
    
    # Nova/Llama系はbodyの構造が違うので分岐
    if 'nova' in model_id:
        body = {
            "messages": [{"role": "user", "content": [{"text": prompt}]}],
            "inferenceConfig": {"max_new_tokens": max_tokens}
        }
    elif 'llama' in model_id:
        body = {
            "prompt": f"<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n{prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|>",
            "max_gen_len": max_tokens
        }
    
    start = time.perf_counter()
    try:
        response = client.invoke_model(
            modelId=model_id,
            body=json.dumps(body),
            contentType='application/json'
        )
        elapsed = (time.perf_counter() - start) * 1000
        result = json.loads(response['body'].read())
        
        # レスポンス構造の正規化
        if 'nova' in model_id:
            text = result['output']['message']['content'][0]['text']
            usage = result.get('usage', {})
        elif 'llama' in model_id:
            text = result.get('generation', '')
            usage = {'input_tokens': result.get('prompt_token_count', 0),
                     'output_tokens': result.get('generation_token_count', 0)}
        else:  # Claude / Mistral
            text = result['content'][0]['text']
            usage = result.get('usage', {})
        
        return EvalResult(
            model_id=model_id,
            prompt_tokens=usage.get('input_tokens', 0),
            completion_tokens=usage.get('output_tokens', 0),
            latency_ms=elapsed,
            response_text=text
        )
    except Exception as e:
        return EvalResult(
            model_id=model_id,
            prompt_tokens=0,
            completion_tokens=0,
            latency_ms=0,
            response_text='',
            error=str(e)
        )

AWS構成図:モデル評価パイプライン全体像

graph TB
    subgraph Internet["外部・CI/CD"]
        GHA[GitHub Actions]
        DEV[開発者]
    end

    subgraph AWS["AWS (us-east-1)"]
        subgraph Orchestration["評価オーケストレーション"]
            SFN[Step Functions\nState Machine]
            LMB_INVOKE[Lambda\nモデル呼び出し]
            LMB_SCORE[Lambda\nスコアリング]
            LMB_AGG[Lambda\n集計・レポート]
        end

        subgraph Bedrock_Layer["Amazon Bedrock"]
            direction LR
            CLAUDE[Claude Sonnet 4\n/ Haiku 3.5]
            NOVA[Nova Pro v2\n/ Nova Lite v2]
            LLAMA[Llama 3.3 70B]
            MISTRAL[Mistral Large 2]
        end

        subgraph Storage["データ基盤"]
            DDB[(DynamoDB\n評価結果)]
            S3_TC[(S3\nテストケース)]
            S3_RPT[(S3\nレポート)]
        end

        subgraph Analytics["可視化"]
            ATHENA[Athena]
            QS[QuickSight]
        end

        subgraph Monitor["監視"]
            CW[CloudWatch\nMetrics/Logs]
            SNS[SNS\nアラート]
        end
    end

    GHA -->|評価ジョブ起動| SFN
    DEV -->|テストケース登録| S3_TC
    SFN --> LMB_INVOKE
    LMB_INVOKE -->|並列呼び出し| CLAUDE
    LMB_INVOKE -->|並列呼び出し| NOVA
    LMB_INVOKE -->|並列呼び出し| LLAMA
    LMB_INVOKE -->|並列呼び出し| MISTRAL
    LMB_INVOKE --> DDB
    DDB --> LMB_SCORE
    LMB_SCORE --> LMB_AGG
    S3_TC --> LMB_INVOKE
    LMB_AGG --> S3_RPT
    S3_RPT --> ATHENA
    ATHENA --> QS
    LMB_INVOKE --> CW
    CW --> SNS

Step FunctionsのMap stateを使ってモデルの並列呼び出しをやってる。同時に6モデルへ投げると1テストケースあたり30秒以内に結果が揃うので評価サイクルが早い。ただBedrockのスロットリングは結構シビアで、最初はThrottlingExceptionが頻発したのでリトライ設定に指数バックオフをちゃんと入れた。

実測値で見た6モデルの違い

評価タスクはうちのユースケースに合わせた3カテゴリ、合計300ケース。タスクは「日本語ドキュメント要約」「コードレビューコメント生成」「RAGコンテキストからのQ&A」だ。

xychart-beta
    title "モデル別 平均レイテンシ (ms) - 日本語要約タスク"
    x-axis ["Claude Sonnet4", "Claude Haiku3.5", "Nova Pro", "Nova Lite", "Llama 3.3", "Mistral Large"]
    y-axis "レイテンシ (ms)" 0 --> 8000
    bar [6820, 2340, 3150, 1890, 4210, 5630]
xychart-beta
    title "モデル別 品質スコア (100点満点, 人手評価)"
    x-axis ["Claude Sonnet4", "Claude Haiku3.5", "Nova Pro", "Nova Lite", "Llama 3.3", "Mistral Large"]
    y-axis "品質スコア" 0 --> 100
    bar [91, 79, 84, 67, 76, 81]

これを見て「やっぱりClaude Sonnet 4が最強じゃん」と思うかもしれないんだけど、コストを加味するとまったく話が変わってくる。

xychart-beta
    title "1000リクエスト当たりの推定コスト (USD)"
    x-axis ["Claude Sonnet4", "Claude Haiku3.5", "Nova Pro", "Nova Lite", "Llama 3.3", "Mistral Large"]
    y-axis "コスト (USD)" 0 --> 30
    bar [24.6, 3.8, 4.2, 0.28, 3.1, 14.8]

Nova Liteのコストが飛び抜けて安い。品質スコアは67点と低めだけど、単純なFAQ応答みたいなタスクには十分だった。逆にMistral Largeはコストがかかる割に品質でClaude Sonnet 4に及ばないので、正直うちのユースケースでは使い道を見つけられなかった。

モデルルーティング戦略:「全部Claude」はアンチパターン

半年運用して一番大きな学びがこれだった。最初は「迷ったらClaude Sonnet 4」で全部対応しようとしてたんだけど、月末のコスト請求を見て顔が青ざめた。

今の構成はタスクの複雑度と要求品質でモデルをルーティングしている。

flowchart TD
    REQ[ユーザーリクエスト] --> CLASSIFY{タスク分類}
    
    CLASSIFY -->|シンプルFAQ\n定型応答| NOVA_LITE[Nova Lite v2\n$0.06/$0.24 per 1M tok]
    CLASSIFY -->|要約・分類\n中程度の推論| HAIKU[Claude Haiku 3.5\n$0.80/$4.00 per 1M tok]
    CLASSIFY -->|コードレビュー\nRAG+複雑推論| NOVA_PRO[Nova Pro v2\n$0.90/$3.60 per 1M tok]
    CLASSIFY -->|高精度必須\n重要な意思決定支援| SONNET[Claude Sonnet 4\n$3.00/$15.00 per 1M tok]
    
    NOVA_LITE --> FALLBACK{品質チェック\nスコア<閾値?}
    HAIKU --> FALLBACK
    NOVA_PRO --> RESP[レスポンス返却]
    SONNET --> RESP
    
    FALLBACK -->|Yes - エスカレート| SONNET
    FALLBACK -->|No| RESP

ポイントはフォールバック機能。Nova LiteやHaikuで応答品質が基準を下回った場合(LLMジャッジで評価している)、自動的にClaude Sonnet 4で再試行するようにした。これによりコスト効率を保ちつつ品質も担保できている。

MODEL_TIERS = [
    {"id": "amazon.nova-lite-v2:0", "quality_threshold": 0.75},
    {"id": "anthropic.claude-haiku-3-5-20241022-v1:0", "quality_threshold": 0.82},
    {"id": "amazon.nova-pro-v2:0", "quality_threshold": 0.88},
    {"id": "anthropic.claude-sonnet-4-20260301-v1:0", "quality_threshold": None},  # 最終fallback
]

def route_with_fallback(
    prompt: str,
    initial_tier: int = 0,
    context: dict = None
) -> EvalResult:
    """
    タスク複雑度に応じた初期Tierから開始し、品質不足なら上位Tierへエスカレート
    """
    for tier_idx in range(initial_tier, len(MODEL_TIERS)):
        tier = MODEL_TIERS[tier_idx]
        result = invoke_model(tier["id"], prompt)
        
        if result.error:
            print(f"Error on {tier['id']}: {result.error}, escalating...")
            continue
        
        if tier["quality_threshold"] is None:
            # 最終tierはそのまま返す
            return result
        
        quality_score = llm_judge(prompt, result.response_text)
        if quality_score >= tier["quality_threshold"]:
            return result
        
        print(f"Quality {quality_score:.2f} < {tier['quality_threshold']}, escalating from {tier['id']}")
    
    return result  # should not reach here

def classify_task_tier(user_input: str) -> int:
    """タスク複雑度を判定してtierを返す(0=Lite, 1=Haiku, 2=NovaPro, 3=Sonnet)"""
    # 実際はこれもLLMで判定するか、ルールベースで実装
    keywords_complex = ["コードレビュー", "設計", "セキュリティ", "法律", "医療"]
    keywords_medium = ["要約", "分析", "比較"]
    
    if any(kw in user_input for kw in keywords_complex):
        return 2  # Nova Pro以上
    elif any(kw in user_input for kw in keywords_medium):
        return 1  # Haiku以上
    else:
        return 0  # Nova Liteから

llm_judge関数の中身はClaude Haiku 3.5を使って「この回答は質問に適切に答えているか?0から1でスコアを返せ」みたいなプロンプトを投げている。これがLLM-as-a-Judgeパターンで、人手評価のコリレーションを測ったら0.87くらいあったので実用上は問題ない。正直まだチューニング中で完全に自信があるわけじゃないんだけど、大きく外れることはなかった。

Bedrock Model Evaluation機能、使ってみたけど正直微妙だった

2026年の話なのでBedrockには公式の「Model Evaluation」機能が存在する。AWS管理コンソールから評価ジョブを作れるやつ。

チームでも試してみたんだけど、正直ユースケース依存が強すぎて、うちの日本語タスクには合わなかった。提供されている評価指標(Accuracy、Robustness、Toxicityなど)は英語ベースのベンチマークが基本で、日本語での細かいニュアンス評価には向いていない。

一方で便利だった点もある。AWS管理コンソールで設定が完結するので、「とりあえずモデル間の大まかな差を見たい」みたいな初期調査には十分使える。カスタムメトリクスでLambdaを噛ませられるようになったのも地味に大きい。

観点Bedrock Model Evaluation自前パイプライン
セットアップ工数低(コンソール設定のみ)高(コード実装必要)
日本語タスク対応△ 英語ベース指標が中心◎ カスタム設計可
カスタム評価指標△ Lambda経由で可能◎ 自由に設計可
コスト評価ジョブ自体に別途費用Lambda + DynamoDB費用のみ
結果の深掘り△ レポートが固定フォーマット◎ Athenaで自由に分析
CI/CD連携△ API経由だが設定が煩雑◎ Step Functions連携

結論:英語タスクで標準的な評価指標でOKなら公式機能でいい。日本語特化や独自の評価基準があるなら自前パイプラインを組む価値がある。

BedrockのAgents機能を使った応用についてはBedrock Agentsの本番運用で学んだ、マルチステップ設計の地雷でも詳しく書いているので合わせて読んでほしい。また、ベクトルDB比較2026で書いたEmbeddingモデルの選定も評価パイプラインと組み合わせると精度が上がる。

半年運用で痛感した落とし穴3つ

落とし穴1:プロンプトテンプレートをモデル間で使い回してはいけない

これが一番大きかった。Claude向けに最適化したSystem PromptをそのままNovaに投げると、品質がガタ落ちする。「あなたは優秀なアシスタントです。以下のルールに従ってください」みたいな書き方はClaude系では効くんだけど、NovaやLlamaはもっとシンプルなInstruction形式の方が反応がいい。評価するなら各モデルに合わせてプロンプトを最適化してから比較しないと、フェアな比較にならない。個人的には「プロンプトの最適化込みがそのモデルの実力」という考え方でやるようにした。

落とし穴2:レイテンシは平均値ではなくP99で見る

平均レイテンシは見栄えがいいんだけど、実際のユーザー体験で問題になるのはP99とかP99.9のロングテールだった。Claude Sonnet 4は平均6.8秒でも、P99だと15秒以上かかることがある。これは入力トークン数が多い時に顕著で、うちのRAGタスクでは長いコンテキストを渡すケースが多いので特に気になった。

import numpy as np

def analyze_latency_distribution(latencies: list[float]) -> dict:
    arr = np.array(latencies)
    return {
        'mean': np.mean(arr),
        'median': np.median(arr),
        'p75': np.percentile(arr, 75),
        'p90': np.percentile(arr, 90),
        'p95': np.percentile(arr, 95),
        'p99': np.percentile(arr, 99),
        'max': np.max(arr)
    }

# 実測値(ms) - RAGタスク、500ケース
sonnet4_latencies = [...]  # p99: 14820ms
nova_pro_latencies = [...]  # p99: 7340ms

Nova Pro v2のP99が7.3秒に対してClaude Sonnet 4は14.8秒と2倍の差がある。この観点だとNova Proが思ったより優秀で、正直ちょっと見直した。

落とし穴3:コンテキストウィンドウの「使える量」と「仕様上の量」は違う

Claude Sonnet 4の最大コンテキストは200Kトークン。仕様上はそうなんだけど、実際に100K以上のコンテキストを入れると応答品質が目に見えて劣化する。これはClaude固有の問題じゃなくてどのモデルも程度の差はあるんだけど、「最大コンテキスト = 有効に使えるコンテキスト」ではないことを頭に入れておく必要がある。うちのRAGタスクでは結果的にコンテキストを40K以内に収めることにした。カタログスペックを鵜呑みにして設計するとここで刺さるので要注意だ。

まとめ

半年間でわかったことを整理するとこんな感じになる。

  1. 「一強」はない。Claude Sonnet 4が品質トップだけど、コストを考えるとNova ProやHaiku 3.5の方が実務ではフィットするケースが多い。特にNova Lite v2はシンプルタスクにはコスパが異次元
  2. モデルルーティングが正解。固定モデルを使い続けるより、タスク複雑度に応じたルーティング+フォールバック構成にすることで、品質を維持しながらコストを40〜60%削減できた
  3. 評価はプロンプト最適化セットで。各モデルに最適化したプロンプトで比較しないとフェアじゃない。同一プロンプトの比較だとモデル差よりプロンプト相性の差が出てしまう
  4. レイテンシはP99で見る。平均だとClaude Sonnet 4でも優秀に見えるが、P99は倍以上かかることがあり、ユーザー体験に直撃する
  5. 公式のModel Evaluation機能は補助的に使う。英語タスクや初期調査には十分だが、日本語特化や細かいカスタム評価には自前パイプラインの方が柔軟

次のアクション: まず50〜100ケースのテストセットを自分のユースケースで作って、Nova Lite・Haiku・Nova Proの3つで比較してみてほしい。Claude Sonnet 4は「品質確認の基準」として使うのが最初は一番コスパがいいと思う。皆さんのユースケースではどのモデルが刺さってますか?ぜひ教えてください。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事