Август 2026 AI-аналитик 13 мин

Как я строил AI-аналитика и что придумывал, чтобы ему не верить на слово

Учебная сборка на ритейл-данных: шесть этапов, семнадцать видов проверок и несколько чисел, которых я не ожидал.

Задача была простая: собрать сборку AI-аналитика целиком — данные, семантический слой, агент, отвечающий на вопросы естественным языком — и разобраться, как это устроено изнутри. Таких сборок много, и сама по себе она ничего не доказывает.

Интересным оказалось другое. Почти вся работа ушла не на то, чтобы система отвечала, а на то, чтобы понять, можно ли верить её ответам. И именно эта часть — про проверки — оказалась содержательной.

Ниже — что делалось на каждом этапе и какая проверка на нём появлялась.

Пайплайн: шесть этапов, на каждом своя проверка ДАННЫЕ 1. Генератор событий 1.05 млн строк · 9 видов грязи parquet + DuckDB схема на чтение 2. received_at + курсор что мы знали на дату X проверки: пересчёт мира, 38 шт. СЕМАНТИКА dbt 9 моделей · staging и marts metrics.yml 3 метрики · решения явно store_timezones.csv знания нет в данных проверки: 35 тестов · свежесть витрин АГЕНТ И ПРОВЕРКА ВЫВОДА 3. Retrieval → SQL → петля 6 уровней контекста 4–6. Сетка измерений 102 прогона questions/ + make check 5 вопросов с эталонами проверки: белый список · спроси трижды Четыре этапа из шести — про данные и семантику. Агент пишется за вечер; всё остальное время уходит на то, с чем сравнивать его ответы.
Пайплайн: шесть этапов

Этап 1. Мир с известной правдой

Данные синтетические. Не потому, что реальных нет, а потому что на реальных нельзя проверить агента: когда он говорит «выручка за март — 12.4 миллиона», сверить это можно только написав запрос руками — а если бы я хотел писать руками, агент был бы не нужен.

Мир порождается событиями, а не сводками. Сезонность задана как параметр потока заказов в день, а не как множитель на выручку — тогда разброс появляется сам, из механизма, а не дорисовывается шумом. Заказы разыгрываются пуассоновским потоком, конверсии биномиально.

Получилось 1.05 миллиона позиций заказов, 425 тысяч заказов, два года. Тренд +18% в год, будни против выходных 1.23, ноябрь-декабрь к июню-июлю вдвое, промо поднимает продажи в 1.75 раза и возвраты в 1.34.

Хранилище — parquet плюс DuckDB, схема на чтение: база не копирует файлы внутрь себя, а держит представления поверх них. Перегенерировал данные — база уже актуальна.

Грязь заложена намеренно. Дубли от ретрая оплаты, отменённые заказы в общей таблице, гостевые заказы без идентификатора клиента, смешанные часовые пояса. Смысл не в правдоподобии как таковом: каждый вид грязи даёт честный способ посчитать одно и то же двумя способами.

Вот цена трёх из них на одном датасете:

Как считали выручкуСумма
наивно174 817 091
с дедупликацией171 295 739
ещё и без отменённых160 941 195
ещё и через связь с клиентами139 780 391
2026-08-28T08:18:15.502144 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ наивно с дедупликацией без отменённых через связь с клиентами 0 25 50 75 100 125 150 175 200 млн 174.8 171.3 160.9 139.8 −20% Один датасет, четыре корректных запроса
Один датасет, четыре корректных запроса

Двадцать процентов между крайними. Каждый запрос корректен.

Всё это описано в двух файлах: DIRT.md для человека и truth.json для машины.

Проверка этапа

verify.py — независимый пересчёт мира по данным.

Два решения, которые здесь важны.

Генератор не импортируется. Проверка, которая берёт функции у проверяемого, не проверяет ничего. Но и дублировать константы во втором файле нельзя — они молча разъедутся.

Выход: генератор выгружает объявленные входы мира в truth.json, а verify их читает и меряет данные. Именно входы, а не измеренные выходы — иначе данные сверялись бы сами с собой, и проверка проходила бы всегда.

Пороги допуска остаются в проверке. «12% гостевых заказов» принадлежит миру. «Расхождение до 5% приемлемо» принадлежит измерению. Смешивать нельзя.

Этап 2. Время регистрации и семантический слой

