Сапёр в эпоху LLM: Создание Text-to-SQL агента для базы данных SAP ERP

Титульный рисунок
Титульный рисунок

Привет, Хабр! Если вы читали мою прошлую статью Сапёр в эпоху LLM: Повайбкодим на ABAP , то уже знаете, что попытка «повайбкодить» на ABAP с помощью LLM — затея, мягко говоря, неоднозначная. Модели «галлюцинируют», выдумывают несуществующие BAPI и таблицы, и в целом чувствуют себя в закрытой экосистеме SAP не очень уверенно. Как говорится, вайбкодинг не задался. В комментариях к статье прозвучала здравая мысль: будь у модели больше контекста, она бы справилась лучше.Раз появились такие идеи — значит, пора воплощать их в жизнь. На этот раз — новая серия экспериментов: в этот раз займемся переводом вопросов по SAP из обычного языка в SQL-запросы, плюс построим агента с необходимыми для этого инструментами.

Советуют - дай больше контекста в запрос, и все должно сработать. Часто это действительно работает. Но есть нюанс. «Больше» в мире SAP — это очень много. Десятки тысяч стандартных объектов. Попытка «скормить» всё это богатство в контекст любого современного LLM обречена на провал — мы просто упремся в лимит токенов. Ниже на скриншоте яркий пример - просто название всех существующих таблиц в системе превышает 6 млн токенов

Превышение токенов
Превышение токенов

Кто-то скажет: «Можно попробовать сохранить всю документацию и использовать RAG и векторные базы данных?». Идея для других областей вполне жизнеспособная, но с SAP нас ждёт засада. Названия объектов в SAP часто очень похожи, отличаются буквально одним символом. Для семантического поиска это почти одно и то же, а для системы — две совершенно разные сущности. Такая «мешанина» при поиске по смыслу приведёт к ещё большим ошибкам.
Так что же, всё безнадёжно? Не совсем.
Но здесь скрывается интересный парадокс, который и стал отправной точкой для этого исследования.

В чём секретное преимущество SAP для LLM? В его колоссальном цифровом следе. За десятилетия существования SAP вокруг него выросла гигантская экосистема: официальная документация на Help Portal, тысячи тредов на SAP Community и Stack Overflow, бесчисленные книги, блоги и опенсорс-проекты на GitHub. Многое из этого попало в обучающие данные больших языковых моделей.

В отличие от уникальной, закрытой корпоративной базы данных, стандартные таблицы SAP для LLM — старые знакомые. Модель «видела» VBAK, соединенную с VBAP, тысячи раз в разных контекстах. Она подсознательно «знает», что VBELN — это номер сбытового документа, а BUKRS — код компании.

С другой стороны, эта «осведомленность» не спасает от ранее озвученных проблем - многочисленных "галюцинаций"

А что, если дать LLM не просто задачу, а инструменты для её решения в реальном времени? Что, если она сможет использовать свои «воспоминания» о VBAK как гипотезу, а затем проверить её, задав прямой вопрос системе: «А есть ли такая таблица?», «А какие поля в ней?», «А что вернет вот этот простенький запрос?».

Так родилась идея создать агента, способного транслировать запросы на естественном языке в SQL-запросы к базе данных SAP, и проверить, сможет ли такой подход преодолеть проблемы системы.

Готовим полигон для испытаний

1. Тестовый датасет

Чтобы проверить, как справится LLM, я собрал небольшой датасет из 13 вопросов. Подход был простой:

  • Вопросы должны быть конкретными и понятными. Такие, на которые может ответить любой SAP-специалист, не переспросив десять раз «а что именно имелось в виду». Например вопрос "Покажи мне название задач потока операций, которые завершились с ошибкой", надо заменить на "Покажи мне название задач потока операций в статусе ошибка", тк интерпритации , что такое ошибка может быть несколько.

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

  • Определенные области. Я специально взял задачи из интеграции, поддержки системы, стандартой демо базы, тк это обезличенные, технически нейтральные. Это позволяет проверять, как модель работает в разных зонах SAP, не переживая за конфидинциальность.

  • Только стандарт. Все запросы построены на стандартных таблицах и полях. Кастомные объекты у каждой компании свои и уникальные, тестировать на них смысла нет.

