Сапёр в эпоху 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
Как действовал Агент.


Как видите, возможность пошагово проверять свои гипотезы кардинально меняет дело.
Этап 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.

Вывод оказался любопытным: мой узкоспециализированный, «самописный» агент отработал значительно лучше, чем универсальные агенты из «коробки». Это подтверждает гипотезу, что для таких специфических областей, как SAP, заточенное под задачу решение оказывается эффективнее общего, пусть и созданного более опытными командами.
Заключение
Эксперимент показал простую вещь: напрямую заставить LLM писать корректные SQL-запросы для SAP не получается. Даже сильная модель вроде GPT быстро спотыкается на чуть более сложных условиях, придумывает таблицы и поля, путает связи.
Как только у модели появляются инструменты — ситуация меняется. Проверка таблиц, проверка полей, пробные запросы — эти простые шаги резко повышают вероятность получить рабочий результат. Да, агент работает медленнее, и тратит намного больше токенов, но зато выдаёт то, что реально можно выполнить в системе.
Сравнение с другими агентами тоже подтверждает: персонализированное решение под SAP работает стабильнее, чем универсальные конструкции. Контроль шагов и жёсткая схема выигрывают у «магии» без правил.
Поэтому мой вывод такой:
«Наивный» подход без инструментов в SAP не работает.
Агент с проверками и пошаговой логикой — это уже рабочий вариант.
Дальше можно думать про расширение: подтягивать связи и описание типов из DDIC, подключать внешний поиск итд.
Код для построения агентов, доступен в репозитории на GitHub Text to SQL SAP Agent.
Пробуйте, экспериментируйте! Буду рад вашим комментариям, идеям и критике.