Привет, на связи команда BI-разработчиков коммерческого департамента: Алексей Дубинец и Павел Беспалов.
В статье расскажем, как построили пайплайн миграции наших витрин с помощью локальной LLM.
Сразу оговорюсь: это не история про то, как мы дали в Codex или Claude Code, написали скилл и получили магический результат. Покажем инженерную часть задачи: где локальная модель срабатывает не так, какие наивные решения не работают и что пришлось построить вокруг неё, чтобы перевод стал воспроизводимым.
В этой статье:

Зачем понадобился инструмент
Авито переходит с базы данных Vertica на Trino. Нам нужно было перевести около 65 витрин данных из одного диалекта в другой.
Витрины представляют собой header управления, множество временных таблиц, различные преобразования, обогащение логики. В одной таблице могло быть от 500 до 4000 строк кода.
Первые мысли по задаче были: «Где найти стажера, чтобы он всё сделал сам?» → «Где найти инструмент, который облегчит работу по миграции?». В открытом доступе ничего адекватного найти не удалось. Рассматривали SQL Glot и RegExp, а ручной перевод — долгий, дорогой и нестабильный.
Облачный LLM можно использовать, только если компания готова передавать свои данные: бизнес-логику, название схемы таблиц, другую внутреннюю информацию. Другой нюанс: такой перевод может быстро съесть 5-часовые лимиты и ограничен по масштабированию.
На этом фоне локальные LLM модели выглядели хорошей альтернативой. Они условно бесплатные и все данные остаются в вашем контуре.
Хотелось взять Vertica SQL код и отдать его локальной модели со словами: «Сделай перевод из Vertica SQL в Trino SQL, только без ошибок», — и чтобы она сама разобралась со всеми проблемами.
Но так, к сожалению, не работало. Качество маленьких локальных моделей было на порядок хуже облачных. Они плохо держали контекст, теряли саму структуру запроса, пропускали правила из промпта, галлюцинировали и путали диалект.
В какой-то момент стало понятно: нужна не обособленная локальная LLM, а инженерная система, которая позволит использовать локальную модель в реальной миграции, несмотря на её слабости. Тогда задача с «подобрать промпт, выбрать модель, определить few-shot», сменилась на набор вопросов:
Как ограничить область ответственности модели?
Как разбить задачу на части?
Как сохранить промежуточные состояния?
Как проверить результат?
Как усилить контроль перевода?
Из этого и родился проект. Это не «чат с SQL» и не подбор «царь-промпта». Проект интересен не тем, что умеет переводить Vertica SQL в Trino. Он показывает, как из слабой по качеству, но доступной локальной LLM сделать полезный инструмент, если перестать ждать универсальности и правильно организовать процесс вокруг неё.
Почему идея «дать полный SQL в LLM» не работает
У нас была локальная LLM, понятная задача, хорошо описанные правила перевода с примерами. Казалось, что задача сводится к подбору промпта и few-shot примеров. Но на реальных витринах это не работало.

И даже если контекст целиком загрузился — это не значит, что модель корректно его отработает. Она может начать галлюцинировать, забывать правила или части кода, терять структуру запроса и зависимости по мере диалога. Такое явление называется «контекстное гниение» (Context rot). У небольших локальных моделей на 30B moe деградация качества может наступать уже на >30k токенов.
В обучающих данных модели могло быть очень мало примеров Trino SQL, из-за чего начинала выдавать похожие синтаксические конструкции из других диалектов.
Это связано с принципом работы LLM. Если говорить упрощенно, модель возвращает несколько ответов и выбирает тот, что подходит с наибольшей вероятностью. В таких ответах нет варианта: «Я не знаю, как это сделать» — ближайший вероятный ответ лежит в другом семантическом диалекте.
Интуитивно кажется, что если ты написал что-то в промпте, то LLM всегда будет строго следовать инструкциям. На практике это не так. После перевода все равно оставались неявные ошибки. Их нужно было как-то проверить.

Архитектура проекта
Он устроен как модульный пайплайн с оркестратором, который управляет порядком стадий и записывает metadata.json.

