AI‑тренер по CS2 на DeepSeek: где LLM выдумывает и почему семь недель стоили $1,45. .NET.. .NET. asp.net core.. .NET. asp.net core. deepseek.. .NET. asp.net core. deepseek. Entity Framework.. .NET. asp.net core. deepseek. Entity Framework. llm.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite. Веб-разработка.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite. Веб-разработка. галлюцинации.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite. Веб-разработка. галлюцинации. Игры и игровые консоли.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite. Веб-разработка. галлюцинации. Игры и игровые консоли. Ненормальное программирование.. .NET. asp.net core. deepseek. Entity Framework. llm. SQLite. Веб-разработка. галлюцинации. Игры и игровые консоли. Ненормальное программирование. пет-проект.

Всем привет. Я много лет играю в 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

Источник