В итоге получился такой набор из 13 вопросов (под катом):

Вопрос

Тема

Сложность

1

Сколько исходящих IDoc типа ORDRSP было создано за последние 4 дня?

Интеграция

Низкая

2

Выведи список входящих IDoc из системы HYB_PROD с распределением по типам сообщений (MESTYP) на даты с 20.07.2025 по 24.07.2025.

Интеграция

Средняя

3

Собери 5 самых частых типов ошибок входящих IDoc со статусом 51 (EDIDS) за последнюю неделю, учитывая только последний статус для каждого IDoc.

Мониторинг

Высокая

4

Сколько существует в расписании авиарейсов с вылетом из Франкфурта?

Демо

Низкая

5

Из аэропорта Франкфурта в пределах Германии на самолёте с вместимостью >400 мест: в какие города можно улететь?

Демо

Средняя

6

Найди фирму - изготовителя самолетов, продукцию которого чаще всего используют в авиарейсах.

Демо

Средняя

7

Выведи название 10 стандартных таблиц данных с наибольшим количеством полей

Метаданные

Средняя

8

Покажи мне название задач потока операций в статусе ошибка, которые были созданы 30.09.2025.

Workflow

Низкая

9

Найди тип выходного документа (со средством отправки - на печать) ,который чаще создавался и был успешно обработан за 24.09.2025

Outputs

Низкая

10

В Application Log выведи 10 самых частых результатов об ошибках за май, с указанием программы-источника.

Базис

Средняя

11

Найди фоновые задания, запускавшиеся 20.09.2025, которые длились более 2 часов и завершились успешно. Выведи длительность.

Базис

Средняя

12

Найди фоновые задания, завершившиеся с ошибкой сегодня, и выведи программу, на которой произошла ошибка.

Базис

Средняя

13

Сколько произошло дампов в системе за июнь 2025 года?

Базис

Низкая

2. Инструменты

В испытуемого была выбрана система ChatCPT 4.1. Какой то специальной идее за выбором этой LLM нет, просто она была доступна для .Но техническая реалезация эксперемента гибкая, и вы можете повторить его на любой модели. В качестве инструментов, как и в прошлый раз, был выбран универсальный Python для написании логики и вызова API + SQLite для хранения и анализа результатов.

Этап 1: Проверка «в лоб» — минимум инструментов, максимум галлюцинаций

Для начала я решил проверить, сможет ли модель справиться с задачей без внешних инструментов, полагаясь только на свои знания. Я просто попросил ее вернуть готовый SQL-запрос в ответ на мой запрос.
Результат оказался предсказуемым. Из 13 уникальных вопросов успешно решены были только 2, что составляет всего 15% успеха.
В остальных случаях — полный набор «галлюцинаций», до боли знакомый по прошлой статье:

  • Выдуманные таблицы: Модель уверенно пыталась делать SELECT из несуществующей таблицы SCITY (5 вопрос).

  • Выдуманные поля: В запросах то и дело появлялись несуществующие поля вроде CREATDAT, CRETTS, STARTDATE (2, 3, 11 вопрос).

  • Синтаксические ошибки: Попытки использовать некорректные операторы, например, UFLAG & 64 в SQL (10 вопрос).

Вывод: подход «в лоб» не работает. Модели не хватает знаний о реальной структуре БД, и без обратной связи она абсолютно слепа.

Этап 2. Агент с инструментами

