Messages API のリクエスト上限が 32MB から 128MB に上がった、と更新履歴で知った日のことです。これで画像を何十枚でもまとめて送れるのだろうか、と考えました。
試したのは、API を呼ぶことではなく、手元の画像を数えることでした。個人開発で過去の記事に使っていた画像が、フォルダに 176 枚残っています。合計は 13.11MB。base64 にすると 17.50MB で、128MB の 14% にも届きません。
それでも、送れませんでした。バイトではなく、枚数が先に止まります。 その順番と、送る前に確かめる関数を、順にお渡しします。なお、本文の数値はすべて送信前の見積もりです。実際のリクエストは送っていないので、サーバー側のエラー文は引用していません。
4 つの上限を、当たる順に並べる
公式の vision と API 概要のページから、画像に関係する上限を抜き出しました(2026-10-30 に確認)。
128MB は、この表のいちばん下の行です。画像の束で先に当たりやすいのは、上の 3 行のほうでした。vision のページにも、600 枚まで送れるとされていても、リクエストサイズが先に上限に届くことがある、という注意書きがあります。私の手元では、逆の順番になりました。
手元の 176 枚で数えると
まず、バイトだけを見る最初の版で数えました。ファイルサイズの合計は 13.11MB です。ここに base64 の膨らみがあります。3 バイトを 4 文字にするので、約 1.334 倍になります。実ファイルで確かめると、348,714 バイトの JPEG が 464,952 文字になり、計算式と一致しました。
files=176 raw=13.11MB wire=17.50MB (x1.334)
limit=128MB safe=115.2MB headroom=97.7MB
バイトだけを見ていれば、ここで「余裕です」と判断していたと思います。次に、枚数と寸法を足した検査を通しました。
route=first-party files=176 wire=17.50MB / 128MB max_edge=1514px >3000px=0
NG 枚数 176 > 100
NG 1 件
止まった理由は枚数でした。 長辺は最大 1,514px で、3,000px の制限には掛かりません。1 枚あたりのサイズも小さく、バイトにも余裕があります。ただ、200k コンテキストのモデルを指定するなら、1 リクエストに 100 枚までです。
反対側の確認もしました。Bedrock の 20MB に当てはめると、17.50MB は上限の 87% です。検査では「上限の 9 割(18MB)まで」を安全圏にしているので、ぎりぎり通ります。余裕があると言えるほどではありません。
送る前に 4 つを見る検査
先ほどの出力を作ったスクリプトです。画像の寸法だけを読み、デコードはしないので、176 枚でも一瞬で終わります。Pillow が必要です(pip install pillow)。
#!/usr/bin/env python3
"""image_gate.py — 1 リクエストに載せる画像群を、送る前に 4 つの上限で検査する。
①枚数 ②1 枚の base64 サイズ ③20 枚超なら長辺 3,000px 以下 ④リクエスト全体(経路別)
使い方: python3 image_gate.py <dir> [--route first-party|bedrock|gcp-google|aws-foundry]
終了コード: 0=通る / 1=どれかが超過 / 2=画像なし
"""
import sys, math, pathlib
from PIL import Image
MB = 1024 * 1024
ROUTES = {"first-party": 128, "bedrock": 20, "gcp-google": 30, "aws-foundry": 32}
PER_IMAGE_B64 = {"first-party": 10, "bedrock": 5, "gcp-google": 10, "aws-foundry": 10} # aws-foundry の 1 枚上限は未確認・仮置き
MAX_IMAGES = 100 # 200k コンテキストのモデル。それ以外のモデルは 600
MANY_IMAGES, MANY_EDGE = 20, 3000
B64 = lambda n: 4 * math.ceil(n / 3)
EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
def main():
root = pathlib.Path(sys.argv[1])
route = sys.argv[sys.argv.index("--route") + 1] if "--route" in sys.argv else "first-party"
files = sorted(p for p in root.rglob("*") if p.suffix.lower() in EXTS and p.is_file())
if not files:
print("画像なし"); sys.exit(2)
total = 0; over_one = []; over_edge = []; edges = []
for p in files:
b = B64(p.stat().st_size); total += b + 87 # 87 = content ブロックの JSON 枠
if b > PER_IMAGE_B64[route] * MB: over_one.append(p.name)
with Image.open(p) as im: w, h = im.size # 寸法だけ読む
edges.append(max(w, h))
if max(w, h) > MANY_EDGE: over_edge.append((p.name, max(w, h)))
bad = 0
print(f"route={route} files={len(files)} wire={total/MB:.2f}MB / {ROUTES[route]}MB max_edge={max(edges)}px >3000px={len(over_edge)}")
if len(files) > MAX_IMAGES: print(f"NG 枚数 {len(files)} > {MAX_IMAGES}"); bad += 1
if over_one: print(f"NG 1 枚が上限超え: {over_one[:3]}"); bad += 1
if len(files) > MANY_IMAGES and over_edge:
print(f"NG 20 枚超のリクエストで 3,000px 超: {len(over_edge)} 枚(例 {over_edge[:2]})"); bad += 1
if total > ROUTES[route] * MB * 0.9: print("NG リクエスト全体が上限の 9 割超え"); bad += 1
print("OK" if not bad else f"NG {bad} 件")
sys.exit(1 if bad else 0)
main()
書き方で気をつけたのは 3 点です。
- 経路ごとの定数は辞書に分けました。直接 API の 128MB を Bedrock の 20MB に変えるだけで、同じ検査が別の経路にも使えます。経路別の定数の持ち方は、Bedrock は 20MB のまま、という記事で詳しく書いています。
- 3,000px の検査は、20 枚を超えたときだけ掛けます。19 枚なら 6,000px の画像も弾かれません。枚数によって規則が変わる点は、公式の表だけを眺めていると見落としやすいところです。
- 終了コードを 3 種類に分けたので、夜間のスクリプトや CI から呼んでも、「画像がなかった」と「超えた」を取り違えません。
超えたら、順番を保ったまま束ねる
NG が出たときは、送るのを諦めるのではなく、分けて送ります。枚数とバイトの両方を見ながら、順番を保って束ねる関数です。
import math, pathlib
MB = 1024 * 1024
B64 = lambda n: 4 * math.ceil(n / 3)
def chunk_images(paths, max_count=100, max_wire_mb=115.0, overhead=87):
"""枚数とバイトの両方が上限内に収まるように、順番を保ったまま束ねる。"""
chunks, cur, cur_bytes = [], [], 0
for p in paths:
w = B64(pathlib.Path(p).stat().st_size) + overhead
if cur and (len(cur) >= max_count or cur_bytes + w > max_wire_mb * MB):
chunks.append(cur); cur, cur_bytes = [], 0
cur.append(p); cur_bytes += w
if cur: chunks.append(cur)
return chunks
同じ 176 枚で動かした結果です。
{} → 2 束 [100, 76]
{'max_count': 20} → 9 束 [20, 20, 20, 20, 20, 20, 20, 20, 16]
{'max_wire_mb': 16.2} → 2 束 [100, 76] # 18MB の 9 割を上限にした場合
見てほしいのは 2 行目と 3 行目です。Bedrock の 20MB を意識してバイトの上限を下げても、束は 2 つのままでした。 176 枚が小さな画像ばかりなので、枚数の 100 枚が先に効いているためです。逆に、1 枚が数 MB の写真を並べる場合は、バイトが先に効き始めます。どちらが先に当たるかは、画像の束によって変わります。
運用に入れるときの手順
私は次の順で、画像を送る処理に組み込みました。おすすめは、検査を送信処理の「手前」に置き、NG のときだけ分割へ進む構成です。
- 送りたい画像を 1 つのフォルダ(または一覧)に集め、
image_gate.py を通す。
- 終了コード 0 なら、そのまま 1 リクエストで送る。
- 終了コード 1 なら、出力の NG 行を読む。枚数なら
max_count を、バイトなら max_wire_mb を決めて chunk_images で束ねる。
- 20 枚を超える束で 3,000px 超の画像が出たら、束ねる前に長辺を縮める(再エンコードは別の処理として切り出す)。
- 経路を変えるときは
--route だけを変えて、同じ手順を繰り返す。
NG の行に「何が」超えたかを必ず出すのは、直し方が上限ごとに違うからです。枚数は分割、長辺は縮小、1 枚のバイトは圧縮で、手当てが一致しません。
落とし穴
- 枚数の上限はモデルで変わります。 200k コンテキストなら 100 枚、それ以外は 600 枚です。モデルを切り替えるときは、
MAX_IMAGES も合わせて直してください。
- 20 枚ちょうどと 21 枚で規則が変わります。 21 枚目を足した途端、すべての画像に 3,000px の制限が掛かります。束ねるときに 20 枚で区切る手もあります(上の 2 行目の設定です)。
aws-foundry の 1 枚の上限は、確認できた資料がありません。 コードのコメントのとおり仮置きです。使う前に、ご自身の経路のドキュメントで確かめてください。
- base64 の見積もりに、リクエストの残りの部分は入っていません。 プロンプト本文や他の content ブロックが大きいときは、
overhead を増やすか、安全圏の 9 割をもう少し下げてください。
- 巨大な PDF や、何度も使う画像は、Files API に置いて参照する方法もあります。使い分けはFiles API の記事にまとめています。
次の一歩
まず、ご自身の画像のフォルダに image_gate.py を当ててみてください。私は、バイトの余裕を見て安心していたぶん、枚数の NG に驚きました。思い込みで進めなくてよかった、と感じています。上限が上がったときは、上がった数字ではなく、自分の束がどの数字に最初に当たるかを確かめます。 今回の気づきとして、書き残しておきます。
送信前のサイズ見積もり全般は、413 を設計で避ける記事でも扱っています。画像の束を分ける基準を決めておくと、上限がまた変わる日にも、定数を 1 行直すだけで済みます。