Этап Split — нужно было уменьшить размер контекста, упростить задачу модели и сделать каждую итерацию перевода дешевле и стабильнее. Это было важное решение, которое задало ритм всего процесса.
Этап позволил видеть статус каждого блока, хранить версии правок, локализовать ошибку, продолжить работу с нужного места, отображать прогресс на дашборде.

Translate переводит части через compiled DSPy модуль. Каждая часть обрабатывается отдельно. На небольшой части несложного кода может быть значительное количество изменений: в примере ниже — 9 правок.

Pattern_guard — после перевода могли остаться «простые» ошибки, известные запрещенные Vertica-паттерны. Такие ошибки отлавливаются здесь через regexp. Набор запрещенных паттернов хранится в отдельном файле и легко расширяется.

Format — форматирует SQL с помощью sqlfluff. К сожалению, этот модуль принёс больше проблем, чем пользы. Пришлось его отключить по умолчанию в .env — остался как legacy.
Assemble — собирает итоговый файл. Важно, что в сборке участвуют только последние версии частей.
Api_validate — проверяет платформенные правила и применяет известные сценарии исправлений.
Trino_test — запускает SQL в Trino и может включать цикл исправлений.
Compare — сверяет результат с эталонной Trino-витриной.
Report — собирает аналитический отчет.
Finalize — переносит результат в “workflow/done” или “workflow/review”.
Metadata — на первый взгляд метаданные выглядят, как служебная техническая деталь. Но на практике именно они сделали пайплайн зрелым: перевод перестал быть одноразовым LLM-сеансом и превратился в воспроизводимый процесс с памятью.
По мере развития проекта в метаданных начали жить зависимости частей, история исправлений, диагностика по стадиям, сведения для retry и артефакты, из которых потом вырос dashboard.
Если коротко: metadata.json довольно быстро перестал быть логом и стал фактической операционной памятью всей системы.
Что это дает:
Видим, на каком этапе находится конкретная витрина, какие части уже готовы, какие правки внесены и какие ошибки обнаружены.
Безопасно возобновляем работу с того же места после остановки.
Отслеживаем прогресс через дашборд.
Собираем сводную статистику по процессу миграции.
Сохраняем названия частей и зависимости между ними, чтобы использовать их как дополнительный контекст.
Почему не harness + skill? Была попытка сделать Kilo Code + Skill, но это почти сразу не сработало. Локальному агенту нужно удерживать слишком много контекста. Также у него быстро размывалась ответственность: где он переводит, где чинит, а где диагностирует. Если процесс обрывался на середине, всё накопленное состояние терялось.
📋 Более детальная схема процесса

DSPy framework
Мы попросили DeepSeek, Qwen и Kimi сгенерировать системные промпты для перевода Vertica SQL в Trino — получили три варианта, которые заметно различались.
Без понятной метрики невозможно было объективно оценить, какой из них лучше. А как только инженерная задача начинает опираться на ощущения, воспроизводимость быстро теряется.
Нам нужно было выжать максимум из локальной модели, значит, и к подбору промпта стоило подходить не интуитивно, а инженерно.
К тому моменту у нас уже накопились десятки переведенных витрин. Ещё было описание основных типов паттернов миграции: то есть появилась база, на которой можно было обучать DSPy.
Вначале хотелось взять полный код, сделать «SQL было» → «SQL стало», и отдать все это DSPy, чтобы он подобрал лучший промпт + few-shot. Но так не работало.
Полный SQL слишком тяжелый. Он занимает много контекста и плохо подходит для большого числа сравнительных прогонов.
DSPy нужна метрика относительно которой он будет делать оптимизацию промпта
Полное выполнение SQL может быть слишком долгим для проверки обучения.
Поэтому мы приняли решение обучать его не на полных витринах, а на изолированных паттернах.