После провала «в лоб» стало понятно: модели нужна помощь.Идея в том, чтобы дать LLM возможность использовать внешние инструменты для проверки своих гипотез.Я предоставил агенту три таких инструмента, которые он мог вызывать по своему усмотрению:

  • are_tables_present(table_names: list): Проверяет существование таблиц в системе.

  • get_table_fields(table_name: str): Возвращает список полей для таблицы с их описаниями и свойствами.

  • run_sap_sql_query(sql_query: str): Выполняет SQL-запрос и возвращает результат или ошибку.

Как это реализовано «под капотом», как соединить Python и SAP ERP? Вариантов может быть несколько: можно написать OData-сервис для трансляции вызовов в SQL, использовать RFC-вызовы (например, RFC_READ_TABLE) c помощью библиотеки PyRFC . Или мой вариант - использовать SAP GUI Scripting для взаимодействия с транзакцией DBACOCKPIT. Выбор пал на этот вариант , как на самый быстрый в реализации и не требующий особых полномочий. И разумеется, соблюдаем строгий принцип безопасности: LLM применяется только для чтения данных, а все операции изменения заблокированы на программном уровне даже на тестовой системе.

Проектируем «мозг» агента

В качестве архитектуры я выбрал подход, похожий на SGR (Schema-Guided Reasoning ). Агент на каждом шаге определяет, что ему делать дальше, и генерирует JSON-объект следующего шага: проверить таблицы, изучить поля, сделать пробный запрос или выдать финальный ответ. Мой Python-скрипт выступал в роли диспетчера: получал JSON, вызывал нужный инструмент и возвращал результат агенту для следующего шага.Цикл размышлений останавливался, когда LLM решала, что задача выполнена, либо заканчивались шаги для размышления, которые мы явно прописываем в цикле. Схема агента и промт следующий:

# ===== СХЕМЫ =====
class Tool_GetTableFields(BaseModel):
    tool: Literal["gettablefields"]
    table_name: Annotated[str, MinLen(1), MaxLen(40)]
class Tool_RunSapSqlQuery(BaseModel):
    tool: Literal["runsapsql_query"]
    query: Annotated[str, MinLen(10)]
    name: Optional[Annotated[str, MinLen(2), MaxLen(40)]] = None
class FinalAnswer(BaseModel):
    intent_summary: Annotated[str, MinLen(8), MaxLen(400)]
    sql_used: Annotated[str, MinLen(10)]
    result_summary: Annotated[str, MinLen(8), MaxLen(2000)]
    confidence: Annotated[float, Ge(0.0), Le(1.0)]
class Step_SelectTables(BaseModel):
    kind: Literal["select_tables"]
    thought: Annotated[str, MinLen(10)]
    tables_to_verify: Annotated[List[str], MinLen(1)]
class Step_ExploreAndProbe(BaseModel):
    kind: Literal["explore_and_probe"]
    thought: Annotated[str, MinLen(10)]
    actions: Annotated[List[Union[Tool_GetTableFields, Tool_RunSapSqlQuery]], MinLen(1)]
class Step_ExecuteFinalQuery(BaseModel):
    kind: Literal["execute_final_query"]
    thought: Annotated[str, MinLen(10)]
    final_sql: Annotated[str, MinLen(10)]
class Step_ProvideFinalAnswer(BaseModel):
    kind: Literal["provide_final_answer"]
    answer: FinalAnswer
class NextStep(BaseModel):
    next_step: Union[Step_SelectTables, Step_ExploreAndProbe, Step_ExecuteFinalQuery, Step_ProvideFinalAnswer]

