Инженерный кейс

Governed SQL analyst agent

Slack-агент-аналитик, который отвечает на вопросы бизнеса запросами SQL по аналитической витрине. Модель пишет запрос и формулировку. Детерминированный код решает, можно ли вообще отдать ответ.

PythonDuckDB, колоночная витринаВалидация AST через sqlglot gpt-4o-miniSlack Block KitРегрессионный набор
В двух словах

Человек в Slack задаёт вопрос обычными словами. Агент превращает его в запрос к базе. Запрос проверяется до выполнения, а ответ до отправки: число пересчитывается по отчёту, которым компания уже пользуется, и если они расходятся, не уходит ничего. Всё, что агент сделал, записано, и человек может это потом отмотать.

32 секунды: обычный вопрос, просьба выдать персональные данные и запрос, который выглядит нормально, но даёт неверное число. Все цифры в ролике из настоящего аудит-лога.

Где здесь настоящая сложность

Заставить модель написать SQL это не интересная задача. В витрине, где неверное число двигает деньги, интересное начинается дальше: что решает, можно ли отдать полученное число человеку, и сумеет ли кто-нибудь потом восстановить это решение.

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

Один вопрос от начала до конца

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

вопрос
  -> входной скрининг       сообщение-инструкция отклоняется и логируется
  -> план                   модель пишет SQL и объявляет, какой канонической
                            метрике должно равняться её главное число
  -> валидация              AST: один оператор, только чтение, только
                            разрешённые таблицы, без закрытых колонок,
                            без файловых и сетевых функций, лимит строк
       отклонено -> правило возвращается модели, до 2 попыток починки
  -> выполнение             соединение только на чтение, таймаут 15 секунд
  -> формулировка           ответ пишется строго по вернувшимся строкам
  -> проверки               пустой результат, обрезка, свежесть данных,
                            отрицательные счётчики, канонический cross-check
       любой провал -> число не уходит, ход эскалируется
  -> ответ + доказательство + запись в аудит

Слой управления запросами

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

ПравилоЧто отклоняет
not_read_onlyвсё, что не SELECT: DROP, UPDATE, COPY, ATTACH, PRAGMA
multi_statementсклейку операторов, обычный способ протащить второй запрос
relation_not_allowedлюбую таблицу вне белого списка, включая CTE, маскирующиеся под разрешённые имена
denied_columnзакрытые колонки с персональными данными, с префиксом таблицы и без
star_over_restricted_relationSELECT * по таблице, где есть закрытые колонки
denied_functionфайловые, сетевые и расширенческие функции, в том числе в FROM
лимит строкподставляется принудительно: без LIMIT становится 1000, больший обрезается

Защита многослойная, а не сведённая к входному фильтру. Инъекция, проскочившая скрининг, всё равно не даст запроса за пределы белого списка, а модель, решившая помочь с адресами почты, всё равно не сможет выбрать эту колонку.

Проверки до того, как число уйдёт

ПроверкаЧто ловит
non_emptyпустой результат, вокруг которого модель охотно построит рассказ
row_capобрезанную выборку, поданную как итог
freshnessчисло, посчитанное по таблице, которая ещё не догрузилась
non_negative_countsотрицательное значение в колонке-счётчике
canonical_crosscheckглавное число, пересчитанное по доверенной витрине, блок при расхождении больше 0.5%

Провалившаяся проверка не даёт осторожный ответ с оговоркой. Она не даёт числа вообще, вместо него уходит trace id, по которому человек откроет ход целиком. И последнее слово о том, запускать ли cross-check, не за моделью: если запрос вернул одно число, чей алиас называет каноническую метрику, проверка выводится из самого запроса. Контроль, который срабатывает, только когда модель о нём вспомнила, это не контроль.

Что видит ревьюер

Каждый ход дописывает полную запись в аудит-лог, и консоль ревью генерится из этого лога: отклонённые запросы стоят рядом с правилом, которое их отклонило, а кнопки оценки экспортируются как данные для evals.

Успешный ход. Cross-check пересчитал число по витрине, которой бизнес уже доверяет, расхождение 0.005%.
Попытка вытащить персональные данные под видом обычного аналитического вопроса. Политика по колонкам отклоняет запрос до базы, починка возвращается той же самой, и ход останавливается, а не тратит ещё один вызов модели.
Намеренный регрессионный replay: запрос тихо теряет фильтр по статусу, именно так уплывает определение метрики в проде. Число расходится на 29.6% и до пользователя не доходит.

Дашборд, по которому команда рулит

Агент записывает каждое своё решение, поэтому его собственные рабочие показатели достаются одним запросом. Дашборд генерится из этого лога и истории прогонов: объём решений, причины, по которым ход не дал числа, поведение каждой проверки, дрейф сверки относительно порога 0,5%, задержки, история набора и очередь ходов, на которые человеку стоит посмотреть.

Собран по 50 настоящим ходам. Ни библиотеки графиков, ни внешних запросов: SVG рисуется на Python, поэтому страница спокойно живёт на статическом сайте.

