Модель Anthropic отказала посреди пайплайна: ловим stop_reason=refusal и падаем на резервную модель
stop_reason=refusal - это отказ Claude API отвечать: приходит как обычный ответ HTTP 200 с пустым content, а причина лежит в stop_details.category. Ловить его надо по полю stop_reason, а лечить - ретраем того же запроса на другой модели (например, Claude Opus 4.8) через параметр fallbacks, SDK-мидлварь или ручной ретрай.
Claude API вернул пустой ответ с stop_reason=refusal? Почему модель Anthropic отказывает даже на безопасных задачах, как поймать отказ в headless-пайплайне и заложить фолбэк на другую модель.

Короче, картина знакомая любому, кто гоняет Claude в автомате: скрипт уходит к модели за текстом, а возвращается пустота. Не ошибка, не таймаут - формально валидный ответ, в котором ноль контента и stop_reason=refusal. Модель Anthropic просто отказалась отвечать. И если пайплайн headless, без человека рядом, эта пустота молча течёт дальше по конвейеру. Разберём, почему так выходит и как заранее заложить фолбэк, чтобы отказ не ронял всю цепочку.
Что такое stop_reason=refusal в Claude API
stop_reason=refusal - это способ Claude API сказать "я отказываюсь отвечать". Приходит он не как ошибка, а как обычный успешный ответ HTTP 200: поле content пустое, output_tokens равен нулю, а причина лежит в объекте stop_details. По данным документации Anthropic (июль 2026), так ведут себя защитные классификаторы на модели Claude Fable 5.
Всего у ответа семь возможных значений stop_reason: end_turn (модель закончила сама), max_tokens (упёрлась в лимит), stop_sequence, tool_use, pause_turn, model_context_window_exceeded и вот этот refusal. Обычно ты ждёшь первые два. refusal стоит особняком: это не сбой генерации и не проблема сети, а сознательный отказ модели. Поэтому и обрабатывать его надо отдельной веткой, а не в общей куче ошибок.
Почему модель отказывается выполнять безопасную задачу
Модель отказывается, когда её защитный классификатор решает, что запрос попадает под запрещённую категорию. В поле stop_details.category приезжает одна из четырёх: cyber (вредонос, разработка эксплойтов), bio (биоугрозы), frontier_llm (помощь конкурирующим ИИ-моделям) или reasoning_extraction (просьба выложить внутренние рассуждения в ответ). Когда отказ не мапится ни на одну категорию, category приходит null.
Главная засада вот в чём. По данным Anthropic, безопасная работа тоже цепляет эти категории: легальный пентест ловит cyber, обычный ML - frontier_llm, науки о жизни - bio. То есть отказ - это не всегда "ты попросил плохое".
Живой пример из июля 2026. В канале llm_under_hood разобрали бенчмарк бизнес-задач (abdullin.com/llm-benchmarks): одна из моделей после обновления рухнула с 12-го места на 39-е. Причина - 15 пустых ответов с stop_reason=refusal там, где раньше был нормальный текст. И это не запросы "как сделать бомбу", а тщательно подобранные безопасные задачи на код и интеграции. Классификатор перестраховался, а бенчмарк это честно показал циферками.
Вот почему на refusal нельзя смотреть как на "сам виноват, плохой промпт". Иногда и правда виноват. Но часто это ложное срабатывание на нормальной задаче, и закладываться в пайплайне надо именно на этот случай.
Как обнаружить пустой ответ с refusal в headless-пайплайне
Ловить refusal надо по одному полю - stop_reason. Не по пустому content и не по stop_details: и то и другое может обмануть. content бывает пустым по другим причинам, а stop_details на отказе иногда приходит null. Проверяй ровно stop_reason == "refusal" сразу после ответа, до того как отдать результат дальше по конвейеру.
resp = client.messages.create(model="claude-fable-5", ...)
if resp.stop_reason == "refusal":
# контента нет, дальше по пайплайну пускать нечего
handle_refusal(resp)Отдельная боль автоматизации: refusal - это HTTP 200. Твой мониторинг, построенный на счётчике ошибок и кодах 5xx, его в упор не видит. По данным доков Anthropic, отказ нужно инструментировать как отдельный сигнал: одно событие на каждый refusal, одно на каждый ответ, спасённый фолбэком, и алерт на разрыв между этими счётчиками. Иначе узнаешь о проблеме, когда в блоге выйдет пустая статья. Спрашивайте, почему я про это вспомнил.
На какую модель переключаться при отказе
Переключаться надо на другую модель, не на ту же самую. Повторный запрос к отказавшей модели почти гарантированно словит второй отказ - классификатор сработает так же. По данным Anthropic, запрос, который завернул классификатор Claude Fable 5, обычно спокойно отрабатывает другая модель, например Claude Opus 4.8. Так что фолбэк - это буквально "тот же запрос, другая модель".
Пара граблей, на которые легко наступить в пайплайне с субагентами:
- Бюджет ретраев считай на запрос, а не на сессию. Один ход агента может выдать несколько отказов подряд - сам агент плюс его субагенты, каждому свой.
- Фолбэк не наследуется внутрь инструментов. Если субагент дёргает модель из своего кода, ему нужен собственный фолбэк - родительский туда не протечёт.
Как заложить фолбэк: сервер, SDK или руками
Способов три. Серверный фолбэк - добавляешь в запрос параметр fallbacks и бета-заголовок, а Anthropic сам ретраит на резервной модели и возвращает один готовый ответ. SDK-мидлварь - настроил на клиенте один раз, дальше ретраи идут сами на любой платформе. И ручной ретрай - полный контроль, но за кэш промпта следишь сам.
Серверный вариант выглядит так:
resp = client.beta.messages.create(
model="claude-fable-5",
max_tokens=1024,
messages=[{"role": "user", "content": "..."}],
fallbacks=[{"model": "claude-opus-4-8"}],
betas=["server-side-fallback-2026-06-01"],
)
# resp.model покажет, кто реально ответилМелочь, которая экономит деньги: fallback credit. Ручной ретрай пишет кэш промпта резервной модели с нуля, а это дороже чтения готового кэша. Серверный фолбэк и мидлварь применяют кредит за тебя автоматически; если пилишь ретрай руками - включай бета-заголовок сам. Плюс есть sticky-роутинг: после первого фолбэка API примерно час помнит, кто ответил, и следующие ходы той же беседы шлёт сразу на резервную модель, не тратя попытку на заведомый отказ.
И про счёт. За отказ, который прилетел до первого токена вывода, ты не платишь: content пустой, токены в usage видны, но не тарифицируются и не жгут рейт-лимит. Платный случай один - отказ в середине стрима, уже после куска вывода: тогда спишутся вход и уже отданные токены, а недоделанный вывод всё равно надо выкинуть.
Что я в итоге поменял у себя
У меня статьи для этого блога пишет headless-пайплайн: cron дёргает claude в режиме -p, скилл идёт в базу за темой, гоняет ресёрч и кладёт черновик обратно в SQLite. Человек подключается уже на утверждении. Красиво, пока в середине конвейера не прилетит refusal и в базу не ляжет пустой черновик - молча, без единой строчки в логе ошибок.
Что вынес из этой истории:
- Ветвись по stop_reason, а не по длине ответа. "Пустой текст" - симптом десятка разных болезней, а refusal - конкретный диагноз с понятным лечением.
- Фолбэк - свойство запроса, а не глобальный флажок. Общий тумблер "фолбэк включён" рано или поздно рассинхронится, и без защиты останется ровно тот запрос, которому она нужнее всего.
- Резервную модель прописывай на каждом пути запроса. Ретраи, фоновые воркеры, обработчики ошибок - фолбэк нужен во всех ветках, а не только в основном сценарии.
- Отказы - отдельная метрика. HTTP 200 проходит мимо счётчика ошибок, так что нужен свой счётчик и свой алерт, иначе проблему заметит читатель, а не ты.
Мораль простая: refusal - это не край света, а штатная ветка, которую надо предусмотреть заранее. Один if на stop_reason плюс одна резервная модель - и пайплайн переживёт день, когда классификатор решит перестраховаться. А я пойду прикручивать этот if к своему конвейеру, пока он опять не отдал мне пустоту.
Частые вопросы
Платит ли клиент за ответ с stop_reason=refusal?
За отказ, пришедший до первого токена вывода, платить не нужно: content пустой, токены видны в usage, но не тарифицируются и не жгут рейт-лимит. Платный случай один - отказ в середине стрима, уже после части вывода: тогда списываются вход и уже отданные токены.
Чем refusal отличается от обычной ошибки Claude API?
refusal - это успешный ответ HTTP 200, а не ошибка 4xx или 5xx. Поэтому мониторинг на кодах ошибок его не видит. Отказ надо ловить проверкой stop_reason == "refusal" и инструментировать отдельным событием и алертом.
Можно ли убрать отказы, просто переписав промпт?
Иногда да: если запрос реально задевает чувствительную тему, переформулировка помогает. Но часть отказов - это ложные срабатывания классификатора на безопасных задачах (код, ML, пентест), и промптом их не убрать. Надёжнее заложить фолбэк на другую модель.