# ===== ПРОМПТ =====
SCHEMA_JSON = json.dumps(NextStep.model_json_schema(), indent=2, ensure_ascii=False)
SYSTEM_PROMPT = f"""
Ты ассистент по SAP (ECC/S/4). Преобразуй запрос пользователя в SQL пошагово.
На каждом ходе верни РОВНО ОДИН JSON по схеме <JSON SCHEMA>.
ИНСТРУМЕНТЫ:
- are_tables_present
- get_table_fields
- run_sap_sql_query
ПРАВИЛА РАБОТЫ:
- CDS/HANA views не использовать. Z* таблицы не предлагать.
- При сомнениях существования таблиц — select_tables; для анализа полей таблиц — gettablefields.
- Разрешены пробные запуски в процессе размышления run_sap_sql_query. Ключевая особенность пробных запусков — всегда использовать ORDER BY для детерминированности и LIMIT для безопасности.
- Финальный SQL  - выполняется отдельно , без ограничений. 
ВСПОМОГАТЕЛЬНАЯ ИНФОРМАЦИЯ ДЛЯ ПОИСКА ОТВЕТА:
- У многих объектов в системе есть основная запись(header), и позиции, подпозиции, статусы итп. При поиске и связях не забываем группировать по основному номеру, если это требуется.


ВОЗВРАЩАЙ ТОЛЬКО JSON ПО СХЕМЕ.
<JSON SCHEMA>
{SCHEMA_JSON}
</JSON SCHEMA>
""".strip()

Этап 3: Получение сводных результатов

Прогнав те же 13 вопросов через нового агента, я получил кардинально иные результаты. Вместо галлюцинаций и синтаксических ошибок я увидел осмысленную цепочку действий.
Вот сухая выжимка из логов, которая говорит сама за себя:
Агент успешно справился с 11 из 13 задач (85% успеха)! Разница колоссальная.

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

Задача. Найди фоновые задания, запускавшиеся 20.09.2025, которые длились более 2 часов и завершились успешно. Выведи длительность.

Подход «в лоб».

SELECT BJ.JOBCOUNT, BJ.JOBNAME, BJ.STATUS, BT.STARTDATE, BT.STARTTIME, BT.ENDDATE, BT.ENDTIME, 
  ( 
    (TO_NUMBER(SUBSTRING(BT.ENDTIME,1,2)) * 3600 + TO_NUMBER(SUBSTRING(BT.ENDTIME,3,2)) * 60 + TO_NUMBER(SUBSTRING(BT.ENDTIME,5,2)))
    - (TO_NUMBER(SUBSTRING(BT.STARTTIME,1,2)) * 3600 + TO_NUMBER(SUBSTRING(BT.STARTTIME,3,2)) * 60 + TO_NUMBER(SUBSTRING(BT.STARTTIME,5,2)))
  ) / 3600 AS DURATION_HOURS
FROM BTCJOB BJ
JOIN BTCTIME BT ON BJ.JOBCOUNT = BT.JOBCOUNT
WHERE BT.STARTDATE = '20250920'
  AND BJ.STATUS = 'F'
  AND (
        (
          (TO_NUMBER(SUBSTRING(BT.ENDTIME,1,2)) * 3600 + TO_NUMBER(SUBSTRING(BT.ENDTIME,3,2)) * 60 + TO_NUMBER(SUBSTRING(BT.ENDTIME,5,2)))
          - (TO_NUMBER(SUBSTRING(BT.STARTTIME,1,2)) * 3600 + TO_NUMBER(SUBSTRING(BT.STARTTIME,3,2)) * 60 + TO_NUMBER(SUBSTRING(BT.STARTTIME,5,2)))
        ) / 3600
      ) > 2
ORDER BY DURATION_HOURS DESC

Ошибка: invalid table name: Could not find table/view BTCJOB

Как действовал Агент.

Агент_Размышление1
Агент_Размышление1
Агент_Размышление2
Агент_Размышление2

Как видите, возможность пошагово проверять свои гипотезы кардинально меняет дело.

Этап 4: Анализ ошибок и повторная обработка

Проанализируем, ошибочнчные результаты. Оставшиеся 2 неудачи были связаны не с синтаксисом, а с более тонкими логическими ошибками - был перепутан тип входящего и исходящего IDoc. Делаем вывод, что агенту не хватает знаний о текстовых значениях к доменам. Он должен чеко понимать, что исходящий тип - это 1 , а входящий -2 . Поможем ему в этом, а именно создадим еще один инструмент

  • get_domain_texts(domain_name: str): Возвращает список с постоянными идентификаторами и названиями домена.

