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

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