Выяснилось, что в таблицах есть время события, но нет времени, когда строка доехала. А без него не ответить на вопрос «что мы знали на 1 марта».

Добавил received_at во все таблицы событий. Задержки разыгрываются экспоненциально — время ожидания должно иметь правый хвост.

СобытиеМедианная задержка регистрации
подтверждение оплаты2.6 минуты
регистрация возврата29.5 минуты
отмена заказа4.3 часа
ночная досылка пачкой, 3 магазина11.9% строк против 0.02% фона

Из этого сразу выросли следствия, которых не было в плане. Отменённый заказ доезжает дважды — сначала позиции с оплатой, потом отмена часами позже; 62 452 позиции доехали раньше отмены своего заказа. Недоехавшая оплата выбрасывает заказ из выгрузки целиком. Недоехавшая отмена делает заказ completed — не пустым, не «неизвестно», а правдоподобным значением.

Дальше — курсор: дата, на которую мир виден. Витрины собираются на данных, доехавших к этому моменту. Отматывать можно и назад — мир зафиксирован целиком, движется только точка наблюдения.

Курсор живёт в UTC. Альтернатива — двадцать четыре разных момента под одной датой, и сумма перестаёт быть числом. Цена записана: отчёт «за 1 марта» расходится с кассой сдвинутого магазина на 0.7% по заказам.

И тут же обнаружилось важное: смещение магазина невозможно вывести из данных. Колонка наивная, по профилю часа сдвиг угадывается статистически, по одной строке — никак. Знание либо записано руками, либо не существует.

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

Поверх этого — dbt: 9 моделей, staging чинит шкалу и применяет отсечку по курсору, дедупликация ровно в одном месте. Отменённые и гостевые заказы не фильтруются: слой, решивший за метрику, потом не переубедить.

И metrics.yml — три метрики, каждая с явными решениями: окно, что считается активностью, как обходятся гостевые, отсечка.

Проверки этапа

35 тестов dbt — зерно, ссылочная целостность, отсутствие NULL.

Проверка свежести витрин. Витрины построены на курсоре. Сдвинул курсор без пересборки — система обязана отказаться считать, иначе получишь вчерашнее знание под сегодняшней датой. Реализовано сравнением времени манифеста dbt с курсором.

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

На курсоре 1 июля 2025
событие случилось, строка в пути17 заказов, 13 позиций, 5 возвратов
событие ещё не случилось, заказ уже сделан1 379 возвратов

Два порядка разницы. Первое лечится ожиданием минут, второе — тремя неделями. Проверок стало две.

Проверка понимания семантического слоя. Меняю одну строку в определении, данные не трогаю:

guests: exclude          → 21 580 активных клиентов
guests: count_each_order → 25 866 активных клиентов  (+19.9%)

Обе честные. Первая считает известных клиентов, вторая превращает человека с тремя гостевыми заказами в трёх. Настоящего числа в данных нет вовсе.

Этап 3. Агент

Три модуля, написанные руками. Vanna, Cube и LangChain намеренно не использовались: они прячут ровно то, ради понимания чего проект и затевался.

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

Генерация — через metrics.yml, а не по схеме: выбрать метрику, собрать запрос из её определения. Если подходящей метрики нет — отказаться и сказать об этом. Отказ здесь валидный ответ, а не ошибка.

Петля исполнения — SQL упал, ошибка возвращается модели, до трёх попыток. Логируется каждая попытка: что упало, что агент поменял, стал ли запрос считать то же самое.

Ответ всегда показывает SQL рядом с числом.

Проверки этапа

Пять вопросов с эталонами. questions/ — вопросы, у которых известен правильный ответ, включая случаи, где правильных ответов два.

«Спроси трижды». Один вопрос, три прогона, ответы должны сойтись. Простейшая проверка, и она поймала больше всех — об этом ниже.

Валидатор решений с белым списком. Появился не сразу, а после случая, разобранного ниже: форма записи разбирается терпимо, но каждое значение сверяется со списком допустимых.

Впрыск ошибки. Побочный эффект генерации через метрики — за весь этап ни один запрос не упал сам, и петля осталась непроверенной. Пришлось завести флаг, роняющий запрос специально. Две пробы: на ошибку не по адресу агент повторил план без изменений; на ошибку, толкающую убрать условие, — убрал именно тот вариант, не переписав смысл остальных.

Что поймала проверка «спроси трижды»

