Каждую ночь у нас в JobPath запускается Celery-задача, которая берёт свежие вакансии из каталога (мы собираем их из открытых источников и Telegram-каналов) и превращает сырой текст в структурированную карточку. Изначально это делала Claude Sonnet, и делала отлично. Проблема была в цене: по прогнозу на наш объём выходило около двух тысяч долларов в месяц, и цифра росла вместе с каталогом.

В июле мы переехали на модель, которая стоит в 70 раз дешевле. Качество при этом просело с 4.48 до 4.06 по нашей шкале (запрягли Fable создать шкалу), и ниже я расскажу, почему это осознанный размен, а не утрата. А ещё про то, как дешёвые модели ломаются: пустые ответы при коде 200, поля не того типа, опечатки в именах полей из схемы, перепутанные годовые и месячные зарплаты. И про то, как один недосмотр в обработке ошибок биллинга сжёг у нас шестнадцать тысяч вызовов за ночь..

корявая пикча от клода
корявая пикча от клода

Что за задача

Вакансия из открытого источника выглядит плохо. Это может быть простыня HTML без структуры, пост из Telegram-канала с эмодзи через слово или три строки текста, из которых непонятно ничего. Чтобы по такому можно было искать и фильтровать, мы прогоняем каждую вакансию через LLM и просим вернуть структуру через tool call с жёсткой JSON-схемой:

  • краткое резюме вакансии (1-2 предложения) и секция "О позиции"

  • списки: обязанности, требования, "будет плюсом", "что предлагают"

  • навыки каноническими английскими именами (чтобы Postgres и PostgreSQL не были двумя разными скиллами)

  • оценка сложности позиции от 1 до 5 с комментарием

  • нормализованная зарплата: медиана и рыночная вилка в рублях в месяц (если в тексте её нет, модель оценивает по рынку и честно помечает это в комментарии)

Плюс ещё несколько полей, которые кормят другие фичи продукта. На выходе карточка, по которой работают фильтры, поиск и почтовые подборки.

Почему сначала была дорогая модель

Банально: когда запускали фичу, вопрос стоял "работает ли это вообще", а не "сколько это стоит". Sonnet с первой попытки выдавала аккуратные карточки, не путалась в русском и не выдумывала зарплату. Для запуска это правильный выбор: сначала доказать ценность на лучшей модели, потом оптимизировать цену. Ловушка в том, что "потом" наступает внезапно, когда каталог вырастает и счёт из строчки в биллинге превращается в статью расходов.

$0.0485 за вакансию звучит безобидно, пока не умножишь на размер каталога. При нашем объёме это складывалось в те самые $2000 в месяц.

Как выбирали замену

Собрали эвал: 50 реальных вакансий из прода, максимально разные (айтишные и не очень, с зарплатой и без, длинные простыни и огрызки в три строки). Каждую прогнали через всех кандидатов по одной и той же схеме. Результаты оценивал слепой судья: opus 4.8 получал исходный текст вакансии и две карточки без указания, какая модель их сделала, и ставил оценки от 1 до 5 по полноте, точности, качеству русского языка и адекватности зарплатной оценки.

Судья-LLM это компромисс, и я это понимаю. Вручную мы выборочно проверили около трети карточек, и в целом всё норм было. Для решения "какую модель брать" этого достаточно, для научной статьи конечно нет)

Результаты:

Модель

Цена за вакансию

Прогноз на месяц

Качество (1-5)

Вердикт

Claude Sonnet 4.6

$0.0485

~$2000

4.48

эталон, но дорого

qwen3-235b

~$0.0014

~$59

4.24

лучший русский среди дешёвых, но 52% зарплат вернул как null и путал годовую с месячной

qwen3.5-flash

$0.0007

~$30

4.06

выбрали её

qwen3.5-9b

ещё дешевле

не считали

2.76 по русскому

выдумывает факты, отсев

deepseek-v3.1

сопоставимо с flash

не считали

не дошёл до оценки

в 95 случаях из 100 вернул списки одной строкой вместо массива

Пара наблюдений из таблицы, которые стоили нам вечера удивления.

Во-первых, "лучшая дешёвая модель по качеству текста" и "лучшая модель для продакшена" это разные номинации. У qwen3-235b русский язык действительно лучше, чем у flash. Но модель, которая в половине случаев не может извлечь зарплату, а в остальных иногда путает 3 миллиона в год с 3 миллионами в месяц, в прод не поедет, какой бы красивый текст она ни писала.

