Рассуждающая модель молча съедает ваш ответ
Провайдер подменил модель на рассуждающую, а ваш конвейер по-прежнему отвечает 200 OK. Ничего не падает. В логах спокойно. И каждый ответ тихо пуст.
Мы поймали это дважды в одной системе, с разницей в три недели, в двух местах, никак между собой не связанных. Ниже — как выглядит симптом, как подтвердить его одним запросом к API и в чём состоит настоящее лечение. Включая ту часть, которую почти никто не проверяет: фолбэк, превращающий пустой ответ в правдоподобное число.
Что рассуждающая модель делает с бюджетом
Рассуждающая модель думает перед тем, как ответить. Размышление — это токены, и вот главное: они расходуются из того же max_tokens, который вы задали для ответа.
Вы ставите max_tokens=4096 — и модель получает не 4096 токенов на ответ. Она получает 4096 токенов на размышление плюс ответ. Если размышление затянулось, бюджет заканчивается до того, как выдан первый символ ответа. API возвращает finish_reason: "length" и пустое тело сообщения.
Не обрезанное. Не испорченное. Пустое.
Вот что мы намерили напрямую по API на deepseek-v4-flash — модели, которая в нашем же реестре описана как «быстрая модель общего назначения», а на деле является рассуждающей:
| Промпт | max_tokens | Итог |
|---|---|---|
| Короткий, 6 сегментов | 1500 | stop, 1272 токена размышления, валидный JSON |
| Полный пакетный, 6 сегментов | 4096 | length, размышление 4095/4095, текста 0 |
| Полный пакетный, 6 сегментов | 16384 | length, размышление 16384, текста 0 |
| Продовый пакет, 10 сегментов | 10000 | length, размышление 10000, текста 0 |
Прочитайте вторую и третью строки вместе. Мы учетверили бюджет и получили тот же результат: модель растянула размышление на всё, что ей дали, и до ответа не дошла. Поднять max_tokens — не решение. Это первое, что приходит в голову, и оно не даёт ничего, кроме большего счёта.
Почему этого никто не замечает
Пустой ответ легко обработать неправильно, потому что «модель ничего не вернула» и «модель ничего не нашла» выглядят одинаково для кода, который не присматривается.
Мы нашли один и тот же дефект в двух подсистемах, и в каждой он был в своей маскировке.
Маскировка первая: ретрай, похожий на отказоустойчивость
Наш пакетный переводчик отправляет пронумерованные сегменты и ждёт обратно пронумерованный массив. Когда ответ пуст, индексы не сходятся, и код делает разумное на вид: повторяет каждый недостающий сегмент по отдельности.
И это работало. Все сегменты возвращались переведёнными. Документ получался нормальным.
А ещё это означало, что 38 сегментов из 38 уходили в поштучные вызовы на каждом документе: пакетный путь незаметно перестал быть пакетным. Ретрай так хорошо прятал поломку, что единственными видимыми симптомами были счёт и часы. Одна китайская таможенная декларация: стадия MT — 133,6 секунды, весь документ — 359 секунд, $0,0354.
После починки: стадия MT — 5,1 секунды, документ — 47 секунд, $0,00098. Та же модель, тот же документ, то же качество на выходе: числовая целостность 9 из 9 в обоих случаях. Разница в стоимости в 36 раз — целиком из токенов, потраченных на размышление, которое так и не дало ответа.
По всей системе история расходов говорит ещё резче. Та же модель отдавала в среднем 34 234 выходных токена за вызов в один день и 697 на следующий — в 49 раз меньше, — и единственным изменением была эта починка.
Маскировка вторая: фолбэк, который врёт
Второй случай стоит взять на вооружение как урок.
Наша оценка качества работает по GEMBA-MQM: модель выставляет машинному переводу баллы по типологии ошибок. Она звала тот же API без выключателя размышления и с потолком max(4096, n * 150) — то есть ровно 4096 для любого пакета меньше 27 сегментов. Значит, каждый вызов возвращал пустоту.
И каждый вызов проваливался в _create_fallback_score, который ставит нейтральные 75.
Оценка качества выглядела работающей неделями. Она выдавала баллы. Баллы были правдоподобными. Они стояли на каждом сегменте. На 75 никто не смотрел дважды — именно столько и должен получать посредственный машинный перевод.
Признаком, когда мы всё-таки посмотрели, было то, что там всегда были ровно 75. Никогда 74, никогда 78. Настоящий оценщик даёт распределение; константа — это отпечаток фолбэка.
После починки те же 14 сегментов заняли 6,1 секунды и получили тринадцать сотен и одну 96 — с настоящей находкой style/awkward при этой 96.
Фолбэк, возвращающий правдоподобную константу, хуже падения. Падение чинят во вторник. Правдоподобная константа уезжает в прод и становится числом, по которому люди принимают решения.
Как подтвердить одним запросом
Инструментирование не нужно. Отправьте свой настоящий промпт со своим настоящим max_tokens и напечатайте три вещи:
response = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": ваш_настоящий_промпт}],
max_tokens=4096,
)
choice = response.choices[0]
usage = response.usage
print("finish_reason:", choice.finish_reason)
print("content:", repr(choice.message.content))
print("токены размышления:", getattr(usage, "reasoning_tokens", None)
or getattr(usage.completion_tokens_details, "reasoning_tokens", None))
Если finish_reason равен "length", а content — None или пустая строка, и счётчик токенов размышления упёрся в ваш потолок, вы нашли дефект. Один вызов, около половины цента.
Берите настоящий промпт. Наш прекрасно отработал на коротком тестовом при 1500 токенах — это первая строка таблицы — и лёг на полном. Размышление рассуждающей модели растёт вместе с кажущейся сложностью задачи, поэтому игрушечный промпт ничего не доказывает про тот, который у вас в проде.
Лечение
Две части, и нужны обе.
Выключить размышление там, где оно ничего не покупает. Для OpenAI-совместимых провайдеров параметр — reasoning_effort: "none":
response = await client.chat.completions.create(
model=model,
messages=messages,
max_tokens=max_tokens,
reasoning_effort="none",
)
Некоторые провайдеры принимают также extra_body={"thinking": {"type": "disabled"}}. Осторожно: пространство параметров полно почти-попаданий, которые не делают ничего или делают обратное. В наших замерах reasoning_effort: "minimal" размышление, наоборот, увеличил; enable_thinking=False и reasoning.enabled=False не подействовали вовсе. Выключатель надо проверять, а не предполагать.
Сделать бюджет растущим вместе с работой. Фиксированные 4096 — это дефект, ждущий пакета побольше. У нас потолок теперь растёт с числом сегментов, и пакету из 40 сегментов не выдаётся тот же лимит, что пакету из четырёх.
Для провайдера, который о параметре не слышал, его надо ронять, а не падать: отправить, поймать 400 про неизвестное поле и повторить без него. Claude и Gemini наш параметр просто игнорируют — и это правильное поведение.
Где выключать размышление, а где нет
Выключатель — не бесплатное качество. Выключайте осознанно, по каждому месту вызова.
Выключайте на механической работе. Перевод, извлечение полей, транскрибирование, конвертация форматов — всё, где модель преобразует вход, а не принимает решение. В задаче «передай эту графу таблицы по-русски» рассуждать не о чем, и потери качества мы не намерили: числовая целостность 9 из 9 и с размышлением, и без. Выход даже стал чище — остаточных иероглифов в русском тексте стало 11 вместо 31, потому что модель, которая не задумывается до упора, скорее просто доводит работу до конца.
Оставляйте на суждении. Многошаговый разбор, неоднозначная классификация — всё, где вы хотите, чтобы модель взвесила варианты. Просто дайте бюджет, в который поместятся и размышление, и ответ.
Ловушка в том, что механическая работа обычно и есть высоконагруженный путь. То есть ровно там, где невидимый множитель стоимости в 36 раз наносит наибольший урон.
Главное
- Токены размышления и токены ответа делят один бюджет. Когда размышление его исчерпывает, вы получаете
finish_reason: "length"и пустое тело — не ошибку и не обрезку. - Поднятие
max_tokensне помогает. Мы прошли 4096 → 16384 и получили тот же пустой ответ: размышление растягивается на всё, что дали. - Пустое тело ответа — это провал бюджета, а не «результата нет». Логируйте отдельно. У нас пустое тело пишется как
[LLM_EMPTY], обрезанный JSON — как[LLM_TRUNCATED]. - Проверяйте каждое место вызова LLM, а не только то, где заметили симптом. Мы нашли один дефект дважды, с разницей в три недели, в несвязанных подсистемах. Ищите вызовы без флага размышления.
- С подозрением смотрите на фолбэки, возвращающие правдоподобную константу. Наш ставил нейтральные 75 каждому сегменту и делал сломанную оценку качества похожей на рабочую.
FAQ
Как понять, что моя модель — рассуждающая?
Не доверяйте описанию. Наша в нашем же реестре значилась как «быстрая модель общего назначения». Отправьте один настоящий запрос и посмотрите, сообщает ли объект usage токены размышления, — только этот ответ и считается.
Почему модель возвращает пустоту, а не частичный ответ?
Потому что размышление идёт первым. Модель исчерпывает бюджет до того, как начинает выдавать ответ, поэтому возвращать нечего: тело сообщения действительно пустое, а не обрезанное.
Не пострадает ли качество перевода от выключения размышления?
В наших замерах — нет. Числовая целостность совпала с ним и без него, а остаточных символов исходного письма стало даже меньше. Перевод — это преобразование, а не рассуждение. Для по-настоящему аналитических задач размышление стоит оставить и вместо этого поднять бюджет.
Что логировать, чтобы поймать это в проде?
Логируйте finish_reason и расход токенов размышления на каждом вызове и поднимайте тревогу, когда finish_reason == "length" совпадает с пустым телом. И ещё — на подозрительно постоянный выход любого оценщика: метрика, которая никогда не меняется, обычно фолбэк, а не измерение.
Касается ли это Claude и GPT?
Общий бюджет свойственен рассуждающим моделям в целом, но управление отличается у провайдеров, а некоторые параметр просто игнорируют. Диагностика везде одна: настоящий промпт, настоящий потолок, сверка finish_reason с телом сообщения.
Заключение
Этот дефект опасен именно тем, что не выглядит дефектом. Нет исключения, нет 500, нет красной линии на дашборде. Есть медленный конвейер, выросший счёт и — если не повезло — метрика, которая сообщает удобное число, ничего при этом не измерив.
Диагностика стоит одного запроса к API. Лечение — один параметр плюс бюджет, растущий с работой. Трудная часть — дисциплина: найдя это в одном месте, проверьте в тот же день все остальные вызовы и внимательно посмотрите на любой фолбэк, который возвращает что-то правдоподобное вместо того, чтобы падать громко.
KTTC переводит официальные документы по шаблонам, где потерянный код или выдуманная дата — не стилистическая мелочь, а испорченный документ. Поэтому мы так подробно измеряем собственный конвейер и публикуем то, что показывают замеры. Попробуйте KTTC и посмотрите на числа на своих документах.