Агент передал решение строкой "[completed]" — со скобками, как выглядит список в питоновском выводе. Мой сборщик разложил её посимвольно:

where status in ('[', 'c', 'o', 'm', 'p', 'l', 'e', 't', 'd', ']')

Ни одна строка не подошла, продажи стали нулём, из нуля вычлись возвраты. Ответ — минус 1 466 746 вместо 13 141 142.

Запрос валидный. Исключений ноль. Петля не сработала — падать было нечему. dbt test прошёл, данные целы. verify.py прошёл, мир собран правильно.

Отсюда вывод, который стоит отдельно:

Петля самопочинки бесполезна против тихой порчи по построению. Она чинит то, что падает. Метрика, посчитавшая не то, не падает никогда.

И второе, менее приятное: ошибка была в моём коде, а выглядела бы как дефект модели. Не полез бы в лог — записал бы «модель нестабильна, два прогона из трёх врут».

Этап 4. Сколько стоит контекст

Сетка: пять уровней контекста, три повтора, вопросы из questions/. На уровнях без metrics.yml агенту нечего выбирать, поэтому завёл второй путь генерации — свободный SQL.

уровеньчто добавляетсяточностьтокенов
L0\*голый DDL33%2 219
L1отбор таблиц33%1 269
L2описания колонок33%1 396
L3примеры значений33%1 507
L4metrics.yml89%3 136

\* L0 прогонялся только в четвёртом заходе; в пятом, после починки ретривера, его не повторяли. Остальные строки — пятый заход.

2026-08-27T17:05:55.104184 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 25% 50% 75% 100% Точность 33% 33% 33% 33% 89% 89% весь скачок на одном шаге между L1 и L3 разброс между повторами не меньше разницы между уровнями Стоимость контекста: плоско, потом обрыв L0 голый DDL L1 +отбор таблиц L2 +описания колонок L3 +примеры значений L3.5 +metrics текстом L4 +сборка из метрик Токенов 2219 1269 1396 1507 2779 3136
Кривая стоимости контекста

Числа стоит читать с поправкой на то, чем они измерены. Три повтора на ячейку — этого хватает, чтобы увидеть обрыв между L3 и L4, и не хватает, чтобы различить L1, L2 и L3 между собой: разброс между повторами там не меньше разницы между уровнями. По ходу работы нашёлся дефект ретривера, и после его починки те же уровни дали другие проценты — 56% вместо 33% на L2 и L3 в предыдущем заходе. Обе версии лежат в отчёте; починка точность не подняла, а в одной ячейке формально опустила. То есть 33 и 56 здесь — не два разных результата, а одно и то же число внутри собственного разброса. Твёрдым в этой таблице является только обрыв на последнем шаге.

Форма кривой — плоско, потом обрыв. Три описательных слоя стоят лишних 238 токенов и дают прирост в пределах шума. Отбор таблиц вычитает 950 токенов, и я долго считал, что даром. Это оказалось неверно, и выяснилось это только в следующем эксперименте — разбор ниже, в разделе про починки.

Проверки этапа

Парный счёт вместо средних. При трёх повторах разброс неизбежен. Считать надо не средние, а сколько вопросов сменило исход между уровнями. Вышло: L1→L2 ноль, L2→L3 ноль, L3→L4 два из трёх. Про первые три шага сетка молчит — разброс между повторами не меньше разницы между уровнями, и так и написано.

Пересчёт по сохранённым логам. Каждый раз, когда находился дефект в измерителе, пересчёт делался повторным исполнением записанного SQL, без единого нового обращения к модели. Иначе это была бы подгонка.

Прямая проверка после починок. Метрика «агент выдумал колонку» показала ноль на всех уровнях — после того, как я трижды чинил её код. Ноль после трёх починок подозрителен, поэтому пришлось перебрать все 155 запросов вручную и убедиться, что явление действительно не воспроизводится.

Гипотеза записывается до прогона. Иначе задним числом объясняется любой исход.

Этап 5. Знание или принуждение

Между L3 и L4 менялись две вещи сразу: появлялись определения метрик и одновременно менялся способ генерации — со свободного SQL на сборку из определения. Обрыв кривой мог быть эффектом любого из двух.

Завёл промежуточный уровень: определения подаются текстом, но SQL агент пишет сам.

