Переключение агента на нативный механизм вызова инструментов

Переключение агента на нативный механизм вызова инструментов

Момент, когда я подумал: «Если модель будет придерживаться формата JSON, всё должно быть в порядке», стал началом настоящего поиска решения.

Мы работали над добавлением ИИ-агента на нашу внутреннюю платформу, чтобы пользователи могли управлять сервисами на естественном языке. Система состояла из двух сервисов. MCP-сервер отвечал за список инструментов, политики, согласования и аудит, а хост агента — за обработку диалога, вызовы LLM и цикл выполнения инструментов. Мы руководствовались одним принципом: поручить рассуждения универсальной LLM, но оставить контроль над выполнением за платформой.

Первоначальная реализация работала. Но «работает» и «можно доверять» — не одно и то же. Решения в цикле зависели от списка инструментов, встроенного в промпт, и от прямого разбора JSON, который модель выводила как текст. Если формат хотя бы один раз оказывался неверным, цикл повторял попытку, а затем завершался, не вызвав ни одного инструмента.

В этой статье рассказывается, как мы заменили цикл работы с инструментами, основанный на разборе JSON из промпта, на нативный для провайдера вызов инструментов, а собственные реализации вызовов LLM и клиента MCP — на Spring AI 2.0. Симптомов было несколько, но корневая причина — одна. И хотя мы заменили весь уровень выполнения, в уровне управления не изменили ни одной строки. В этом и состояли ключевые моменты нашей работы.

1. Предпосылки выбора технологии — почему разбор JSON из промпта не сработал

Прежде чем принимать решение о переходе, мы тщательно изучили уже работавший цикл. В ходе анализа обнаружились следующие проблемы.

  • Решения зависели от формата. Список инструментов добавлялся в промпт в виде строки, а JSON, который модель выводила как текст, например {"action":"call_tool", ...}, разбирался напрямую. Если формат был неверным, цикл дважды повторял попытку, а затем завершался, не вызвав инструмент.

  • Наблюдения накапливались без ограничений. На каждом шаге полный текст всех предыдущих наблюдений снова добавлялся для формирования нового сообщения пользователя. По мере увеличения числа шагов промпт рос линейно.

  • Защиты от немедленного ответа без вызова инструмента не было. В системном промпте лишь говорилось: «Не выдумывай бизнес-данные», но никакого кода, который обеспечивал бы соблюдение этого правила, не было.

  • Инструментов было больше 40, и каждый запрос включал их все. Один только список инструментов занимал около 4 000 токенов на шаг.

  • Мы самостоятельно реализовали клиент MCP на основе низкоуровневых HttpClient и JSON-RPC, и из-за того, что после initialize мы не отправляли notifications/initialized, существовал риск нарушения протокола.

Сначала мы перечислили эти пункты как отдельные задачи по улучшению. Но когда мы проследили их до первопричин, оказалось, что первые три связаны одной общей проблемой: AgentAskRequest, порт вызова LLM, состоял всего из двух строк — prompt и systemPrompt.

[Корень проблемы — порт движка всего с двумя строками]

// Before: the engine port had no notion of a messages array.
public class AgentAskRequest {
    private String prompt;        // question + tool list + observations, all in here
    private String systemPrompt;  // rules go here
}

В структуре не было понятия массива сообщений, поэтому в ней было негде хранить историю диалога или список инструментов. В результате оставался только один вариант: добавлять и список инструментов, и наблюдения в промпт в виде строк. Иными словами, структура не просто не использовала нативный вызов инструментов — она делала его невозможным.

Вывод был очевиден. Вместо того чтобы исправлять симптомы по одному, мы решили обновить порт движка, перейдя к модели messages + tools, и переключить путь выполнения на нативный для провайдера вызов инструментов. Одновременно мы решили заменить собственные REST-вызовы провайдерам и клиент MCP на Spring AI 2.0. Хотя spring-ai-bom:2.0.0 уже был объявлен в корневом build.gradle, клиентский модуль не импортировал ни одного класса Spring AI и вручную повторно реализовывал те же функции.

2. Процесс реализации

Работа проходила в четыре этапа: определение границ ответственности, переключение порта движка, управление именами и списками инструментов и проверка того, что уровень управления остался неизменным. Этапы были упорядочены так, чтобы каждый из них можно было откатить независимо.

2.1 Определение границ ответственности — что поручить библиотеке, а что оставить за собой

Прежде чем переносить код, мы определили границы. Нужно было разобраться, что можно заменить библиотекой, а за что мы должны отвечать сами.

Категория

Обрабатывает Spring AI

Обрабатывает непосредственно агент

Область ответственности

Вызовы LLM, преобразование сообщений и tool_calls, протокол и клиент MCP, схема инструментов, метрики и трассировка

Правила работы цикла, политика авторизации инструментов, оценка рисков и согласование, ограничения на число вызовов и время, аудит, события SSE

Принцип

Протоколы и транспорт — области со стандартами, в которых у нас нет причин пытаться справиться лучше самостоятельно

