Оптимизация запросов 1С: цикл, в котором запрос переписывает LLM

Оптимизация запросов 1С на боевой базе почти всегда начинается одинаково. Отчёт, который на демо-данных открывался мгновенно, у клиента висит так, что пользователи успевают сходить за чаем. Дальше идёт ручная работа: поднять замер производительности или технологический журнал, поймать план запроса, разобрать, где скан вместо поиска по индексу, переписать текст, прогнать заново, сравнить цифры. Один такой круг — это не минуты, а полдня на несколько итераций, и бо́льшая часть времени уходит не на размышление, а на механику: собрать план, сопоставить _Reference441 с реальным справочником, свести метрики «до» и «после» в одну таблицу.

Механику можно отдать модели. Только не в формате «скопировал текст запроса в чат и попросил ускорить»: без плана выполнения и без структуры хранения ответ получается красивый и мимо. Ниже — разбор сервиса, который я собрал именно под этот цикл: расширение 1С отдаёт метаданные по HTTP, профайлер гоняет запрос по реальной базе и возвращает план с метриками, LLM переписывает запрос, и всё повторяется, пока метрики улучшаются.

Если нужен не разбор, а рабочий стенд под свою базу, такой цикл мы собираем со студентами на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — от расширения до замеров.

Почему «просто спросить у нейросети» не работает?

Текст запроса на языке 1С — это верхушка. Реальная стоимость выполнения живёт в трёх местах, которых в тексте нет:

  • структура хранения. Регистр накопления разворачивается в несколько физических таблиц, у каждой свой набор индексов, порядок полей в индексе решает, будет seek или scan. Из текста запроса это не видно вообще;
  • план выполнения. Какой оператор выбрал оптимизатор СУБД, сколько строк он оценил и сколько получил по факту, где встал Hash Match с мусорной оценкой кардинальности;
  • метрики. duration_us, logical_reads, cpu_time_us — единственный объективный признак того, что переписанный запрос действительно стал быстрее, а не просто выглядит аккуратнее.

Модель без этих трёх слоёв ведёт себя предсказуемо: предлагает индексировать реквизит, которого в конфигурации нет; советует CREATE INDEX, который в 1С поломает обновление конфигурации; переставляет соединения наугад. Это не дефект конкретной модели, это дефект постановки — ей нечем проверить гипотезу.

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

Схема стенда

Три процесса и одна база:

  1. Расширение 1С NS_SQL_Optimization — публикуется как HTTP-сервис. Отдаёт описание объектов метаданных и результат ПолучитьСтруктуруХраненияБазыДанных, а также умеет выполнять сам запрос, помечая его в SQL-трассировке.
  2. profiler-api — sidecar над MSSQL с Extended Events. Просит 1С выполнить запрос, ловит окно событий по маркеру, достаёт из кэша ShowPlanXML и отдаёт наверх метрики каждого стейтмента.
  3. Python-сервис на FastAPI — оркестратор. Извлекает из запроса объекты метаданных, ходит за ними в 1С, сжимает всё это до промпта, вызывает LLM, получает новый запрос и запускает следующий круг.

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

Сторона 1С: расширение, которое метит запрос и отдаёт метаданные

Чтобы вытащить из Extended Events события именно нашего запроса, а не всего потока рабочей базы, запрос обрамляется маркерами. Перед выполнением и после него 1С выполняет тривиальный запрос-метку с уникальным trace_id — по этим двум меткам профайлер потом вырезает нужное окно из ring buffer:

Функция ВыполнитьЗапрос(TraceID, ТекстЗапроса, ПараметрыЗапроса = Неопределено) Экспорт
    УстановитьПривилегированныйРежим(Истина);
    Результат = Новый Структура("rows, columns, elapsed_ms, error",
        Новый Массив, Новый Массив, 0, Неопределено);
    Попытка
        ВыполнитьМаркер(TraceID, "START");
        Старт = ТекущаяУниверсальнаяДатаВМиллисекундах();

        Запрос = Новый Запрос(ТекстЗапроса);
        Если ЗначениеЗаполнено(ПараметрыЗапроса) Тогда
            Для Каждого Параметр Из ПараметрыЗапроса Цикл
                Запрос.УстановитьПараметр(Параметр.Ключ, Параметр.Значение);
            КонецЦикла;
        КонецЕсли;
        Таблица = Запрос.Выполнить().Выгрузить();

        Результат.elapsed_ms = ТекущаяУниверсальнаяДатаВМиллисекундах() - Старт;
        ВыполнитьМаркер(TraceID, "END");
    Исключение
        Результат.error = ОписаниеОшибки();   // сюда попадёт и синтаксис от парсера 1С
    КонецПопытки;
    Возврат Результат;
КонецФункции