шагчто меняетсяточностьпроговорил развилку
L3 → L3.5появляется знание33% → 89%50% → 100%
L3.5 → L4появляется принуждение89% → 89%100% → 100%
2026-08-27T17:05:55.126937 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L3 → L3.5 появляется знание L3.5 → L4 появляется принуждение +56 +0 +50 +0 Работает знание, не принуждение прирост точности, п.п. прирост проговорённых развилок, п.п.
Работает знание, не принуждение

Принуждение не даёт ничего. Семантический слой работает не как механизм, а как текст.

Практически это разные деньги: положить определения в промпт — час работы, строить сборку запроса из определения — другая архитектура и другой срок.

Проверка этапа

Обе версии чисел стоят рядом. По ходу нашёлся дефект в моём ретривере — таблица курсора выпадала из контекста на средних уровнях. После починки точность там не выросла, а в одной ячейке стала хуже опубликованной. Соблазн подправить задним числом был максимальный.

Обе версии лежат в отчёте, с объяснением, что изменилось. Скрытая правка задним числом хуже, чем признанный дефект.

Этап 6. Когда метрики нет

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

определения текстомсборка из определений
посчитал6/60/6
отказался0/66/6
подставил определение молча0/60/6

Я ожидал, что без принуждения агент молча подставит правдоподобное определение. Не подставил ни разу: считая, он каждый раз перечислял свои решения в ярлыках — «за всё время; активность = завершённый заказ; гости исключены».

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

Тот же класс отказов с другой стороны — со стороны эксперимента, а не данных — разобран в статье Почему A/B-тест врёт: там тринадцать способов получить правдоподобное число, которое ничего не значит.

Каталог проверок

Собственно то, ради чего писался текст. Семнадцать проверок, сгруппированных по тому, что каждая способна поймать.

ГруппаСколькоЧто ловит
данные5мир собран не так, как объявлен
трансформация3витрины испортили данные или устарели
агент4решение вне списка, петля не проверена
вывод3посчитано не то при целых данных
само измерение1измеритель врёт в удобную сторону

Проверки данных

1. Пересчёт мира по данным. Все объявленные параметры мира измеряются заново и сверяются.

2. Независимость проверки от генератора. Импорт запрещён, константы не дублируются; связь идёт через файл объявленных входов.

3. Входы, а не выходы. В файл правды попадают заказанные параметры, не измеренные результаты. Иначе данные сверяются сами с собой.

4. Контрольная фальсификация. Проверка, которая никогда не падала, — не проверка. Сломать мир в копии вне репозитория и убедиться, что упали ровно нужные пункты и ни одного лишнего.

5. Разделение слитых понятий. «Скрыто» оказалось двумя разными явлениями с разницей в два порядка. Проверка, проходящая слишком легко, — повод разобраться, а не радоваться.

Проверки трансформации

6. Тесты витрин. Зерно, ссылки, NULL.

7. Свежесть. Витрины не старше курсора; иначе система отказывается считать.

8. Проверка понимания слоя. Меняем одну строку определения при неизменных данных и смотрим, как переворачивается ответ.

Проверки агента

9. Валидатор решений. Форма разбирается терпимо, значения — по белому списку.

10. Показ SQL рядом с числом. Всегда. Стоит ноль, а без этого ошибку не увидеть в принципе.

11. Логирование каждой попытки петли. Не служебный лог, а предмет изучения: что упало, что агент поменял, стал ли запрос считать то же самое.

12. Впрыск ошибки. Если петля ни разу не сработала сама, она не проверена. Ронять специально.

Проверки вывода

13. Вопросы с эталонами. Единственный уровень, который смотрит на ответ, а не на данные.

14. «Спроси трижды». Воспроизводимость. Поймала больше всех остальных.

Её граница. Она ловит недетерминированность, а не неправильность. Повторы расходятся, когда модель отвечает по-разному; ошибка, сделанная воспроизводимо, проходит проверку с отличным результатом. Во втором раунде эксперимента с продактом агент исключил гостевые заказы из числителя и не исключил из вычитаемого — возвраты гостей вычитались из выручки, в которой гостей нет. Средний чек занижен на 1.9%, чего не видно, и этого хватило, чтобы перевести решение через границу. Три повтора дали разброс 1.8% — худший в наборе, где остальные восемь вопросов дали ровный ноль. Согласованность повторов читается как надёжность, а означает лишь, что ошибка детерминированная. Поймало её не сравнение ответов между собой, а разложение ответа на составные части: заказы и валовая выручка сошлись до копейки, разошлась ровно одна величина. Такой проверки в наборе не было.