Но как вообще оценивать какой промпт лучший, если у нас есть только паттерны?
Мы выбрали proxy-метрику из трех частей:
1️⃣ 0.6 балла дает llm_score. Модель-судья (LLM-as-a-judge) получает исходный паттерн, решение и эталон, а затем оценивает качество перевода.
2️⃣ 0.2 балла дает validity-сигнал по запрещенным паттернам. Если есть запрещенный паттерн, то 0, если нет, то 0.2.
3️⃣ 0.2 балла еще дает exact сигнал (совпадение текста по Jaccard similarity), который помогает усилить результат в аккуратных решениях.
Total = 0.2 validity + 0.2 exact + 0.6 * llm_score
Отдельную роль сыграло полное логирование процесса обучения DSPy. Именно оно помогло увидеть, на каких паттернах модель чаще всего ошибается и усилить обучающую выборку.
По дороге всплыло несколько неприятных, но очень полезных выводов:
Если сразу обнулять итоговую оценку за один запрещенный паттерн, модель хуже различает «совсем плохо» и «почти правильно» и учится заметно слабее.
Один неудачный regexp в метрике способен несколько дней убеждать тебя, что качество не растет, хотя проблема вообще не в модели.
Если примеры в датасете разложены неудачно, можно случайно получить красивую валидацию на знакомых паттернах и провал на новых.
Reasoning mode не дал ощутимого прироста качества, но заметно увеличивал время обучения и перевода.
Промпт, который хорошо работал с Qwen Coder Next, мог заметно просесть с Gemma 4. Промпты оказались привязаны к моделям и при переносе теряли в качестве.
Даже хороший результат proxy-метрики не отменял остальной pipeline: pattern_guard, trino_test и compare все равно оставались обязательными.
Результат
DSPy не сделал слабую модель сильной сам по себе, но помог поднять стартовое качество перевода и перевести работу с промптом из режима шаманства в режим инженерного цикла. По внутренней proxy-метрике, качество обучения удалось поднять примерно с 81% до 90%.
Проверка результата трансляции
Для проверки использовали три слоя — в порядке нарастания цены их использования.
1️⃣ Pattern_guard
Это regexp проверка запрещенных конструкций. Просто и понятно: если ошибка повторяется, хорошо формализуется и дешево ловится, ее можно вынести в отдельный предсказуемый и масштабируемый слой.
Контекст срабатывания сохраняется в metadata — её роль тут расширяется. Metadata хранит не только состояние процесса, но и найденные дефекты, попытки починить трансляцию и результаты исправления.
Слой должен отлавливать известные проблемы ближе к месту их появления. Поздние ошибки дороже ранних.
2️⃣ API_validate
В Авито реализованы автотесты SQL скриптов, которые можно вызвать по API. Они проверяют платформенные требования к витринам. Требования могут не относиться к семантике Trino: header, допустимые объекты и их наличие в БД, структура и так далее.
Также тут происходит проверка на наличие всех объектов в БД и замена на актуальные имена витрин. Важно, что нерешенные проблемы на этом этапе не блокируют статус done, а записываются, как предупреждение.
Мы старались не зашивать в промпт правила, как оформить header. На этапе обучения DSPy было предусмотрено поле context_hint, в которое потом подмешиваются разные дополнительные правила или требования для разных этапов исправления.
3️⃣ Trino_test
Отвечает за техническую исполнимость каждой отдельной части в Trino. Реальные ошибки передаются как контекст в LLM. В проекте этот блок постепенно стал почти отдельным контуром починки.
Здесь у модели появляются инструменты:
Посмотреть на ошибку.
Прочитать другую часть SQL по связи в Trino или Vertica.
При необходимости запросить дополнительный контекст из Trino или Vertica
Построить гипотезу ошибки и план исправления.
Внести правку.
Проверить код.
Compare отвечает за самый важный вопрос: совпали ли данные? На период миграции у нас есть возможность сделать реплику витрины из Vertica в Trino для того, чтобы провести сопоставление.
Эта стадия работает как отдельная Trino-only проверка и сейчас не делает LLM вызовов. Она сравнивает итоговую таблицу с эталонной. Результат записывается в reports/compare_reports.json.
Важно, что Compare проверяет не просто «таблица собралась», а «сохранилась ли семантика витрины». На практике одного COUNT(*) недостаточно: результат может иметь то же число строк, но отличаться по ключам, атрибутам, метрикам, обработке Null и так далее.
Поэтому compare стоит делать в нескольких режимах: точное сравнение по ключу, сверка keys+metrics, а для крупных витрин — агрегированный режим и выборочные диагностические срезы. Особенно критичны расхождения, типичные для миграции: Null, округления, Datetime, касты.
Если Trino_test или Compare остаются неуспешными, workflow уходит в ревью. Также по итогу работы все результаты изменений записываются в analysis_report.

