Всем привет. Я много лет играю в Counter‑Strike и тренируюсь, как большинство: захожу в Deathmatch «размяться», играю три катки, тильтую, выхожу. Сервисы статистики при этом исправно сообщают, что у меня просела точность и выросло время реакции. Спасибо, а делать‑то что?
Весной я решил собрать себе тренера сам. Сразу пара слов для контекста: я самоучка, в команде никогда не работал, основной опыт — десктопные утилиты и автоматизация, из веба до этого были только админки под собственные инструменты. В CS тоже звёзд с неба не хватаю, Premier в районе трёх тысяч, на FACEIT вообще первый уровень. Так что история будет не про успешный успех, а про то, где я наступил на грабли и что из этого вынес.
Сам сайт делает вот что: подтягивает историю матчей, отдаёт агрегированную статистику LLM, а та разбирает сильные и слабые места и составляет недельный план тренировок, с конкретными картами, режимами и минутами. Отметил выполненное, сыграл новые матчи — план подстроился.
Проект мой, ссылка будет одна и в конце. Статья состоит из трёх частей: сколько это всё стоит в токенах, где модель выдумывает и как я это ловлю кодом, ну и подводные камни DeepSeek API и связки EF Core + SQLite.
Что за штука
Коротко, чтобы был контекст, дальше пойдёт техника. Петлю я описал выше, добавлю только, что следующий цикл учитывает не просто «сделал/не сделал», а почему пропустил: устал, не было времени, заболел. Отдельно живут мелочи вроде «стоит ли тебе сегодня идти в матчмейкинг, или ты уже наиграл пару каток в минус и лучше закрыть клиент».
Стек без сюрпризов: ASP.NET Core 9 (MVC), EF Core, DeepSeek в роли мозга, обычный дешёвый VPS. База — SQLite, и выбрал я её не из архитектурных соображений, а из экономических: изначально стоял MSSQL в докере, но для пет‑проекта он оказался слишком прожорливым по ресурсам. Переезд занял день и подарил один хороший подводный камень, про него дальше.
Ну и одно решение я принял сразу: в LLM уходят только агрегаты, никогда — сырые матчи. Во‑первых, дёшево. Во‑вторых, на длинных сырых данных модель начинает теряться в числах, и качество разбора от этого только падает.
Сколько всё это стоит
Учёт токенов я включил в начале июня. С тех пор набежало: семь недель, четыре игрока (включая меня), около 350 вызовов LLM и 2,8 млн токенов.
Итого по прайсу DeepSeek: $1,45. За всё, на всех.
Жирный плюс DeepSeek — кэш промптов: кэшированный вход дешевле обычного примерно на два порядка, а системные промпты у меня длинные и стабильные, так что кэш попадает часто. Самая дорогая операция — генерация недельного плана, там самый большой промпт и самый длинный структурированный ответ, на неё пришлась примерно половина счёта.
Вывод скучный, но запишу: если не делать глупостей (агрегаты вместо сырых данных, кэш результатов в БД, перегенерация по событиям, а не кроном каждые пять минут), то расходы на LLM в пет‑проекте можно вообще не считать. Основные деньги уходят на VPS и домен.
Где LLM выдумывает и как это ловить кодом
Главное, что я понял за эти месяцы: галлюцинации — это не какой‑то редкий сбой, это фон, и строить систему приходится с оглядкой на этот факт. Расскажу четыре истории.
Оценка того, чего нет
Ранние версии анализа выдавали игроку оценки по навыкам, среди которых были «коммуникация» и «экономика». Данных про голосовой чат у меня нет никаких, их не отдаёт ни один источник. Модель это не смущало: она уверенно ставила шестёрку из десяти и советовала «чаще давать инфу тиммейтам». Звучит правдоподобно, придраться невозможно, проверить нечем.
Убрал эти оси целиком: если по метрике нет данных, у модели не должно быть самого поля в схеме ответа. Просить «не оценивай то, чего не знаешь» бесполезно, LLM не говорит «не знаю», пока у неё есть техническая возможность сказать что‑нибудь.
Метрика‑призрак
Эта история дороже. В интеграции с источником статистики с первого дня жило поле KAST (процент раундов, где игрок был полезен). Колонка в БД была, в отчётах модель про KAST рассуждала, на дашборде висела карточка. А потом я полез в прод посмотреть распределение: 527 матчей — 527 NULL.
API источника эту метрику не отдаёт. Вообще. Никогда не отдавал: колонка в БД и поле в моделях появились «на вырост» ещё при первой интеграции, парсер писал туда честный null, а дальше все, включая меня и модель, считали метрику настоящей. Модель, впрочем, отсутствием данных не смущалась, смотри историю выше.
Вычистил всю цепочку от парсера до UI. Теперь перед тем как подключать новую метрику, лезу в прод и смотрю, лежит ли в колонке хоть что‑то кроме NULL.
Доказательства с выдуманными цифрами
Чтобы анализ не превращался в гороскоп, каждый тезис в отчёте обязан опираться на конкретное число из статистики: «время реакции 650 мс при ориентире твоего ранга 600». И вот тут начинается интересное: модель охотно цитирует числа, которых во входе не было. 620 вместо 650 — округлила, усреднила два соседних значения, приснилось. Выглядит ровно так же убедительно, как настоящее число, и глазами это не ловится в принципе.
Решение концептуально простое: ответ модели проходит проверку кодом, и каждое число из «доказательства» обязано существовать во входных данных. Тезис с числом‑фантомом выбрасывается целиком, пусть лучше в разборе будет на один пункт меньше, чем пункт с враньём. Реализацию оставлю за кадром, скажу только, что наивные варианты сверки разваливаются быстрее, чем кажется.
Тренд из трёх матчей
Дай модели два числа, и она увидит динамику. Если игрок сыграл три матча за неделю, сравнивать «до и после» статистически бессмысленно, но модель сравнит с удовольствием и напишет «заметен уверенный рост». Запретить это промптом можно, работает через раз. Надёжно работает другое: если данных мало, дельта не вычисляется вовсе и во вход не попадает.
Общий принцип: LLM предлагает, код решает
К этому сводятся все четыре истории. Финальную версию и анализа, и плана всегда собирает детерминированный код: ответ модели — черновик, который проходит валидацию, чинится и дополняется жёсткими правилами. Если модель дважды подряд не справилась, есть скучный запасной вариант вообще без AI: план соберётся по статичному шаблону, а вместо свежего анализа повиснут прошлые кэшированные оценки с плашкой «AI временно недоступен». Продукт продолжает работать и не зависит от того, с какой ноги сегодня встала модель.
Подводные камни
За время работы с DeepSeek и после переезда на SQLite я насобирал камней, которые наверняка ждут кого‑то из читателей. Пробежимся.
Параметр, который молча не работал
У DeepSeek есть управление глубиной рассуждений — reasoning_effort. Я положил его туда, куда подсказывала интуиция, внутрь объекта thinking, рядом с включением самого режима:
{
"model": "...",
"thinking": { "type": "enabled", "reasoning_effort": "max" }
}
API принял запрос, вернул двести, всё работало. Три недели спустя выяснилось: параметр должен лежать на верхнем уровне запроса, а неизвестные поля внутри thinking молча игнорируются. Всё это время модель рассуждала на дефолтной глубине.
{
"model": "...",
"thinking": { "type": "enabled" },
"reasoning_effort": "max"
}
Отсутствие ошибки ничего не говорит о том, что параметр применился, строгой валидации схемы запроса у большинства LLM‑API попросту нету. Проверять надо по эффекту: длина reasoning‑блока, латентность, качество ответа.
Пустой ответ при непустом счёте
Симптом из прода: пользователю приходит AI‑разбор матча, в котором нет разбора, шапка со статистикой есть, текста нет. В логах при этом успешный вызов и списанные токены.
Причина: у reasoning‑моделей токены на «подумать» и токены на ответ — один бюджет. Стоял max_tokens: 8000; с включённым thinking модель успевала израсходовать всё на рассуждения, упиралась в лимит с finish_reason: length, и content приезжал пустой строкой.
Фикс получился мальца в лоб, но рабочий: поднять лимит до потолка (у DeepSeek это 32k, при их ценах жадничать бессмысленно), а если content всё‑таки пришёл пустым — ретраить запрос уже без thinking. Ответ получается попроще, зато он есть.
LINQ, который компилируется, но не выполняется
Обещанный камень с переезда MSSQL → SQLite. Вот запрос, который на MSSQL работал бы, — я написал его уже после переезда, рукой, привыкшей к MSSQL:
var used = ctx.Plans
.Where(p => p.PlayerId == playerId)
.SelectMany(p => p.Items.Select(i => new { p.WeekStart, i.DrillId }));
Коррелированный SelectMany с проекцией поля из внешней сущности превращается в APPLY, которого в SQLite нет. Лечится разворотом запроса от дочерней таблицы через reference‑навигацию:
var used = ctx.PlanItems
.Where(i => i.Plan.PlayerId == playerId)
.Select(i => new { i.Plan.WeekStart, i.DrillId });
Шкала, которая убила фичу молча
Источник статистики отдаёт ключевой рейтинг в единицах, в сто раз меньших, чем показывает пользователю его же сайт. Я это знал и в одном месте конвертировал. А в другом написал порог падения в пользовательских единицах и сравнил его с сырым значением из БД. Условие получилось в сто раз жёстче задуманного.
Фича — детект тильта, который советует сделать паузу после серии провальных каток, — выглядела законченной, компилировалась и месяц не срабатывала ни разу. Тестов на неё, по иронии, тогда не было, а один‑единственный тест на реальном значении из прода поймал бы подмену за минуту. Нашлось в середине июня, когда я сел сверять документацию с кодом, глазами, строчка за строчкой. С тех пор конверсия единиц на границе чужого API живёт в одном месте с внятным именем, а тесты на пороги берут реальные значения из прода.
Что не получилось
Корреляция «тренировался → стал играть лучше» по‑хорошему требует контрольной группы и объёмов статистики, которых у обычного игрока просто нет. Ранняя версия рисовала красивые дельты по любым данным; когда я ужесточил методику, страница у большинства игроков опустела. Оставил как есть, показывать шум, выдавая его за сигнал, было бы возвращением к гороскопам, с которых всё начиналось.
Температура у анализа не нулевая, и пока я не объяснил системе, что разница между двумя прогонами на одних данных — это шум, а не «изменение результатов», она порывалась радостно уведомлять игроков об «изменившейся оценке» после каждой перегенерации.
Ну и главное: игроков четыре. Про рост, ретеншн и прочие продуктовые материи рассказывать пока нечего, если через полгода будет что, напишу продолжение.
Заключение
LLM в проде пет‑проекта — это дёшево и совсем не страшно, если принять одно правило: модель никогда не разговаривает с пользователем напрямую. Завёл бы его с первого дня — сэкономил бы месяц чисток.
Сайт — drillmaster.pro, проект мой, бесплатный. Вход через Steam.
Вопросы в комментариях приветствуются, про LLM‑часть, стек и подводные камни отвечу подробно. Спасибо за внимание.
Автор: Cherep111


