こんにちは~ 浮田です。
前回の記事では、プロンプトを自動で改良するループを回して書類分類の精度を上げる、という実験をしていました。
そこで一番効いた教訓は「モデル選定が全て」でした。
判定役をNova Liteにすると82%で頭打ちだった分類が、Claude Haiku 4.5に替えた瞬間に100%。プロンプトをこねる前にモデルを替えろ、という話です。
で、そうなると次の疑問が出てきます。
そのClaudeを、Step Functionsからどう呼ぶのが最適なのか?
調べると方法は2つありました。
正直なところ「なんとなくBedrockのほうが楽そう」という感覚はありました。でも、根拠がありません。速いのか、安いのか、何がどれだけ違うのか、何ひとつ数字で言えない。
そこで前回と同じ手法(p50/p95、サーバ側タイムスタンプ、複数回の再現確認)で、同じ画像・同じプロンプト・同じパラメータを両方に流して測ってみることにしました。
結論を先に言うと、速いのはBedrock、安いのはHTTP Task。
※ 補足: 厳密には3つ目の経路もありえます。HTTP Task から Bedrock の bedrock-mantle エンドポイント(Anthropic Messages API 互換)を叩く形です。bedrock-mantleについてはこちらの記事を参照してください。
■ 2. 2つの経路は何が違うのか
まず構造から。
A案:
Step Functions ──(SDK統合)──> Bedrock ──> Claude (AWS内・日本ジオ)
B案:
aStep Functions ──(HTTP Task)──> api.anthropic.com ──> Claude (Anthropic側)
ここをオフィスの荷物運びでたとえると分かりやすいです。
A案は社内便です。同じビルの中をカートで運ぶので速いし、警備を通るのも社員証(IAM)だけで済みます。
B案は外部の宅配便。一度ビルを出て公道に乗るので、道の混み具合に左右されます。伝票(APIキー)も要ります。
この「一度ビルを出るかどうか」が、後で出てくるp95の差にそのまま効いてきます。しかも思っていたより直接的に効いていました(第9節)。
構造の違いは3点に集約されます。
|
|
A: Bedrock最適化統合 |
B: HTTP Task |
|
認証 |
IAMロールだけ |
APIキー→EventBridge connection→Secrets Managerに自動保存→実行時にx-api-key注入 |
|
結果の取り出し |
$states.result.Body.content[0].text |
$states.result.ResponseBody.content[0].text |
|
ペイロード制限 |
256KB(S3から入力を読む逃げ道あり) |
256KB(逃げ道なし) |
B案の認証は手数が多そうに見えますが、connectionを1つ作るだけで、あとはStep Functionsが実行時にヘッダーへ注入してくれます。
ステートマシン定義にシークレットが一切現れないのは、地味にありがたいポイントでした。
レスポンス構造は、中身はどちらも同一のMessages API形式です。
ただ包み方が違うので、JSONataのパスが1階層ずれます。これが後で刺さります(後述)。
さて、実装に入る前にいきなり躓きました。
Bedrockのモデルカードを見ると、Claude Haiku 4.5は ap-northeast-1(東京)で In-Region 提供に✓が付いています。
だから素のモデルID anthropic.claude-haiku-4-5-20251001-v1:0 で呼べるものだと思っていました。実際に叩くとこうなります。
▲ 素のモデルIDでオンデマンド呼び出ししたときのエラー
オンデマンドでは推論プロファイル経由が必須でした。
日本の場合は jp.anthropic.claude-haiku-4-5-20251001-v1:0(東京+大阪の日本ジオルーティング)を使います。
つまり「ドキュメント上のIn-Region提供」は、「単一リージョンに閉じたオンデマンド呼び出しができる」を意味しません。提供されている(=そのリージョンで処理される)話と、そのリージョンIDだけで呼べる話は別レイヤーなんですね。
これ、以前Location Serviceの公共交通ルーティングを検証したときに得た「リージョン対応≠データ網羅」とまったく同じ形の罠です。ドキュメントの✓は、自分が知りたいことの✓とは限らない。同じところで二度転んだので、そろそろ体で覚えたいところです。
※ 補足: 計測期間中の課金は全量が東京(APN1)に計上されていました。jp.プロファイルは名目上「東京+大阪」ですが、実際はほぼ東京で処理されていたようです。
前回記事で使った合成日本語ビジネス書類データセット(7クラス×24枚)の test split 56枚を使います。1実行=1画像の分類で、両ステートマシンにまったく同じ入力(base64画像+プロンプト+パラメータ)を渡します。
プロンプトについてひとつ。
前回育てた高精度プロンプトはあえて使いませんでした。 経路の比較に、前回記事の成果物への依存を持ち込みたくなかったからです。
ここで育成済みプロンプトを使うと「この差はプロンプトのせいでは?」と自分で疑う余地が残ってしまいます。
比較実験は、条件を揃えるところが8割だと思っています。やったことは以下です。
この記事はこの3語で全部説明してしまうので、先に整理しておきます。私も最初は「平均でいいじゃん」と思っていた側なので、同じ方向から来る人向けの節です。
56回の実行を、速かった順に1列に並べたと想像してください。
電車でたとえると分かりやすいです。p50は「いつもだと何分で着く」、p95は「遅延した日だと何分かかる」。通勤で本当に困るのは後者のほうですよね。
ではなぜ平均を使わないのか。
平均は、極端に遅い1回に引っ張られて実態より悪く見えることもあれば、逆に大量の速い実行に薄められて「たまに5秒かかる」という事実を消してしまうこともあります。どちらの方向にも嘘をつくんですね。p50とp95を並べて出せば「ふつうの速さ」と「最悪付近の速さ」が分けて見えるので、この記事ではこの2つで通します。
そしてこの記事の結論は、実はここに集約されます。2つの経路はp50では約400msしか変わらないのに、p95では2倍以上開く。 つまり「ふつうの速さ」より「運が悪いときの速さ」に差が出るタイプの違いでした。ユーザーを待たせる処理では、効いてくるのは後者です。
CDKでconnection・IAMロール・ステートマシン2本をデプロイします。ASLの本体はどちらも1ステートだけです。
A案(抜粋):
B案(抜粋):
見ていただくと分かりますが、差分は実質Resourceと引数の形だけです。「Step FunctionsからLLMを呼ぶのは大変そう」というのは、私の思い込みでした。Lambdaを1つも書かずに、どちらの経路もこれで動きます。
まずは主測定。1語分類を56枚、3回です。
|
run |
A: Bedrock p50 / p95 |
B: HTTP Task p50 / p95 |
|
day1 |
1,084 / 1,210ms |
1,502 / 2,511ms |
|
day1-rep2 |
1,069 / 1,176ms |
1,504 / 2,668ms |
|
day2 |
1,106 / 1,211ms |
1,410 / 2,647ms |
3回測って数字がほぼ動かないので、これは素性のいいデータだと思います。読み取れることは2つ。
平均だけ見ていたら気づかない差です。
ユーザーを待たせる同期処理なら、効いてくるのは間違いなくp95側です。正解率は両経路とも55/56・54/56で実質同一。ここは主題ではないので流します。
それより収穫だったのが、input_tokensが全実行で1,204トークンに完全一致したことです。
両経路が同じトークナイズを経て同じモデルに到達している、という強い証拠になりました。ここが揃っていないと比較そのものが無効になるので、正直ホッとしました。
■ 7. 実測②:出力を長くすると差は拡大する
さて、ここで疑問が出ます。この400msは、接続を確立するための固定オーバーヘッドなのでは?
最初はそう思っていました。違いました。同じ画像で、出力長だけを3段階に変えてみます。
|
条件 |
出力トークン |
A p50 |
B p50 |
差 |
|
1語ラベルのみ |
約7 |
1,084ms |
1,502ms |
+418ms |
|
+判定根拠100字 |
約146 |
2,332ms |
3,289ms |
+957ms |
|
+詳細読解400字 |
約398 |
4,204ms |
5,394ms |
+1,190ms |
別日の再計測でも
medium: A 2,328 / B 3,018ms、
long: A 4,228 / B 5,146ms と同じ構図が再現しました。
Aの数字は2回でほぼ一致(2,332→2,328、4,204→4,228)していて、揺れているのはBのほうだけ、というのも示唆的です。
固定差ではありませんでした。出力が伸びるほど差も伸びています。
増分から実効生成速度を概算すると、A ≈ 7〜9ms/トークン、B ≈ 9〜13ms/トークン。つまり接続の入り口だけの話ではなく、生成している最中のスループット自体が、HTTP Task経由だと1〜2割遅いということになります。
これは使い分けにそのまま効きます。1語分類なら0.4秒差で済みますが、要約や長文生成を回すなら差は素直に積もっていきます。
Bedrockは InvocationLatency(モデル側の処理時間)をCloudWatchに出してくれます。day1の56実行と突き合わせると、こう割れました。
実行全体 1,119ms
├─ モデル処理 903ms (InvocationLatency)
└─ SFn統合+転送 216ms
A案のオーバーヘッドは約0.2秒。1.1秒のうち0.9秒はモデルが考えている時間なので、経路側でこれ以上削れる余地は小さいと分かります。
そして、ここで気づいたのがもう一つの差です。B案には同等の公式メトリクスが存在しないので、この分解ができません。
これ、遅い/速い以上に運用で効いてくると思いました。
本番で「レスポンスが遅い」と言われたとき、モデルが遅いのか経路が遅いのかを切り分けられるかどうかは、障害対応の速さに直結します。
「Bedrockは内訳を検証できる、直叩きはブラックボックス」という観測可能性の差は、比較表に1行足す価値があります。
さて本題です。
残った宿題は、B案のp95でした。あの重いテールはどこから来るのか。候補は3つ考えました。
そこで、画像を渡さず「OK」と1語返すだけの最小リクエストを各70本流す、という切り分け実験をしました。
モデルの仕事をほぼゼロに潰せば、残った時間は接続と転送の分しかない、という考え方です。
|
|
A: Bedrock |
B: HTTP Task |
|
p50 |
730ms |
1,035ms |
|
p95 |
836ms |
2,122ms |
|
範囲 |
608〜865ms |
849〜5,211ms |
|
1.5秒超のスパイク |
0 / 70 |
9 / 70(13%) |
この実験の表だけ、p50/p95に加えて「範囲」と「スパイク数」を並べています。
理由があって、テールの正体を見たいときはp95では粗いからです。
p95は「後ろから3番目くらい」の1点しか教えてくれないので、その先最悪値がどこまで伸びているのか、遅い実行が何本あるのかが見えません。ここではまさにその「先」が知りたいので、最小値〜最大値の幅と、しきい値(1.5秒)を超えた本数を数えました。
Bのp95は2,122msですが、最悪値は5,211ms。p95の2倍以上先まで裾が伸びているわけです。一方Aは上限が865msで、p95(836ms)とほとんど差がありません。この「p95より先がどうなっているか」の違いが、次の切り分けの材料になります。
結果は明快でした。
対してAは、70本すべてが608〜865msという狭い帯に収まりました。
範囲の上限が865msに対して、Bは最悪5,211ms。この安定性の差が、p95の差(1.2秒 vs 2.5秒)の正体です。
そしてもうひとつ、おまけの発見がありました。
最小リクエスト同士のp50差は約305ms(1,035 − 730)。主測定で見えた差は約420msでしたから、引き算すると内訳が見えます。
約420msの差のうち、大部分(約305ms)はベースの経路オーバーヘッドの差で、残り約100msが出力7トークン分の生成速度の差。
第7節で「出力が伸びると差が広がる」と書きましたが、正確に言うと下駄が305msあって、そこに生成速度の差が積み上がっていく構造だったわけです。
1語なら下駄が支配的、400字なら生成速度差が支配的になる。ここまで割れると、自分のユースケースでどちらが効くか見積もれます。
ここは机上計算ではなく、計測で実際に発生した実請求(Cost Explorer)から単価を逆算しました。公表価格表を写すだけだと、プロファイル経由の扱いが本当にその単価なのか確認できないからです。
トークン単価(1Mトークンあたり)
|
|
入力 |
出力 |
|
A: Bedrock(jp.プロファイル) |
$1.10 |
$5.50 |
|
B: Anthropic API 直 |
$1.00 |
$5.00 |
Bedrockはちょうど1.10倍でした。
プロファイル経由でも係数は素直です。
そしてStep Functions側は、どちらの経路も1実行=3遷移×$0.000025で完全に同額。
内訳はこうなります。
開始(Start → InvokeModel) 1
InvokeModel の実行 1
終了(→ End / Succeeded) 1
──────────────────────────
合計 3
請求上の1,368遷移÷456実行=3.0でも裏が取れました。
100万回換算(今回のshort条件: 入力1,204+出力7トークン)
|
|
A: Bedrock |
B: HTTP Task |
|
トークン |
$1,363 |
$1,239 |
|
Step Functions |
$75 |
$75 |
|
合計 |
$1,438 |
$1,314 |
B案が約9%安い、という結果です。
おもしろいのは、Step Functions側の$75が両経路とも同額だという点です。
A案とB案の引き算では丸ごと消えるので、経路差$124はまるごとトークン単価の差ということになります。Expressに変えれば$72ほど削れますが、それも両方に等しく効くので順位は動きません。
※ 今回Standardを選んだのは、DescribeExecutionのサーバ側タイムスタンプでレイテンシを測りたかったからでもあります。コストだけ見てExpressにすると、この計測手法は使えません。
LLMワークフローのコストは経路のトークン単価差で決まり、オーケストレーション費用は論点にならない。
Step Functionsのコストを気にして構成を歪めるくらいなら、経路とモデルを見直したほうが桁違いに効く、ということですね。
|
観点 |
A: Bedrock統合 |
B: HTTP Task直叩き |
|
p50レイテンシ |
約1.1秒 |
約1.5秒 |
|
p95レイテンシ |
約1.2秒(安定) |
約2.5秒(テール重い) |
|
テールの正体 |
スパイクなし(0/70) |
経路由来の確率的遅延(9/70) |
|
生成スループット |
約7〜9ms/トークン |
約9〜13ms/トークン |
|
コスト(100万回) |
$1,438 |
$1,314(-9%) |
|
認証 |
IAMのみ |
APIキー+connection |
|
観測可能性 |
CloudWatchで内訳分解できる |
ブラックボックス |
|
モデル鮮度 |
Bedrockでの提供待ち |
Anthropicの最新に即追従 |
|
リージョン |
日本ジオ内で完結 |
国外へ出る |
|
事前準備 |
モデル有効化+jp.プロファイル |
connection作成のみ |
見事に「全部勝ち」がいないのが、この表の面白いところでした。
※ データの取り扱いや保持の条件は各サービスの規約と設定によります。要件がシビアな場合は必ず一次情報で確認してください。
きれいに動いた話はここまで。ここからが、この記事のいちばん書きたかった部分です。
第3節の話です。モデルカードのIn-Region ✓を信じてValidationExceptionを食らいました。jp.推論プロファイルを使います。あわせてIAMポリシーはプロファイルARNと基盤モデルARN(東京・大阪の両方)の両方に許可が必要です。
出力を説明付きにした途端、正解率が61%に急落して見えました。
一瞬「説明させるとモデルが混乱するのか?」と思ったのですが、生出力を確認するとモデルはちゃんと正解しています。犯人は自前の正規化コードでした。「領収書 …請求書ではなく…」のような文から、ラベルを配列順で探していたので、後ろに出てくる否定形のラベルを拾っていたのです。出現位置が最も早いラベルを採るよう直して解決。
採点系を疑う前にモデルを疑うな。 これ、プロンプト育成記事に続いて二度目の教訓です。二度目なので、もう言い訳できません。精度が急に落ちたときは、まず自分の集計コードを見る。
同じClaudeの返答なのに、JSONataのパスが違います。
A案(Bedrock): $states.result.Body.content[0].text
B案(HTTP): $states.result.ResponseBody.content[0].text
経路を差し替えるとき、後続ステートをコピペしただけでは動きません。地味ですが確実に一度は踏むポイントです。
bedrock:InvokeModelの許可先を東京の基盤モデルだけに絞ると、大阪へルーティングされたときに落ちる可能性があります。今回の課金証跡上は東京のみでしたが、両リージョンを許可しておくのが安全です。
npx cdk destroy
aws secretsmanager delete-secret --secret-id anthropic-api-key --force-delete-without-recovery
Secrets Managerは論理削除で最短7日残るので、検証用のキーは--force-delete-without-recoveryで消しています。
作るのは一瞬、片付けは意外と気を遣うところです。
「Step FunctionsからClaude呼べる?」の答えは「2通りあって、どちらも1ステートで書ける」でした。
「じゃあどっちがいい?」は用途次第ですが、少なくとも判断基準は数字で出せました。
検証として一番おもしろかったのは、この最小リクエスト実験でした。
「Bが遅い」で終わらせずに、画像も生成も剥がして最小リクエストにしてみたら、テールが設計で回避できない性質のものだと分かり、しかも420msの内訳(下駄305ms+生成差100ms)まで割れてしまった。
仮説を3つ立てて2つ潰す、という作業がここまできれいに決まることは滅多にないので、これは素直に気持ちよかったです。
そして個人的な一番の収穫は、比較そのものより「ドキュメントの✓と、実際に呼べることは別物」「採点系のバグは二度起きる」という、実測しないと絶対に手に入らない2つの教訓のほうでした。
モデルカードのIn-Region ✓を信じてValidationExceptionを食らい、正解率61%の急落に青ざめて、原因は自分の正規化コードだった。この手の話は、机上の比較記事からは一行も出てきません。
最後に、この記事の数字がどこまでの話なのかを書いておきます。
この結果は「1モデル(Claude Haiku 4.5)・1リージョン(ap-northeast-1)・1アカウント・2026年7月時点」のスナップショットです。
トークン単価もルーティングもテールの振る舞いも変わりえますし、モデルやリージョンが違えば数字は変わります。
また、時間帯とテールの相関(たとえば米国のピーク時間にBが悪化するのか)は未検証です。
最小リクエスト実験でスパイクが実行途中に散っていたことは確認できましたが、時刻軸での偏りを主張できるだけのサンプルは取っていないので、本記事では触れていません。ここは宿題として残します。