Почему кормить LLM сырыми SQL-запросами — это выжигание денег
Типичная сцена: AI-агент получает задачу «выбрать все заказы клиента за последний месяц». LLM генерирует SQL, отправляет его в базу, парсит результат. Звучит логично? На практике это — финансовое безумие. Opus 4.5 сожрет тысячи токенов только на том, чтобы сформулировать правильный запрос. А если SQL ошибочный — цикл повторяется.
Ещё хуже: генерация SQL через LLM — главный источник недетерминированных багов. Один раз она выдаст SELECT * FROM orders WHERE user_id = ?, в другой — SELECT * FROM orders WHERE customer_id = ?. Разница в одной букве, а результат — потеря данных. В агентном workflow Suzano именно от таких ошибок отказывались на первых итерациях.
Ключевое заблуждение: умная LLM == умный SQL. Нет. LLM не понимает схему базы, она просто угадывает. Доверять генерацию запросов нейросети — всё равно что нанять джуна, который не смотрел на ER-диаграмму.
Решение лежит на поверхности: нужно дать агенту готовые функции, которые принимают параметры и выполняют безопасные, заранее написанные запросы. Паттерн называется tool calling или функция-инструмент. Spring Data JDBC здесь — идеальный кандидат: он лёгкий, не тащит JPA-магию и легко оборачивается в REST-подобные методы.
Spring Data JDBC Skill: архитектура, которая режет затраты в 10 раз
Вместо того чтобы заставлять LLM писать SQL, мы создаём набор Skill-функций. Каждая функция — это метод репозитория, который описан в JSON Schema и зарегистрирован в AI-агенте. Агент выбирает нужную функцию, передаёт параметры, получает результат. Весь «умный» SQL остаётся в Java-коде.
Архитектура выглядит так:
- Класс сущности (например,
Order). - Репозиторий, наследующий
CrudRepository. - Сервис, который вызывает методы репозитория и возвращает DTO.
- Описание функции (JSON Schema) для LLM-провайдера.
- Регистрация функции в
ToolCallbackили аналогичном механизме.
Зачем здесь Spring AI? Этот фреймворк (подробный обзор тут) предоставляет готовые адаптеры для OpenAI, Anthropic, Ollama и других. Он автоматически сериализует вызов функции и встраивает её в промпт. Без Spring AI пришлось бы руками парсить ответы — а это частая причина багов.
Пошаговая сборка: от интерфейса репозитория до AI-инструмента
Покажу на примере: агент должен уметь получать заказы по ID клиента и статусу. Сделаем две функции. Весь код — Java 21, Spring Boot 3.4, Spring Data JDBC 3.4, Spring AI 1.4.
1 Сущность и репозиторий
@Table("orders")
public record Order(@Id Long id, Long customerId, String status, BigDecimal total) {}
public interface OrderRepository extends CrudRepository<Order, Long> {
List<Order> findByCustomerIdAndStatus(Long customerId, String status);
List<Order> findByCustomerId(Long customerId);
}
Spring Data JDBC сам сгенерирует SQL на основе имени метода. Без магии, без runtime-прокси — чистый SQL, который вы написали. Это безопасно и предсказуемо.
2 Сервис-обёртка с DTO
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
// Функция 1: получить заказы клиента по статусу
public List<OrderDTO> getOrdersByCustomerAndStatus(Long customerId, String status) {
return repository.findByCustomerIdAndStatus(customerId, status)
.stream()
.map(o -> new OrderDTO(o.id(), o.customerId(), o.status(), o.total()))
.toList();
}
// Функция 2: получить все заказы клиента
public List<OrderDTO> getOrdersByCustomer(Long customerId) {
return repository.findByCustomerId(customerId)
.stream()
.map(o -> new OrderDTO(o.id(), o.customerId(), o.status(), o.total()))
.toList();
}
}
3 Описание функций (JSON Schema) и регистрация в агенте
Используем Spring AI ToolCallback. Описываем каждую функцию через аннотации @Tool (доступен в Spring AI 1.4+).
@Service
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(name = "get_orders_by_customer_and_status", description = "Возвращает заказы клиента по ID и статусу")
public List<OrderDTO> getOrdersByCustomerAndStatus(
@ToolParam(description = "ID клиента") Long customerId,
@ToolParam(description = "Статус заказа (NEW, PROCESSING, SHIPPED)") String status) {
return orderService.getOrdersByCustomerAndStatus(customerId, status);
}
@Tool(name = "get_all_orders_by_customer", description = "Возвращает все заказы клиента по ID")
public List<OrderDTO> getAllOrdersByCustomer(
@ToolParam(description = "ID клиента") Long customerId) {
return orderService.getOrdersByCustomer(customerId);
}
}
Теперь подключаем инструменты к агенту. В конфигурации Spring AI:
@Bean
public ChatClient chatClient(ChatClient.Builder builder, List<ToolCallback> toolCallbacks) {
return builder
.defaultSystem("Ты — помощник по работе с заказами. Используй инструменты для запросов в БД.")
.defaultTools(toolCallbacks)
.build();
}
Готово! Теперь агент (даже на дешёвой модели вроде Claude 3.5 Haiku через Ollama) может выполнять точные запросы к базе, тратя на вызов функции 200-300 токенов вместо 2000+ на генерацию SQL.
Три грабли, на которые наступают все (и как их обойти)
| Грабли | Последствия | Решение |
|---|---|---|
| Слишком общие названия функций | Агент вызывает не ту функцию или игнорирует параметры | Именуйте как в REST: get_orders_by_customer_and_status, а не fetch_data |
| Отсутствие описаний у параметров | LLM передаёт мусорные значения (например, статус "active" вместо "ACTIVE") | Добавляйте enum-ограничения в "enum": ["NEW","PROCESSING","SHIPPED"] |
| Не проверять результаты на стороне агента | Пустой список трактуется как ошибка, начинается бесконечный ретрай | Возвращайте явный флаг: "orders": [], "found": false |
Вот что случается, если не обработать пустой результат: агент начинает повторять вызов с другими параметрами, сжигая токены. В статье про DQ-шаблон с MCP мы описывали похожий кейс — там агент зациклился на проверке качества из-за того, что не отдавал пустой результат как валидный ответ.
Ещё одна типичная ошибка — транзакции. Если функция выполняет запись, а агент вызывает её параллельно, возможны состояния гонки. Решение: сделать все методы write-функций синхронизированными или использовать @Transactional на уровне сервиса.
Считаем деньги: Opus против дешевой модели + JDBC Skill
Возьмём типовой сценарий: агент обрабатывает 10000 запросов в месяц. Каждый запрос требует двух обращений к БД.
| Сценарий | Среднее токенов на вызов | Стоимость 1K токенов (вход) | Итого в месяц |
|---|---|---|---|
| Opus 4.5, генерация SQL | ~1500 токенов (вход 1000 + выход 500) | $0.015 (выход ~$0.075) | ~$75 |
| Haiku 3.5 + JDBC Skill | ~200 токенов (вход 100 + выход 100) | $0.00025 (выход ~$0.00125) | ~$1.25 |
Разница в 60 раз. И это без учёта повторов на ошибках. Если Opus генерирует невалидный SQL в 20% случаев, цена взлетает до $90. Haiku с инструментом ошибается только в выборе функции (редко, если хорошо описали).
Кстати, на Open Agent Leaderboard видно: агенты с tool calling стабильно обходят чистые LLM по точности при одинаковой цене. А инструменты на Spring Data JDBC ещё и быстрее — в бенчмарке IBM JDBC-функции показали latency в 2 раза ниже, чем JPA.
Совет, который вы не ожидали
Самый частый вопрос: «А если я захочу дать агенту доступ к любой таблице?» Не делайте так. Полный произвольный SQL через функцию — это дыра в безопасности и гарантированный хаос. Вместо этого создайте мета-функцию, которая возвращает описание схемы, и позвольте агенту вызывать только заранее зарегистрированные запросы. Это как DESCRIBE TABLE, только для AI.
Попробуйте внедрить такой подход в своём проекте. Начните с одной критической таблицы — результаты удивят. Если понадобится оптимизировать промпты для выбора функций, рекомендую глянуть на GEPA optimize_anything — он как раз умеет подбирать описания инструментов, чтобы LLM реже ошибалась.