Skip to main content

Рассуждающая модель молча съедает ваш ответ

Алекс Чен15.09.20268 min read
llm-инженериярассуждающие-моделиdeepseekбюджет-токеновкачество-машинного-перевода

Провайдер подменил модель на рассуждающую, а ваш конвейер по-прежнему отвечает 200 OK. Ничего не падает. В логах спокойно. И каждый ответ тихо пуст.

Мы поймали это дважды в одной системе, с разницей в три недели, в двух местах, никак между собой не связанных. Ниже — как выглядит симптом, как подтвердить его одним запросом к API и в чём состоит настоящее лечение. Включая ту часть, которую почти никто не проверяет: фолбэк, превращающий пустой ответ в правдоподобное число.

Что рассуждающая модель делает с бюджетом

Рассуждающая модель думает перед тем, как ответить. Размышление — это токены, и вот главное: они расходуются из того же max_tokens, который вы задали для ответа.

Вы ставите max_tokens=4096 — и модель получает не 4096 токенов на ответ. Она получает 4096 токенов на размышление плюс ответ. Если размышление затянулось, бюджет заканчивается до того, как выдан первый символ ответа. API возвращает finish_reason: "length" и пустое тело сообщения.

Не обрезанное. Не испорченное. Пустое.

Вот что мы намерили напрямую по API на deepseek-v4-flash — модели, которая в нашем же реестре описана как «быстрая модель общего назначения», а на деле является рассуждающей:

Промптmax_tokensИтог
Короткий, 6 сегментов1500stop, 1272 токена размышления, валидный JSON
Полный пакетный, 6 сегментов4096length, размышление 4095/4095, текста 0
Полный пакетный, 6 сегментов16384length, размышление 16384, текста 0
Продовый пакет, 10 сегментов10000length, размышление 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", а contentNone или пустая строка, и счётчик токенов размышления упёрся в ваш потолок, вы нашли дефект. Один вызов, около половины цента.

Берите настоящий промпт. Наш прекрасно отработал на коротком тестовом при 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 и посмотрите на числа на своих документах.

We use cookies to improve your experience. Learn more in our Cookie Policy.