社内の問い合わせ対応エージェントが、ある日から回答の途中で文が切れる。別の日は、検索ツールを呼んだまま何も返さず無言になる。エラーログは空です。原因の多くは、APIの応答に含まれる stop_reason を見ずに、常に「正常終了」とみなしているコードにあります。
stop_reasonとは何で、なぜ分岐が必要なのか
stop_reasonはClaudeが生成を止めた理由を示す値で、7種類あります。HTTP 200で返るため、エラー監視だけでは異常を拾えません。
stop_reason はMessages APIの成功応答に必ず入る項目です。4xx/5xxのエラーとは別の仕組みで、通信が成功していても「途中で切れた」「ツールの結果待ち」「回答を拒否した」といった状態を示します。公式ドキュメントが定める値は次のとおりです。
| 値 | 意味 | 基本の扱い |
|---|---|---|
end_turn | 自然に回答を終えた | 応答を使う。空応答に注意 |
max_tokens | max_tokens の上限に達した | 上限を上げるか続きを生成 |
stop_sequence | 指定した停止文字列が出た | response.stop_sequence で種類を確認 |
tool_use | ツールを呼び出したい | 実行して tool_result を返す |
pause_turn | サーバーツールのループが反復上限(既定10)に達した | assistantの内容をそのまま返して継続 |
refusal | 回答を拒否した | stop_details を読み、フォールバックを検討 |
model_context_window_exceeded | コンテキストウィンドウを使い切った | 切り詰められた応答として扱う |
設計の出発点は、end_turn を前提にせず、値ごとに明示的に分岐することです。エージェントの全体構造はエージェントハーネスのアーキテクチャと合わせて読むと位置づけが分かります。
ループはどの値で回し、どの値で止めるのか
続行するのは tool_use と pause_turn の2値だけです。それ以外は終了か異常として扱い、ループの無限化を防ぎます。
エージェントループの骨格は単純です。tool_use と pause_turn は「続きがある」ので次のリクエストを送り、他の値は終了処理に回します。
``python``
MAX_STEPS = 20 # ループ上限は自社で決める
for step in range(MAX_STEPS):
resp = client.messages.create(model=MODEL, max_tokens=4096,
messages=messages, tools=tools)
match resp.stop_reason:
case "tool_use":
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": run_tools(resp)})
case "pause_turn":
messages.append({"role": "assistant", "content": resp.content})
case "end_turn" | "stop_sequence":
return resp
case "max_tokens" | "model_context_window_exceeded":
return mark_truncated(resp)
case "refusal":
return handle_refusal(resp)
raise LoopLimitExceeded()
pause_turn は公式ドキュメントでも継続回数に上限を設けるよう書かれています。ステップ数の上限は、課金の暴走を止める最後の砦です。上限と予算の設計はエージェントのループ実行制御で詳しく扱っています。
tool_useとmax_tokensでは何に気をつけるのか
tool_resultは1通のuserメッセージにまとめ、先頭に置きます。max_tokensでtool_useが切れた場合は実行せず、上限を上げて再試行します。
tool_use では、次の規則を守らないと400エラーか空の end_turn になります。
tool_useブロックすべてに対応するtool_resultを返す(tool_use_idを一致させる)tool_resultは1つのuserメッセージにまとめ、contentの先頭に置く。テキストはその後ろ- サーバーツールの未完了呼び出しが残る場合は、
tool_resultだけを送る - ツール実行が失敗したら
is_error: trueを付け、「何が起き、次に何を試すか」を書く
max_tokens は特に注意が必要です。最後のブロックが tool_use なら、引数のJSONが途中で切れていて実行できません。公式の手順どおり、より大きい max_tokens で同じリクエストを再送します。テキストだけなら、続きを生成させるか、切り詰めた旨を利用者に表示します。出力形式を固定している場合の失敗パターンは構造化出力の失敗設計にまとめています。
refusalと空のend_turnはどう扱うのか
refusalは正常応答として届くため、独立した指標で監視します。空のend_turnは、tool_result直後のテキスト追加が主因です。
refusal: 拒否は4xxではなくHTTP 200で返ります。エラー率だけの監視では見えないため、refusal の件数を別指標にします。stop_details には category と explanation が入りますが、どちらも null になり得ます。stop_reason で分岐し、利用者向け文言は自社で用意してください。公式の指針では、拒否後は会話コンテキストをリセットするか、別のClaudeモデルで再試行します。同じモデルに同じ内容を再送しても拒否が続くためです。モデルごとのフォールバック対応は異なり、Haiku 5.5にはサーバー側フォールバックがないので、クライアント側で再試行を実装します。バッチ処理でも、拒否は成功結果として stop_reason: "refusal" で返ります。
空の end_turn: 2〜3トークンで内容のない end_turn が返ることがあります。公式が挙げる原因は、tool_result の直後にテキストブロックを足したことと、完了済みの応答をそのまま再送したことです。対処は、まず tool_result だけを送ること。それでも空なら、最後の手段として「続けてください」のuserメッセージを足して再呼び出しします。
ストリーミング: stop_reason は message_start では null で、message_delta イベントで届きます。ストリームを受ける実装は、最後まで読んでから判定してください。
規模別の留意点(SMB)
従業員数十名規模では、まず max_tokens・refusal・ループ上限の3つを実装するだけで事故の大半を防げます。停止理由別の件数をログに出しておくと、モデル変更後の回帰にも気づけます。
参考
- Stop reasons and fallback(Claude Platform Docs)
- Handle tool calls(Claude Platform Docs)
- Handle streaming refusals(Claude Platform Docs)
まとめ
stop_reason の分岐は、エージェントを止まらず、暴走せず、静かに壊れないようにする最小の設計です。続行は tool_use と pause_turn、終了は end_turn 系、異常は max_tokens・refusal・コンテキスト超過、と役割を分けてください。分岐設計の見直しや運用の定着は、Kuu株式会社のAI Ops(導入〜定着の伴走)にご相談ください。
