AI-агент для склада в Джеймикс. Часть 1

от автора

Основы и 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 параметров не очень хороши в переводах и могут путаться.

Агент должен:

  1. Понять запрос.

  2. Выбрать подходящий tool (поиск товара по описанию, получение остатков, перечисление складов).

  3. Возможно, вызвать несколько tools последовательно.

  4. Сформулировать ответ для пользователя.

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-обёртки (ProductDtoStockDto и т.п.). Это распространённый подход, и он решает реальные проблемы:

  • 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 call findProducts with 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, qwen3llama3.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-агент не нужен», чтобы понять, как не забивать гвозди микроскопом.

Что почитать дальше

ссылка на оригинал статьи https://habr.com/ru/articles/1046868/