15. Проверка правила, а не числа. Один вопрос сначала сверялся с конкретным эталоном и провалил верный ответ, собранный иначе. Переписан на проверку правила: варьируется ли решение, из-за которого ответ двоится.

17. Разложение ответа на части. Сверять не итог, а составляющие: заказы, валовую сумму, вычитаемое, каждый элемент разбивки. Пересчёт независимый — только сырые таблицы.

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

На том самом среднем чеке, который повторы пропустили:

заказов            агент     31 913.00   пересчёт     31 913.00   сошлось
выручка gross      агент 12 744 925.74   пересчёт 12 744 925.74   сошлось
вычтено возвратов  агент  1 466 746.66   пересчёт  1 250 412.88   РАЗОШЛОСЬ на 17.3%

Разница с остальными проверками в том, что она указывает место ошибки, а не факт её наличия. Совпадение трёх частей из четырёх сузило поиск до одной величины, и в запросе сразу стало видно: гостевые заказы исключены из числителя и не исключены из вычитаемого — возвраты гостей вычитались из выручки, в которой гостей нет. Всего сверено 37 частей на одиннадцати вопросах, разошлась одна.

Как эта проверка сработала на живом случае — во второй статье: Продакт задал двадцать вопросов AI-аналитику. Там продакт задаёт вопросы без аналитика, и разложение ловит единственное тихо-неверное, которое проверка на повторах пропустила.

Проверки самого измерения

16. Пересчёт без новых вызовов, обе версии рядом, гипотеза до прогона.

Последняя группа появилась не от аккуратности, а по необходимости. За проект я пять раз находил ошибки в собственных измерительных скриптах, и все пять смещали результат в сторону более эффектного вывода: нули точности вместо реальных 33–56%, выдумки там, где их не было, «агент не следует определению», «посчитает молча».

Шестой случай нашёлся уже при подготовке этого текста к публикации, когда каждое число сверялось с отчётом. Ошибок оказалось три, и все — в статье, а не в измерителе: таблица этапа 4 была склеена из двух заходов (токены и точность из четвёртого, а L4 = 89% — из пятого, где в четвёртом было 78%); объём данных округлён до 1.06 миллиона строк вместо 1.05; на схеме пайплайна стояло восемь видов грязи вместо девяти. Все три — в сторону более круглого или более эффектного. Правило сработало на тексте о самом правиле.

Седьмой нашёлся уже после публикации. Заход 2 добавил received_at, и это сдвинуло поток случайных чисел в генераторе: количества строк остались прежними, а значения изменились. Суммы выручки в этом разделе перестали воспроизводиться из данных — расхождение от 0.15% до 0.39%, — и полгода никто этого не заметил. verify.py не ловит такое по построению: он сверяет параметры мира с заявленными, да ещё с допуском в 12–15%, а суммы, которые цитируются в прозе, не проверяет никто. Шестнадцати проверок хватило на данные и не хватило на текст о данных.

Восьмой — самый неприятный, потому что этот дефект уже был найден и уже был исправлен. Тот самый fetchone()[0], что брал первую колонку вместо нужной. В заходе 4 его нашли и починили — но починили пересчётом сохранённого SQL, а в самой петле исполнения оставили как было. Числа того захода стали верными, а причина осталась жить.

Через шесть заходов она испортила другой эксперимент. Агент на вопрос «выросла ли выручка год к году» вернул запрос с тремя колонками — выручка за последние 365 дней, за предыдущие и growth_pct. Петля записала первую. В отчёт попали 79 миллионов рублей там, где вопрос требовал процента.

Починка, сделанная не в том месте, где дефект, не является починкой. Она делает симптом невидимым, и следующий раз дефект проявится в месте, где его не ищут.

И девятый, из того же эксперимента: отбор таблиц ломает определение метрики. Тот слой, который выше назван бесплатным.

metrics.yml объявляет revenue.basis: net_of_returns — выручка за вычетом возвратов. Отбор таблиц выбирает, что показать агенту, по словам вопроса. В вопросах «выросла ли выручка» и «какие категории дают больше выручки» слова «возврат» нет, и таблицу возвратов агент не увидел.

Он это заметил и сказал прямо: в схеме нет ни флага возврата, ни его даты, поэтому net_of_returns посчитан как gross. Не соврал — честно описал контекст, который ему дали. И подставил другое определение.