Управление и ответственность — области, регулируемые политиками нашей платформы

Эта таблица стала основой для всех дальнейших решений. Когда возникал вопрос «Куда это отнести?», ответ становился очевиден, если выяснить, касается ли дело стандарта или политики.

2.2 Переключение порта движка — напрямую вызывать ChatModel, но не передавать ему управление выполнением

Больше всего внимания при внедрении Spring AI мы уделили автоматическому выполнению инструментов. В Spring AI есть путь, при котором фреймворк сам выполняет инструменты, запрошенные моделью. Это удобно, но такой подход обошёл бы наши политики, согласования и аудит.

Мы подтвердили, что уровень ChatModel в Spring AI 2.0 вызывает только ToolCallingManager.resolveToolDefinitions и не вызывает executeToolCalls. Иными словами, автоматическое выполнение инструментов не происходит, если вызывать ChatModel напрямую. Мы использовали это поведение и добавили адаптер, который передаёт описания инструментов только в виде схем и возвращает намерения выполнить их, не запуская сами инструменты.

[Порт движка, который получает только намерения выполнить инструменты]

// The engine returns only the model's tool-call intent. It never executes.
public interface NativeToolCallingEngine extends AgentEngine {

    AgentNativeToolResponse askWithTools(AgentNativeToolRequest request);
}

// Calling ChatModel directly: ToolCallback only supplies the schema, it is not run.
ChatResponse chatResponse = chatModel.call(new Prompt(nativeMessages(request), options));
AssistantMessage assistant = chatResponse.getResult().getOutput();
List<AgentNativeToolCall> toolCalls = toolCalls(assistant);   // structured tool_calls

Вместо того чтобы снова добавлять вызовы инструментов и результаты предыдущего шага в виде строк, мы накапливали их в массиве сообщений как AssistantMessage и ToolResponseMessage. Проблема накопления наблюдений переместилась с повторного формирования промпта туда, где ей и место, — в массив диалога.

Мы поддержали несколько вызовов инструментов и выполняли их последовательно, обеспечивая получение ответа для каждого tool_call_id. Запрещённые инструменты и повторные вызовы не вызывали исключений, прерывающих цикл: вместо этого они возвращались как наблюдения, начинающиеся с ERROR:, чтобы модель могла принять новое решение.

2.3 Имена и списки инструментов — учитывать ограничения только на границе

Во время перехода мы столкнулись с одним практическим ограничением. Имена инструментов имели формат navi.<service>.<verb>, но спецификация имён функций в стиле OpenAI не допускала точки (.).

Можно было изменить схему именования, но тогда пришлось бы менять имена, используемые в MCP, журналах, политиках и аудите. Поскольку ограничение действовало только в одном месте — в поле, передаваемом LLM, — мы решили преобразовывать имена с помощью таблицы соответствий только на этой границе.

[Учитывать ограничение на имена на одной границе]

// Convert the name only at the LLM boundary.
// MCP, policy and audit keep the original name.
private String nativeName(String qualifiedName) {
    String sanitized = qualifiedName.replaceAll("[^A-Za-z0-9_-]", "_");
    if (sanitized.length() <= MAX_NATIVE_NAME_LENGTH) return sanitized;
    String hash = String.format("%08x", qualifiedName.hashCode());
    return sanitized.substring(0, MAX_NATIVE_NAME_LENGTH - hash.length() - 1) + "_" + hash;
}

// If two names collapse into one, fail fast instead of silently overwriting.
String previous = nativeNames.putIfAbsent(nativeName, qualifiedName);
if (previous != null && !previous.equals(qualifiedName)) {
    throw new AgentNativeToolException("Native tool name collision: " + previous);
}

Поскольку для соблюдения максимальной длины имена иногда приходилось сокращать, мы настроили систему так, чтобы она немедленно выбрасывала исключение, если после сокращения у разных инструментов получались одинаковые имена. Если молча игнорировать такое совпадение, проблема может обнаружиться намного позже — когда выяснится, что «модель вызвала не тот инструмент».

Мы также сократили сам список инструментов. Мы добавили механизм отбора, который сужает список на основе терминов предметной области в промпте, но оставляет полный список, если совпадений нет. Кроме того, мы отвечаем на запросы, для которых инструменты не нужны, например на приветствия и просьбы о помощи, ещё до открытия сеанса MCP, а для каталога инструментов используем кэш с коротким TTL.

2.4 Не трогать уровень управления

Самым важным показателем успеха этой работы была не производительность, а ответ на вопрос «Что не изменилось?». Поскольку мы заменяли весь уровень выполнения, любое изменение ToolExecutionGateway, отвечающего за политики, согласования и аудит, означало бы, что мы неверно провели границы ответственности.

Как выяснилось, в ToolExecutionGateway не изменилось ни одной строки. Метод выполнения сменил разбор JSON из промпта на нативный вызов инструментов, но с точки зрения шлюза по-прежнему передавались только «идентификатор сервера + имя инструмента + аргументы».