А так же добавим новые инструменты в код и укажем в промте возможность его использования

# ===== СХЕМЫ =====
class Tool_GetDomainTexts(BaseModel):
    tool: Literal["get_domain_texts"]
    domain_name: Annotated[str, MinLen(1), MaxLen(40)]

Пробуем прогнать новую реализацию на ошибочных вопросах. Агент разобрался в типах сообщений и выдал верный SQL. Бинго!

Инсайты и практические выводы

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

  • Фиксируйте всё.

    Промпт, изменения промпта, ответы модели, финальные результаты — всё это лучше сохранять. Когда у вас есть тестовый датасет и логи, можно реально смотреть, как каждое изменение влияет на итог. Это превращает эксперименты в осознанный процесс, а не в хаотичное «попробовал так, попробовал эдак».

  • Ограничивайте запросы.

    LLM не думает о нагрузке на систему. Если дать ей полную свободу, она может сгенерировать запрос на миллионы строк и повесить вам базу. Поэтому я всегда ставил лимиты и фильтры: и в промптах, и на уровне инструментов. Исключение можно сделать только для финального запроса — но и там лучше перестраховаться.Так же контролируйте число шагов. Нужен разумный потолок итераций и ретраев. Иначе агент уедет в цикл, а вы — в расходы по токенам. Баланс: шагов достаточно для проверки гипотез, но не бесконечно.

  • Пошаговый подход работает.

    Да, это медленнее, но зато значительно надёжнее. Проверка таблицы → проверка полей → пробный SQL — эта последовательность реально снижает количество «галлюцинаций».

  • Не верьте в 100% повторяемость.

    Один и тот же промпт, запущенный несколько раз, может дать разные цепочки рассуждений. Для оценки стабильности я прогонял одни и те же вопросы несколько раз.

    Эти простые правила позволили заметно повысить процент правильных ответов. И именно на этом этапе стало ясно: да, у такого подхода есть перспектива.

Бонус-трек: Специализированный агент vs. универсальные платформы

Напоследок мне стало интересно: а как справятся с этой задачей другие, более общие агентские платформы? Я взял свои три инструмента и подключил их как MCP к IDE Trae, которую упоминал в прошлой статье, и прогнал те же вопросы на разных LLM.

MCP для SAP SQL в Trae
MCP для SAP SQL в Trae

Вывод оказался любопытным: мой узкоспециализированный, «самописный» агент отработал значительно лучше, чем универсальные агенты из «коробки». Это подтверждает гипотезу, что для таких специфических областей, как SAP, заточенное под задачу решение оказывается эффективнее общего, пусть и созданного более опытными командами.

Заключение

Эксперимент показал простую вещь: напрямую заставить LLM писать корректные SQL-запросы для SAP не получается. Даже сильная модель вроде GPT быстро спотыкается на чуть более сложных условиях, придумывает таблицы и поля, путает связи.
Как только у модели появляются инструменты — ситуация меняется. Проверка таблиц, проверка полей, пробные запросы — эти простые шаги резко повышают вероятность получить рабочий результат. Да, агент работает медленнее, и тратит намного больше токенов, но зато выдаёт то, что реально можно выполнить в системе.
Сравнение с другими агентами тоже подтверждает: персонализированное решение под SAP работает стабильнее, чем универсальные конструкции. Контроль шагов и жёсткая схема выигрывают у «магии» без правил.
Поэтому мой вывод такой:

  • «Наивный» подход без инструментов в SAP не работает.

  • Агент с проверками и пошаговой логикой — это уже рабочий вариант.

  • Дальше можно думать про расширение: подтягивать связи и описание типов из DDIC, подключать внешний поиск итд.

Код для построения агентов, доступен в репозитории на GitHub Text to SQL SAP Agent.
Пробуйте, экспериментируйте! Буду рад вашим комментариям, идеям и критике.