「Claude の応答が途中で切れている気がする」— 本番に出した直後、サポートにこの報告が届いて慌てた経験がある方は少なくないはずです。私自身、初めて Messages API を本番投入したとき、stop_reason を真面目に見ていなかったために、max_tokens で打ち切られた応答をそのままユーザーに表示してしまったことがありました。
stop_reason はレスポンスの「終わり方」を教えてくれる小さなフィールドですが、ここを正しく分岐するだけで、未完了出力の取りこぼし・不要なリトライ・ツール呼び出し後の継続忘れといった、本番でよく起きる事故をまとめて防げます。ここでは6種類の stop_reason を「どう見分け」「どう扱うか」を、私が現場で踏んだ落とし穴とともに整理します。
stop_reason は応答の「終わり方」を教える唯一の信号
Messages API は、応答が完了したときに必ず stop_reason を返します。代表的な値は次の6つです。
end_turn: モデルが自然にターンを終えた(最も健全な終了)max_tokens:max_tokensの上限に達して打ち切られた(応答は未完了の可能性が高い)stop_sequence: リクエストで指定したstop_sequencesのいずれかが出現したtool_use: ツール呼び出しブロックが含まれており、ツール結果を返して継続が必要pause_turn: 拡張思考や長時間ツール実行の途中で「いったん区切られた」状態。継続呼び出しが必要refusal: モデルが安全性の判断で応答を拒否した
ここで誤解されがちなのは、「stop_reason が返ってきた = 完了」と読むことです。実際には、max_tokens・tool_use・pause_turn の3つは「途中で止まった」状態であり、後続の処理を呼び出さないとユーザーに不完全な出力が届きます。
end_turn と max_tokens を取り違えると本番で詰まる
最も多い事故が、max_tokens の応答を end_turn と同じように画面に出してしまうケースです。たとえば次のようなシンプルな実装を考えてみてください。
# 最低限の Messages API 呼び出し(このままでは本番に出せません)
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "長文の議事録を要約してください..."}],
)
# ⚠️ stop_reason を見ずに本文だけ返してしまう実装
print(resp.content[0].text)このコードは、応答が max_tokens で打ち切られた場合、文の途中でぷつりと切れたテキストをそのまま表示します。私が実際に踏んだ事例では、議事録要約の最終段落の途中で切れており、ユーザーから「重要な決定事項が抜けている」とクレームを受けました。
最低限、次のように分岐させておくだけで救われます。
# stop_reason を分岐する最小実装
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "..."}],
)
text = "".join(b.text for b in resp.content if b.type == "text")
if resp.stop_reason == "end_turn":
return {"status": "ok", "text": text}
elif resp.stop_reason == "max_tokens":
# 続きを要求するか、max_tokens を上げて再試行する
return {"status": "truncated", "text": text, "hint": "max_tokens を上げてください"}
elif resp.stop_reason == "refusal":
return {"status": "refused", "text": text}
else:
return {"status": resp.stop_reason, "text": text}
# 期待出力例(max_tokens で打ち切られた場合):
# {"status": "truncated", "text": "...議事録の途中まで...", "hint": "max_tokens を上げてください"}このとき大切なのは、max_tokens を機械的に倍にして再試行するのではなく、「そもそも max_tokens がユースケースに対して小さすぎないか」を疑うことです。要約タスクで 1,024 トークンに収まることはまれで、初期値を 4,096〜8,192 に引き上げるだけで max_tokens 検知はほぼゼロになります。
tool_use と pause_turn は「継続」を要求するシグナル
tool_use が返ったときは、応答に含まれる tool_use ブロックを実行し、tool_result を含む新しいユーザーメッセージとして同じ会話を継続する必要があります。ここで継続を忘れると、ユーザーから見ると「ツールを呼び出すと毎回応答が止まる」状態になります。
# tool_use を受けて継続する典型パターン
def run_with_tools(messages, tools):
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
while resp.stop_reason == "tool_use":
tool_blocks = [b for b in resp.content if b.type == "tool_use"]
tool_results = []
for block in tool_blocks:
output = run_tool(block.name, block.input) # 実装はアプリ側
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "assistant", "content": resp.content})
messages.append({"role": "user", "content": tool_results})
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages,
)
return resppause_turn は、拡張思考や長時間のサーバーサイドツール(コード実行など)を使ったときに、モデル側が「いったん区切るのでこのまま続きを呼んでほしい」と返してくる値です。実装パターンは tool_use と似ており、レスポンスをそのまま messages に積んで、追加メッセージなしで messages.create をもう一度呼ぶだけで継続できます。
詳しい実装はClaude API のストリーミングとツール利用を組み合わせるで扱っているので、ストリーミング併用時の細かなイベント順序を知りたい方はあわせて参照してください。
refusal は再試行ではなく「設計」で受け止める
refusal は、モデルが安全性の観点で応答を拒否した場合に返ります。ここで多くのチームが最初にやってしまうのが、自動リトライです。私もやらかしました。同じプロンプトをそのまま投げ直しても結果は変わらず、ただレートリミットを消費するだけになります。
refusal を受け取ったときの実装方針は、シンプルに次の3点に絞ると安定します。
- 同じプロンプトで自動リトライしない(無意味であり、コストが膨らむ)
- ユーザーには「この内容には応答できない」旨を表示する(拒否理由をそのまま見せない)
- 入力をログに残し、本当に拒否すべき入力なのか・プロンプトに改善余地があるかを後で人がレビューする
Claude API のエラーハンドリング設計で取り上げているリトライ方針と組み合わせると、429 や 5xx と refusal を取り違えずに扱えるようになります。
ストリーミングでの stop_reason は最後に届く
ストリーミングを使うと、message_delta イベントの中で stop_reason が後から確定します。最初の message_start には含まれません。これを知らずに「ストリーム開始時点で stop_reason を読みたい」と書いてしまうと、必ず null になります。
# ストリーミングで stop_reason を取得する
stop_reason = None
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{"role": "user", "content": "..."}],
) as stream:
for event in stream:
if event.type == "message_delta" and event.delta.stop_reason:
stop_reason = event.delta.stop_reason
# text_delta などの処理は省略
print("stop_reason:", stop_reason)
# 期待出力例: stop_reason: end_turnストリーミングでは、UI に「考え中」表示を出している間に pause_turn が来る可能性もあります。表示側の状態管理を「終わり = end_turn のみ」にしておくと、pause_turn を継続せずに UI が止まったように見える事故を防げます。
stop_sequence は便利だが、設定を忘れがち
stop_sequence は、リクエスト時に渡した stop_sequences のいずれかが出力に出現したときだけ返ります。出力フォーマットを強制したいときには強力で、たとえば </answer> という終端タグが出た瞬間に応答を打ち切り、後続処理を簡潔にできます。
# stop_sequences で出力を制約する
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
stop_sequences=["</answer>"],
messages=[{"role": "user", "content": "回答を <answer>...</answer> で囲んでください"}],
)
if resp.stop_reason == "stop_sequence":
# どの停止文字列にヒットしたかは resp.stop_sequence で確認できます
print("matched:", resp.stop_sequence)私がコードレビューでよく見かける落とし穴は、過去に stop_sequences=["\n\n"] を仕込んで応答を短く保っていたケースです。数ヶ月後に「複数段落の応答が出なくなった」と問い合わせが来て、原因を追うと忘れ去られた stop_sequences が残っているという展開です。「短い応答しか返らない」と感じたら、まず stop_sequences を全文検索して確認するのが早道です。
stop_reason ごとの最小実装チェックリスト
最後に、私が現場で各 stop_reason に対して入れている最小限の挙動を一覧にしておきます。
end_turn— そのまま応答を返す。完了扱いmax_tokens— ログとエラートラッカーに記録し、truncated状態として返す。必要ならmax_tokensを引き上げるstop_sequence— 一致した停止文字列を必要に応じて除去して返す。設定が古くないか定期的に見直すtool_use— ツールを実行し、assistant/userメッセージを追加して再呼び出し。ループ上限を必ず設けるpause_turn—assistantメッセージだけを追加して再呼び出し。追加のユーザー入力は不要refusal— 中立的なメッセージを表示し、入力をレビュー用にログに残す。自動リトライしない
まずは max_tokens の取りこぼしから潰す
stop_reason の6種類を一度に完璧に扱おうとすると、実装が膨らみます。私のおすすめは、まず「max_tokens の取りこぼしを検知してログに残す」だけを今日のうちに入れることです。これだけで、本番で起きている「応答が途切れる」系の問題の半分以上は可視化できます。残りの tool_use・pause_turn・refusal は、ユースケースが拡がってから順に対応しても十分間に合います。
後者は API クライアントの設計章で、外部サービス呼び出しの結果コードをどう分岐するかという考え方が整理されており、stop_reason の取り扱いにも応用できます。