はじめに / 対象と前提
Claude API を叩いて返ってきたレスポンスから、いきなり response.content[0].text を読んでいないだろうか。自分はやっていた。そして本番で JSON パースがランダムに落ちた。
原因は stop_reason を見ていなかったこと。Claude API は「生成が途中で打ち切られた」ケースでも 例外を投げずに HTTP 200 を返す。SDK は成功扱いなので、壊れた文字列がそのまま後段に流れる。
- 想定読者: Claude API を業務コードに組み込んでいる人。tool use やエージェントループを書いている人。
-
前提環境: Python 3.13 /
anthropic1.x 系 / モデルはclaude-opus-5 - 前提知識: Messages API を一度は叩いたことがある
TL;DR
-
stop_reasonは 6 値(end_turn/max_tokens/stop_sequence/tool_use/pause_turn/refusal)。end_turn以外は「正常終了ではない」 と思って分岐を書く -
max_tokensで切れても HTTP 200・例外なし。JSON を期待しているコードは後段のパースで初めて落ちる -
stop_detailsはstop_reason == "refusal"のときだけ値が入る。他の値のときはNoneなので、無条件アクセスはAttributeError - server-side tool(web 検索・コード実行)を使うと
pause_turnが返る。"Continue." を足さず、そのまま再送すれば続きから走る
この記事では、この 4 点を 1 つのディスパッチャ関数にまとめるところまでやる。
手順 / 動かし方
1. まず stop_reason の値を目で見る
理屈より先に、実際に max_tokens を絞って切ってみるのが早い。
import anthropic
client = anthropic.Anthropic()
res = client.messages.create(
model="claude-opus-5",
max_tokens=64, # わざと小さくする
messages=[{
"role": "user",
"content": "日本の都道府県を全部 JSON 配列で返して。説明文は不要。",
}],
)
print("stop_reason:", res.stop_reason)
print("output_tokens:", res.usage.output_tokens)
print("text:", res.content[0].text)
実行結果:
stop_reason: max_tokens
output_tokens: 64
text: ["北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島
ここが最初の落とし穴。例外は飛んでいない。try/except で囲んでいても何も捕まらない。この壊れた文字列を json.loads() に渡した瞬間、コードのぜんぜん違う場所で JSONDecodeError になる。エラーが出る場所と原因の場所が離れているので、デバッグでかなり時間を溶かした。
2. 6 値を明示的に分岐するディスパッチャを書く
if を散らかすとまた漏れるので、返り値を受け取る入口を 1 箇所にする。
from dataclasses import dataclass
class StopReasonError(Exception):
"""生成が正常終了しなかったことを呼び出し側に知らせる"""
def __init__(self, reason: str, detail: str = "", partial: str = ""):
super().__init__(f"{reason}: {detail}")
self.reason = reason
self.detail = detail
self.partial = partial # 途中まで生成された分(ログ用)
@dataclass
class Completed:
text: str
truncated: bool = False
def _first_text(res) -> str:
for block in res.content:
if block.type == "text":
return block.text
return ""
def handle(res) -> Completed:
reason = res.stop_reason
if reason == "end_turn":
return Completed(text=_first_text(res))
if reason == "stop_sequence":
# 自分で stop_sequences を指定した場合。意図した停止なので成功扱い
return Completed(text=_first_text(res))
if reason == "max_tokens":
# 例外なく 200 で返ってくる。ここで必ず止める
raise StopReasonError(
"max_tokens",
f"max_tokens={res.usage.output_tokens} に到達して打ち切られた",
partial=_first_text(res),
)
if reason == "refusal":
# stop_details が入るのはこの分岐だけ
cat = res.stop_details.category if res.stop_details else None
exp = res.stop_details.explanation if res.stop_details else ""
raise StopReasonError("refusal", f"category={cat} / {exp}")
if reason == "tool_use":
# ツール実行して会話を継続する。ここで返してはいけない
raise StopReasonError("tool_use", "エージェントループ側で処理すべき応答")
if reason == "pause_turn":
raise StopReasonError("pause_turn", "サーバー側ループが一時停止。再送が必要")
raise StopReasonError(reason or "unknown", "未知の stop_reason")
動作確認:
try:
out = handle(res)
print(out.text)
except StopReasonError as e:
print(f"[NG] {e.reason} / 途中まで: {e.partial[:30]}...")
[NG] max_tokens / 途中まで: ["北海道", "青森県", "岩手県", "宮城県"...
これで「壊れた JSON が静かに下流へ流れる」経路が塞がる。
3. max_tokens 切れを自動で救済する
止めるだけだと運用が回らないので、リトライも用意する。ポイントは 闇雲に倍々にしないこと。
def create_guarded(messages, *, max_tokens=16000, retries=2, **kw):
budget = max_tokens
for attempt in range(retries + 1):
res = client.messages.create(
model="claude-opus-5",
max_tokens=budget,
messages=messages,
**kw,
)
if res.stop_reason != "max_tokens":
return handle(res)
if attempt == retries:
raise StopReasonError("max_tokens", f"{retries}回リトライしても収まらず")
budget = min(budget * 2, 64000)
print(f"[retry] max_tokens を {budget} に引き上げて再試行")
max_tokens を大きくする場合、非ストリーミングだと HTTP タイムアウトに引っかかりやすい。おおよそ 16000 を超えるあたりから client.messages.stream(...) に切り替えて get_final_message() で受けるほうが安定した。
with client.messages.stream(
model="claude-opus-5",
max_tokens=64000,
messages=messages,
) as stream:
res = stream.get_final_message()
out = handle(res) # 受け取ったあとは同じ関数に通せる
ストリーミングでも stop_reason の扱いは変わらない。get_final_message() が返すのは通常のレスポンスオブジェクトなので、ディスパッチャを使い回せる。
4. pause_turn を再開する
web 検索やコード実行などの server-side tool を使うと、サーバー側のサンプリングループが既定 10 イテレーションで頭打ちになり、pause_turn が返る。
def run_with_server_tools(user_text, tools, max_continuations=5):
messages = [{"role": "user", "content": user_text}]
for _ in range(max_continuations):
res = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
tools=tools,
messages=messages,
)
if res.stop_reason != "pause_turn":
return handle(res)
# ここが肝: "Continue." のような user メッセージを足さない
messages = [
{"role": "user", "content": user_text},
{"role": "assistant", "content": res.content},
]
raise StopReasonError("pause_turn", f"{max_continuations}回継続しても終わらず")
max_continuations を必ず入れる。入れ忘れると while が回り続けてトークンを焼く。自分は検証中にこれで数百円ぶん無駄にした。
ハマりどころ
ハマりどころ 1: max_tokens 切れは例外にならず HTTP 200 で返る
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 87 (char 86)
原因: Claude API は「モデルの出力が max_tokens に達した」ことを 正常なレスポンス として返す。HTTP ステータスは 200、SDK も例外を投げない。切れた事実は stop_reason にしか現れない。
回避策: レスポンスを受け取ったら content より先に stop_reason を見る。これを規約にするだけで防げる。構造化出力(output_config.format)を使っていても同じで、スキーマ制約は途中で切れることを防いでくれない。スキーマに沿った JSON を生成している途中で上限に当たれば、やはり壊れた JSON が返る。
ついでに max_tokens を低く見積もりすぎるのもやめたほうがいい。非ストリーミングなら 16000 前後、ストリーミングなら 64000 前後を既定にして、分類タスクのように短いと分かっているものだけ絞る、という運用に変えたらこの手の事故が消えた。
ハマりどころ 2: stop_details は refusal のときだけ非 None
AttributeError: 'NoneType' object has no attribute 'category'
原因: stop_details が値を持つのは stop_reason == "refusal" のときだけ。end_turn でも max_tokens でも tool_use でも None が入る。「詳細情報だからいつでも読めるはず」と思ってログ出力に組み込むと、平常時の成功レスポンスで落ちる。ログ処理で本体が死ぬのは最悪のパターンだ。
# NG: 成功時に落ちる
logger.info("stop=%s cat=%s", res.stop_reason, res.stop_details.category)
# OK: refusal のときだけ読む
cat = res.stop_details.category if res.stop_details else None
logger.info("stop=%s cat=%s", res.stop_reason, cat)
回避策: res.stop_details and res.stop_details.category の形でガードする。category は "cyber" "bio" などの拡張されうる集合なので、値で if 分岐を組まないこと。新しいカテゴリが増えた瞬間に分岐から漏れる。ログに残して人間が見る、程度の扱いが無難だった。
ハマりどころ 3: pause_turn に "Continue." を足すと壊れる
原因: 直感的には「続けて」と言いたくなるが、pause_turn の再開はメッセージを足す操作ではない。API は末尾の server_tool_use ブロックを見て「これは再開だ」と判断する。余計な user メッセージを挟むとその判定が崩れ、サーバー側ループの続きではなく新しい指示として解釈される。
回避策: user メッセージと、返ってきた assistant 応答(res.content 全体)だけを積んで再送する。ここで res.content[0] のようにテキストブロックだけ抜き出すと server_tool_use ブロックが落ちて再開できなくなる。content は丸ごと渡す。
もう 1 つ。SDK の tool runner は pause_turn を自動再開しない。ランナーは一時停止した応答を「最終メッセージ」として返して、エラーも出さずに終わる。ランナー任せにしていると「なぜか途中で終わる」現象になるので、server-side tool を使うなら手書きループか、外側で pause_turn を拾う作りにする。
背景・補足
なぜ打ち切りが例外ではなく 200 なのか。理由はシンプルで、途中まで生成されたトークンには意味があるから。エラーにして中身を捨てるより、部分的な結果を返して呼び出し側に判断させるほうが情報量が多い。ストリーミングで途中まで表示している場合を考えると、この設計は妥当だ。
tool_use を「正常終了ではない」側に置いているのも同じ発想。tool_use は会話が続くことを意味していて、そこで打ち切るのはアプリ側のバグである可能性が高い。自分はここを end_turn と同じ扱いにしていて、「ツールを呼びたい」という空っぽの応答をユーザーに表示してしまったことがある。
まとめると、stop_reason は 「誰が次に動くべきか」を示すフィールドとして読むと整理しやすい。
stop_reason |
次に動くのは |
|---|---|
end_turn / stop_sequence
|
呼び出し側(完成品として使える) |
max_tokens |
呼び出し側(予算を増やして再試行) |
tool_use |
呼び出し側(ツールを実行して継続) |
pause_turn |
サーバー側(そのまま再送すれば続く) |
refusal |
人間(方針判断が要る) |
まとめ
- Claude API のレスポンスは
contentより先にstop_reasonを見る。この 1 行のルールでランダムな JSON パースエラーが消えた -
max_tokens到達は例外にならず HTTP 200。構造化出力を使っていても壊れた JSON は返ってくる -
stop_detailsはrefusal専用。無条件アクセスは平常時にAttributeErrorを起こす -
pause_turnは "Continue." を足さず、res.content丸ごとを積んで再送。max_continuationsを必ず添える - 6 値すべてを明示的に分岐する関数を 1 つ用意して、レスポンスの入口をそこに集約するのが結局いちばん安全だった