素材フォルダを丸ごと仕分けに回そうとした朝のことです。62 枚をひとつのリクエストに詰めて送りましたら、返ってきたのは分類結果ではなく invalid_request_error でした。
最初に疑ったのは容量です。ところが手元で数え直しますと、base64 に直しても 15.19 MB で、リクエストサイズの上限 32 MB の半分にも届いておりません。1 枚あたりの上限 10 MB を超えている画像も、1 枚もありませんでした。
落ちた理由は、容量ではなく枚数でした。上限という言葉から真っ先にバイト数を思い浮かべてしまったのは、私のなかで「大きい=重い」という感覚が先に立っていたからかもしれません。
21枚目が、ほかの61枚の条件まで変えます
公式ドキュメントの「Request limits」に、読み飛ばしやすい一文が置かれています。1 回のリクエストに 20 枚を超える画像が含まれると、そのリクエスト内のすべての画像に、より厳しい 1 枚あたりの寸法上限が適用される 、というものです。上限を超えた画像は invalid_request_error で拒否され、そのメッセージには "many-image requests" という語が入ります。
つまり 21 枚目は、自分だけが弾かれるのではありません。同じリクエストに乗っている 1 枚目から 20 枚目までの判定基準まで、まとめて引き上げてしまいます。回避の仕方は 2 通りだけです。各画像の縦横どちらも 2000px 以下に収めるか、画像とドキュメントのブロックを 20 個以下に保つか——後者のほうが、素材の寸法を選ばないぶん確実です。
数に入るものが少し広いことも、あとから効いてきます。過去のターンで送った画像を履歴として再送していれば、それも数えられます。tool_result の中に入れた画像——computer use に返すスクリーンショットなどがこれにあたります——も同じです。Amazon Bedrock と Google Cloud では、PDF などの document ブロックまで同じ枠に加算されます。エージェントのループを長く回すほど、自分では 1 枚しか足していないつもりでも、閾値に近づいていく構造になっています。
私の 62 枚のうち、長辺が 2000px を超えているのは 8 枚でした。20 枚ずつに切って送れば、この 8 枚もそのまま通ります。ひとつにまとめた瞬間だけ、同じ 8 枚がリクエストごと落とす側に回ったのです。
視覚トークンは、面積の割り算ではなく28pxのタイルの数です
見積もりを直すにあたって、まず数え方そのものを間違えていたことに気づきました。
以前の私は「面積 ÷ 750」という概算を使っておりました。桁を合わせるだけなら、これで足ります。ただ、境界のすぐ手前にいるかどうかを判断するには粗すぎます。実際の数え方は、画像を 28×28 ピクセルのタイルに切り、その枚数を数えるというものです。
視覚トークン = ⌈幅 / 28⌉ × ⌈高さ / 28⌉
切り上げが 2 回入るところが要点です。そして Claude は、リサイズの有無にかかわらず、下辺と右辺を 28 の倍数までパディングしてから見ます。パディングされた部分に中身はありませんので、座標を扱うときはパディング後ではなくリサイズ後の寸法で正規化する必要があります。
縮小が起きる条件も 1 つではなく 2 つです。モデルごとに「辺の上限」と「視覚トークンの上限」が決まっており、Claude はその両方を満たす最大のサイズ へ、縦横比を保ったまま縮めます。
ティア 対象モデル 長辺の上限 視覚トークンの上限
標準 上記以外のモデル 1568px 1568
高解像度 Claude 4.7 以降のモデル 2576px 4784
写真やスクリーンショットでは、たいてい先に効くのはトークン側の上限です。辺の上限が先に来るのは、パノラマや縦長のスクリーンショットのような、細長い画像に限られます。
手元の62枚を、数え直してみました
推測をやめて、実際に持っている素材を流しました。個人開発で使っているアイコン・OGP・配信用のカード類が混ざった、実運用そのままのフォルダです。
元の寸法 枚数 標準ティアで送られる寸法 視覚トークン 高解像度ティア 視覚トークン
1200×1200 12 1092×1092 1,521 1200×1200 1,849
1920×1081 8 1456×820 1,560 1920×1081 2,691
2400×2400 4 1092×1092 1,521 1932×1932 4,761
3040×1712 4 1456×820 1,560 2576×1451 4,784
1200×630 4 1200×630 989 1200×630 989
48×48 5 48×48 4 48×48 4
62 枚の合計は、標準ティアで 53,532 視覚トークン、高解像度ティアで 93,888 視覚トークンでした。標準ティアでは 62 枚中 31 枚が、送った寸法のままではなくサーバー側で縮められてから見られていた計算になります。
見積もり器を信用してよいかどうかは、公式が公開している換算表と突き合わせて確かめました。1920×1080 が 1456×819 の 1,560 トークン、3840×2160 が高解像度ティアで 2576×1449 の 4,784 トークン、130 DPI で取り込んだ A4 の 1075×1520 が 924×1307——いずれも手元の計算と一致いたしました。ここが合わないまま先に進みますと、あとで出てくる判断がすべて砂の上に乗ってしまいます。
なお 1075×1520 の例は、私にとっていちばん意外な行でした。縦も横も 1568px を超えていないのに縮小されます。39 × 55 = 2,145 トークンとなり、辺ではなくトークン側の上限に引っかかるためです。
ファイルサイズを削っても、トークンは1つも減りませんでした
同じ表のなかに、確認したかったことの答えが入っておりました。
1920×1081 の 8 枚は、ファイルサイズが 14 KB から 65 KB まで 4 倍以上ばらついています。それでも視覚トークンは、8 枚とも 1,560 でした。1200×1200 の 12 枚も、177 KB から 202 KB のあいだで散らばりながら、全部が 1,521 です。
最初のうち、私は送信前に画質を落として「安くした」つもりでおりました。結果は芳しくありませんでした。請求は動きませんし、代わりに圧縮による細かいノイズだけが増えていたのです。
枚数はリクエストの形を変え、寸法は費用を変え、ファイルサイズはどちらも変えません。
圧縮そのものが無意味というわけではありません。効く先が違うだけです。ファイルサイズは、リクエストの往復時間と、1 枚 10 MB・1 リクエスト 32 MB という上限に効きます。費用に効かせたいのであれば、触るべきは縦横のピクセル数のほうです。この 2 つを別々の目盛りとして持つようになってから、送信前に何をすべきかで迷わなくなりました。
縦長の画像は、与えられた枠を使い切れません
もうひとつ、数えてみるまで気づけなかったことがあります。
スマートフォンの縦画面サイズ、たとえば 1290×2796 を標準ティアに送りますと、723×1568 へ縮小されて 1,456 トークンになります。上限は 1,568 ですから、112 トークンぶん余ったまま止まっているわけです。細長い画像では辺の上限が先に効いてしまい、トークンの枠を最後まで使えません。
同じ 1,568 という枠を与えられていながら、正方形に近い画像のほうが多くの画素を持ち込めます。分類や読み取りの精度は、最終的に「Claude が見た画素数」で決まりますので、縦に長い素材ほど不利な条件で判定されていることになります。
対処は、縮小ではなく切り出しでした。縦長の素材は、判断に要る部分だけを切り出して正方形に近づけてから送ります。画素の総数は減るのに、Claude が見る解像度はむしろ上がるという、少し面白い逆転が起きます。
モデルの世代を上げた日に、同じ画像が高くなります
高解像度ティアは、Claude 4.7 以降のモデルで自動的に有効になります。ベータヘッダーもクライアント側のオプトインも要りません。ここが、静かに効いてくるところです。
手元の実測では、2400×2400 の 4 枚が 1,521 から 4,761 へ、3040×1712 の 4 枚が 1,560 から 4,784 へ動きました。倍率にして 3.1 倍ほどです。62 枚全体では 1.75 倍にとどまりましたが、これは小さなアイコンが混ざって平均を下げているからで、大きな素材だけを流すパイプラインではそのまま 3 倍が出ます。
コードは 1 行も変えておりません。モデル名を新しい世代に差し替えただけで、画像入力のトークンが 3 倍になる経路がある——この事実だけは、見積もりを他人に説明するときに必ず添えるようにしています。細部の判別が要る仕事であれば 3 倍を払う価値はありますし、色と構図だけで仕分ける仕事であれば、送る前に縮めてしまったほうが素直です。
黙って縮小されないようにしておきます
送信前に自分でリサイズしておけば、Claude が見る画像と手元の画像は一致します。ただ、これは「パイプラインが正しい寸法を出し続けているあいだ」だけ成り立つ約束です。新しい素材の供給元が増えたときや、別のティアのモデルへ乗り換えたときに、サーバー側のリサイズが何も言わずに復活します。
その揺らぎを目に見えるエラーへ変える設定が用意されています。画像ブロックに transformations を添えるだけです。
{
"type" : "image" ,
"source" : { "type" : "base64" , "media_type" : "image/png" , "data" : "YOUR_BASE64_IMAGE_DATA" },
"transformations" : { "oversized_image" : "error" }
}
こうしておきますと、縮小されるはずだった画像は 400 の invalid_request_error で弾かれ、メッセージに現在の寸法と「収まる最大の寸法」が両方入ってきます。あとはその値へ合わせて送り直すだけです。既定値は downsize で、従来どおり自動的に縮小されます。
設定が画像ごとである点も、実務ではありがたいところでした。座標を返させたいスクリーンショットには error を付け、寸法が意味を持たないロゴには何も付けない、という混在が同じリクエストで成立します。Token counting のエンドポイントもこの設定を尊重しますので、推論を走らせる前に「この画像は縮小されずに通るか」を確かめられます。ただし埋め込みの base64 に限られ、URL や file ID で渡した画像は Messages の段階まで判定されません。
過信しないでおきたい点も 1 つあります。この設定は縮小を止めるだけで、8000px の上限や、先ほどの 21 枚目の上限を通す力はありません。それらは別の拒否として返ってきます。
3つの上限を、同時に見てバッチへ割ります
ここまでで見てきた上限は 3 つです。枚数(20 を超えると全画像の条件が変わります)、寸法(視覚トークンと費用を決めます)、ペイロード(32 MB のリクエスト上限に効きます)。どれか 1 つだけを見て組んだバッチは、残りの 2 つでいずれ落ちます。
送信前に確かめている順番は、いつも次の 3 つです。
長辺が 2000px を超える画像が 1 枚でもあるかを数えます。 1 枚でもあれば、そのバッチは 20 枚以下で固定します。
視覚トークンの合計を、使うモデルのティアで出します。 ティアを取り違えると、そのあとの見積もりが丸ごとずれます。
base64 化したペイロードの合計を、履歴とプロンプトのぶんを引いた残りと比べます。 画像だけで上限を埋めないようにします。
この 3 つを毎回そろえて確認できるようにしたかったので、割り当てまでをスクリプトにしました。
"""送信前に画像バッチを組み立てるプランナー。
枚数・寸法・ペイロードの3つの上限を同時に見て、通るバッチに割ります。
"""
import math
import os
import sys
from PIL import Image
PATCH = 28 # 視覚トークン1つ分の1辺(px)
MANY_IMAGE_THRESHOLD = 20 # これを超えると全画像に厳しい寸法上限がかかります
MANY_IMAGE_MAX_EDGE = 2000 # 21枚以上のリクエストで各画像に課される長辺(px)
REQUEST_BYTE_BUDGET = 30 * 1024 * 1024 # 32MB の上限に対して余裕を持たせた値
TIERS = {
"standard" : { "max_edge" : 1568 , "max_tokens" : 1568 },
"high" : { "max_edge" : 2576 , "max_tokens" : 4784 },
}
def visual_tokens (width: int , height: int ) -> int :
"""28x28 のパッチ1つが視覚トークン1つです。"""
return math.ceil(width / PATCH ) * math.ceil(height / PATCH )
def _fits (w: int , h: int , max_edge: int , max_tokens: int ) -> bool :
# パディング後の辺で判定します(28 の倍数へ切り上げられるため)。
return (math.ceil(w / PATCH ) * PATCH <= max_edge
and math.ceil(h / PATCH ) * PATCH <= max_edge
and visual_tokens(w, h) <= max_tokens)
def served_size (w: int , h: int , tier: str = "standard" ) -> tuple :
"""サーバー側で縮小されたあとの寸法を返します。縮小不要ならそのまま返します。"""
limits = TIERS [tier]
max_edge, max_tokens = limits[ "max_edge" ], limits[ "max_tokens" ]
if _fits(w, h, max_edge, max_tokens):
return (w, h)
if h > w: # 縦長は転置して同じ探索に載せます
rw, rh = served_size(h, w, tier)
return (rh, rw)
aspect = w / h
lo, hi = 1 , w # lo は必ず通り、hi は必ず通りません
while lo + 1 < hi:
mid = (lo + hi) // 2
if _fits(mid, max ( round (mid / aspect), 1 ), max_edge, max_tokens):
lo = mid
else :
hi = mid
return (lo, max ( round (lo / aspect), 1 ))
def inspect (path: str , tier: str = "standard" ) -> dict :
with Image.open(path) as im:
w, h = im.size
sw, sh = served_size(w, h, tier)
raw = os.path.getsize(path)
return {
"path" : path,
"size" : (w, h),
"served" : (sw, sh),
"tokens" : visual_tokens(sw, sh),
"resized_by_server" : (sw, sh) != (w, h),
"payload" : math.ceil(raw / 3 ) * 4 , # base64 は元バイト数の約 4/3 に膨らみます
"needs_shrink_for_large_batch" : max (w, h) > MANY_IMAGE_MAX_EDGE ,
}
def plan (paths: list , tier: str = "standard" ) -> list :
"""枚数・ペイロードのどちらかに触れる手前でバッチを切ります。"""
items = [inspect(p, tier) for p in paths]
batches, current, used = [], [], 0
for item in items:
over_count = len (current) + 1 > MANY_IMAGE_THRESHOLD
over_bytes = used + item[ "payload" ] > REQUEST_BYTE_BUDGET
if current and (over_count or over_bytes):
batches.append(current)
current, used = [], 0
current.append(item)
used += item[ "payload" ]
if current:
batches.append(current)
return batches
if __name__ == "__main__" :
root = sys.argv[ 1 ]
tier = sys.argv[ 2 ] if len (sys.argv) > 2 else "standard"
paths = sorted (
os.path.join(dirpath, name)
for dirpath, _, names in os.walk(root)
for name in names
if name.lower().endswith(( ".jpg" , ".jpeg" , ".png" , ".webp" , ".gif" ))
)
batches = plan(paths, tier)
total = sum (i[ "tokens" ] for b in batches for i in b)
shrink = [i for b in batches for i in b if i[ "needs_shrink_for_large_batch" ]]
resized = [i for b in batches for i in b if i[ "resized_by_server" ]]
print ( f "対象 { len (paths) } 枚 / ティア { tier } " )
print ( f "バッチ数 { len (batches) } (1バッチ最大 { MANY_IMAGE_THRESHOLD } 枚)" )
print ( f "視覚トークン合計 { total :, } " )
print ( f "サーバー側で縮小される画像 { len (resized) } 枚" )
print ( f "長辺 { MANY_IMAGE_MAX_EDGE } px 超(21枚以上のリクエストでは拒否) { len (shrink) } 枚" )
for n, batch in enumerate (batches, 1 ):
mb = sum (i[ "payload" ] for i in batch) / 1048576
tk = sum (i[ "tokens" ] for i in batch)
print ( f " batch { n } : { len (batch) :>2 } 枚 / { tk :>6, } tok / payload { mb :5.2f } MB" )
先ほどの 62 枚に対して走らせますと、次の出力が返ります。
対象 62 枚 / ティア standard
バッチ数 4(1バッチ最大 20 枚)
視覚トークン合計 53,532
サーバー側で縮小される画像 31 枚
長辺 2000px 超(21枚以上のリクエストでは拒否)8 枚
batch 1: 20 枚 / 4,507 tok / payload 4.77 MB
batch 2: 20 枚 / 17,520 tok / payload 2.45 MB
batch 3: 20 枚 / 28,409 tok / payload 4.85 MB
batch 4: 2 枚 / 3,096 tok / payload 3.12 MB
なぜこう書いたのかを 2 点だけ補足いたします。
ひとつは、20 枚という上限を設定値ではなく定数として固定した理由です。「20 枚を超えたら 2000px へ縮める」という分岐も書けますが、そうしますと寸法を落とす判断がバッチサイズに引きずられ、費用と精度が同時に動いてしまいます。バッチの切り方と画像の寸法は、別々に決められる状態に保っておきたかったのです。
もうひとつは、リクエストのバイト数の目安を 32 MB ではなく 30 MB にした点です。プロンプトの本文・ツール定義・過去ターンの履歴が同じリクエストに乗りますので、画像だけで上限を使い切る前提で組みますと、履歴が伸びた日に落ちます。余白の 2 MB は、その日のための保険です。
同じ考え方を非同期側へ持っていく場合は、Claude API Messages Batches API でコストを半額にする — 個人開発者が学んだ非同期処理設計 で書いた分割の型がそのまま使えます。ツールから画像を返している場合は、その画像も 20 枚の枠に入りますので、ツールが返す画像を Claude に「見せる」 — tool_result を画像ブロックにしてトークンを約10分の1にする の組み立て方とあわせて確認していただければと思います。
次の一歩
まずは手元のフォルダを 1 つ選んで、長辺が 2000px を超えている画像が何枚あるかだけを数えてみてください。そこが 0 枚であれば、枚数の上限を踏む日はまだ先です。私がこの数え直しの必要に気づいたのは、同じフォルダを何度も送り直したあとでした。1 枚でもあれば、バッチを 20 枚で区切る理由がその場で腑に落ちるはずです。
仕分けを任せる範囲についても、ひと言だけ書き残しておきます。私が画像を Claude へ渡しているのは、分類とタグ付けという運用の工程だけです。壁紙アプリの一括カテゴリ分類を Claude Vision API で自動化した話 でも触れましたが、古い紙の写真を修復する作業そのものは、いまも自分の手で進めております。どこまでを渡し、どこから先を自分で持つのか——その線引きだけは、費用の話とは別の場所に置いたままにしています。
最後までお読みいただき、ありがとうございました。同じ境界で手が止まった方の、数え直しの起点になれば幸いです。