Процедура ВыполнитьМаркер(TraceID, Тип)
    Маркер = Новый Запрос("ВЫБРАТЬ """ + "AGENT_MARK_" + TraceID + "_" + Тип + """");
    Маркер.Выполнить();
КонецПроцедуры

Обратите внимание на Результат.error: текст ошибки платформы («Поле не найдено», «Таблица не найдена») здесь не глушится, а поднимается наверх. Дальше он пригодится — по нему оркестратор понимает, что модель сломала синтаксис, и запускает отдельный цикл починки.

Второй метод расширения отдаёт метаданные. Здесь важна не столько выгрузка реквизитов, сколько ПолучитьСтруктуруХраненияБазыДанных — именно она связывает объект конфигурации с физической таблицей MSSQL и её индексами:

Функция ОписаниеМетаданных(Имена) Экспорт
    УстановитьПривилегированныйРежим(Истина);
    Объекты   = Новый Массив;
    Найденные = Новый Массив;
    Для Каждого ПолноеИмя Из Имена Цикл
        Объект = Метаданные.НайтиПоПолномуИмени(ПолноеИмя);
        Если Объект = Неопределено Тогда
            Объекты.Добавить(Новый Структура("full_name, found", ПолноеИмя, Ложь));
            Продолжить;
        КонецЕсли;
        Объекты.Добавить(СформироватьОписаниеОбъекта(ПолноеИмя, Объект));
        Найденные.Добавить(Объект);
    КонецЦикла;

    Storage = Новый Массив;
    Если Найденные.Количество() > 0 Тогда
        ТЗ = ПолучитьСтруктуруХраненияБазыДанных(Найденные, Истина);
        Для Каждого СтрокаТаблицы Из ТЗ Цикл
            Storage.Добавить(СериализоватьЗаписьХранения(СтрокаТаблицы));
        КонецЦикла;
    КонецЕсли;

    Возврат Новый Структура("objects, storage", Объекты, Storage);
КонецФункции

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

Замер: план и метрики с реальной базы

Оркестратор не разговаривает с MSSQL напрямую. Он просит profiler-api выполнить trace_run — тот дёргает 1С, ждёт маркеры и возвращает список стейтментов с метриками и планами.

async def trace_run(self, query_text, params=None, *, target_db=None,
                    fetch_plans=True, trace_id=None):
    body = {"query_text": query_text, "params": params or {}, "fetch_plans": fetch_plans}

    # Чистим ring_buffer ПЕРЕД каждым trace_run: лимит memory_partition_target
    # ≈ 5000 событий, при параллельном трафике 1С маркер легко вытесняется
    # до того, как profiler-api успеет его прочитать.
    await self.clear_buffer()

    async with self._client() as c:
        r = await c.post("/trace/run", json=body)
        if r.status_code == 404 and "marker" in (r.text or ""):
            # ring_buffer всё-таки переполнился за время выполнения запроса —
            # чистим и повторяем ровно один раз
            await self.clear_buffer()
            r = await c.post("/trace/run", json=body)
        return r.json()

Этот кусок — хорошая иллюстрация того, чем замер на реальной базе отличается от замера в вакууме. Рабочая база живёт своей жизнью, ring buffer у Extended Events не резиновый, и половина инженерной работы уходит на то, чтобы гарантированно найти в потоке событий именно свой запрос.

Что именно кладём в промпт модели?

Сырой ShowPlanXML одного тяжёлого стейтмента — это десятки тысяч символов: повторяющиеся Database/Schema/Table в каждой ссылке на колонку, полные OutputList, блоки OptimizerStatsUsage. Отдавать это модели целиком бессмысленно и дорого. План сжимается в текстовое дерево операторов: имя оператора, оценка стоимости и строк, таблица с индексом, ключи соединения, predicate, и отдельным блоком — MissingIndexes и Warnings. На реальных планах из прогонов сжатие выходит десятикратное и больше (37k символов превращаются в 1-3k) при сохранении всего, что нужно для решения.

Метаданные сжимаются похожим образом: парсер вытаскивает из текста запроса псевдонимы источников (ИЗ <ПолноеИмя> КАК <Псевдоним>) и обращения к полям, после чего из описания объектов выбрасывается всё, к чему запрос не обращался. Системные поля, индексированные реквизиты, измерения и ресурсы регистров остаются всегда — они формируют ключ таблицы, и без них план не читается.

Сам промпт собирается одним JSON-блоком: исходный запрос, результат выполнения со стороны 1С, дедуплицированные стейтменты и метаданные.

def _build_user_message(req_query_text, trace, metadata=None):
    onec = trace.get("onec") or {}
    block = {
        "original_query_text": req_query_text,
        "onec": {
            "rows_count": len(onec.get("rows") or []),
            "elapsed_ms": onec.get("elapsed_ms"),
            "error": onec.get("error"),
        },
        "statements": _statements_for_prompt(trace),   # дедуп + top-10 по duration
    }
    if metadata and (metadata.objects or metadata.storage):
        block["metadata"] = {
            "objects": [o.model_dump() for o in metadata.objects],
            "storage": [s.model_dump() for s in metadata.storage],
        }
    return (
        "Исходные данные для оптимизации:\n\n"
        f"```json\n{json.dumps(block, ensure_ascii=False, indent=2)}\n```\n\n"
        "Верни JSON по схеме SQLData."
    )

_statements_for_prompt схлопывает одинаковые стейтменты по нормализованному тексту (один запрос 1С часто разворачивается в десятки почти одинаковых SQL), берёт по каждой группе максимум метрик и отдаёт десятку самых тяжёлых по длительности. Ответ модели тоже структурный: Pydantic-схема с двумя полями — sql (переписанный запрос строго на языке запросов 1С) и research (обоснование, куда уходят рекомендации по индексам, которые нельзя выражать через DDL).

Такой контекст-инжиниринг — половина успеха в любой связке «1С + модель». Тот же принцип работает и когда агент пишет код конфигурации: подробнее об этом — в разборе вайбкодинга в 1С и в статье про Claude Code на задачах 1С.

Разбор промптов, схем ответа и режимов отказа мы делаем на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов» — там же собирается вся связка целиком, от расширения до цикла.

Когда останавливать цикл оптимизации?

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

0. ensure_session()                      — создать и запустить XE-сессию
1. extract_entities(llm, query_text)     — LLM извлекает объекты метаданных из текста
2. OnecClient.fetch_metadata(entities)   — структура объектов и хранения из 1С
3. apply_filter(...)                     — сжать метаданные до используемых полей
4. итерация 0 (baseline)                 — trace исходного запроса
5. итерации 1..N                         — LLM переписывает → trace → сравнение

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

new_query = (sql_data.sql or "").strip()
if not new_query:
    artifact.stop_reason = "llm_empty_sql"
    break

if _normalize_query(new_query) == _normalize_query(current_query):
    # LLM решил, что текущий запрос оптимален — фиксируем итерацию
    # с research, чтобы пользователь увидел обоснование, и выходим
    artifact.stop_reason = "llm_query_unchanged"
    break

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

Про синтаксис отдельно. Когда 1С отклоняет переписанный запрос («Поле не найдено», «Ожидается …»), запускается отдельный короткий цикл с другим промптом — модель видит только сломанный запрос и текст ошибки, без плана и метрик. Такая починка не расходует бюджет оптимизационных итераций: это не улучшение, а возврат в рабочее состояние.

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

Что из этого переносится на любой похожий цикл

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

  • Модели нужен не текст, а стенд. Контекст обязан содержать три вещи: что изменяем, в каких условиях это исполняется (схема, структура хранения, окружение) и объективный замер. Уберите любую из трёх — получите правдоподобные галлюцинации.
  • Замер после каждой итерации, а не в конце. Без промежуточного замера цикл превращается в цепочку предположений, и к третьей итерации никто уже не знает, какая правка что дала.
  • Критерий остановки формулируется заранее и в терминах системы, а не «на глаз». «Модель вернула тот же артефакт», «лимит итераций», «модель вернула пустой ответ», «результат не проходит валидацию» — каждая причина остановки должна быть записана в артефакт прогона, иначе разбирать неудачные прогоны нечем.
  • Ошибки исполнения — отдельный подцикл со своим бюджетом. Починка синтаксиса и содержательное улучшение — разные задачи, и смешивать их лимиты вредно.
  • Семантика не должна меняться. В нашем случае это проверяется числом строк результата: если после переписывания их стало другое количество, версия отбрасывается. Ускорение ценой другого ответа — не оптимизация.

Что проверить руками до того, как звать модель?

Половина тяжёлых запросов чинится по чек-листу, без всякого стенда. Соединение с подзапросом или виртуальной таблицей выносится во временную таблицу с ИНДЕКСИРОВАТЬ ПО. ИЛИ в условии разбивается на ОБЪЕДИНИТЬ ВСЕ. Обращение через точку к полю составного типа (классика — Продажи.Регистратор.Дата) разворачивается платформой в соединение со всеми типами документов и лечится через ВЫРАЗИТЬ либо денормализацию. Условия отбора виртуальной таблицы переносятся из ГДЕ в её параметры.

Стенд с моделью нужен там, где чек-лист закончился: индексы не совпадают с условиями, план выбирает скан вместо seek, а причина неочевидна без цифр. Обзор того, где вообще ИИ сегодня даёт 1С-разработчику реальный выигрыш, я собрал отдельно — ИИ для 1С в 2026 году.

Где проходит граница

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

За кадром остались вещи, которые определяют, будет ли эта конструкция работать на чужой базе: формулировки промптов оптимизации и починки синтаксиса, разбор режимов отказа (модель меняет семантику, придумывает реквизиты, зацикливается на одной гипотезе), фильтрация метаданных до размера, который влезает в контекст, и сравнение бэкендов — облачная модель против локальной. Это разбирается на курсе «ИИ для 1С: разработка ИИ-агентов, RAG и MCP-серверов»: там мы собираем связку целиком — расширение, сервис, промпты, цикл — и прогоняем её на реальных запросах.

Частые вопросы