Обе поверхности живые, откройте и потыкайте:

дашборд производительностиконсоль ревью

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

Проверка качества

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

251/256кейсов на шестнадцати полных прогонах
$0.012за полный прогон, около 26 тысяч токенов
0.3 софлайн-прогон, ноль токенов, без API-ключа
16кейсов: ответы, отказы, оговорки, тесты предохранителей, один намеренный регресс

Все пять промахов это один и тот же кейс: среднее число ставок на активного игрока в день, которое модель примерно в трети случаев не вытягивает. Она либо не успевает написать запрос за три попытки, либо выдаёт число, которое отклоняет проверка. Неверного числа по нему не ушло ни разу: уходит отсутствие числа. Кейс остаётся в наборе со своей настоящей долей прохождения, а не переформулирован до зелёного. Остальные пятнадцать не упали ни разу за шестнадцать прогонов.

Что набор поймал по ходу разработки, и каждое стало правкой, а не заметкой:

  • Агент сравнивал колонку timestamp с голой конечной датой и молча терял последний день каждого диапазона. Поймано cross-check'ом на расхождении 10.9%.
  • Он суммировал все выводы средств там, где каноническая витрина считает только проведённые. Именно так в проде уплывает определение метрики. Поймано на 29.6% и оставлено постоянным регрессионным кейсом.
  • Он отказался отвечать на вполне отвечаемый вопрос, потому что указание предпочитать витрины прочиталось как запрет на базовые таблицы. Лишний отказ это дефект, и набор оценивает его как дефект.
  • Когда набор начал проверять, запустился ли cross-check вообще, а не только выглядит ли ответ правильным, один кейс упал до 1 из 5: агент отвечал верно, но пропускал объявление, и число уходило непроверенным. Починка была не в промпте. Вывод проверки переехал в детерминированный код, и кейс вернулся к 5 из 5.
  • Потом багом оказалась сама сверка. Прогон банка из 40 живых вопросов дал четыре ответа, заблокированных по бессмыслице: бонусная сумма сравнивалась с игровыми выплатами, счётчик отклонённых выводов с суммой в евро, самый крупный вывод оказался «на 1862% мимо» месячного итога. Проверка опознавала метрику по названию колонки. Теперь она ещё требует, чтобы метрика бралась из таблицы, где она определена, чтобы агрегат совпадал по типу (максимум это не сумма, среднее на игрока это не количество) и чтобы запрос не отвечал по срезу. Все четыре стали постоянными регрессионными кейсами.
  • Чистые депозиты приходили с обратным знаком, выводы минус депозиты, и так весь батч прогонов. Починка была не в промпте: определения метрик переехали в каталог витрины, который читает агент, прямо к колонкам, которые они описывают, потому что определению место там.
  • Запрос, прошедший валидацию и упавший уже в базе, раньше обрывал ход. Ошибка базы это такая же исправимая ошибка, поэтому теперь она возвращается модели так же, как отклонённый запрос.
  • Два кейса всё же не прошли на более раннем этапе. В одном число по чистым депозитам разошлось с канонической витриной, и cross-check его заблокировал: пользователь получил не неверное число, а никакого. Во втором верный ответ не прошёл проверку оценки и не повторился на двенадцати дальнейших прогонах. Оба записаны, потому что набор, который всегда зелёный, это набор, из которого никто ничего не узнаёт.

Что здесь настоящее, а что упрощено

Настоящее

  • Валидация SQL это разбор синтаксического дерева, а не регулярка по тексту запроса.
  • Выполнение идёт по соединению только на чтение, с таймаутом и подставленным лимитом строк.
  • Cross-check пересчитывает число по независимой витрине и блокирует при расхождении.
  • Аудит-лог полностью восстанавливает ход, и консоль ревью генерится из него.
  • Набор evals гоняется по живой модели и падает на регрессе.

Упрощено сознательно

  • DuckDB вместо ClickHouse: тоже колоночная и близкая по SQL, но без кластера и без управления стоимостью запросов.
  • Ответ собирается в блоках Slack Block Kit, но самого Slack-приложения за ним нет.
  • Все инструменты только на чтение. Подтверждения действий и лестница автономии вне объёма.
  • ML-моделей нет: отток, LTV и риск-скоринг в этот проект не входят.
  • Везде gpt-4o-mini, выбрана как ограничитель бюджета. Модель посильнее поднимет качество ответов и не сдвинет границу управления, а именно она здесь и важна.
  • Вызовы наружу выключены по умолчанию. Весь конвейер, включая проверки, аудит-лог, консоль и дашборд, работает вообще без доступа в сеть, и это доказано тестом, который подменяет сетевой слой на падающий при любой попытке соединения. Сама эта страница статическая и не делает ни одного запроса к третьим сторонам.

Все данные в проекте синтетические и генерятся с фиксированным зерном. Никаких реальных компаний, клиентов и транзакций в нём не используется. Сделано в августе 2026 года. Исходный код показываю по запросу.