はじめに / 対象と前提
数百件のテキストを Claude API で分類・要約したいとき、1件ずつ for で回すと遅すぎる。かといって asyncio.gather に全部投げると、1件のエラーで全部の結果が消えたり、同時実行数が思ったように絞れなかったりする。この記事は、自分が実際に詰まった3点を、手元で動かした出力付きでまとめたもの。
- 想定読者:Python で Claude API を叩いたことがあり、
async/awaitは一応書ける人 - 前提環境
- Python 3.14.3(
asyncio.TaskGroupを使う部分は 3.11 以上が必要) -
anthropicPython SDK 1.14.0 - macOS(Linux でも同じ挙動)
- Python 3.14.3(
Batch API(結果が非同期で返ってきて半額になる仕組み)を使えるなら、そちらの方が安い。この記事は「数十〜数百件を、今この場で数分以内に処理したい」ケースが対象。
TL;DR
- 並列化は
AsyncAnthropicを1つだけ作って使い回し、asyncio.Semaphoreを1つだけ作って全タスクで共有する -
asyncio.gatherはreturn_exceptions=Trueを付ける。付けないと1件の失敗で成功分の結果まで受け取れなくなる - 「1件でも失敗したら残りを止めたい」なら
gatherではなくasyncio.TaskGroupを使う
手順 / 動かし方
1. 最小構成
import asyncio
import anthropic
MODEL = "claude-sonnet-5-5"
MAX_CONCURRENCY = 5 # 同時に飛ばすリクエスト数の上限
async def classify(client: anthropic.AsyncAnthropic,
sem: asyncio.Semaphore,
text: str) -> str:
async with sem: # ここで同時実行数を絞る
msg = await client.messages.create(
model=MODEL,
max_tokens=64,
messages=[{
"role": "user",
"content": f"次の文の感情を positive/negative/neutral の1語で答えて:\n{text}",
}],
)
return msg.content[0].text.strip()
async def main(texts: list[str]) -> list[str | BaseException]:
sem = asyncio.Semaphore(MAX_CONCURRENCY)
async with anthropic.AsyncAnthropic(max_retries=4) as client:
return await asyncio.gather(
*(classify(client, sem, t) for t in texts),
return_exceptions=True,
)
if __name__ == "__main__":
texts = ["最高の一日だった", "電車が止まって最悪", "明日は火曜日"]
for t, r in zip(texts, asyncio.run(main(texts))):
print(t, "->", r)
ポイントは3つだけ。
-
AsyncAnthropicはasync withで1回だけ作る -
Semaphoreはmainで1回だけ作り、引数で全タスクに渡す -
gatherにreturn_exceptions=Trueを付け、結果リストに例外が混ざる前提で後処理する
2. 失敗分だけ拾い直す
return_exceptions=True にすると、戻り値は「成功した文字列」と「例外オブジェクト」が入力と同じ順番で並んだリストになる。順番は入力どおりに保証されるので、zip で元データと突き合わせられる。
results = asyncio.run(main(texts))
failed = [t for t, r in zip(texts, results) if isinstance(r, BaseException)]
ok = {t: r for t, r in zip(texts, results) if not isinstance(r, BaseException)}
print(f"成功 {len(ok)} 件 / 失敗 {len(failed)} 件")
# failed だけ時間をおいて再実行する
SDK 自体も 429 や 5xx を自動でリトライする(既定は2回、上の例では max_retries=4 に増やしている)。それでも落ちたものだけを、ここで拾い直す。
ハマりどころ
ここからは、API キーなしで再現できるように asyncio.sleep で API 呼び出しを模した関数で挙動を確かめた。2件目だけ必ず失敗するようにしてある。
async def fake_call(i):
await asyncio.sleep(0.2)
if i == 2:
raise RuntimeError(f"overloaded_error (req {i})")
return f"ok {i}"
ハマり1:gather で1件の失敗が全件の結果を道連れにする
症状:100件中1件だけ 529(overloaded)で落ちたら、残り99件分の結果も1つも手に入らない。
try:
r = await asyncio.gather(*(fake_call(i) for i in range(5)))
except Exception as e:
print("gather raised:", e)
実行結果:
gather raised: overloaded_error (req 2)
r は代入されないので、成功した4件の結果も受け取れない。さらに厄介なのは、gather は最初の例外を投げた時点で他のタスクをキャンセルしないこと(公式ドキュメントにも明記されている)。つまり残りのリクエストは裏で走り続けてトークンを消費するのに、その結果はどこにも返ってこない。お金だけ払って結果を捨てている状態になる。
回避策:return_exceptions=True を付ける。
return_exceptions: ['ok 0', 'ok 1', RuntimeError('overloaded_error (req 2)'), 'ok 3', 'ok 4']
逆に「1件でも失敗したら残りは即中止したい」(例:API キーが間違っていて全部 401 になるのが分かっている)場合は、Python 3.11 以降の asyncio.TaskGroup を使う。こちらは1件が例外を投げた時点で残りのタスクをキャンセルし、例外を ExceptionGroup にまとめて投げてくれる。
try:
async with asyncio.TaskGroup() as tg:
for i in range(5):
tg.create_task(fake_call(i))
except* RuntimeError as eg:
print("TaskGroup:", [str(e) for e in eg.exceptions])
TaskGroup: ['overloaded_error (req 2)']
自分は「全件処理して失敗だけ後で拾う」なら gather(return_exceptions=True)、「前提が崩れたら即停止」なら TaskGroup、と使い分けている。
ハマり2:Semaphore を関数の中で作って同時実行数が絞れない
症状:Semaphore(2) で2並列に絞ったつもりが、全件が一斉に飛んで 429(rate_limit_error)が大量に出る。
原因はこう書いていたこと。
async def bad_call(i):
sem = asyncio.Semaphore(2) # 呼ばれるたびに新しい Semaphore を作っている
async with sem:
await call_api(i)
Semaphore は「同じ1個を全員で取り合う」から上限が効く。タスクごとに別々の Semaphore を作ると、全員が自分専用の枠を持っているのと同じで、何の制限にもならない。
同時実行中の数を数えて、ピーク値を比べてみた(10タスク・各0.1秒)。
bad peak 10
good peak 2 0.51
関数内で作った版はピークが10、つまり全部同時。外で1つ作って渡した版はピーク2で、所要時間も 0.1秒 × 10件 ÷ 2並列 = 約0.5秒と計算どおりになった。
回避策:Semaphore は main() など イベントループの中で1回だけ 作り、引数で渡す。Python 3.10 以降はモジュールのトップレベルで作っても動くが、テストで asyncio.run を複数回呼ぶ構成だと見通しが悪くなるので、自分は main() 内で作る派。
なお、並列数をいくつにするかは使っているプランのレート制限(1分あたりのリクエスト数・トークン数)次第。いきなり大きくせず、5 前後から始めて 429 の出方を見て調整するのが無難。
ハマり3:クライアントを毎回作って接続を使い捨てる
症状:並列化したのに思ったほど速くならない。長時間回すと ResourceWarning やファイルディスクリプタ不足のエラーが出ることがある。
async def classify(text):
client = anthropic.AsyncAnthropic() # 呼ぶたびに新しいクライアント
return await client.messages.create(...)
AsyncAnthropic はインスタンスごとに自分の接続プール(使い終わった HTTPS 接続を取っておいて再利用する仕組み)を持っている。毎回作ると、毎回 TLS ハンドシェイクからやり直しになり、しかも明示的に閉じていないので、古い接続はガベージコレクションに回収されるまで残る。
SDK 1.14.0 の既定値を確認すると、1クライアントで最大1000接続・keep-alive 100本まで持てる設定になっている。
import anthropic._constants as c
print(c.DEFAULT_CONNECTION_LIMITS, c.DEFAULT_MAX_RETRIES, c.DEFAULT_TIMEOUT)
Limits(max_connections=1000, max_keepalive_connections=100, keepalive_expiry=5.0) 2 Timeout(connect=5.0, read=600, write=600, pool=600)
(_constants は SDK 内部のモジュールなので、確認用に覗くだけにしておく)
数百件程度なら クライアント1つで十分。async with で使えば抜けるときに確実に閉じる。
async with anthropic.AsyncAnthropic(api_key="x") as c2:
print("ctx ok", c2.is_closed())
print("after", c2.is_closed())
ctx ok False
after True
ついでに、上の出力から 既定のタイムアウトが読み取り600秒 なのも分かる。短い分類タスクで1件が詰まって10分待たされるのは困るので、自分は用途に合わせて短くしている。
client = anthropic.AsyncAnthropic(timeout=60.0, max_retries=4)
背景・補足:SDK のリトライと自前リトライを二重にしない
SDK は 429・5xx・接続エラーで自動リトライする(初回 0.5秒待ち・最大 8秒の指数バックオフ。いずれも上の定数で確認できる)。ここに自前の while True リトライを重ねると、1件あたり「SDK のリトライ × 自前のリトライ」回まで膨らみ、Semaphore の枠を長時間占有して全体が詰まる。
自分の落としどころは次のとおり。
- 一時的なエラーは SDK の
max_retriesに任せる - それでも落ちたものは
return_exceptions=Trueで結果に残し、全体が終わってからまとめて再実行 - 401 / 400 のように再実行しても直らないエラーは、
anthropic.APIStatusErrorのstatus_codeを見て再実行対象から外す
def is_retryable(e: BaseException) -> bool:
if isinstance(e, anthropic.APIConnectionError):
return True
if isinstance(e, anthropic.APIStatusError):
return e.status_code == 429 or e.status_code >= 500
return False
retry_targets = [t for t, r in zip(texts, results)
if isinstance(r, BaseException) and is_retryable(r)]
まとめ
-
AsyncAnthropicは1つだけ作ってasync withで閉じる。毎回作ると接続を使い捨てる -
Semaphoreは1つだけ作って全タスクで共有する。関数内で作ると制限が効かない(実測でピーク10 vs 2) -
gatherはreturn_exceptions=True必須。付けないと成功分の結果も失い、残りのリクエストは裏で走り続ける - 「1件失敗で全停止」なら
TaskGroup、「全件処理して後で拾う」ならgatherと使い分ける - リトライは SDK に任せ、自前では「全体が終わった後の再実行」だけにする