0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazon Bedrock Converse APIでマルチモデル切替 ― Qwen3 80B無料枠とClaude Sonnet 4.6の二層設計

0
Posted at

個人開発のAIアプリを フリーミアム で提供するとき、一番の悩みは「無料ユーザーのLLMコストをどう吸収するか」です。GPT-4やClaude Sonnetクラスのモデルだけで無料枠を回そうとすると、ユーザーが伸びるほど赤字が膨らみ、広告収益では追いつかなくなります。

恋愛メッセージ分析アプリ Relora では、Amazon Bedrockの Converse API を使って:

  • 無料プラン: Qwen3 Next 80B(1分析あたり約0.12円)
  • 有料プラン: Claude Sonnet 4.6(1分析あたり約1.8円、月額¥980〜)

という二層のモデル切替を、同一のAPIコードで実現しています。本記事はその実装と、リージョンフォールバック・Guardrails統合を含めたLambda設計を解説します。

この記事で分かること

  • Bedrock Converse APIによるモデル差異の吸収
  • Qwen3 / Anthropic Claudeを同一コードで呼び分ける方法
  • モデルごとにリージョン制約が違う問題への対処(フォールバック)
  • Bedrock Guardrailsとの統合(プロンプトインジェクション・PII対策)
  • コスト実測値(Qwen3: 約0.12円 vs Sonnet 4.6: 約1.8円/分析)
  • 「無料でも十分使える、有料でさらに良くなる」二層設計の考え方

背景: なぜBedrockか

個人開発で複数のモデルを切り替える選択肢:

サービス メリット デメリット
OpenAI API 品質・SDK充実 1社ロックイン
Anthropic API Claude品質 無料枠弱い
Google Vertex AI Gemini安い 運用難度が高め
Amazon Bedrock 複数プロバイダ1APIで統合、AWS統合強い リージョン制約あり

Reloraはバックエンド全体をAWS(Cognito / API Gateway / Lambda / DynamoDB)に寄せていたので、Bedrockが自然でした。特に Qwen3 Next 80BとClaude Sonnet 4.6を同じConverse APIで呼べる のが決め手でした。

モデル設定

LambdaのPythonコードでは次のように定義しています。

MODELS = {
    "free": {
        "model_id": "qwen.qwen3-next-80b-a3b",
        "region": "us-east-1",
        "fallback_region": "us-west-2",
    },
    "premium": {
        "model_id": os.environ.get(
            "MODEL_PREMIUM",
            "global.anthropic.claude-sonnet-4-6"
        ),
        "region": "default",      # クライアント作成時に既定リージョン
        "fallback_region": None,
    },
}
  • Qwen3はリージョン限定(us-east-1 / us-west-2)
  • Sonnet 4.6はクロスリージョン推論プロファイル(global. プレフィックス)
  • fallback_region で us-east-1 がスロットリングしたら us-west-2 に自動切り替え

Converse API呼び出し

def _invoke_bedrock(
    model_id: str,
    system_prompt: str,
    user_prompt: str,
    region: str,
    fallback_region: str | None,
) -> tuple[str, int, int, str | None]:
    """Bedrock Converse APIを呼び、(output_text, in_tokens, out_tokens, guardrail_action)を返す"""

    messages = [{"role": "user", "content": [{"text": user_prompt}]}]
    system = [{"text": system_prompt}]

    kwargs = {
        "modelId": model_id,
        "messages": messages,
        "system": system,
        "inferenceConfig": {
            "maxTokens": MAX_TOKENS,
            "temperature": 0.7,
            "topP": 0.9,
        },
    }

    # Guardrails
    if GUARDRAIL_ID:
        kwargs["guardrailConfig"] = {
            "guardrailIdentifier": GUARDRAIL_ID,
            "guardrailVersion": GUARDRAIL_VERSION,
            "trace": "enabled",
        }

    try:
        client = _get_bedrock_client(region)
        response = client.converse(**kwargs)
    except Exception as e:
        if fallback_region and _is_throttle_or_region_error(e):
            print(f"[WARN] Failed in {region}, retrying in {fallback_region}")
            client = _get_bedrock_client(fallback_region)
            response = client.converse(**kwargs)
        else:
            raise

    output = response["output"]["message"]["content"][0]["text"]
    usage = response["usage"]
    guardrail_action = response.get("trace", {}).get("guardrail", {}).get("action")

    return (
        output,
        usage["inputTokens"],
        usage["outputTokens"],
        guardrail_action,
    )

ここが肝: Converse APIは モデルごとのリクエスト形式の差異を吸収してくれる ため、このコード1つでQwen3とClaudeの両方が動きます。Invoke APIを使うと、モデルごとに messages の形式を書き分ける必要があり、保守が辛くなります。

リージョンフォールバック

Qwen3は us-east-1 で提供されていますが、スロットリング時やリージョン障害時に自動で us-west-2 にフォールバックします。

def _is_throttle_or_region_error(e: Exception) -> bool:
    if not hasattr(e, "response"):
        return False
    code = e.response.get("Error", {}).get("Code", "")
    return code in (
        "ThrottlingException",
        "ServiceUnavailableException",
        "ModelNotReadyException",
    )

小規模個人アプリでもリージョンスロットリングは意外に起きます。フォールバックを入れるとユーザー体感の可用性が一段上がります。

クライアント遅延初期化(コールドスタート対策)

_bedrock_clients: dict = {}

