Основы и 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.
ссылка на оригинал статьи https://habr.com/ru/articles/1046868/