Проблема:
У крупного пищевого холдинга накоплена огромная база третичных продаж:
552 млн строк, 53 столбца, 21 месяц данных.
Это sellout из розничных сетей - «Пятёрочка», «МАГНИТ», «ДИКСИ», «ЛЕНТА» и ещё с десяток - с детализацией до конкретного магазина, SKU, цены, промо-активности, типа упаковки и даже признака «халяль»
Аналитики регулярно задают похожие, но каждый раз чуть отличающиеся вопросы:
«Покажи топ-30 варёно-копчёных колбас в Пятёрочке по Москве»,
«Какие творожные продукты растут в продажах?»,
«Где точки роста сырой мясной продукции в Тверской области?»,
«Сформируй матрицу индекса цен по сетям и категориям за год».
Есть ряд типовых задач: от простых top-N выборок до сегментации магазинов и выявления негативных трендов с их причинами.
Каждый такой запрос - это цикл:
понять контекст, написать SQL, проверить фильтры (где
Пятёрочка с буквой ё, где МАГНИТ капсом, где Творожная группа вместо Творог), прогнать, оформить выводы. Обычный пользователь (не познавший SQL кунг фу) не сможет самостоятельно сделать запрос и получить вменяемый результат.
Задача:
дать бизнес-пользователю инструмент, где он пишет вопрос на русском языке и получает готовый аналитический ответ с цифрами и выводами.
Ключевой инфраструктурный контекст:
Локальная модель
gpt-oss-120b поднята на сервере у Валеры в подвале (да, это жирно для такой задачи, но для скорости решил на большой модельке, потом буду оптимизировать для работы на более мелкой)Данные о продажах - коммерческая тайна, и отправлять 550 млн строк третичных продаж в облачные API категорически нельзя.
Вся цепочка: LLM, ClickHouse, Telegram-бот (для тестирования выбрал фронтом) живёт на одной площадке, ничего не уходит наружу.
С точки зрения кода это означает, что мы используем
OpenAIChatCompletionsModel из OpenAI Agents SDK, но подключаем его к локальному эндпоинту через AsyncOpenAI(base_url=..., api_key=...)SDK абстрагирует протокол - ему всё равно, что на другом конце не OpenAI, а наш собственный инференс-сервер.
Модель работает с
temperature=0 для детерминированности аналитических ответов.Почему не «один вызов LLM —> один SQL»
Первая архитектура была наивной: вопрос → LLM генерирует один SQL → выполняем → отдаём результат.
Быстро стало ясно, что это не работает на реальных задачах.
Проблема в данных.
53 столбца с неочевидной семантикой.
Пятиуровневая товарная иерархия:
category -> subcategory -> group_name -> subgroup. Пользователь скажет «творог», а в данных это
subcategory = 'Творожная группа', не 'Творог'. Скажет «в Пятёрочке», а в столбце
client значение 'Пятёрочка' с буквой ё, не 'ПЯТЕРОЧКА'. Скажет «варёные колбасы», это
group_name, не subcategory и не category.Один неправильный фильтр и запрос возвращает 0 строк, пользователь получает пустой ответ.
А есть и более сложные задачи: «Обозначь точки роста продаж сырой мясной продукции в Тверской области, исключая сезонность».
Тут нужно:
1. Сначала разведать, какие значения есть в данных — какие
group_name внутри мясной продукции, что именно в данных соответствует Тверской области.2. Построить основной запрос с агрегацией по месяцам.
3. Рассчитать тренд роста через
lagInFrame() — потому что в ClickHouse нет linearRegression.4. Отфильтровать сезонные всплески.
Один LLM-вызов с одним SQL этого не потянет.
Архитектура: Agent Loop на OpenAI Agents SDK
Ядро -
Agent из OpenAI Agents SDK с agent loop и tool calling (можно и на SGR agent core сделать легко):Telegram User
│
▼
TelegramBotApp ─── typing indicator
│
▼
AnalyticsAgentService
│ Runner.run(agent, input, context, max_turns=25)
▼
Agent Loop (LLM ⇄ Tools, до 25 итераций)
│
├── run_sql → SQL-запрос к ClickHouse
├── get_reference_values → разведка значений полей
│
└── финальный аналитический ответ → Telegram
🔵 Продолжение следует
@alexs_journal