AVOCAD

同じ Claude を API 直と Bedrock で並べたらエラー 3 連発 / Gemini は 1024 tok 中 985 tok を考えるのに使っていた

LLMAWSBedrockGemini

概要

LLM 22 モデル(Anthropic 7・OpenAI 9・Google 6)に同じプロンプトを並列で投げて、出力・レイテンシ・トークン数・コストを横並びで比べる Web アプリを作りました。そのなかで、同じ Claude を Anthropic の API 直と AWS Bedrock 経由の 2 経路で並べて比べられるようにしたところ、Bedrock 側で立て続けにエラーを踏みました。

あわせて、Gemini だけ回答が極端に短く切れる、GPT-5 系で max_tokens が通らない、といった「プロバイダをまたぐと出てくる差」もいくつか踏んだので、まとめておきます!

先に結論です。

  • Bedrock の Claude はベース ID のままでは呼べないことが多い。Cross-Region Inference Profile のプレフィックス(global. など)を付ける。apac. は今の Claude には無いので注意
  • Tokyo(ap-northeast-1)の Opus 4.6 は jp. が使えず、global. しか使えない
  • Gemini 2.5 / 3 系は thinking がデフォルト ON で、思考トークンも maxOutputTokens に数えられる。1024 にしたら 985 tok を思考に使って、回答が 39 tok で切れました
  • GPT-5 系は Chat Completions の max_tokens が通らないので、Responses API の max_output_tokens に移しました
  • usage のフィールド名は 3 社でバラバラなので、アダプタで共通の形にそろえる

同じ Claude を 2 経路で並べる

モデル表に 2 エントリ登録する

モデル定義のテーブルに、同じ Claude を route: "direct" と route: "bedrock" の 2 エントリで登録しています。UI 上の ID は @bedrock を付けて衝突を避けました。

// Anthropic は direct と bedrock の 2 エントリを生成
// Bedrock ID はモデルごとに固有(公式 models overview の AWS Bedrock ID 列を渡す)
function anthropic(
  baseId: string,
  label: string,
  input: number,
  output: number,
  bedrockSdkId: string,
): ModelSpec[] {
  return [
    { provider: "anthropic", route: "direct", id: baseId, sdkId: baseId, /* ... */ },
    { provider: "anthropic", route: "bedrock", id: `${baseId}@bedrock`, sdkId: bedrockSdkId, /* ... */ },
  ];
}

呼び出し側は @anthropic-ai/sdk と @anthropic-ai/bedrock-sdk のどちらも messages.create という同じ形なので、route を見てクライアントを切り替えるだけで済みます。

const isBedrock = model.route === "bedrock";
const client = isBedrock ? getBedrockClient() : getDirectClient();
const modelId = isBedrock ? bedrockModelId(model.sdkId) : model.sdkId;

レイテンシの測り方

レイテンシは、サーバー側(Next.js の API ルート)で SDK 呼び出しの前後を performance.now() で測っています。

const started = performance.now();
// ...
const res = await client.messages.create({ /* ... */ });
const latencyMs = Math.round(performance.now() - started);

:::note warn ブラウザから見た E2E のレイテンシではなく、サーバーから見た所要時間です。Direct と Bedrock は同じサーバーから同じ測り方で呼んでいるので、2 経路の差を見るのには使えますが、ユーザーの体感とは別物です。 :::

なお、この記事では Direct と Bedrock のレイテンシの実測値は載せていません。手元の資料に、ちゃんと条件をそろえて記録した数字が残っていなかったためです(比べられる仕組みを作ったところまで、ということで…)。

Bedrock で踏んだエラー 3 連発

2 経路で並べた直後、Bedrock 側だけが次々に落ちました。15 分ほどの間に 3 回直しています。

1. The provided model identifier is invalid

最初は Bedrock のモデル ID を anthropic.${baseId} と単純につないでいました。ところが Bedrock の ID はモデルごとに形がバラバラで、-v1 が付くもの・日付と -v1:0 が付くもの・何も付かないものが混ざっています。

モデル Bedrock のモデル ID
Opus 4.7 anthropic.claude-opus-4-7
Opus 4.6 anthropic.claude-opus-4-6-v1
Opus 4.5 anthropic.claude-opus-4-5-20251101-v1:0
Sonnet 4.6 anthropic.claude-sonnet-4-6
Haiku 4.5 anthropic.claude-haiku-4-5-20251001-v1:0

規則で組み立てるのはやめて、Anthropic の models overview にある AWS Bedrock ID の列をそのままモデル表に書く形にしました。

