Claude API の refusal ハンドリング実践 — stop_details.category(cyber / bio)で拒否を仕分け、空 refusal は非課金に

Claude API は安全分類器が介入すると stop_reason: "refusal" を返します。2026-05-28 に stop_details(category = cyber / bio / null と explanation)が beta ヘッダ不要で公開され、2026-06-02 からは出力前に拒否された場合は課金されなくなりました。refusal の検知・カテゴリ別の仕分け・会話コンテキストのリセットまで、実装パターンをコード付きで整理します。

Claude API は、安全分類器(streaming classifier)がポリシー違反の可能性を検知すると、エラーではなく stop_reason: "refusal" という正常応答を返します。アプリ側でこれを検知・処理しないと、「空っぽの応答が返る」「拒否が連鎖する」といった分かりにくい挙動になります。

この領域に、2026-05〜06 の Claude API リリースで実務的な改善が 2 つ入りました。

  • 2026-05-28: 拒否応答に stop_details(category = cyber / bio / nullexplanation)が付き、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"。名前付きカテゴリに該当しない場合は null
  • stop_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 のリリース情報を継続フォローしています。

参考