- BrainTools - https://www.braintools.ru -

AI‑тренер по CS2 на DeepSeek: где LLM выдумывает и почему семь недель стоили $1,45

Всем привет. Я много лет играю в Counter‑Strike и тренируюсь, как большинство: захожу в Deathmatch «размяться», играю три катки, тильтую, выхожу. Сервисы статистики при этом исправно сообщают, что у меня просела точность и выросло время реакции [1]. Спасибо, а делать‑то что?

Весной я решил собрать себе тренера сам. Сразу пара слов для контекста: я самоучка, в команде никогда не работал, основной опыт [2] — десктопные утилиты и автоматизация, из веба до этого были только админки под собственные инструменты. В CS тоже звёзд с неба не хватаю, Premier в районе трёх тысяч, на FACEIT вообще первый уровень. Так что история будет не про успешный успех, а про то, где я наступил на грабли и что из этого вынес.

Сам сайт делает вот что: подтягивает историю матчей, отдаёт агрегированную статистику LLM, а та разбирает сильные и слабые места и составляет недельный план тренировок, с конкретными картами, режимами и минутами. Отметил выполненное, сыграл новые матчи — план подстроился.

Проект мой, ссылка будет одна и в конце. Статья состоит из трёх частей: сколько это всё стоит в токенах, где модель выдумывает и как я это ловлю кодом, ну и подводные камни DeepSeek API и связки EF Core + SQLite.

Что за штука

Коротко, чтобы был контекст, дальше пойдёт техника. Петлю я описал выше, добавлю только, что следующий цикл учитывает не просто «сделал/не сделал», а почему пропустил: устал, не было времени, заболел. Отдельно живут мелочи вроде «стоит ли тебе сегодня идти в матчмейкинг, или ты уже наиграл пару каток в минус и лучше закрыть клиент».

Стек без сюрпризов: ASP.NET Core 9 (MVC), EF Core, DeepSeek в роли мозга [3], обычный дешёвый 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. Я положил его туда, куда подсказывала интуиция [4], внутрь объекта thinking, рядом с включением самого режима:

{
  "model": "...",
  "thinking": { "type": "enabled", "reasoning_effort": "max" }
}

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

{
  "model": "...",
  "thinking": { "type": "enabled" },
  "reasoning_effort": "max"
}

Отсутствие ошибки [5] ничего не говорит о том, что параметр применился, строгой валидации схемы запроса у большинства 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 [6], проект мой, бесплатный. Вход через Steam.

Вопросы в комментариях приветствуются, про LLM‑часть, стек и подводные камни отвечу подробно. Спасибо за внимание [7].

Автор: Cherep111

Источник [8]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/33501

URLs in this post:

[1] реакции: http://www.braintools.ru/article/1549

[2] опыт: http://www.braintools.ru/article/6952

[3] мозга: http://www.braintools.ru/parts-of-the-brain

[4] интуиция: http://www.braintools.ru/article/6929

[5] ошибки: http://www.braintools.ru/article/4192

[6] drillmaster.pro: http://drillmaster.pro

[7] внимание: http://www.braintools.ru/article/7595

[8] Источник: https://habr.com/ru/articles/1062718/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1062718

www.BrainTools.ru

Rambler's Top100