В любом случае, когда перевод закончен, итоговая проверка и решение остается за человеком. Только человек может нести ответственность за результат.
Для мониторинга прогресса мы добавили dashboard на streamlit.

Какая модель сработала
Отдельный результат проекта — практическое понимание поведения локальных моделей в таком workflow.
Мы проверяли разные варианты: GLM 4.7 flash, Qwen 3.5 122b-a10, Qwen 3.6 35b-a3, qwen 3.6 27b dense, QwenCoder Next, Devstral small, Gemma 4 26b-a4, Gemma 4 31b dense и другие.
Для проекта важно не только как модель переводит, но и то, как она:
Понимает Trino SQL
Придерживается инструкций
Не галлюцинирует
Работает с длинным контекстом
Умеет пользоваться инструментами в зависимости от ситуации
💡 Один из выводов: хороший переводчик не обязательно хороший агент и наоборот, хороший агент может в целом плохо переводить основную часть.
🔴 Qwen Coder Next и Devstral small показали хорошие результаты на переводе, но хуже подходили для стадии тестирования и ремонта. Они не понимали, как использовать инструменты чтобы решить задачу.
🔴 Большие надежды были на qwen 3.6 27b dense, но она очень плохо переводила основную часть, добавляла синтаксис Postgres SQL, путала функции, DDL и явные приведения типов.
🔴 Не оправдала ожиданий reasoning. Интуитивно кажется, что дать модели подумать должно улучшить качество исправления, но на практике существенного прироста не было, а время обучения и перевода выросло 8 раз.
🟢 В итоге остановились на Gemma 4 31b qat. Модель достаточно хорошо переводит, держит контекст и соблюдает инструкции, не разваливается на ремонте и работает с приемлемой скоростью.

Что уже получилось
На момент подготовки статьи нам удалось перевести 15 витрин.
Перевод одной сейчас занимает 30–60 минут, тогда как раньше могло уходить 3–5 часов. Это важный показатель воспроизводимости: система уже прошла не только демонстрационные примеры, но и реальные рабочие сценарии.

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

Но даже в таком режиме ценность инструмента уже вполне практическая:
Уменьшает объем ручной переписки SQL
Делает длинные миграции восстанавливаемыми
Позволяет систематизировать и накапливать историю ошибок для обучения
Ускоряет цикл «перевести → проверить → исправить»
Сохраняет историю решений и диагностику
Выводы
Если свести весь проект к одной мысли, то она довольно простая.
Мы не нашли модель, которая сама безошибочно переводит Vertica SQL в Trino SQL. Но слабую локальную LLM можно сделать полезной, если не требовать от нее идеального результата с первого раза, а встроить ее в систему, которая ограничивает цену ошибки, хранит состояние, ловит типовые дефекты, запускает SQL, сверяет данные и честно отправляет проблемные случаи в ревью.
Что можно развивать дальше
Следующие шаги хорошо видны из текущих ограничений.
👉 Расширять golden dataset не только хорошими примерами, но и типовыми ошибками с исправлениями. Это усилит и перевод, и repair-сценарии.
👉 Добавить примеры негативного обучения в обучающий датасет. Чего не должно остаться в результате и как выглядит типовая неудачная попытка.
👉 Развивать модуль compare. Сейчас он хорошо отвечает на вопрос «сошлось или нет».
Следующий уровень — помогать понять, почему не сошлось, и локализовывать источник: на уровне ключей, конкретных метрик, диапазонов дат, джойнов или промежуточных частей пайплайна.
Тогда compare станет не финальным сигналом ошибки, а инструментом расследования, который сокращает путь от «не сошлось» до «вот где именно сломалась логика».
👉 Отдельно измерять модель как транслятор и как агент по ремонту SQL. Практика уже показала, что это разные способности, и смешивать их в одной оценке вредно.
👉 Улучшать объяснимость для человека. Чем сложнее пайплайн, тем важнее показывать не только итоговый SQL, но и историю: какие стадии вмешивались, какие дефекты нашли, какие попытки починки были и почему результат считается готовым или отправлен в review.
Если хотите знать больше о работе со сложными продуктами, подписывайтесь на телеграм-канал «Коммуналка аналитиков». Там интересно!