2. Invocation of model ID ... with on-demand throughput isn't supported

ID を直すと、今度はこれです。Bedrock の Claude は多くのモデルで on-demand の直接呼び出しに対応しておらず、Cross-Region Inference Profile(CRIS)か Provisioned Throughput が必要です。CRIS はモデル ID の先頭にプレフィックスを付けて使います。

種類 プレフィックス 用途
Global CRIS global. 全モデル × 全リージョンで使える
Geo CRIS us. / eu. / jp. / au. データの置き場所に要件があるとき

3. apac. は存在しない

最初の実装では、AWS_REGION を見てプレフィックスを自動で決めていました。

const region = process.env.AWS_REGION ?? "";
if (region.startsWith("us-")) return `us.${sdkId}`;
if (region.startsWith("ap-")) return `apac.${sdkId}`;
if (region.startsWith("eu-")) return `eu.${sdkId}`;
return sdkId;

これで ap-northeast-1 の Opus 4.6 を呼ぶと、また The provided model identifier is invalid が返ってきました。原因は 2 つです。

  • apac. は今の Claude モデル群に割り当てられていない(Geo CRIS は US / EU / JP / AU で、APAC は対象外)
  • そもそも Tokyo の Opus 4.6 は Geo CRIS に対応しておらず、Global しか使えない

Tokyo での対応状況はこうなっていました。

モデル In-Region Geo jp. Global global.
Claude Opus 4.7 ✅ ✅ ✅
Claude Opus 4.6 ❌ ❌ ✅
Claude Sonnet 4.6 ❌ ✅ ✅
Claude Haiku 4.5 ✅ ✅ ✅

なので、デフォルトは global. にして、どのモデルでも動くようにしました。データの置き場所の要件があるときだけ、環境変数で jp. などに上書きします。

// Bedrock の Cross-Region Inference Profile プレフィックス決定
function bedrockModelId(sdkId: string): string {
  const override = process.env.AWS_BEDROCK_INFERENCE_PROFILE_PREFIX;
  if (override !== undefined) {
    return override ? `${override}.${sdkId}` : sdkId;
  }
  return `global.${sdkId}`;
}

AWS_BEDROCK_INFERENCE_PROFILE_PREFIX="" にすると、プレフィックスなし(In-Region の直接呼び出し)になります。

おまけ: web_search は Bedrock に送れない

あとで Web 検索ツールを足したとき、Bedrock 経由だけ 400 Input tag 'web_search_20260209' found using 'type' does not match any of the expected tags で落ちました。Anthropic の web_search サーバーツールは Direct API だけの機能で、Bedrock では受け付けられません。web_search_20250305 でも同じでした。

// web_search は Direct API のみ。Bedrock では受け付けない tool タイプなので送らない
if (options.enableWebSearch && !isBedrock) {
  list.push({ type: "web_search_20250305", name: "web_search" });
}

「同じ Claude」でも、経路によって使える機能が違う、というのは並べてみて初めて気づきました。

Gemini の思考が出力枠を食う件

1024 tok 中 985 tok が思考だった

比べ始めてすぐ、Gemini だけ回答が妙に短く、途中で切れていました。

原因は thinking です。Gemini 2.5 / 3 系の Flash / Pro は 内部の reasoning(thinking)がデフォルト ON で、しかも 思考トークンも maxOutputTokens の予算から引かれます。当時は出力上限を 1024 にしていたので、

  • 思考: 985 tok
  • 回答: 39 tok

で打ち切られていました。ほぼ全部、考えるのに使っていたわけです…。

まず thinkingBudget = 0 で止めた

最初は、比較のときの条件をそろえるために thinking を切りました。

config: {
  maxOutputTokens: maxTokens,
  // Gemini 2.5+ は内部 reasoning(thinking)がデフォルト ON で、
  // maxOutputTokens の予算を食い潰し最終回答が途切れることがある。
  // 比較目的では一貫性のため thinking を無効化し、指定上限を全部出力に回す
  thinkingConfig: { thinkingBudget: 0 },
},

ただ、思考を切ると複雑なタスクの品質は落ちます。「Gemini の本来の実力」を比べたいのに、それだと比較の意味が薄れてしまいます。

結局、出力枠のほうを広げた

そこで直後に方針を変えて、thinkingBudget: 0 は外し(thinking はモデル任せに戻す)、出力上限のデフォルトを 1024 → 4096、上限を 4096 → 16384 に引き上げました。思考に使っても回答の分が残るようにする、という考え方です。

