0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude API の stop_reason を全パターン処理する実装手順 — max_tokens で切れても HTTP 200・stop_details は refusal のときだけ・pause_turn の再開、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

Claude API を叩いて返ってきたレスポンスから、いきなり response.content[0].text を読んでいないだろうか。自分はやっていた。そして本番で JSON パースがランダムに落ちた。

原因は stop_reason を見ていなかったこと。Claude API は「生成が途中で打ち切られた」ケースでも 例外を投げずに HTTP 200 を返す。SDK は成功扱いなので、壊れた文字列がそのまま後段に流れる。

  • 想定読者: Claude API を業務コードに組み込んでいる人。tool use やエージェントループを書いている人。
  • 前提環境: Python 3.13 / anthropic 1.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 つ用意して、レスポンスの入口をそこに集約するのが結局いちばん安全だった
0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?