Во-вторых, у дешёвых моделей ломается не интеллект, а дисциплина. Deepseek прекрасно понимал вакансии. Он просто упорно игнорировал схему и возвращал requirements строкой с буллетами вместо массива строк. Формально это тоже ответ. Фактически это сломанный пайплайн

Грабли номер один: шлюзы и агрегаторы

К моделям мы ходим через агрегаторы (для qwen это OpenRouter, часть трафика идёт через ещё один шлюз). Это удобно: один API-диалект, единый биллинг, смена модели это правка одной переменной окружения. Но у этой прослойки есть свои режимы отказа, и они противные, потому что не похожи на ошибки.

Самое неприятное, что мы ловили: код ответа 200, tool call на месте, имя тула правильное, а input пустой. Это не 5xx, ретраи на уровне HTTP-клиента не срабатывают, ошибки нет нигде. Просто в поле, где должна быть карточка вакансии, лежит пустой объект. Причём одиночные запросы руками проходили, а в батче через раз приходила пустота. Лечится только ретраем на уровне бизнес-логики: обёртка вокруг вызова проверяет, что input непустой и обязательные поля на месте, и повторяет запрос несколько раз с паузой.

Второй сюрприз: поле приходит не того типа, который объявлен в схеме. У нас difficulty это объект со score и комментарием. Иногда вместо объекта приходила строка. Код делал data["difficulty"].get("score") и падал с 'str' object has no attribute 'get'. Отдельно обидно, что падал он внутри цикла записи батча, и одна кривая вакансия убивала всю пачку. Теперь каждое поле проходит через isinstance-проверку с приведением типа, а каждая вакансия в батче обёрнута в свой try/except: одна сломалась, остальные доехали.

И третье, уже из области эксплуатации: протухший ключ одного из шлюзов отвечал не "ключ невалиден", а 502 и 529 "Overloaded". Мы полчаса думали, что у провайдера инцидент, и ждали, пока рассосётся. Не рассосалось: это был мёртвый ключ, который маскировался под перегрузку. С тех пор правило простое: если "инцидент" у провайдера длится дольше 15 минут и не подтверждается его статус-страницей, проверяй ключ и биллинг.

Грабли номер два: защитный слой вокруг дешёвой модели

Flash оказалась рабочей лошадкой, но вокруг неё пришлось выстроить слой защиты, который с Sonnet был не нужен. Все пункты ниже это реальные кейсы из эвала и первых недель в проде, не теоретизирование.

Списки строками. Та же болезнь, что у deepseek, только реже: вместо массива приходит одна строка с переносами и буллетами. Дописали коэрсию: строка с буллетами разбивается в массив на нашей стороне.

Опечатки в именах полей. В схеме поле называется rationale. Модель иногда возвращала rational. Схема жёсткая, additionalProperties запрещены, и по-хорошему такой ответ невалиден. Но ронять вакансию из-за одной буквы жалко, поэтому парсер знает частые опечатки.

Зарплатный кап. Модель путает годовую зарплату с месячной, и тогда в карточке появляется вилка "от 3 500 000 ₽ в месяц" на позицию джуна. Теперь любая месячная цифра выше 3.5 миллионов рублей считается подозрительной: сначала ретрай с уточнением, если не помогло, зарплата в карточке скрывается. Лучше честное "не определили", чем уверенная чушь.

Деградация вместо падения. Если после всех ретраев обязательные поля так и не собрались, мы записываем частичную карточку и помечаем её. Полкарточки лучше, чем ничего: фильтры по скиллам работают, а зарплатный блок появится после следующего прогона.

Ошибки биллинга останавливают батч немедленно. Это самый дорогой урок. Ночью у одного из наших LLM-провайдеров закончился баланс. Задача обработки восприняла это как обычную ошибку конкретного вызова: ретрай, следующая вакансия, ретрай, следующая. К утру счётчик показал около шестнадцати тысяч заведомо мёртвых вызовов. Денег они, к счастью, не стоили (баланса же нет), но батч был сожжён впустую, а очередь распухла. Теперь ошибка класса "payment required" или "insufficient balance" не ретраится, а роняет весь батч сразу и будит мониторинг.

Fail-open или fail-closed: решите до инцидента, а не после