さらにその後、思考の強さを UI で None / Low / Medium / High から選べるようにしました。指定したときだけ、各社のパラメータに変換して渡します。

function effortToBudget(effort: "low" | "medium" | "high"): number {
  return { low: 1024, medium: 4096, high: 12288 }[effort];
}
// ...
...(useThinking
  ? { thinkingConfig: { thinkingBudget: effortToBudget(options.reasoningEffort!) } }
  : {}),

:::note info 同じ罠は Anthropic の extended thinking にもあります。こちらは thinking.budget_tokens < max_tokens が必須なので、effort を指定したときは max_tokens = max(maxTokens, budget + 4096) に持ち上げて、回答の分を必ず残すようにしています。 :::

const budgetTokens = useThinking ? effortToBudget(options.reasoningEffort!) : 0;
const effectiveMaxTokens = useThinking
  ? Math.max(maxTokens, budgetTokens + 4096)
  : maxTokens;

GPT-5 系は max_tokens が通らない

OpenAI は最初 Chat Completions で書いていましたが、GPT-5 系では max_tokens / messages の形が Unsupported parameter で弾かれました。Responses API に移して、input / max_output_tokens / output_text を使う形にしています。

-    const res = await getClient().chat.completions.create({
+    // GPT-5 系では Chat Completions の max_tokens が廃止されているため Responses API を使用
+    const res = await getClient().responses.create({
       model: model.id,
-      max_tokens: maxTokens,
-      messages: [{ role: "user", content: prompt }],
+      input: prompt,
+      max_output_tokens: maxTokens,
     });

あわせて、GPT-5 系と o 系(o1 / o3 / o4)は reasoning モデルで、temperature / top_p を送ると 400 になります。モデル ID のプレフィックスで判定して、送らないようにしました。

function isReasoningModel(sdkId: string): boolean {
  return /^(gpt-5|o1|o3|o4)/i.test(sdkId);
}

usage のフィールド差をアダプタで吸収する

トークン数(=コスト)を横並びにするには、各社のレスポンスから入力・出力のトークン数を取り出す必要があります。ここも名前がバラバラです。

プロバイダ 入力トークン 出力トークン
Anthropic usage.input_tokens usage.output_tokens
OpenAI(Responses API) usage.input_tokens usage.output_tokens
Google usageMetadata.promptTokenCount usageMetadata.candidatesTokenCount

ちなみに OpenAI も Chat Completions のときは prompt_tokens / completion_tokens だったので、Responses API に移したことで Anthropic と同じ名前になりました。

各アダプタは、どのプロバイダでも同じ形(latencyMs / inputTokens / outputTokens / costUsd)で返すようにしています。

const inputTokens = res.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = res.usageMetadata?.candidatesTokenCount ?? 0;

ストリーミング(SSE)にすると、usage が届くタイミングもまた違います。

プロバイダ usage の取り方
Anthropic 入力は message_start、出力は message_delta に累積で来るので最後の値で上書き
OpenAI response.completed イベントの response.usage
Google 最後の chunk に usageMetadata が乗ることが多い。念のため毎回上書き

Web 検索の入力トークンは比べられない

もう 1 つ、Web 検索を ON にしたときの入力トークンの数え方も各社で違います。

  • OpenAI(web_search): 検索結果のテキストが usage.input_tokens に全部足される。短い質問でも入力 30,000 tok 級になる
  • Google(googleSearch): 検索結果は promptTokenCount に入らない。同じ質問で入力 13 tok のように小さく見える

横に並べると OpenAI だけ入力トークンとコストが桁違いに見えますが、バグではありません。単純に入力トークンで比べると Gemini が不当に有利に見えるので、UI に注記を出しています。

まとめ

  • Bedrock の Claude は、モデルごとの正式な ID + CRIS プレフィックスが要る。迷ったら global.。apac. は無い
  • Tokyo の Opus 4.6 は global. のみ
  • 同じ Claude でも、Bedrock では web_search が使えないなど、経路で機能差がある
  • thinking 系のモデルは、思考トークンが出力枠を食う。思考を切るか、出力枠を広げるかを決めておく
  • GPT-5 系は Responses API。reasoning モデルには temperature / top_p を送らない
  • usage は各社バラバラなので、アダプタで同じ形にそろえてから比べる

複数のプロバイダを 1 つの画面で並べると、「同じことをしているつもりで条件がそろっていない」ところがたくさん見えてきます。モデル選定で比較するときの参考になればうれしいです!

← ブログ一覧へ戻る