Когда говорят про обвязку AI-аналитика, представляют одну машину: на входе вопрос, на выходе число. Но аналитик отдаёт не только вопросы. Я прогнал агента на четырёх типах задач — число, правка отчёта, выгрузка, A/B-тест — и увидел, что общая у обвязки примерно половина. Вторая половина меняется от типа к типу, и от неё зависит, можно ли задачу отдавать.
Агент — это модель плюс обвязка. Модель пишет запрос или выносит вердикт. Всё остальное делает обвязка: решает, что модель увидит, в каком виде она может ответить, сколько раз посмотрит на результат, что останется в записи.
Из чего собирается промпт и что в него не попадает, я разбирал в прошлой статье — «AI-аналитик видит не ваш проект, а промпт». Здесь другой вопрос: что в обвязке меняется, когда меняется сама задача.
Термины и файлы, на которые я дальше ссылаюсь:
| термин | что это | где лежит |
|---|---|---|
| промежуточная таблица | почищенные сырые данные | stg_* |
| код сборки витрин | SQL, который строит витрины | dbt/models/*.sql |
| витрина | результат кода сборки | fct_*, dim_* |
| описания витрин | текст к таблицам и колонкам; до агента доезжает через манифест, после пересборки | schema.yml → dbt/target/manifest.json |
| файл определений метрик | записанные решения: окна, статусы, гости, сравнения | metrics/metrics.yml |
| отбор контекста | часть обвязки: что положить в промпт | agent/retrieval.py |
| эталонный запрос | верный ответ, написанный по сырым таблицам | truth_sql/*.sql |
| модель | только языковая модель | — |
| агент | модель плюс обвязка | — |
У всех типов задач одно ядро. Оно отвечает на вопрос «что агент знает о данных»: доступ к базе, список объектов, описания витрин и примеры значений, файл определений метрик metrics/metrics.yml, склейка промпта в agent/retrieval.py, лог с отпечатками блоков из agent/loop.py. От типа задачи ядро не зависит, его строят один раз.
Над ядром — сменная часть. Она отвечает на другой вопрос: что от агента хотят и как понять, что он справился.
Та же картина таблицей, с тем, что измерено:
| число | правка отчёта | выгрузка | A/B-тест | исследование | |
|---|---|---|---|---|---|
| что даём | вопрос | действующий запрос и что поменять | заказ словами | сводка по тесту | вопрос «почему» |
| что видит | отбор контекста со слоем tables: на одном из вопросов — 3 объекта из 15 | вся схема, слоя tables нет | вся схема, слоя tables нет | только сводку | — |
| раннер | grid_subjects.py, two_arms.py | edit_run.py | extract_run.py | run_verdicts.py | нет |
| что может вернуть | ответ, уточнение или отказ | один запрос | один запрос | один из четырёх вердиктов | вывод или уточнение |
| может спросить | да | нет | нет | нет | да |
| сколько ходов | один | один | один, лучше два | один на сводке | много |
| чем принимать | сверка с эталоном | регрессия против прошлой версии | внутренние проверки и сверка ключей и значений | вердикт против заложенного | устойчивость вывода |
| измерено | 105 + 18 попыток | 18 попыток | 15 попыток | 15 в счёте, 20 всего | одна задача, три повтора |
Что даём. Текст действующего запроса и одну фразу правки: убрать фильтр, расширить период, добавить колонку. Действующий запрос — это эталонный запрос truth_sql/v1.sql…v5.sql, правка сверяется с v1_rev.sql…v6.sql. Раннер — experiments/edit_run.py.
Этот вход делает для агента больше, чем кажется. В старом запросе уже закрыты все решения: какие статусы считать, какой период, как считается сумма. Агенту не нужно их угадывать — он читает их в коде.
Что вышло. Восемнадцать правок из восемнадцати верны. Во всех восемнадцати агент сам назвал в замечаниях решения, которые не трогал или принял сам. В семи поменял в запросе больше, чем просили — чаще всего удалил неиспользуемый кусок. На результат это не повлияло.
В правке «добавить колонку скидки» есть два пути: взять готовую колонку order_items.discount или пересчитать скидку через таблицу акций promotions. По числам они почти неотличимы — 11 заказов из 16 019 на копейку, из-за разного округления. Какой путь выбрал агент, видно только по тексту запроса.
Чем принимать. Прошлая версия отчёта — готовый эталон, и есть она в любой компании. Но правки бывают двух видов, и проверяются они по-разному.
И сравнивать надо с допуском. Побайтовое сравнение дало бы 1 206 ложных «строка пропала» из 16 019 — это шум сложения дробных чисел, максимум 9·10−13.
Подавайте агенту действующий запрос целиком — это и объяснение решений, и эталон для проверки. И смотрите не только на результат, но и на изменения в коде.
Что даём. Заказ словами и всю схему.
Вся схема оказалась важнее, чем я думал. По карте, составленной до прогона, из девяти решений, которые требовали пять заказов, восемь были записаны там, где агент их видит. Но одно из восьми — правило про повторные записи при оплате — лежит в описании промежуточной таблицы stg_order_items, в dbt/models/staging/schema.yml. Раннер выгрузки experiments/extract_run.py работает без слоя tables, поэтому описания stg_* доходят. При отборе контекста со слоем tables это описание отфильтровалось бы. Дойдёт ли решение до агента, зависит не от того, в каком файле оно лежит, а от того, как настроена обвязка.
А после прогона выяснилось, что карта была неполной: незаписанных решений оказалось не одно, а два — какое поле значит «гость» и какие статусы входят в долю возвратов. Второе нашёл сам агент, уже отвечая на заказ.
Что вышло. Пятнадцать выгрузок, семь помечены неверными.
Главный кадр. Заказ: выгрузить заказы гостей с каналом. Кого считать гостем, агенту нигде не записано. Верное решение — заказ без customer_id — живёт в коде сборки stg_orders.sql, а код сборки в промпт не попадает. В данных два способа: колонка is_guest_order в витрине fct_order_items и флаг is_guest в срезе клиентов customers_asof. Агент взял оба сразу, через «и».
И пустая таблица прошла все внутренние проверки: ключ уникален, дублей нет, лишних колонок нет. Всё верно — потому что строк нет.
Запрос агента фильтровал двумя условиями сразу: WHERE foi.is_guest_order = TRUE AND c.is_guest = TRUE.
Внутренних проверок мало, нужна сверка с чем-то внешним. И проверка должна стоять в обвязке до того, как результат уйдёт заказчику, а не шагом «потом посмотрим».
Что даём. Вопрос. Агент может ответить, переспросить или отказаться.
Что вышло. Из 105 попыток верное число дали 20, в девяти агент ответил, но число не сошлось. Я разобрал все девять: все на решениях, которых агент не мог прочитать нигде — какие периоды сравнивать, в каком виде нужен ответ, делать ли поправку на категории.
Тогда я записал эти решения в новую ревизию metrics/metrics.yml — например, compare_periods: calendar_half для сравнения год к году и basis: new_registered для «базы клиентов» — и задал те же вопросы, по три раза каждый. Раннер — experiments/two_arms.py: два плеча с одинаковой подачей, разница только в блоке файла определений, а отпечаток layer_sha подтверждает, какой файл видела модель.
В четырёх случаях агент посчитал верно, но выдал два числа вместо процента — форму ответа я не записывал. В трёх разошлось определение «промо»: в витрине fct_order_items поле on_promo — это discount > 0, была скидка; в эталонном запросе truth_sql/s11a.sql — на товар действовала акция из promotions. Агент посчитал по полю витрины.
И обратный случай: без новой записи агент взял из metrics.yml окно window_days: 90 и честно посчитал на нём год к году. Решение было записано, но не для этого вопроса.
Решения я записывал, уже зная эталон. Запись делает верный ответ доступным агенту — она его не находит.
С правом спросить агент на одном из вопросов в двух попытках из трёх остановился и переспросил — про то самое определение, которое я потом записал. Без права он выбрал бы сам. Контракт ответа — не формальность: он решает, увидите ли вы развилку.
Что даём. Сводку по тесту: дизайн — метрика, единица рандомизации, длительность, альфа, практический порог, охранные метрики — и таблицу результатов по группам. Кейсы взяты из синтетического корпуса ab-factory. У каждого три файла: contract.json — дизайн, data.csv — результаты по группам, truth.json — эталон, поле expected_decision. Сводку собирает build_case() из tools/build_trainer_pool.py.
У эталона три значения, а у агента четыре вердикта. ship — раскатывать, do_not_ship — не раскатывать, а investigate принимает и «не готов», и «тест сломан». Делить investigate самому значило бы принять за корпус незаписанное решение, поэтому главный счёт — по эталону корпуса.
Сначала пришлось решить, чего агенту не давать. В корпусе почти в каждом поле кейса лежит подсказка к ответу.
| поле кейса | что в нём | что сделали |
|---|---|---|
notes — заметки автора | прямо называют проблему: «Underpowered — cannot conclude» | убрали |
title — заголовок | у 21 кейса выдаёт вид ловушки | убрали |
segments — разбивка по сегментам | есть только у четырёх видов ловушек — само присутствие подсказывает | не подаём никому |
randomization.target_split — целевая доля групп | указана только у кейсов со сломанным делением | подаём всем, по умолчанию 50/50 |
case_id — номер кейса | корпус публичный, номер — крючок для узнавания | убрали |
Что может вернуть. Один из четырёх вердиктов: раскатывать, не раскатывать, не готов, тест сломан. «Не готов» — законный ответ, а не уход от ответа, и обвязка обязана его позволять.
Что вышло. Четырнадцать верных из пятнадцати. Правило, которое по умолчанию раскатывает всё значимое и в плюс (decision_agent.py), на тех же кейсах — девять. Ложных выкаток у агента ни одной.
Самое наглядное — тест со сломанным делением на группы. Правило его выкатило бы. Агент три раза из трёх сказал «тест сломан» и каждый раз сам посчитал проверку.
Единственная ошибка. На тесте, где данных честно мало, агент два раза сказал «не готов», а в третий — «не раскатывать», по тем же самым числам. Решение «незначимый результат на малой выборке — это „эффекта нет“ или „данных мало“» в сводке не записано: я не подал ни минимальный детектируемый эффект mde_relative, ни целевую мощность power_target, хотя в contract.json они есть у всех 660 кейсов. Их просто не извлекает build_case(). Незаписанное решение модель закрывает каждый раз заново.
Что вход сделал нерешаемым. На тесте с эффектом новизны улика только в заметках, которые я убрал, и агент трижды сказал «раскатывать» — как и правило. Проблему, которой не видно в числах, он не выдумал.
На тесте с множественными сравнениями вердикт верен три из трёх, но механизм не назван ни разу: лишних метрик в сводке нет.
Формат входа определяет, какие ошибки в тесте вообще можно заметить. Сводка ловит сломанное деление на группы и недобор данных. Новизну, множественные сравнения и парадокс Симпсона она прячет — для них нужны сырые данные, разбивка по сегментам и второй ход.
Вопрос «почему упала выручка» не решается одним запросом. Нужно выдвинуть гипотезу, посмотреть на результат, выдвинуть следующую.
Одноходовой агент за это не берётся: три повтора из трёх — уточнение, ни одного запроса. Ядро из трёх вариантов стабильно во всех прогонах, а определения повторной покупки в каждом прогоне разные.
Одна задача, три повтора. Верность здесь мерить не с чем, только устойчивость — и измерилась устойчивость постановки, а не вывода. Для исследования нужна другая обвязка, многоходовая, и это следующий шаг.
| решение | где проявилось | что вышло |
|---|---|---|
| что показать | выгрузка, A/B | слой tables в agent/retrieval.py включает и выключает сразу схему, описания и примеры; в сводке A/B любое лишнее поле может оказаться подсказкой |
| что агент может вернуть | число, правка, выгрузка, A/B | у числа — answer, clarify, refuse; у правки и выгрузки инструкция «верни ОДИН SELECT»; где спросить нельзя, развилку агент закрывает сам |
| сколько раз посмотреть | выгрузка, исследование | один ход — агент не может посмотреть на данные: он спрашивает или выбирает молча; пустая таблица проходит молча |
| что останется в записи | все | промпт не сохраняется, только отпечатки блоков layer_sha и context_sha; у 705 старых прогонов не было и отпечатков |
Создать решение.
В A/B то же самое: агент сорвался ровно на том решении, которое я не подал в сводке.
Обвязка решает, дойдёт ли решение до агента. Придумать его она не может — это по-прежнему работа аналитика.
schema.yml доезжают через манифест — правка без пересборки до агента не дойдёт.Отдельный агент под каждый тип задач не нужен. Нужно одно ядро и небольшой модуль под тип: вход, контракт ответа, ходы, проверка.
Число, правка и выгрузка — на синтетическом стенде retail-lab, где верный ответ известен по построению. A/B — на синтетическом корпусе ab-factory, шесть кейсов по три повтора. Объявления и отчёты по числу, правке и выгрузке: docs/81, docs/96–docs/101 в retail-lab. Отчёт по A/B-прогону пока не опубликован; корпус — ab-factory.
Перед каждым прогоном — объявления: что меряется, какие исходы бывают, какие ожидания в обе стороны. Потом проверка приёмки на подложенных ответах — заведомо верных и заведомо неверных — без единого вызова модели. На правке она нашла два дефекта в самой приёмке: один признак ошибки искался не в том месте запроса и не сработал бы ни разу. После проверки приёмка замораживалась, и только тогда шла дымовая проба и прогон.
Эталон тоже проверялся. В корпусе A/B у одного кейса метка «мало данных» противоречила его собственным числам — интервал исключал практический порог. У другого агент нашёл сломанное деление на группы, которого не было в метке. Оба кейса выведены из счёта и заменены по правилу, объявленному до следующего вызова.
Один стенд и один корпус, оба синтетические. Одна модель. У правки, выгрузки и A/B агент не мог переспросить, у числа мог. Решения для второго прогона числа записаны при известном эталоне. В A/B по одному кейсу на сценарий, и подача — сводка, а не сырые данные. Все замеры — на одноходовом агенте.
Переносится устройство, а не числа.