Однако резервный вариант мы сохранили. Нативный вызов инструментов гарантирует только формат; ответы всё ещё могут быть пустыми, а модель может промолчать. При этом мы стали передавать режим цикла и finishReason вместе в событии завершения SSE и в журнал аудита, чтобы путь к завершению всегда фиксировался.

3. Уроки, извлечённые методом проб и ошибок

Во-первых, найти первопричину быстрее, чем пересчитывать симптомы. Сначала мы перечислили разрастание наблюдений, ошибки формата и проверку аргументов как отдельные проблемы. Все три возникли из-за одного и того же ограничения — у интерфейса движка было всего две строки, — и устранение первопричины решило их разом. Если бы мы разбирались с каждой проблемой по отдельности, мы бы исправили три проблемы, а затем столкнулись с четвёртой.

Во-вторых, массив диалога следует с самого начала считать первоклассной сущностью в интерфейсе интеграции LLM. Даже если вы начинаете с ответов на вопросы в одном ходе диалога, использование сообщений в качестве структуры данных по умолчанию избавит от необходимости позже переделывать интерфейс для добавления инструментов или многошаговых диалогов. На самом деле половина работы по переходу ушла на исправление интерфейса, а не на добавление функций.

В-третьих, зависимость считается внедрённой не потому, что её объявили, а потому, что её действительно используют. BOM уже был объявлен, но клиентский модуль всё ещё вручную реализовывал те же функции, и так продолжалось какое-то время. После удаления собственной реализации JSON-RPC исчезла и вызванная отсутствием notifications/initialized ошибка — вместе с её первопричиной.

В-четвёртых, прежде чем использовать удобный путь автоматизации, проверьте, не позволит ли он обойти границы управления. Автоматическое выполнение инструментов фреймворком выглядит привлекательно, но этот подход позволил бы выполнять инструменты без согласования и аудита. Прежде чем принять его, мы проверили в коде, что уровень ChatModel не выполняет инструменты.

В-пятых, резервные пути незаметно устаревают, если ими не пользоваться. При последующей очистке мы удалили и ярлык, который с помощью регулярных выражений жёстко связывал предложения с инструментами, и цикл принятия решений на основе JSON. Оба пути были созданы «на всякий случай», но на практике их конфигурация оставалась пустой, поэтому они никогда не использовались и лишь удваивали количество потоков, которые нам приходилось поддерживать.

4. Результаты

Результаты миграции можно подытожить следующим образом.

Параметр

До

После

Путь принятия решений

Разбор JSON из промпта + две повторные попытки

Вызов инструментов средствами провайдера (структурированные tool_calls)

Состояние диалога

На каждом шаге собирали транскрипт наблюдений в строку

Накапливали сообщения ассистента и инструментов в массиве messages

Список инструментов

Отправляли все 40+ инструментов — около 4 000 токенов на шаг

Выбирали инструменты по домену с помощью кеша TTL; MCP пропускали, когда он был не нужен

Клиент MCP

Raw HttpClient + собственная реализация JSON-RPC

Spring AI 2.0 (ошибка пропуска протокола исправлена)

Уровень управления

Шлюз политик, согласования и аудита

Без изменений (0 строк)

Теперь и встроенный, и резервный пути используют Spring AI для передачи данных, поэтому код провайдера не был разделён на две отдельные реализации. Сборка и тесты прошли успешно, и мы добавили семь видов модульных тестов.

Было и дополнительное преимущество. Массив messages, необходимый для встроенного вызова инструментов, — это в точности та же структура данных, которая нужна для сеансов диалога, поэтому эта миграция также заложила основу для хранения истории диалога. Позже, добавляя историю диалогов для нескольких сеансов, нам не пришлось перерабатывать контракт сервера.

5. Ограничения и дальнейшие планы

Во-первых, мы по-прежнему зависим от модели. Нам нужно было выяснить, возвращает ли используемая внутри системы модель один или несколько вызовов инструментов за раз; флаг параллельного выполнения, включённый в политику, так и не используется циклом. Мы считаем, что безопаснее всего включать параллельное выполнение постепенно, ограничив его инструментами только для чтения.

Во-вторых, возможности сужения списка инструментов с помощью доменной лексики ограничены: чем больше становится доменов с пересекающейся лексикой, тем сложнее это делать. В дальнейшей работе мы развили этот подход, заменив полное предоставление списка инструментов их индексированием и загрузкой по имени. Об этом мы расскажем отдельно.

В-третьих, если позволить модели излагать результаты работы инструментов своими словами, их представление будет каждый раз разным. Это привело нас к подходу, при котором сервер предоставляет контракт отображения, а интерфейс выводит данные без изменений.

Эта работа ещё раз подтвердила: создавать агента самостоятельно не значит самостоятельно создавать механизм инференса. Решения всегда принимает LLM; мы же создаём цикл, подключения к инструментам, политики и механизмы аудита. Явно обозначив эту границу в структуре кода, мы смогли заменить весь уровень выполнения, не изменив ни одной строки уровня управления.

Источники

Junny

Site footer