«Перепишите темы писем — открываемость нулевая». Совет выглядел разумно, и цифра под ним была настоящая: открытий 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 по одному проекту оборвалась за месяц до отчётного окна. Внутри тридцатидневного окна остались только строки-заполнители:

ДатаinstallsactiveDeviceInstallsaverageRating
2026-07-29000
2026-07-28005

Что получалось. Средний рейтинг разрешился верно, в 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.
Почему юнит-тестов оказалось недостаточно?
Два дефекта из шести поймали тесты чистых модулей, третий вскрылся только на данных продакшена. Моки писал тот же человек и с тем же пониманием предметной области, что и код, поэтому мока с историей до границы окна никто не сочинил. Вдобавок существующий тест был слеп: мок отдавал один и тот же массив на два запроса с разной сортировкой.