Основы и tool-вызовы
Это первая из двух статей про построение AI-агента внутри Джеймикс-приложения. Джеймикс (или Jmix, ex. CUBA) - высокоуровневый фреймворк для разработки корпоративных приложений на Java, автор не будет слишком сильно в него погружаться, в наше время любой запрос к AI даст Вам всю нужную информацию. В этой части мы соберем минимальный, но рабочий пример: пользователь задает вопрос на естественном языке, агент решает, какие операции вызвать на бэкенде, дергает их и возвращает осмысленный ответ. В качестве предметной области возьмем склад - сценарий, узнаваемый для большинства бизнес-приложений и достаточно широкий, чтобы во второй части обсудить уже не только чтение, но и запись данных, безопасность, fetch plans и метаданные.
Зачем это вообще нужно? Данные корпоративного приложения живут за списками и формами с фильтрами. Это отлично работает, когда пользователь знает, по каким полям фильтровать - и плохо для размытых, многокритериальных вопросов вроде "где у нас заканчивается кофе тёмной обжарки по северным складам?". Когда иначе пришлось бы открыть несколько экранов и руками свести результаты, AI-агент даёт возможность просто спросить - и собирает ответ из бэкенд-операций, которые у вас уже есть.
Почему строить это внутри Джеймикс-приложения, а не отдельным сервисом? В случае Джеймикса агент едет на том же доступе к данным и той же безопасности, что уже есть во фреймворке, его tools идут через DataManager, поэтому он видит ровно то, что разрешено текущему пользователю - никакого параллельного пути к данным, никакого обхода прав. Именно это свойство делает агента приемлемым в enterprise-контексте, и это поведение - сквозная нить обеих частей.
Эта статья - для разработчиков, которым комфортно с Java и Spring, и для тех, кто хочет добавить к реальному приложению слой общения на естественном языке. Мы не спорим, нужен ли агент вообще - в конце второй части будет честный раздел "когда не нужен" - и не делаем обзор ландшафта агентных фреймворков. Здесь мы сразу берёмся за дело.
Статья написана по мотивам Spring AI tutorial: Building AI agents with Spring AI из InfoWorld. Там же показан вариант на чистом Spring без Джеймикс - будет полезно сравнить.
Полный исходник демо лежит здесь: https://github.com/jmix-edu/ai-warehouse - можно клонировать и сразу запустить.
Что такое agent loop и почему это не чат-бот
Чат-бот - это функция: текст на вход, текст на выход. Все, что он знает - содержится в промпте (prompt) и в весах модели. Если задача требует доступа к данным приложения или к внешним системам - простой чат-бот ее не решит.
AI-агент устроен принципиально иначе. Это цикл, в котором модель не просто отвечает, а выбирает действие из заранее объявленного набора. Каждое действие - это обычный Java-метод, помеченный аннотацией @Tool из Spring AI. Модель возвращает вызов инструмента (tool) с аргументами, фреймворк этот вызов выполняет, результат складывается обратно в контекст, и цикл повторяется до тех пор, пока модель не сочтет, что у нее достаточно данных для финального ответа.
В псевдокоде это выглядит примерно так:
loop: response = model.call(messages + system_prompt + tools_spec) if response is final_answer: return response.text for tool_call in response.tool_calls: result = invoke(tool_call.name, tool_call.arguments) messages += tool_result(result) if iterations > MAX: return "could not finish"
Этот цикл можно реализовать руками - и полезно один раз увидеть как именно, чтобы потом понимать, что Spring AI скрывает за ChatClient.call(…). Давайте глянем на настоящий код.
Цикл руками: что именно делает фреймворк
Если посмотреть на agent loop без Spring AI, в нём всего четыре сущности:
Messages - история разговора. Их обычно три типа: SystemMessage (роль и инструкции), UserMessage (запросы пользователя), AssistantMessage (ответы модели; могут содержать tool-вызовы вместо текста). Плюс ToolMessage - результат tool, который мы кладём обратно в историю, чтобы модель его видела на следующей итерации.
Tools spec - JSON-схема всех доступных tools (имена, описания, типы параметров), который уходит модели в каждом запросе.
Decision - то, что мы получаем от модели: либо финальный ответ, либо вызов tool с аргументами.
Loop с лимитом итераций - чтобы не зациклиться, если модель не может найти ответа.
Грубо в коде:
record AgentDecision(String action, // "tool" или "done" String toolName, Map<String, Object> arguments, String finalAnswer) {} public String run(String userQuestion) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage(SYSTEM_PROMPT)); messages.add(new UserMessage(userQuestion)); for (int i = 0; i < MAX_ITERATIONS; i++) { // 1. ask the model with current history and tools spec ChatResponse response = chatModel.call(new Prompt(messages, withTools(toolsSpec))); AssistantMessage assistantMsg = response.getResult().getOutput(); messages.add(assistantMsg); AgentDecision decision = parseDecision(assistantMsg.getText()); // 2. Готов ответ? if ("done".equals(decision.action())) { return decision.finalAnswer(); } // 3. Иначе - отдать выбранному моделью инструменту Object result = invokeTool(decision.toolName(), decision.arguments()); messages.add(new ToolMessage(result.toString(), decision.toolName())); } return "Could not finish within " + MAX_ITERATIONS + " iterations."; }
Этот код мы дальше использовать не будем - Spring AI делает всё это сам. Но три детали из него стоит запомнить, потому что они проявляются в самых неочевидных багах:
Контекст растёт. В messages копится вся история, включая результаты всех tools. Длинный диалог быстро упирается в лимит контекста модели.
MAX_ITERATIONS обязателен. Иначе при ошибке или недостаточности инструмента модель может бесконечно его повторно вызывать. У Spring AI лимит зашит в default-настройках, но осознавать его нужно.
Парсинг decision хрупкий. Если модель не понимает и не выполняет tool-вызов в нужном формате (а слабые модели любят напечатать JSON как обычный текст вместо реального вызова), вся схема перестаёт работать. К этому ещё вернёмся в разделе "Где это может сломаться".
Полная реализация ручного варианта - в оригинальной статье Spring AI на InfoWorld. Дальше у нас - то же самое, но через ChatClient.
Что мы строим
Бизнес-сценарий: складская система. Пользователь, не открывая привычные экраны со списками и фильтрами, спрашивает в свободной форме:
"Do we have any dark-roast coffee available in Hamburg?"
"Which warehouses are running low on Espresso blend? Less than five units."
"Show me products in the accessories category that are out of stock everywhere."
Про английский
Примечание: вопросы модели в примерах будут задаваться на английском языке, так как это почти всегда гарантирует более высокий результат ответа - модели около 7B параметров не очень хороши в переводах и могут путаться.
Агент должен:
Понять запрос.
Выбрать подходящий tool (поиск товара по описанию, получение остатков, перечисление складов).
Возможно, вызвать несколько tools последовательно.
Сформулировать ответ для пользователя.
UI - обычный Джеймикс View с полем ввода, кнопкой отправки и полем для вывода результата. Никакого REST-контроллера: агент дергается прямо из Java контроллера View.
В первой части мы ограничимся read-only операциями. Запись данных, резервирование остатков, создание заявок на пополнение - это вторая часть, потому что там всплывает целый отдельный набор тем (безопасность, транзакции, аудит), которые в read-only сценарии звучат не так громко.
Подготовка пректа
Предполагается, что у вас уже есть Джеймикс-проект. Если нет - быстрее всего создать его в Джеймикс Studio через File → New → Project → Jmix Project; базовая инфраструктура проекта нас тут почти не интересует. Нам просто нужно Full-stack Джеймикс приложение. Стартовый туториал по созданию пустого проекта есть в официальной документации.
Spring AI подключается как обычный Spring Boot starter. В build.gradle:
ext { set('springAiVersion', "1.0.0") } dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-ollama' // ... остальные jmix-зависимости } dependencyManagement { imports { mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}" } }
Здесь стартер ollama, а не openai - это сознательный выбор. Для базовых задач поиска и выборки локальной модели через Ollama зачастую хватает, и могут быть не нужны внешние ключи и токены. Если позже вы захотите переключиться на OpenAI, Anthropic или другой провайдер - меняется starter и блок spring.ai.* в конфигурации, остальной код не меняется. Это, собственно, и есть главный смысл Spring AI как абстракции.
Конкретную модель выбирать нужно очень критично: не всякая модель умеет в native tool calling, и слабые модели об этом стесняются предупредить. К моменту, когда выяснится, что что-то не так, вы уже думаете, что у вас баг в коде. Подробнее об этом - в разделе "Где это может сломаться". Здесь возьмём qwen3:8b - достаточно стабильно поддерживает tools в Ollama, помещается в 5 ГБ, на CPU работает приемлемо для демо. При выборе другой модели ориентируйтесь на тег tools на странице модели в Ollama registry - это явный маркер поддержки native tool calling, хотя и не стопроцентный.
Конфигурация модели в application.properties:
spring.ai.ollama.base-url=http://localhost:11434 spring.ai.ollama.chat.options.model=qwen3:8b spring.ai.ollama.chat.options.think=false spring.ai.ollama.chat.options.temperature=0.2
Низкая температура здесь не случайна. Для агентного сценария нам нужно, чтобы модель выбирала tool стабильно, а не "креативила". Креативность пригодится в задачах генерации текста, не в задачах диспетчеризации вызовов.
Запускаем Ollama локально:
ollama pull qwen3:8b ollama serve
Доменная модель
Минимальный набор сущностей:
Product - артикул, название, описание, категория.
Warehouse - название и локация.
StockItem - связка "товар на складе" с полем количества и резерва.
Этого достаточно для read-tools первой части. Во второй части мы добавим ReplenishmentRequest, но об этом - там.
Product
@JmixEntity @Table(name = "PRODUCT") @Entity public class Product { @JmixGeneratedValue @Column(name = "ID", nullable = false) @Id private UUID id; @InstanceName @Column(name = "NAME", nullable = false) @NotNull private String name; @Column(name = "DESCRIPTION", length = 1024) private String description; @Column(name = "CATEGORY") private String category; // getters/setters }
Обратите внимание на @InstanceName: когда tool возвращает сущность как результат, Джеймикс автоматически использует аннотацию для построения текстового представления. Это упрощает сериализацию в JSON, который пойдет обратно в модель.
Warehouse:
@JmixEntity @Table(name = "WAREHOUSE") @Entity public class Warehouse { @JmixGeneratedValue @Column(name = "ID", nullable = false) @Id private UUID id; @InstanceName @Column(name = "NAME", nullable = false) @NotNull private String name; @Column(name = "CITY") private String city; }
StockItem:
@JmixEntity @Table(name = "STOCK_ITEM") @Entity public class StockItem { @JmixGeneratedValue @Column(name = "ID", nullable = false) @Id private UUID id; @JoinColumn(name = "PRODUCT_ID", nullable = false) @ManyToOne(fetch = FetchType.LAZY, optional = false) @NotNull private Product product; @JoinColumn(name = "WAREHOUSE_ID", nullable = false) @ManyToOne(fetch = FetchType.LAZY, optional = false) @NotNull private Warehouse warehouse; @Column(name = "QUANTITY", nullable = false) @NotNull private Integer quantity; @Column(name = "RESERVED", nullable = false) @NotNull private Integer reserved = 0; }
Liquibase-changelog генерируется Studio автоматически после создания сущностей на старте приложения, останавливаться на этом не будем. По работе с миграциями в Джеймикс - раздел документации.
Tools: операции, которые увидит модель
Tool в Spring AI - это обычный Spring-bean метод с аннотацией @Tool. Имя tool, его описание и описания параметров идут в system prompt автоматически, поэтому формулировки в description важны: именно по ним модель решает, какой инструмент вызвать из доступных.
Соберем сервис WarehouseAgentTools. Несколько ключевых решений в этом коде стоит обсудить отдельно, поэтому сначала покажем код целиком, потом разберем.
@Component public class WarehouseAgentTools { private final DataManager dataManager; public WarehouseAgentTools(DataManager dataManager) { this.dataManager = dataManager; } @Tool(description = "Find products by a keyword that may appear in the product name or description. " + "Returns up to 20 matches. Use this first when the user asks for a product by description.") public List<Product> findProducts( @ToolParam(description = "search keyword, lower case") String keyword) { return dataManager.load(Product.class) .query("select p from Product p " + "where lower(p.name) like :kw or lower(p.description) like :kw") .parameter("kw", "%" + keyword.toLowerCase() + "%") .maxResults(20) .fetchPlan(fp -> fp.addAll("name", "description", "category")) .list(); } @Tool(description = "List available warehouses with their city. " + "Use this to map a city name from the user request to a warehouse id.") public List<Warehouse> listWarehouses() { return dataManager.load(Warehouse.class) .all() .fetchPlan(fp -> fp.addAll("name", "city")) .list(); } @Tool(description = "Get current stock of a specific product across all warehouses. " + "Returns quantity, reserved and available amount per warehouse, where available = quantity - reserved.") public List<StockItem> getStock( @ToolParam(description = "product id (UUID)") String productId) { UUID id = UUID.fromString(productId); return dataManager.load(StockItem.class) .query("select s from StockItem s " + "where s.product.id = :pid") .parameter("pid", id) .fetchPlan(fp -> fp .addAll("quantity", "reserved") .add("product", pFp -> pFp.addAll("name")) .add("warehouse", wFp -> wFp.addAll("name"))) .list(); } @Tool(description = "Find products that have zero available stock (available = quantity - reserved) " + "across all warehouses, filtered by category. " + "Use when the user asks about out-of-stock items.") public List<Product> findOutOfStockByCategory( @ToolParam(description = "product category") String category) { return dataManager.load(Product.class) .query("select p from Product p " + "where p.category = :cat " + "and not exists (select s from StockItem s " + " where s.product = p and (s.quantity - s.reserved) > 0)") .parameter("cat", category) .fetchPlan(fp -> fp.addAll("name", "description", "category")) .list(); } }
Три решения, которые требуют пояснения.
Почему DataManager, а не репозиторий
В оригинальной статье Spring AI используется обычный JpaRepository. В Джеймикс это анти-паттерн: JpaRepository идет напрямую в EntityManager и проходит мимо security, soft delete и аудита. В контексте AI-агента это становится особенно опасно: модель может построить запрос, который пользователю напрямую был бы недоступен в UI, и tool его молча выполнит.
DataManager (или JmixDataRepository, если вам ближе репозиторный стиль) проходит через стандартный слой безопасности Джеймикс. Права на операции с сущностями (CRUD) и row-level constraints применяются здесь так же, как в UI: запрос, недоступный пользователю в интерфейсе, не выполнится молча и из tool.
Одна важная оговорка, о которой легко забыть. Автоматически на уровне data store применяются entity- и row-level права; а вот read-доступ на отдельные атрибуты DataManager не проверяет - это концепт исключительно UI-слоя. Если на атрибут (скажем, description) навешен запрет чтения ролью, но вы положили его в fetch plan, он всё равно загрузится - и уйдёт в модель. То есть fetch plan ограничивает выборку по вашему решению, но не заменяет атрибутивную проверку прав. Когда tool отдаёт сущности в LLM, атрибутивные права надо проверять явно; как именно - разберём во второй части, в разделе про безопасность.
Запомните этот тезис: tools должны идти через DataManager, а не в обход. Все, что в обход - либо специально и сознательно (мы вернемся к этому во второй части, когда обсудим SystemAuthenticator), либо ошибка и дыра в безопасности.
Сущности или DTO из tool-методов
В оригинальной статье tool-методы возвращали DTO-обёртки (ProductDto, StockDto и т.п.). Это распространённый подход, и он решает реальные проблемы:
JSON-сериализация сущности может потянуть незагруженные ленивые связи и вызвать LazyInitializationException в момент, когда Spring AI сериализует результат для модели.
Сущность содержит технические поля (version, deletedDate), которые засоряют контекст модели лишними токенами.
DTO явно фиксирует, что именно увидит модель.
В Джеймикс те же цели достигаются через fetch plan прямо на загрузке - и именно так сделано в нашем демо:
dataManager.load(Product.class) .query("...") .fetchPlan(fp -> fp.addAll("name", "description", "category")) .list();
Fetch plan гарантирует, что загружены ровно нужные поля; незагруженные связи не будут инициализированы при сериализации. Технические поля (version, deletedDate) не включены в план - и не попадут в контекст модели.
DTO - корректный вариант для проекций, у которых нет прямого соответствия сущности. Для остальных случаев fetch plan - Джеймикс-идиоматичный способ, исключающий лишний слой данных.
Формулировки в description
Сам факт того, что tool description - это часть system prompt, который модель читает каждый раз, разработчиками часто недооценивается. Несколько практических наблюдений:
Пишите description на английском, даже если конечные пользователи говорят по-русски. Модели стабильнее работают с английскими инструкциями, особенно небольшие локальные модели.
В description указывайте когда использовать tool, а не только что он делает. Сравните "Search products by keyword" и "Use this first when the user asks for a product by description". Второй вариант агенту понятнее, результат более предсказуем.
Если у вас два или больше tool с близкими описаниями - модель будет путаться. Лучше один tool с понятным контрактом, чем два пересекающихся.
Сборка ChatClient
ChatClient - это фасад Spring AI поверх конкретной модели. Регистрация tools и system prompt делается через builder.
@Configuration public class AgentConfig { @Bean public ChatClient warehouseAgentClient( ChatClient.Builder builder, WarehouseAgentTools tools) { return builder .defaultSystem(""" You are a warehouse assistant. You help the user find products and check stock levels. Tools available to you operate on the warehouse database. Important rules: - When the user mentions a city, first call listWarehouses to get warehouse ids. - When the user describes a product, first call findProducts to get product ids. - Only after you have ids, call getStock or other detail tools. - If you cannot find a match, say so plainly. Do not invent data. Search strategy for findProducts: - Use SHORT keywords. Single word is best. Never pass compound phrases with hyphens (e.g. "dark-roast coffee" is bad; "dark roast" is good). - If findProducts returns 0 results, retry with a simpler or alternative keyword before concluding the product is unavailable. """) .defaultTools(tools) .build(); } }
Несколько моментов:
defaultTools(tools) - принимает любой Spring-bean. Spring AI сам пройдется по нему рефлексией и зарегистрирует все методы с аннотацией @Tool.
System prompt - намеренно директивный. Фразы "First call X, then Y" не лишние: они существенно повышают стабильность поведения, особенно у небольших моделей.
Отдельный блок про search strategy - результат живой попытки. На первом запуске модель собрала в запрос "dark-roast coffee" (с дефисом, составной), не нашла в БД ничего и сдалась. После явной инструкции "пробуй короткие ключи, пробуй снова при пустом результате" - стала вести себя предсказуемо. Подробнее - в разделе "Где это может сломаться".
UI: Джеймикс View
Создадим View warehouse-agent-view через Studio.
Дескриптор:
<view xmlns="http://jmix.io/schema/flowui/view" title="msg://warehouseAgentView.title"> <layout> <vbox padding="true" width="100%" height="100%"> <textArea id="questionField" width="100%" minHeight="3em" helperText="msg://warehouseAgentView.placeholder"/> <button id="askButton" text="msg://warehouseAgentView.ask" icon="MAGIC" themeNames="primary"/> <progressBar id="progressBar" visible="false" indeterminate="true" width="100%"/> <textArea id="answerField" width="100%" minHeight="20em" readOnly="true"/> </vbox> </layout> </view>
Контроллер:
@Route(value = "warehouse-agent", layout = MainView.class) @ViewController("warehouseAgentView") @ViewDescriptor("warehouse-agent-view.xml") public class WarehouseAgentView extends StandardView { @Autowired private ChatClient warehouseAgentClient; @ViewComponent private TextArea questionField; @ViewComponent private TextArea answerField; @ViewComponent private ProgressBar progressBar; @ViewComponent private JmixButton askButton; @Subscribe(id = "askButton", subject = "clickListener") public void onAskButtonClick(ClickEvent<JmixButton> event) { String question = questionField.getValue(); if (question == null || question.isBlank()) { return; } progressBar.setVisible(true); askButton.setEnabled(false); answerField.setValue(""); UI ui = UI.getCurrent(); CompletableFuture.supplyAsync(() -> warehouseAgentClient.prompt() .user(question) .call() .content() ).whenComplete((answer, ex) -> ui.access(() -> { progressBar.setVisible(false); askButton.setEnabled(true); if (ex != null) { answerField.setValue("Error: " + ex.getMessage()); } else { answerField.setValue(answer); } })); } }
Несколько практических моментов:
Вызов warehouseAgentClient.prompt().call() блокирующий и может занять заметное время - модель делает несколько раундов tool-вызовов. Поэтому мы уходим в CompletableFuture и возвращаемся в UI-поток через ui.access(…). Без этого вкладка зависнет на время ответа.
ProgressBar в режиме indeterminate - дешевый способ показать, что что-то происходит. Можно сделать стриминг (Spring AI это умеет), но это уже тема отдельной статьи.
Если вы не знакомы с программной моделью Джеймикс Views - вот раздел Views в документации Джеймикс.
Что мы увидим
На запрос "What dark roast coffee do we have in Hamburg?" реальный лог tool-вызовов выглядит так (формат warehouse=available/quantity, где available = quantity - reserved):
13:45:29 >>> listWarehouses() <<< 3 warehouse(s): [Hamburg DC, Rotterdam DC, Antwerp DC] 13:45:40 >>> findProducts(keyword="dark roast") <<< 2 match(es): [Colombia Supremo 1kg, Espresso blend dark roast 1kg] 13:46:02 >>> getStock(productId="...Colombia Supremo...") <<< Hamburg DC=0/0, Rotterdam DC=24/30 13:46:32 >>> getStock(productId="...Espresso blend...") <<< Rotterdam DC=30/40, Hamburg DC=15/18
После этого модель собрала финальный ответ на английском:
We have Espresso blend dark roast 1kg in Hamburg DC: 15 units available out of 18 in stock. Colombia Supremo 1kg is currently out of stock at Hamburg DC (available in Rotterdam DC: 24 out of 30).
Заметьте: ничего из этого мы не программировали явно. Цикл "вызови tool - посмотри результат - реши, что делать дальше" реализован самим Spring AI на основе ответов модели.
Тайминги в этом вызове - 63 секунды от вопроса до финального ответа на CPU без GPU вообще, за четыре раунда к модели, каждый 10-30 секунд в зависимости от длины контекста. (Этот trace снят на qwen2.5:7b; qwen3:8b, рекомендованный выше, ведёт себя эквивалентно - узкое место в inference на CPU, а не в конкретной модели.) БД отвечает мгновенно (десятки ms на каждый tool). На сервере с GPU или через API эти 63 секунды стали бы 5-10. Это к вопросу о том, где использовать локальные модели, а где нет - и об этом отдельный пункт ниже.
Где это может сломаться
Несколько типичных проблем, с которыми вы столкнетесь, как только запустите этот пример на реальной базе. Первая из них - не из учебника, а из честного "поднял первый раз и получил вот это".
Модель печатает tool-вызов как текст вместо вызова
Первый запуск этого демо был на llama3.1:8b. На вопрос "есть у нас кофе на складах?" модель ответила:
Since the question is in Russian and does not specify a city or product description,
I will assume it's asking for a general availability of coffee products.
To answer this question, we need to callfindProductswith a keyword "кофе" (coffee)
to find matching product IDs.
{"name": "findProducts", "parameters": {"keyword": "кофе"}}
Никакого tool не вызвалось. Сырой JSON ушёл в финальный ответ пользователю.
Что произошло: модель понимает концепцию tools, но не выполняет фактический вызов в нужном protocol-формате (tool_calls в metadata ответа). Spring AI получает обычный assistant-message без tool-calls-меты и считает, что это финальный текстовый ответ. Это не баг Spring AI и не ошибка в промпте. Это просто слабая модель: llama3.1:8b нестабильно работает с native tool calls в Ollama.
Лечится сменой модели. После переключения на модель с надёжным tool calling (qwen2.5:7b на тот момент; в демо сейчас qwen3:8b, но можно пробовать любые другие) тот же запрос на той же конфигурации начал отрабатывать корректно: модель вызвала findProducts, потом getStock для нескольких товаров, и собрала текстовый ответ. Никакого "лечения промптом" не потребовалось, да оно бы и не помогло в этой ситуации.
Урок: выбор модели важнее качества промпта. В демо легко показать ChatClient + tools и забыть, что модель - это отдельная переменная, которая может всё сломать без единой ошибки в коде. Для production - сразу закладывайте серверные модели (OpenAI, Anthropic, ха-ха) или проверенные локальные с явной поддержкой tool calling (qwen2.5, qwen3, llama3.x с правильными chat-template’ами). Общая рекомендация из опыта - вам нужно много VRAM: для по-настоящему крупных моделей это десятки, а скорее - сотни гигабайт.
Анекдот выше касается конкретно llama3.1:8b - более новые варианты (llama3.2, llama3.3) исправили поддержку tool calling для большинства конфигураций. Принцип остаётся тот же: проверяйте тег tools в Ollama registry перед выбором модели, а потом проверяйте модель.
Остальные типичные грабли
Модель путает названия tools. Лечится конкретикой в description и явным порядком вызовов в system prompt.
Модель возвращает выдуманные ID. Помогает валидация в самих tool-методах (UUID.fromString(productId) бросит исключение на мусоре, например) и явное указание в system prompt: "Only use ids returned by previous tool calls".
Большие выборки. Метод findProducts возвращает 20 товаров - это сознательный лимит. Если отдать модели 500 строк, она потеряется и/или съест весь контекст на первом запросе и "поплывёт".
Локальная модель тормозит на CPU. Модель на 7-8B на CPU - один ответ это секунды, иногда десятки секунд. Это не интерактивный опыт. Минимум для удобства - GPU с 16 GB VRAM, такие сейчас называют "дешёвыми видеокартами". Для прода - серверные модели через API или много железа у вас.
Модель отвечает не на том языке. Лечится явным указанием в промпте, как мы и сделали. Не панацея, иероглифы даже у облачных моделей - не редкость.
Промежуточный итог
К концу этой части у нас есть приложение, в котором пользователь задает свободные вопросы про склад и получает осмысленные ответы. Ни одного REST-эндпоинта, ни одного явного использования формы с фильтрами - только текстовый интерфейс и набор tool-методов, которые модель собирает в нужном порядке.
Это не значит, что классический CRUD не нужен. Список товаров с фильтрами и сортировкой быстрее, чем разговор с моделью, да и более предсказуем. Но в сценариях с многомерным поиском (несколько критериев, неточные формулировки) агентный интерфейс начинает выигрывать.
Во второй части:
Дадим агенту право менять данные: резервировать остатки, создавать заявки на пополнение.
Разберем, под каким пользователем выполняется tool, когда и как использовать SystemAuthenticator, и как аудировать действия, которые на самом деле инициировал агент.
Соберем metadata-aware промпт, чтобы модель знала вашу доменную модель без ручного перечисления.
Обсудим, как валидировать то, что возвращает LLM, прежде чем передавать это в DataManager.
И отдельно - короткий раздел "когда AI-агент не нужен", чтобы понять, как не забивать гвозди микроскопом.
Что почитать дальше
github.com/jmix-edu/ai-warehouse - полный исходник демо к этой статье.
Spring AI Reference - официальная документация Spring AI.
Building AI agents with Spring AI - оригинал, на котором построена эта статья, без Джеймикс-специфики.
Документация Джеймикс: DataManager - глубокое погружение в стандартный слой доступа к данным.
Документация Джеймикс: Security - как и какие политики безопасности применяются при работе с DataManager.
