Модель отвечает по витринам. Витрина — не данные, а результат чьих-то решений, принятых задолго до вопроса. Я пошёл считать, сколько их и видит ли их модель.
Чтобы AI-аналитик посчитал число, человек до него пишет семь вещей. Вот они, с путями — чтобы не путать похожее.
| артефакт | где лежит | что это | зачем |
|---|---|---|---|
| задача | трекер | текст | с этого всё начинается |
| вопрос | — | одна строка текста | аналитик выводит его из задачи |
| код сборки витрин | dbt/models/*.sql | девять файлов: три строят витрины, шесть — промежуточные таблицы под них | превращают сырые таблицы в витрины, по расписанию |
| описания витрин | dbt/models/schema.yml | YAML: строка текста на таблицу | что в таблице лежит, человеческими словами |
| тесты сборки | dbt/tests/ | тридцать пять SQL-проверок | ловят, если сборка сломалась; стоят и на витринах, и на промежуточных |
| файл определений метрик | metrics/metrics.yml | YAML: пятнадцать решений и комментарии к ним | что и как считать |
| эталонный запрос | truth_sql/*.sql | SQL по сырым таблицам, не по витринам | правда, с которой сверяют ответ; витрины не читает намеренно — иначе у правды и у проверяемого был бы общий родитель |
Все семь написаны руками. На каждый потрачено время.
Два YAML, которые легко спутать. metrics/metrics.yml — файл определений метрик: что считать метрикой и какие решения в ней приняты. dbt/models/schema.yml — описания витрин: что лежит в таблице. Первый про смысл метрики, второй про содержимое таблицы. В промпт попадают оба, но разными путями.
Как я называю вещи в этом тексте. Код сборки витрин — девять SQL-файлов. В dbt они называются моделями, но слово «модель» здесь занято языковой. Запускаются отдельно от агента: dbt run читает сырые таблицы и записывает результат. Витрина — готовая таблица fct_* или dim_*. Модель — только языковая. Агент — модель плюс обвязка вокруг неё. Отбор контекста — та часть обвязки, что собирает промпт.
Два слова, которые нельзя путать. Артефакт — то, что пишет человек: файл, текст, запрос. Их семь. Блок — часть собранного промпта. Их шесть. Соответствия между ними нет: три артефакта дают блоки, три блока человек не писал вовсе.
Модель не получает ваш репозиторий. У неё окно, и весь проект в него не помещается — поэтому перед каждым вопросом отбор контекста собирает промпт: решает, что положить, и кладёт.
Я пересобрал этот промпт тем же кодом и сверил отпечатки блоков с логом. Совпали. Значит ниже — не реконструкция, а то, что реально ушло модели.
| блок промпта | источник | что именно | на нашем вопросе |
|---|---|---|---|
| инструкция | код обвязки | константа в agent/sql_gen.py | три правила |
| файл определений метрик | файл | metrics/metrics.yml целиком, с комментариями | 4688 знаков |
| схема базы | база | запрос к information_schema | 3 объекта из 15 |
| описания витрин | файл | dbt/target/manifest.json, только поля description | 2 строки |
| примеры значений из базы | база | SELECT DISTINCT … LIMIT 12 по поданным таблицам | 4 пары колонок |
| вопрос | снаружи | пришёл вместе с задачей | одна строка |
Шесть блоков, четыре источника. Два блока берутся с диска, два спрашиваются у самой базы, инструкция лежит константой в коде обвязки, вопрос приходит снаружи.
Файлов проекта отбор контекста открывает ровно два.
| что за файл | путь | открывается |
|---|---|---|
| файл определений метрик | metrics/metrics.yml | да |
| манифест сборки | dbt/target/manifest.json | да |
| код сборки витрин | dbt/models/*.sql | нет |
| описания витрин | dbt/models/schema.yml | нет |
| тесты сборки | dbt/tests/*.sql | нет |
| эталонные запросы | truth_sql/*.sql | нет |
Описания витрин в списке дважды и это не ошибка: файл schema.yml, куда вы их пишете, не открывается. Их содержимое попадает в промпт из манифеста, куда dbt перенёс его при сборке. Правка в schema.yml доедет до модели только после следующего dbt run.
Манифест — это JSON, который dbt пишет при компиляции: в нём описание каждой витрины, список колонок и — отдельным полем compiled_code — сам скомпилированный SQL. Отбор берёт оттуда только описания.
Теперь свяжем одно с другим: какой артефакт человека даёт какой блок промпта.
| артефакт человека | в промпт | каким блоком и как |
|---|---|---|
| задача | нет | — |
| вопрос | да | блок «вопрос», напрямую |
| код сборки витрин | нет | — |
| описания витрин | да | блок «описания» — но не из schema.yml, а из манифеста, куда их перенёс dbt |
| тесты сборки | нет | — |
| файл определений метрик | да | одноимённым блоком, целиком, напрямую |
| эталонный запрос | нет | — |
Из семи артефактов человека блоки дают три. Остальные три блока промпта человек не писал: инструкция лежит в коде обвязки, схема и примеры значений спрашиваются у базы.
Здесь важна последовательность во времени, и её легко упустить.
Витрины не собираются в момент вопроса. Команда dbt run выполняет девять файлов кода сборки, читает сырые таблицы и записывает результат — шесть промежуточных таблиц и три витрины. Запускается это по расписанию, раз в сутки или чаще, и к моменту, когда приходит вопрос, витрины уже лежат готовые.
Модель их не собирает и не пересобирает. Она читает результат.
Решения, закрытые в витрине, приняты заранее — другим человеком, в другой день, и заморожены в колонках.
Отсюда и вопрос, ради которого я полез в код сборки. Витрина — не данные, а чей-то выбор: кто-то решил, какое поле считать датой и кого считать гостем. Значит часть ответа определена до всякой модели и до всякого определения метрики.
Сколько этих решений — и видит ли их модель.
Открываете код сборки. Видите SQL. Кажется, что он просто переносит данные из одного места в другое.
Отличить решение от реализации можно формально: решение — это место, где можно поставить другое и запрос останется рабочим.
| строка в коде сборки | альтернатива | решение? |
|---|---|---|
customer_id is null as is_guest_order | флаг is_guest в таблице клиентов | да — обе исполнимы, результат разный |
status = 'cancelled' | status <> 'completed' | да — при трёх статусах результат разный |
received_at позиции | received_at заказа | да — у отменённых оно часами позже |
соединение по order_id | — | нет — заменить нечем |
Я прошёл по всем девяти файлам — шести промежуточным stg_*.sql и трём витринным fct_order_items.sql, fct_returns.sql, dim_customers.sql — и выписал такие места: что решалось, что выбрано, что было бы при другом выборе, и объявлено ли это где-нибудь, кроме самого SQL.
Получилось сорок четыре.
Сорок четыре — это всё подряд, включая соединения, где вряд ли кто-то сделает иначе. Поэтому я посчитал второй раз, по правилу, объявленному до счёта: альтернатива должна быть выполнима на данных и встречаться как чей-то реальный выбор в материалах проекта. Таких двадцать.
Теперь главный переход. Сорок четыре решения приняты. Модель читает два файла. Что из этих сорока четырёх до неё доезжает?
Разберём точнее, что именно из кода сборки доезжает.
| где записано решение | сущность | путь | в промпт | штук |
|---|---|---|---|---|
| в описании витрины | описания витрин | schema.yml → манифест | да | 9 |
| в файле определений | файл определений метрик | metrics/metrics.yml | да | |
| в описании промежуточной таблицы | описания витрин | schema.yml → манифест | только при выключенном отборе таблиц | 2 |
| в комментарии рядом с кодом | код сборки витрин | dbt/models/*.sql | нет | 12 |
| в тесте | тесты сборки | dbt/tests/*.sql | нет | |
| нигде, только в самом SQL | код сборки витрин | dbt/models/*.sql | нет | 17 |
| намерение не восстановлено | — | — | нет | 4 |
| всего | 9 | 44 |
Сам код сборки в промпт не попадает совсем — ни одной строки SQL, ни одного комментария. Решение доезжает до модели только тогда, когда кто-то отдельно, другими словами, в другом файле написал, что оно принято.
Тесты — обидный случай. В них записано ровно то, что нужно: например, что зерно таблицы позиций проверяется отдельно. Это объяснение существует, оно верное, и отбор его не открывает.
А две строки про промежуточные таблицы доезжают не всегда. Они лежат в описаниях, но отбор таблиц их отфильтровывает: когда он включён, в промпт идут описания только тех таблиц, что связаны с метрикой, а промежуточные в этот список не попадают.
Четырнадцать описаны правильно и подробно — и не доезжают. Кто-то потратил время, объяснил, почему считаем именно так, и записал объяснение в комментарий кода сборки или в тест. Ни то ни другое в промпт не попадает.
Это не «мы поленились». Это «мы написали не туда».
Общее: документация, которую не читает исполнитель, не работает, даже если она верная.
Частное: девять из двадцати трёх — доля моего проекта. Свою можно узнать за час: собрать промпт и поискать в нём свои объяснения.
До сих пор речь шла о доставке: объяснение есть, но не доехало. Дальше выяснилось, что есть и вторая беда, и она другой природы.
Я взял вопрос «сколько активных клиентов сейчас» и задал его дважды. Первый раз — как обычно, по витринам. Второй — по сырым таблицам, с тем же файлом определений.
Модель в обоих случаях ответила с первой попытки, без уточнений и отказов. На сыром пути она сама написала запрос на пятьсот семьдесят шесть знаков: привела время к UTC, обрезала по курсору, сделала три соединения. Технически справилась.
Разрыв — 6550. Я разложил его исполнением, без обращения к модели: менял в запросе по одному условию и смотрел, как двигается число.
| что меняли | даёт |
|---|---|
| какое поле значит «гость» | 6549 |
| забытое приведение времени к UTC | 1 |
| граница окна: строгое сравнение или нет | 0 |
Одна развилка дала весь разрыв.
В файле определений написано: гостевые заказы не считаем вовсе. Эта фраза доехала до модели — она в первых двух файлах, ничего не потерялось.
Но какое поле она значит, не написано. И на витрине вопрос не возникает: там поле одно, is_guest_order, посчитанное при сборке как «заказ без идентификатора клиента».
А в сырых таблицах способов два, и это две разные популяции:
| способ | сколько |
|---|---|
заказы без customer_id | 50 578 |
клиенты с флагом is_guest | 18 096 |
Модель взяла вторую.
Причём гостей она назвала вслух — перечислила эту развилку среди открытых. И всё равно выбрала не ту. А в конце написала, что все решения заданы слоем и потому вариант один. В сыром пути это неправда, и она этого не заметила.
Доставка. Объяснение написано, но в промпт не попало. Лежит в комментарии кода сборки или в тесте. Чинится переносом: тот же текст, но в другом месте.
Неоднозначность. Объяснение попало, модель его прочитала — и всё равно ошиблась, потому что текст допускает два исполнения. Переносом не чинится вообще: сколько ни переноси двусмысленную фразу, она останется двусмысленной.
Файл определений закрывает решение словами. Витрина закрывает его полем. Слова можно понять двояко — поле либо есть, либо нет.
Вот что на самом деле делает витрина. Не ускоряет запрос и не прячет сложность. Она делает решение неоткрываемым заново.
Витрина принимает решение за всех. Если она закрыла развилку неверно, ни одна метрика её уже не переубедит — только пересборка.
Поэтому выбор между словами и полем — не «что лучше», а что чем платится. Слова оставляют метрике свободу и рискуют неоднозначностью. Поле убирает неоднозначность и забирает свободу.
В этом же стенде есть пример обратного решения: витрина возвратов держит обе даты — когда возврат случился и когда о нём узнали, — и в её описании прямо сказано, что выбор между ними принадлежит метрике. Развилка оставлена открытой сознательно.
Теперь всё вместе. Слева то, что существует, справа — доезжает ли оно до модели.
задача трекер ──────────────── нет
↓
вопрос ─────────────────────── ДА, напрямую
код сборки витрин dbt/models/*.sql ────── нет, ни строки
↓ 44 решения ├─ 9 доезжают текстом
витрины ← собраны заранее, dbt run └─ 35 не доезжают никак
описания витрин dbt/models/schema.yml ─ ДА, через манифест
тесты сборки dbt/tests/ ──────────── нет
файл определений метрик metrics/metrics.yml ─── ДА, целиком, напрямую
эталонный запрос truth_sql/*.sql ─────── нет, его читает измеритель
по сырым таблицам
инструкция код обвязки ─────────── три правила схема базы запрос к базе ───────── 3 объекта из 15 примеры значений из базы запрос к базе ───────── 12 значений
Шесть блоков промпта: три пришли от человека — вопрос, файл определений метрик, описания витрин. Три порождаются: инструкция лежит в коде обвязки, схема и примеры значений спрашиваются у базы.
И сорок четыре решения кода сборки, из которых до модели добирается девять — не как код, а как текст описаний.
Промпт нигде не сохраняется, он собирается на лету. Но в лог пишутся отпечатки блоков. Я пересобрал промпт тем же кодом и сверил: отпечатки совпали с записью от восьмого сентября. Поэтому приведённый состав — факт, а не реконструкция.
Реестр решений собран скриптом, не глазами. Строка не записывается, пока не подтвердится, что по указанному адресу стоит нужный код и что объявление лежит там, где сказано.
Разрыв между путями разложен исполнением запросов, без обращения к модели: в SQL менялось по одному условию.
Один стенд, одна предметная область, двадцать одна таблица. Числа отсюда не переносятся, переносится устройство.
Проба двух путей сделана на одном вопросе. Она показывает механизм, а не долю: повторяется ли он, я не проверял.
Пути отличаются не только формой таблиц. В сыром пути пришлось добавить таблицу часовых поясов — без неё запрос неисполним, — и описания там берутся из другой секции манифеста, где их всего два. Оба различия дают сырому пути небольшую фору.
Файл определений в обоих путях один и тот же, и он называет витрины, которых в сыром пути нет. Модель молча подставила сырые эквиваленты и не сообщила об этом.
Правило «не выбирай молча» записано в инструкции. То, что модель перечисляет развилки, — исполнение правила, а не её инициатива. Само поле для этого нигде не описано.
Все замеры сделаны на одноходовом агенте: один план, перезапуск только при падении. Следующий запрос по результату предыдущего он не пишет.