夜間バッチが 413 で止まっていることに気づいたのは、ある朝ログを開いたときでした。個人開発で運営している壁紙アプリのアートワークをまとめて Claude Vision に渡し、タグ付けする処理が、前日から一枚も進んでいない。エラーは request_too_large。トークン数はまだ余裕があるはずなのに、なぜ弾かれるのか。原因を追ううちに見えてきたのは、Claude API のリクエストには「トークン」とは別の「バイト」の壁がある という、公式ドキュメントの隅にしか書かれていない事実でした。
その 413 を運用から消すために私が組んだ設計を、ここでお伝えします。要点は三つ。送信前にバイトを見積もること。413 が「どこで」起きたのかを見分けること。そして、リクエストを壊さずに分割する優先順位を決めておくこと。
413 はトークンの話ではない
Claude API を使い込むと、私たちはつい「コンテキストウィンドウに収まるか」だけを気にするようになります。ところが request_too_large(HTTP 413)は、トークン数とは独立した リクエストボディの物理的なサイズ で判定されます。
標準の Messages エンドポイントでは、リクエスト全体で 32MB が上限です。これを超えると、モデルが一文字も読む前に弾かれます。長文だけを送っているうちは滅多に届かない数字ですが、画像や tool_result を混ぜた瞬間に景色が変わります。
見落としやすいのが base64 の膨張です。画像やファイルを inline で送るとき、私たちはバイナリを base64 に変換します。この変換で、実データは 約1.33倍 に膨らみます。手元で 4MB の PNG が、リクエスト上では 5.3MB を占める。数枚重なれば、体感よりずっと早く 32MB に届いてしまいます。
詰め込むもの 効いてくる上限 体感とのずれ
長文テキストのみ 主にトークン 32MB にはまず届かない
複数画像(inline base64) 32MB のバイト上限 base64 で1.33倍に膨らむ
大きな tool_result 32MB のバイト上限 会話履歴に累積して膨らむ
複数の大きな PDF 処理段階の隠れた上限 32MB 未満でも落ちることがある
送信前にバイトを見積もる
一番効いたのは、リクエストを組み立て終えた直後、送信する前に 実バイトを自分で測る ことでした。SDK に投げてから 413 が返ってくるのを待つのではなく、こちらから先回りして分岐する。
Python の例を示します。メッセージ配列をシリアライズして、実際に送られる JSON のバイト数を測る関数です。
import json
# 標準 Messages エンドポイントのリクエスト上限(安全側に少し余白を残す)
MAX_REQUEST_BYTES = 32 * 1024 * 1024 # 32MB
SAFETY_MARGIN = 512 * 1024 # 0.5MB の余白
BUDGET = MAX_REQUEST_BYTES - SAFETY_MARGIN
def request_byte_size (payload: dict ) -> int :
"""SDK が送るのとほぼ同じ形でシリアライズしてバイト数を測る。"""
# ensure_ascii=False で日本語を実バイトに近づける(UTF-8 でカウント)
body = json.dumps(payload, ensure_ascii = False , separators = ( "," , ":" ))
return len (body.encode( "utf-8" ))
def will_fit (payload: dict ) -> tuple[ bool , int ]:
size = request_byte_size(payload)
return size <= BUDGET , size
ここでのポイントは二つあります。ひとつは separators=(",", ":") で余白を削り、実際に送られる形に近づけること。もうひとつは UTF-8 でエンコードしてから測ること。日本語のプロンプトは1文字が3バイトになりますから、文字数ではなくバイト数で見なければ見積もりが甘くなります。
will_fit が False を返したら、送信を止めて分割へ回します。413 を「受け取ってから対処する」のではなく、「起こさない」 ための最初の関門です。
画像の実バイトを先読みする
画像を含むリクエストでは、base64 化する前の元バイトから、リクエスト上で占める量を先読みできます。全部を base64 に変換してから測るのは無駄が多いので、見積もりは掛け算で済ませます。
import base64
from pathlib import Path
def estimated_inline_bytes (image_path: str ) -> int :
"""inline base64 で送ったときにリクエスト上で占める概算バイト数。"""
raw = Path(image_path).stat().st_size
# base64 は約4/3倍。JSON のキーやメディアタイプ宣言の分も少し足す
return int (raw * 4 / 3 ) + 256
def pack_images_within_budget (image_paths: list[ str ], text_bytes: int ) -> tuple[list[ str ], list[ str ]]:
"""予算に収まる画像だけを1リクエストに詰め、残りは次バッチへ回す。"""
used = text_bytes
fit, overflow = [], []
for path in image_paths:
cost = estimated_inline_bytes(path)
if used + cost <= BUDGET :
used += cost
fit.append(path)
else :
overflow.append(path)
return fit, overflow
私の壁紙バッチでは、この pack_images_within_budget を挟むだけで 413 がほぼ消えました。以前は「だいたい20枚くらいで一括」と雑に決めていたのですが、アートワークの解像度はまちまちで、高精細な数枚が混ざると簡単に上限を超えていたのです。枚数ではなくバイトで束ねる。それだけで、朝のログが静かになりました。
HTTP で落ちる 413 と、処理段階で落ちる 413
ここからが、公式ドキュメントには書かれていない実運用の話です。413 には、実は 二つの顔 があります。
ひとつは、いま見てきた 32MB を超えて HTTP 転送の前に弾かれる 413。これは送信前のバイト見積もりで避けられます。
もうひとつが厄介です。リクエスト自体は 32MB に収まっているのに、複数の大きな PDF をまとめて渡したときに、Anthropic 側がファイルを展開・テキスト抽出する段階で 413 が返ってくることがあります。私が観測した限りでは、複数ファイルの合計が 6MB 前後 を超えたあたりで発生しやすく、これは HTTP の 32MB とは別の、処理リソースの上限のように見えます。
見分け方はシンプルです。
症状 おそらくの原因 対処
リクエストが 32MB を超えている HTTP 転送前の上限 画像縮小・分割で総バイトを削る
32MB 未満なのに複数 PDF で 413 処理段階の隠れた上限 ファイルを1リクエスト1〜2個に分ける
単一の巨大ファイルで 413 そのファイル単体が重い Files API に退避、または事前圧縮
この違いを知らなかった頃、私は「32MB 未満なのに 413 が出る」現象を SDK のバグだと疑い、半日を溶かしました。実際にはこちらが複数の PDF を欲張って束ねていただけだったのです。同じ 413 でも、削るべき場所が違う 。この一点を掴んでから、対処が迷わなくなりました。
分割の優先順位を決めておく
413 に直面したとき、その場で「どう削るか」を考えていては遅い。あらかじめ優先順位を決めておき、コードに落としておきます。私が使っている判断の順番はこうです。
画像を縮小する — 認識精度に影響しない範囲で長辺を落とす。多くのタスクで長辺1500px前後が精度とサイズの折り合いどころでした。まず試すべき、最も損失の少ない手。
Files API に退避する — 同じファイルを何度も参照するなら、inline base64 をやめて Files API にアップロードし、参照だけを渡す。ただし前述の通り、Files API を使っても1リクエストで展開される総量には上限が残る点に注意します。
ドキュメントを分割する — 1リクエストに詰める PDF を1〜2個に絞り、残りは後続リクエストへ。処理段階の 413 に効くのはこれです。
バッチ API に載せ替える — 急がない大量処理なら、非同期のバッチへ。1件あたりの制約は残りますが、束ね方の設計をやり直す良い機会になります。
この順番には理由があります。上にあるほど品質への副作用が小さく、下にいくほど処理の作り替えが大きくなる。まず損の少ない手から試す という並びにしておくと、自動リカバリのコードもそのまま書けます。
def send_with_413_recovery (client, build_payload, images, docs, text_bytes):
"""413 を段階的に回避しながら送るループ。"""
# まず送信前見積もり
fit_images, overflow = pack_images_within_budget(images, text_bytes)
payload = build_payload(fit_images, docs)
ok, size = will_fit(payload)
if not ok:
# 予算超過。画像縮小 → ドキュメント分割の順で削る
fit_images = downscale_until_fit(fit_images, text_bytes, docs)
payload = build_payload(fit_images, docs)
try :
resp = client.messages.create( ** payload)
except APIStatusError as e:
if e.status_code == 413 :
# HTTP を通ったのに処理段階で 413 → ドキュメントを絞って再試行
first_half, second_half = split_docs(docs)
resp = client.messages.create( ** build_payload(fit_images, first_half))
_enqueue_followup(second_half, overflow) # 残りは次バッチへ
else :
raise
return resp, overflow
downscale_until_fit と split_docs の中身はタスク次第ですが、骨格はこの通りです。「見積もりで予防し、それでも来た 413 は原因別に分岐する」 という二段構えにしておくと、無人で回してもバッチが止まりません。
運用で効いた小さな工夫
最後に、ドキュメントに載らない細かな知見をいくつか。
会話履歴の tool_result は静かに膨らみます。エージェントが画像を返すツールを何周も呼ぶと、過去の tool_result がメッセージ配列に累積し、いつのまにか 32MB に近づきます。古い画像 tool_result は要約に置き換える か、参照だけ残して本体を落とす設計にしておくと安全です。
見積もりのログを残すことも勧めます。私は毎リクエストの推定バイト数を構造化ログに書き出しています。「どのバッチが上限にどれだけ近いか」が見えると、413 が出る前に束ね方を直せます。計器のない自動運用は、暗闇で夜間バッチを回しているようなものですから。
そして、上限の数値そのものは変わり得ます。32MB や処理段階の閾値は、この記事を書いた時点での観測です。設計を「特定の数字」ではなく「送信前に測り、原因別に分岐する」という形に寄せておけば、上限が動いても壊れません。数字を追いかけるのではなく、測る習慣を仕組みに埋め込む。それが、静かな朝を取り戻すいちばんの近道でした。
実装の参考になれば幸いです。私自身まだ手探りの部分もありますが、同じ 413 に悩む方の遠回りを少しでも減らせたら嬉しいです。お読みいただき、ありがとうございました。