«Перепишите темы писем — открываемость нулевая». Совет выглядел разумно, и цифра под ним была настоящая: открытий 0. Модель не ошиблась в арифметике и не сломалась на разборе ответа — ей дали число, у которого другой смысл. Ноль означал не «письма не открывают», а «открытия никто не измеряет»: поле заполняется один раз при отправке и больше не обновляется, потому что трекинга открытий в продукте нет вообще. Совет вышел связным, уверенным и выдуманным — и по тексту это не видно.
Зачем эта статья
Мы могли бы написать «мы прикрутили ИИ к аналитике». Таких текстов сотня, и они друг от друга неотличимы. Писать стоило про другое: когда мы подключали продуктовые метрики к языковой модели, почти вся боль оказалась не в модели, не в промпте и не в выборе провайдера, а в том, что числа, технически безупречные, означали не то, что думал о них читающий их код. Такой дефект не ловится ни падением, ни проверкой схемы: модель не жалуется, а выдаёт связный, уверенный и неправильный совет, по тексту неотличимый от правильного.
Задача «подключить продуктовую аналитику к LLM» сейчас массовая, а разбора именно этих граблей почти нигде нет. Поэтому статья не про архитектуру и не про выбор модели, а про шесть конкретных случаев, каждый из которых у нас случился, и про то, как мы каждый закрыли. Плюс неудобная часть: два дефекта поймали тесты, один дожил до продакшена, и почему так вышло.
Что за система. Кросс-канальные AI-рекомендации в маркетинговой платформе: раз в сутки сервис собирает цифры из пяти источников в один JSON, кладёт его в промпт и получает назад карточки с советами. Знание ML не нужно — его в статье нет; знание API и SQL предполагается.
Сбор, нормализация, анализ
Три шага. Сбор — пять источников пишут в базу датированные строки. Нормализация — превращение этих строк в числа, у которых один смысл. Анализ — один JSON уходит в промпт, ответ разбирается в карточки. Сбор и анализ пишутся один раз и дальше почти не меняются; все шесть дефектов сидят в середине.
Формы строк у источников разные, и это не деталь интеграции — это то, из чего растут ошибки.
TikTok через Display API отдаёт только пожизненные счётчики: строка в базе означает «всего с начала времён на такую-то дату», а не результат этого дня.
Instagram — дневной ряд есть только у охвата, остальные метрики приходят одним значением за весь период.
Google Play — выгрузка CSV из облачного хранилища, где дни без данных заполнены нулями-заполнителями.
Search Console — шесть запросов к Google прямо в момент обращения к нашему эндпоинту, с кешем на час; его окно кончается за два дня до сегодня.
Собственные таблицы — посетители, воронка, UTM-метки, позиции по ключевым словам, письма.
Сверху ложатся троттлинг по тарифу и своя частота крона у каждого канала, так что даже «сегодняшние» строки в базе имеют разный возраст.
Три вида чисел: поток, уровень, ставка
Каждая метрика относится к одному из трёх видов, и вид отвечает сразу на два вопроса: как считать и какие строки при этом можно читать. Второй вопрос легко потерять из виду — на нём мы и сломались.
Поток — установки за месяц, просмотры постов, клики. Считается суммой, и читать можно только строки внутри окна отчётности: за его пределами это уже другой период.
Уровень — подписчики, активная база устройств, средний рейтинг. Считается как последнее известное значение, и читать нужно всю историю: уровень не перестаёт существовать оттого, что окно сдвинулось.
Ставка — доля сбоев, CTR, вовлечённость. Тоже последнее известное значение; складывать проценты бессмысленно.
Дальше — шесть случаев, где это различие либо не было проведено, либо было проведено правильно и применено не к тому вопросу. Сначала коротко, что получалось на выходе:
| Что попало в дайджест | Что отвечает модель |
|---|---|
| Кумулятивные снимки, сложенные как дневные значения | Рост в разы больше реального |
| Позиция в выдаче как есть | С 20-й на 4-ю читается как ухудшение |
| opens: 0 там, где трекинга открытий нет | «Перепишите темы писем» под несуществующую проблему |
| Уровень, найденный только внутри окна | «У приложения нет пользователей» — у живого приложения |
| Клики поиска против визитов сайта за одни даты | «Обвал поискового трафика» вместо задержки отчётности |
| 0 вместо null | Выводы из данных, которых никто не собирал |
Ловушка 1. Пожизненные счётчики
Что в данных. TikTok отдаёт нарастающий итог. Если у аккаунта 1000 просмотров, завтра в строке будет 1200, послезавтра 1250 — и каждая строка содержит всю историю целиком, а не прирост за день.
Что получалось. Сумма таких строк складывает не приросты, а тридцать копий всей истории аккаунта. Месяц с реальным приростом в несколько сотен превращался в десятки тысяч, и модель писала про взрывной рост и советовала удвоить ставку на канал.
Как починили. Показатель за период считается как последний снимок минус первый, а не суммой строк. Отдельная оговорка про историю: снимки начинаются с даты подключения аккаунта, раньше их просто нет. Поэтому если аккаунт подключили в середине окна, честный ответ — прирост с даты подключения, а не с начала окна; дозаполнить прошлое неоткуда, прошлые дни TikTok не отдаёт.
У Instagram та же ловушка с другой стороны: раз дневной ряд есть только у охвата, суммирование остальных строк складывает одно и то же значение за период по нескольку раз. Поэтому вовлечённость мы считаем не по снимкам аккаунта, а по строкам постов: у поста есть дата публикации и свои реакции — это честный поток внутри окна.
Ловушка 2. Позиция в выдаче
Что в данных. Позиция — редкая метрика, где меньше значит лучше: первое место это 1, двадцатое — 20.
Что получалось. Пара «было 20, стало 4» без пояснений читается как падение: число уменьшилось, значит, стало хуже. Модель сочувственно предлагала спасать продвижение, которое как раз сработало.
Как починили. В дайджест едет не позиция, а набранные позиции: одно число +16 вместо пары. Направление зашито в саму величину, и угадывать модели нечего. Плюс отдельная строка в промпте о том, что меньшее число позиции — это лучше.
Там же чинится масштаб CTR. Search Console отдаёт долю, 0.032, а в соседнем блоке дайджеста, из собственных таблиц, лежат проценты, 3.2. Одно и то же поле в двух блоках в двух масштабах — это готовый и арифметически безупречный вывод «CTR упал в сто раз». Решение элементарное: приводим всё к процентам на входе, до того как оба числа встретятся в одном JSON.
Ловушка 3. Метрика, которая всегда ноль
Что в данных. Поле со статистикой письма заполняется один раз при отправке и больше не трогается. Открытия в продукте не отслеживаются вообще.
Что получалось. Случай из начала статьи: уверенный совет переписать темы писем ради открываемости, которую никто не измеряет.
Как починили. Не подставлять ноль, не подставлять среднее по рынку и вообще не передавать число. У блока писем стоит флаг openTracking: false, а в промпте написано, что этот флаг означает. После этого модель даёт единственный уместный совет — завести трекинг открытий — вместо того чтобы лечить проблему, которой никто не наблюдал.
Ловушка 4. Уровень, прочитанный внутри окна
Самый дорогой из шести и единственный, которого не поймали тесты.
Что в данных. Выгрузка Google Play по одному проекту оборвалась за месяц до отчётного окна. Внутри тридцатидневного окна остались только строки-заполнители:
| Дата | installs | activeDeviceInstalls | averageRating |
|---|---|---|---|
| 2026-07-29 | 0 | 0 | 0 |
| 2026-07-28 | 0 | 0 | 5 |
Что получалось. Средний рейтинг разрешился верно, в 5: логика «промотать нули назад и взять последнее ненулевое» отработала как задумано. А активная база устройств разрешилась в 0 — её ненулевые показания, доходившие до 15, лежали чуть раньше границы окна, и внутри окна их не было ни одного. Модель получила живое приложение с нулём пользователей и ответила ровно так, как на это следует отвечать: советом запускать то, что уже опубликовано.
В чём была ошибка. Различие «поток против уровня» здесь было проведено, и проведено правильно. Оно определяло способ расчёта: потоку сумма, уровню последнее значение. Но выборка строк осталась общей на оба случая — из окна. То есть код спрашивал «последнее значение среди строк окна», хотя правильный вопрос — «последнее значение вообще». Правило было верным, а его область действия — нет. Заметить это трудно именно потому, что правило уже написано и выглядит правильным.
Как починили. Поиск последнего значения уровня больше не ограничен окном: он идёт по всей доступной истории метрики и возвращает последнее ненулевое измерение, сколько бы дней назад оно ни было. Окно осталось там, где ему и место, — на потоках.
Ловушка 5. Задержка отчётности
Что в данных. Search Console не отдаёт последние два дня: на стороне Google они ещё не досчитаны.
Что получалось. Если положить рядом клики из поиска и посетителей сайта за одни и те же даты, в конце периода зияет дыра. По числам это обвал поискового трафика, и модель честно про него писала — притом что трафик никуда не девался.
Как починили. Задержку не спрятать, но её можно передать как данные. В блоке поиска едет поле lagDays — на сколько дней окно не доходит до сегодня, — а промпт прямо запрещает сравнивать эти два ряда по датам и делать из разрыва вывод об обвале.
Ловушка 6. null и ноль
Что в данных. Любое место, где мы подставляли 0, чтобы не ломать схему JSON.
Что получалось. Ноль — это результат измерения, null — его отсутствие. После схлопывания в одно поле различить их уже невозможно, а разница между «мы измерили, и там ноль» и «мы этого не измеряли» переворачивает вывод.
Как починили. Правило без исключений: неизмеренное едет как null, и в промпте написано, что null означает «не знаем», а не «ноль». Ловушка 3 — частный случай этого правила, доведённый до отдельного флага; деградация в конце статьи — тот же случай для сбоя сети.
Шесть решений в одном месте
| Ловушка | Как закрыли |
|---|---|
| Пожизненные счётчики TikTok | Последний снимок минус первый, а не сумма строк |
| Позиция в выдаче | В дайджест едут набранные позиции: +16 вместо пары чисел |
| Открытия писем, которых не измеряют | Вместо числа флаг openTracking: false |
| Уровень, прочитанный внутри окна | Поиск последнего значения снят с окна и идёт по всей истории |
| Задержка отчётности Search Console | Поле lagDays плюс запрет в промпте сравнивать ряды по датам |
| 0 вместо null | Неизмеренное едет как null, и в промпте сказано, что null не ноль |
Общее у всех шести стоит проговорить отдельно: ни одно решение не про модель. Ни промпт получше, ни модель поумнее ни один из этих случаев не чинят. Везде правится ровно одно — какое число уезжает в JSON.
Куда это вынести в коде
Первое: вся арифметика метрик уехала в чистые модули без доступа к базе. На входе строки, на выходе готовый блок дайджеста. Смысл не в архитектурной красоте, а в том, что такую функцию можно тестировать на семантику, а не на расчёт. Полезный тест здесь звучит не «сумма трёх чисел равна их сумме», а «два одинаковых замера — это измеренный нулевой рост, а один замер — отсутствие измерения, и это разные ответы».
Второе: примерно половина системного промпта — не постановка задачи, а правила чтения чисел. Не «дай рекомендации по маркетингу», а объяснение, что означают поля в JSON. Три правила оттуда дословно:
Ни одно из них не написано из предусмотрительности. Каждое появилось после того, как мы прочитали соответствующий уверенный неправильный ответ.
Третье, помельче: списки в дайджесте урезаны до пяти позиций, чтобы промпт не распухал; аккаунты одного канала агрегируются; ответ модели разбирается строго — с терпимостью к обёрткам из тройных обратных кавычек, нормализацией enum-значений и правилом, что пустой ответ никогда не затирает уже показанные карточки.
Чего тесты не поймали
Два дефекта из шести поймали юнит-тесты чистых модулей — ровно те проверки семантики, о которых выше. Четвёртая ловушка, с базой устройств, вскрылась только на живых данных продакшена.
Причина не в лени. Моки писал тот же человек и с тем же пониманием предметной области, что и код. Мока, где история метрики начинается раньше окна, никто не сочинил: чтобы его сочинить, надо было сначала знать, что граница окна имеет значение. Мок не проверяет допущение, которое сам разделяет.
Отдельно поучительна деталь. Тест на этот блок существовал и проходил, но был слеп: мок отдавал один и тот же массив строк на два разных запроса — на чтение окна по возрастанию даты и на поиск последнего значения по убыванию. Различить их он не мог, поэтому проверка проходила при любой реализации, включая сломанную. Слабым звеном оказался мок, а не сама проверка. Это тот случай, когда зелёный тест хуже отсутствующего: он занимает место, где могла бы стоять настоящая.
Практический вывод простой: если мок отвечает на несколько разных запросов, он должен отвечать на них по-разному. Иначе он проверяет не код, а сам себя.
Деградация: почему при сбое честнее вернуть null
Search Console — единственный источник, который ходит по сети в момент запроса, и делает это шестью вызовами сразу. Он закрыт таймаутом в восемь секунд, и у отказа три разных исхода вместо одного: интеграции нет — не звоним вовсе; настроена наполовину — блок помечается как неподключённый; сбой или таймаут — блок остаётся подключённым, но его цифры null. Все блоки дайджеста собираются независимо друг от друга, так что упавший источник забирает с собой только свой раздел, а не рекомендации целиком.
Третий исход — та же ловушка 6, доведённая до отказов. Нули при недоступности Google выглядят как обвал трафика, и модель начнёт спасать трафик, с которым всё в порядке. Null означает «мы не знаем», и это единственное правдивое, что можно сказать об источнике, который не ответил.
Что изменилось в ответах
Показательно не то, что рекомендации стали длиннее, а то, что до и после — это тексты про разные вещи. До: «отсутствие ключевых слов и конкурентов указывает на слабую SEO-стратегию» — фраза, которая подходит любому проекту, потому что не опирается ни на одно число.
После, на том же проекте: 89 посетителей при нулевых конверсиях; у Instagram выше и вовлечённость, и прирост подписчиков, чем у остальных каналов; у TikTok много просмотров при низкой вовлечённости; средняя позиция 25.1, часть запросов у границы топ-10.
Разница не в красноречии модели и не в размере промпта. Ей просто дали числа, у которых один смысл.
Итог
Если забирать отсюда одну мысль: языковая модель не проверяет смысл чисел, которые ей дали. Она проверяет связность собственного текста — и делает это хорошо. Поэтому семантическая ошибка в данных выходит наружу не сбоем, а уверенным советом, и поймать её можно только там, где число рождается, а не там, где его читают.
Определяйте вид метрики до того, как её считать. Поток, уровень или ставка — от этого зависит и формула, и то, какие строки разрешено читать. Про второе забывают чаще, и обходится оно дороже.
Никогда не подставляйте ноль вместо «не знаем». Ноль — это результат измерения. Отсутствие измерения — это null или отдельный флаг, и в промпте должно быть написано, что он означает.
Кладите в промпт правила чтения, а не только задачу. Половина нашего системного промпта объясняет, что значат поля JSON, и каждая строка там появилась после конкретного неправильного ответа, а не из предусмотрительности.
Проверяйте семантику в чистых модулях — и не доверяйте мокам. Мок, написанный тем же человеком, что и код, разделяет его допущения. Именно поэтому один дефект из шести дожил до продакшена.
Честно про слабое место всего разбора: он не про машинное обучение, он про гигиену данных. Но именно она решает, будет ответ модели полезным или просто гладким, — и делается она до того, как в дело вступает модель.
Частые вопросы
- В чём семантическая ошибка метрики, если само число верное?
- Число достали из правильных строк и правильно посчитали, но означает оно не то, что предполагает модель. Ноль открытий писем означал не «письма не открывают», а «открытия не измеряются». Модель при этом не сообщает об ошибке — она выдаёт связную и уверенную рекомендацию под несуществующую проблему.
- Чем поток отличается от уровня и ставки?
- Поток (установки, просмотры) складывается суммой строго внутри окна отчётности. Уровень (подписчики, активная база устройств, рейтинг) — это последнее известное значение, и окно его не ограничивает. Ставка (CTR, вовлечённость, доля сбоев) — тоже последнее значение, а суммировать проценты бессмысленно.
- Почему уровень, прочитанный внутри окна, дал ноль у живого приложения?
- Выгрузка Google Play оборвалась месяцем раньше, поэтому внутри тридцатидневного окна остались только строки с заполняющими нулями. Единственные ненулевые показания активной базы устройств, доходившие до 15, лежали чуть раньше границы окна. Различие «поток против уровня» решало, как считать, но не решало, какие строки можно читать.
- Почему при сбое источника лучше вернуть null, а не ноль?
- Ноль — это результат измерения, null — его отсутствие. Нули, отданные при недоступном Search Console, выглядят как обвал трафика, и модель начнёт спасать трафик, с которым всё в порядке. Поэтому у отказа три разных исхода: интеграции нет, настроена наполовину и подключено с цифрами null.
- Почему юнит-тестов оказалось недостаточно?
- Два дефекта из шести поймали тесты чистых модулей, третий вскрылся только на данных продакшена. Моки писал тот же человек и с тем же пониманием предметной области, что и код, поэтому мока с историей до границы окна никто не сочинил. Вдобавок существующий тест был слеп: мок отдавал один и тот же массив на два запроса с разной сортировкой.