Claude API は、安全分類器(streaming classifier)がポリシー違反の可能性を検知すると、エラーではなく stop_reason: "refusal" という正常応答を返します。アプリ側でこれを検知・処理しないと、「空っぽの応答が返る」「拒否が連鎖する」といった分かりにくい挙動になります。
この領域に、2026-05〜06 の Claude API リリースで実務的な改善が 2 つ入りました。
- 2026-05-28: 拒否応答に
stop_details(category=cyber/bio/null、explanation)が付き、beta ヘッダ不要で公開ドキュメント化(Opus 4.7 以降)。 - 2026-06-02: 出力を 1 文字も生成せずに拒否された場合、その API リクエストは課金されなくなった。
この記事では、refusal の検知 → カテゴリ別の仕分け → 会話コンテキストのリセットまでを、コード付きで実装目線に落とします。モデル側の文脈は Claude Opus 4.8 発表 も参照してください。
まず:refusal はエラーではなく「正常応答」
stop_reason: "refusal" は HTTP 4xx/5xx のエラーではありません。try/except の例外ハンドラには来ません。あくまで Messages API の成功応答の本文に乗ってくるので、stop_reason を見て分岐する必要があります。
非ストリーミングの最小例(本文に追加メッセージは含まれません):
{
"role": "assistant",
"content": [{ "type": "text", "text": "Hello.." }],
"stop_reason": "refusal"
}
注意: 拒否時に「お断りの定型文」は付きません。ユーザー向けの文言は自分で用意する必要があります。
stop_details でカテゴリ別に仕分ける(2026-05-28)
Opus 4.7 以降のモデルでは、refusal 応答に stop_details オブジェクトが付きます(beta ヘッダ不要)。
stop_details.type— 常に"refusal"stop_details.category— 拒否を引き起こしたポリシーカテゴリ。"cyber"または"bio"。名前付きカテゴリに該当しない場合はnullstop_details.explanation— 人間可読の説明。カテゴリに説明が無ければnull。文言は安定が保証されないので、プログラムでパースしてはいけない
なお stop_details は、refusal 以外の stop_reason ではすべて null です。
何に使えるか
category を見れば、拒否の種類ごとに振る舞いを変えることができます。
cyber/bioのような名前付き拒否は別系統でログを取り、運用側でレビューする- カテゴリ別にユーザー向けメッセージを出し分ける(「この種のリクエストは扱えません」など)
null(汎用的な拒否)は、入力の言い換えを促すフローに回す
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": user_input}],
)
if response.stop_reason == "refusal" and response.stop_details:
category = response.stop_details.category # "cyber" | "bio" | None
# 説明文はログ用途のみ。分岐条件には category を使う(explanation はパースしない)
log_refusal(category=category, explanation=response.stop_details.explanation)
if category in ("cyber", "bio"):
notify_user("このリクエストはポリシー上お受けできません。")
else:
notify_user("内容を調整して、もう一度お試しください。")
category という安定した列挙値で分岐し、explanation はログ・可観測性のためだけに使うのが要点です。
検知:ストリーミングと非ストリーミング
非ストリーミングでは応答本文の stop_reason をそのまま見ます。ストリーミングでは message_delta イベントの delta.stop_reason に出ます(message_start では null、その他のイベントには出ません)。
TypeScript(ストリーミング)の例:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const stream = await client.messages.stream({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [...messages, { role: "user", content: userInput }],
});
for await (const event of stream) {
if (event.type === "message_delta" && event.delta.stop_reason === "refusal") {
resetConversation(); // 後述:必ずコンテキストをリセット
break;
}
}
curl で素早く確認したいときは、ストリーム中の "stop_reason":"refusal" を拾うだけでも検知できます:
curl -N https://api.anthropic.com/v1/messages \
--header "anthropic-version: 2023-06-01" \
--header "content-type: application/json" \
--header "x-api-key: $ANTHROPIC_API_KEY" \
--data '{"model":"claude-opus-4-8","messages":[{"role":"user","content":"..."}],"max_tokens":1024,"stream":true}' \
| grep -q '"stop_reason":"refusal"' && echo "refused"
重要:拒否を受けたら会話コンテキストをリセットする
ここが実装でいちばん事故りやすい点です。refusal を受けたら、続行する前に会話コンテキストをリセットしなければなりません。
- 拒否を誘発したターンを削除または言い換える、もしくは
- 会話履歴をまるごとクリアする
リセットせずにそのまま続けると、拒否が連鎖します(直前の文脈が残っているため)。「空応答が返ったので、その空応答を付けて再送する」は逆効果で、状況は改善しません。
def reset_conversation():
# 拒否を誘発したターンを取り除くか、履歴を初期化する
return []
課金:出力前の拒否は課金されなくなった(2026-06-02)
運用コストに直結する変更です。
- Claude が出力を 1 文字も生成せずに拒否(
stop_reason: "refusal")した場合、そのリクエストは Claude API で課金されません。応答に含まれる usage の数値は**参考値(informational)**です。 - ただし、Claude が何らかの出力を生成した後に拒否された場合は、そのリクエストは課金されます。
なお usage メトリクス自体は拒否時も応答に含まれます。「拒否=必ず無料」ではなく、**境界は『出力を生成したかどうか』**である点に注意してください。
refusal の 3 つの型を区別する
API の拒否は現状 3 通りに分かれます。混同すると、エラーハンドラとレスポンス分岐の設計を誤ります。
| 型 | 応答の形 | 発生タイミング |
|---|---|---|
| streaming classifier の拒否 | stop_reason: "refusal"(正常応答) | ストリーミング中にポリシー違反を検知したとき |
| 入力・著作権バリデーション | 400 エラー | 入力が検証に通らなかったとき |
| モデル自身による拒否 | 通常のテキスト応答 | モデルが自分の判断で断ったとき |
将来の API では、この stop_reason: "refusal" パターンに他の拒否型も統合していく方針が示されています。今のうちに「stop_reason 分岐」「400 のエラーハンドラ」「通常応答の中身チェック」の 3 系統を分けておくと、移行がスムーズです。
実装チェックリスト
stop_reasonを必ず見る —end_turn/tool_use/max_tokensなどと並べてrefusalを分岐に入れるstop_details.categoryで仕分け —cyber/bio/nullで振る舞いを変える。explanationはログ専用、パース禁止- 拒否後はコンテキストをリセット — 該当ターン削除か履歴クリア。リセットせず続けない
- ユーザー向け文言は自前で用意 — 拒否応答に定型文は付かない
- 課金の境界を把握 — 出力前の拒否は非課金、出力後の拒否は課金
- 400 エラーとは別系統で扱う — refusal は正常応答、入力バリデーション失敗は HTTP エラー
補足: 旧モデル(Sonnet 4.5 や非推奨の Opus 4.1)で
refusalが頻発する場合、利用制限の異なる Haiku 4.5(claude-haiku-4-5-20251001)へ切り替える手もあります。
まとめ
stop_reason: "refusal" はエラーではなく正常応答で、放置すると「空応答」「拒否連鎖」として表面化します。2026-05-28 に stop_details.category(cyber / bio / null)が beta ヘッダ不要で公開されたことで、拒否をカテゴリ別に仕分けてログ・UI を出し分ける設計がやりやすくなりました。さらに 2026-06-02 から出力前の拒否は非課金になり、ガードレールを厚めにかけてもコスト面の不安が減りました。「stop_reason を見る → category で仕分け → コンテキストをリセット」の 3 点を押さえるのが実装の勘所です。
release notes タグで Anthropic / Claude API のリリース情報を継続フォローしています。
参考
- Handling stop reasons — Claude Platform Docs
- Handle streaming refusals — Claude Platform Docs
- API リリースノート — Claude Platform Docs(2026-05-28 の
stop_details公開、2026-06-02 の空 refusal 非課金) - 当ブログ: Claude Opus 4.8 発表(2026-05-28)
- 当ブログ: Claude API の prompt caching