Цена на этих данных:

grossnet_of_returns
рост выручки год к году21.02%19.75%
доля лидера категорий46.8%47.4%
второе местоapparelhome

Расхождение доли лидера — 1.3%, на глаз не отличить. Решение не меняется. Меняется ранг категорий: вторым оказывается другой раздел, и продакт, спросивший «куда вкладываться», унесёт неверный ответ, не имея ни одного повода усомниться.

Отсюда поправка к тому, что сказано выше про стоимость контекста. Отбор таблиц действительно вычитает 950 токенов. Но «ничего не портя» было неправдой: слой, который решает, чего агенту не показывать, способен молча заменить определение метрики — а определение метрики и есть то единственное, что отличает число от мнения.

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

Лестница

Если выстроить проверки по тому, насколько тихую ошибку каждая ловит:

Лестница проверок: что каким уровнем ловится Петля исполнения ловит то, что падает Тесты витрин · 44 ловит сломанную структуру Пересчёт мира · 38 ловит мир, собранный не так, как объявлен Валидатор решений ловит значение не из белого списка выше — проверка вывода ниже — проверки данных Вопросы с эталонами · «спроси трижды» ловит: всё в порядке, но посчитано не то −1 466 746 вместо 13 141 142 валидный SQL, ноль исключений Ошибка прошла четыре нижних уровня: данные целы, структура цела, мир собран правильно — а число неверно. В сборках на реальных данных верхнего уровня обычно нет: сверять ответ не с чем.
Лестница проверок

Всё, что ниже черты, проверяет данные. Только верхний уровень проверяет вывод. Минус полтора миллиона прошёл сквозь четыре нижних уровня, потому что данные были целы, структура цела, мир собран правильно — а число неверно.

В большинстве сборок верхнего уровня просто нет: сверять ответ не с чем, потому что данные реальные и правильный ответ неизвестен.

Правило, которое не сдвинулось ни разу

Объявленная развилка проговаривается всегда. Необъявленная — как повезёт.

2026-08-27T17:05:55.176062 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ Без metrics.yml L1, L2, L3 С metrics.yml L3.5, L4 0 50% 100% Развилка проговорена 50% 100% 18 прогонов, ни одного исключения
Объявленная развилка против необъявленной

Ровно 50% на всех уровнях без определений метрик, ровно 100% на обоих уровнях с ними. Восемнадцать прогонов, ни одного исключения.

Проценты точности в этом проекте менялись пять раз, пока я чинил измеритель. Это правило не сдвинулось ни разу — и по-моему, устойчивость к починке собственного прибора говорит о числе больше, чем его величина.

Спросил «сколько активных клиентов» — агент дал несколько чисел с явными ярлыками: гостей исключаем — 21 580, считаем каждый гостевой заказ — 25 866. Обе честные.

А про зерно и про дозревание, которые нигде не объявлены и точно так же двоят ответ, не сказал ни слова.

Агент не хранит развилки в голове. Он их читает.

Границы

Одна модель. Один домен. Три метрики, пять вопросов, 102 прогона.

Про первые три уровня контекста сетка молчит — разброс между повторами не меньше разницы между уровнями.

Дефект ретривера обнаружился только после того, как сетка была прогнана; вывод «описания не помогают» из-за этого ослаблен.

Это не «агенты вообще». Это один агент на одном домене, и цифры стоит читать как порядок величины.

Итог

Пайплайн: генератор событий → parquet и DuckDB → курсор → dbt → определения метрик → агент с retrieval, генерацией и петлёй → вопросы с эталонами.

Из шести этапов четыре ушли на данные и семантику, два на агента. Пропорция кажется мне правильной: агент — самая простая часть системы, и написать его вручную занимает вечер. Всё остальное время уходит на то, чтобы было с чем сравнивать его ответы.

Три вещи, которые я забрал:

Семантический слой нужен не для переиспользования, а чтобы у знания, которого нет в данных, было место кроме головы.

Проверки данных не ловят ошибки вывода. Это отдельный уровень, и в большинстве сборок его нет.

Самопочинка не покрывает главный класс отказов. Она чинит падающее; тихо неправильное не падает.

Откуда это растёт

null, картотека математики: доверяй, но проверяй · честность по построению

Если интересно обсудить — напишите в Telegram.

← Все статьи