個人開発の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が成立する
関連記事:
- Apple Vision OCRのboundingBoxで送信者を自動判定する
- LLMのJSON出力を壊さない ― 多段デコード・自動修復パーサーの設計
- ローカルOCR + クラウドLLMのハイブリッドアーキテクチャ
- スクショ→AI分析アプリの全体設計
Relora(App Store): https://apps.apple.com/app/relora/id6762029713