def _get_bedrock_client(region: str):
    key = region if region in ("us-east-1", "us-west-2") else "default"
    if key not in _bedrock_clients:
        kwargs = {"region_name": region} if key != "default" else {}
        _bedrock_clients[key] = boto3.client("bedrock-runtime", **kwargs)
    return _bedrock_clients[key]

boto3クライアントの初期化は重い(数百ms)ので、モジュールレベルの辞書でキャッシュします。Lambdaのホット実行では2回目以降の呼び出しが数msで済みます。

Guardrailsの統合

Bedrock GuardrailsはCDKから設定し、Lambda環境変数で GUARDRAIL_ID / GUARDRAIL_VERSION を渡します。

Converse APIは guardrailConfig パラメータを直接受け取るので、プロンプト加工やフィルタリングを自前で書く必要がない のが大きな利点です。

介入が発生した場合:

output_text, _, _, guardrail_action = _invoke_bedrock(...)

if guardrail_action == "INTERVENED":
    return {
        "statusCode": 422,
        "body": json.dumps({
            "error": "content_blocked",
            "message": output_text,
        }),
    }

クライアント側で専用の「コンテンツブロック」画面を表示し、ユーザーに理由を伝えます。

プラン別アクセス制御(サーバー側検証)

クライアントが送る tier を鵜呑みにせず、サーバー側のDynamoDBに保存されたプラン情報で検証します。

sub_response = usage_table.get_item(
    Key={"PK": f"USER#{user_id}", "SK": "SUBSCRIPTION"}
)
plan = sub_response.get("Item", {}).get("plan", "free")
expires_ms = sub_response.get("Item", {}).get("expiresDate")
if expires_ms and time.time() > (int(expires_ms) / 1000):
    plan = "free"  # 期限切れは free に降格

if plan == "free" and tier == "premium":
    return _error(403, "Sonnet requires Standard or Premium plan")

プラン情報の真実はサーバーにある という原則です。StoreKit 2のJWS検証済みレシートで書き込む別エンドポイント(/v1/update-subscription)を用意しています。

レート制限(DynamoDBアトミック)

DAILY_LIMITS = {
    "free":     {"free": 99999, "premium": 0},
    "standard": {"free": 99999, "premium": 20},
    "premium":  {"free": 99999, "premium": 50},
}

# アトミックにインクリメント、上限超過時は ConditionalCheckFailedException
try:
    usage_table.update_item(
        Key={"PK": f"USER#{user_id}", "SK": f"DATE#{today}"},
        UpdateExpression=f"SET #cnt = if_not_exists(#cnt, :zero) + :one, #ttl = :ttl",
        ConditionExpression=f"attribute_not_exists(#cnt) OR #cnt < :limit",
        ExpressionAttributeNames={"#cnt": f"count_{tier}", "#ttl": "ttl"},
        ExpressionAttributeValues={
            ":one": 1, ":zero": 0, ":ttl": ttl_value, ":limit": daily_limit,
        },
    )
except usage_table.meta.client.exceptions.ConditionalCheckFailedException:
    return _error(429, "Daily limit reached")

DynamoDBの条件付き更新 を使うことで、競合するリクエストでも「カウントが上限を超えることはない」を保証できます。スレッドセーフを自前でやる必要がありません。

コスト実測

1分析あたりの平均入力/出力トークンと実コスト(2026-04時点、Bedrock on-demand 単価、1USD=150円換算):

モデル 入力 tok 出力 tok 単価(per 1M tokens) 1分析コスト
Qwen3 Next 80B 約 1,800 約 450 input $0.15 / output $1.20 約 0.12円
Claude Sonnet 4.6 約 1,800 約 450 input $3.00 / output $15.00 約 1.8円

Qwen3はClaudeのおよそ 1/15 のコストです。1分析あたりの絶対額は数銭〜数円とどちらも安価ですが、無料ユーザー数が桁を1〜2上がると差が露骨に効いてきます。

二層設計の試算(Sonnet運用と比較):

  • 無料ユーザー1万人 × 月10回分析(Qwen3)= 月12,000円
  • 同じトラフィックを全部Sonnetで回すと = 月18万円(15倍)
  • 有料ユーザー(月100分析想定)1人あたりLLMコスト約180円、月額¥980との差額は約800円
  • 有料ユーザー15人で無料1万人分のコストを相殺できる構造

無料ユーザー規模が小さいうちはどちらでも黒字ですが、スケールしたときの損益分岐点を大きく外側に押し出せる のがQwen3を採用している理由です。

二層設計の体験設計

「無料でも十分使える、有料でさらに良くなる」 を体感してもらうのが肝です。

  • Qwen3 80Bは、素の品質としてGPT-3.5 Turboより上で、Claude Haiku並み。日常のチャット分析には十分
  • Sonnet 4.6は、ニュアンスの読み取り・文体模倣・心理推測の繊細さが段違い

無料で失望させない品質を提供し、有料で「これはもう別次元」という体験を与える。この差分を明確にするには、同じプロンプト・同じスキーマ でモデルだけを切り替える設計が重要です(Converse APIの恩恵)。

まとめ

  • Bedrock Converse APIなら、Qwen3 80BとClaude Sonnet 4.6を同一コードで扱える
  • リージョンフォールバックは小規模アプリでも有効
  • Guardrailsを guardrailConfig で注入すれば、フィルタリングを自前実装しなくて済む
  • プラン情報とレート制限はサーバーで完結、クライアントを信頼しない
  • Qwen3無料枠 + Claude有料の二層設計で、個人開発のフリーミアムAIが成立する

関連記事:


Relora(App Store): https://apps.apple.com/app/relora/id6762029713

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?