Каждую ночь у нас в 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. В переводе на человеческий: карточки стали чуть суше, реже встречаются удачные формулировки в резюме вакансии, изредка модель кладёт в «требования» то, что по-хорошему относится к «будет плюсом». Для карточки в каталоге, которую человек сканирует три секунды, это приемлемо. Косвенное подтверждение: после переезда в поддержку не пришло ни одной жалобы на карточки. Пользователи разницы не заметили, счёт заметил.
Выводы
-
Мигрируйте не «на глазок», а через эвал на своих данных со слепым судьей, фронтир модели для этого отлично подходят. 50 примеров и вечер работы уже дают уверенное решение
-
У дешёвых моделей ломается дисциплина формата, а не понимание. Значит, вокруг них нужен слой из ретраев, коэрсии типов и валидации значений. Закладывайте на этот слой время: у нас он занял больше времени, чем сама миграция
-
Пустой tool input при коде 200 это штатный режим отказа, а не экзотика. Ретраить нужно на уровне бизнес-логики, HTTP-ретраи этого не видят
-
Ошибки биллинга это отдельный класс: они не ретраятся, они останавливают всё
-
Ответьте письменно на вопрос «что делает система, когда модель лежит» для каждого вызова. Fail-open для чтения, fail-closed для действий от имени пользователя
-
Дорогая модель на старте это правильно. Неправильно забыть поставить в календарь день, когда вы вернётесь и посчитаете траты)
Если вы гоняли похожие миграции: интересно, чем вы меряете качество структурной выдачи, кроме судьи-LLM, и попадались ли вам ещё режимы отказа агрегаторов, которых нет в этом списке. Расскажите в комментариях, соберём общую карту граблей 🙂
ссылка на оригинал статьи https://habr.com/ru/articles/1061466/