Отдельная история про то, как ведёт себя система, когда LLM недоступна. У нас, кроме обогащения каталога, есть фильтр релевантности в авто-откликах: пользователь описывает, что ему нужно ("только маркетинг, без продаж"), и модель оценивает каждую вакансию перед откликом.

Когда мы переводили этот фильтр на дешёвый тир другого провайдера, тот начал иногда отдавать 503. А фильтр был написан в логике fail-open: недоступна модель, значит пропускаем вакансию дальше. В обычной жизни это незаметно. В инцидент это превратилось в кампанию, которая при фильтре "только маркетинг" бодро откликалась на закупщиков и продажников. Пользователь видел это как "сервис сошёл с ума", и был прав.

Починили тремя слоями: ретрай, затем фолбэк на альтернативного провайдера, и только если недоступны оба, фильтр закрывается (fail-closed): вакансия пропускается со статусом "фильтр недоступен" и будет переоценена в следующем прогоне. Никакого действия от имени пользователя без работающего фильтра.

Общее правило, которое мы из этого вынесли: для каждого LLM-вызова в проде должен быть письменный ответ на вопрос "что происходит, когда модель недоступна". Если вызов только читает (обогащение каталога), fail-open допустим. Если вызов приводит к действию от имени пользователя, только fail-closed.

Что на дешёвую модель не переехало

Чтобы не создать впечатление, что дешёвые модели решают всё.

Живые фичи. Flash формально поддерживает стриминг, но фактически это псевдо-стрим: первый токен приходит секунд через десять, а потом всё вываливается разом. Для ночного батча безразлично, для интерактивных сценариев неприемлемо. Всё, где пользователь смотрит на экран и ждёт, осталось на моделях с настоящим стримингом.

Флагманские фичи. Там, где качество текста и глубина разбора это лицо продукта (у нас так устроен анализ резюме), модель остаётся топовой. Экономить на таких вызовах значит экономить на том, ради чего люди приходят.

Реалтайм-аудио. У агрегаторов нет ни батч-API, ни реалтайм-вебсокетов, так что речевые фичи живут напрямую у своих провайдеров.

Кстати, страх "прослойка добавит задержку" не подтвердился: мы замеряли одинаковую модель напрямую и через OpenRouter, разница по времени первого токена была в пределах погрешности (0.88 секунды через прослойку против 1.06 напрямую в одном из замеров, то есть иногда через прослойку даже быстрее). Комиссия при пополнении 5.5%, на нашем объёме это копейки.

Итоговая арифметика

Первый полный ночной батч на новой модели: выбрано около тысячи вакансий, обогащены все, ошибок ноль, время 29 минут, стоимость около 70 центов. Месяц обходится примерно в $30 против прогнозных ~$2000 на Sonnet при том же объёме.

Качество по слепому судье просело с 4.48 до 4.06. В переводе на человеческий: карточки стали чуть суше, реже встречаются удачные формулировки в резюме вакансии, изредка модель кладёт в "требования" то, что по-хорошему относится к "будет плюсом". Для карточки в каталоге, которую человек сканирует три секунды, это приемлемо. Косвенное подтверждение: после переезда в поддержку не пришло ни одной жалобы на карточки. Пользователи разницы не заметили, счёт заметил.

Выводы

  1. Мигрируйте не "на глазок", а через эвал на своих данных со слепым судьей, фронтир модели для этого отлично подходят. 50 примеров и вечер работы уже дают уверенное решение

  2. У дешёвых моделей ломается дисциплина формата, а не понимание. Значит, вокруг них нужен слой из ретраев, коэрсии типов и валидации значений. Закладывайте на этот слой время: у нас он занял больше времени, чем сама миграция

  3. Пустой tool input при коде 200 это штатный режим отказа, а не экзотика. Ретраить нужно на уровне бизнес-логики, HTTP-ретраи этого не видят

  4. Ошибки биллинга это отдельный класс: они не ретраятся, они останавливают всё

  5. Ответьте письменно на вопрос "что делает система, когда модель лежит" для каждого вызова. Fail-open для чтения, fail-closed для действий от имени пользователя

  6. Дорогая модель на старте это правильно. Неправильно забыть поставить в календарь день, когда вы вернётесь и посчитаете траты)

Если вы гоняли похожие миграции: интересно, чем вы меряете качество структурной выдачи, кроме судьи-LLM, и попадались ли вам ещё режимы отказа агрегаторов, которых нет в этом списке. Расскажите в комментариях, соберём общую карту граблей :)