· 1 мин

Модель 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-пайплайне и заложить фолбэк на другую модель.

Модель Anthropic отказала посреди пайплайна: ловим stop_reason=refusal и падаем на резервную модель

Короче, картина знакомая любому, кто гоняет 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, пентест), и промптом их не убрать. Надёжнее заложить фолбэк на другую модель.

Влад Новиков
Влад Новиков aka sunm8
Пишу про AI-разработку без глянца. Новые разборы — сначала в канале.
Подписаться