В этой статье мы рассмотрим альтернативный подход вызова инструментов LLM, который использует Structured Output вместо традиционного Function Calling для обеспечения надежности и предсказуемости.
Введение
Большие языковые модели (LLM) обычно взаимодействуют с внешними инструментами через механизм вызова функций (Function Calling). Стандартная реализация подразумевает, что модель генерирует JSON в специальных тегах, после чего эти данные обрабатываются внешним фреймворком. Однако JSON, который генерирует LLM, не всегда гарантированно корректен.
Чтобы решить эту проблему, мы будем использовать подход Structured Output (SO), при котором ответы модели гарантированно соответствуют определённой схеме.
⚠️Примечание: Эта проблема в первую очередь касается локальных open-source моделей и не самых крупных провайдеров. Ведущие поставщики, такие как OpenAI уже предлагают решения этой проблемы, например установкой строгого режима Strict mode для Function Calling.
Проблема с классическим Function Calling
Традиционный Function Calling имеет ряд ограничений:
❌ LLM генерирует JSON-подобный текст, который может быть некорректным
❌ Нет гарантии правильного формата ответа
❌ Сложно отлаживать и предсказывать поведение
Эта проблема активно обсуждается в сообществе Reliable function calling with vLLM.
Преимущества Structured Output для вызова инструментов
✅ Гарантированный формат вывода: обеспечивается корректность сгенерированного JSON.
✅ Динамическая генерация схем: легко адаптировать схемы под параметры любого инструмента.
✅ Совместимость с OpenAI Function Calling: подход иммитирует формат истории вызовов tools, что упрощает интеграцию.
✅ Четкое разделение этапов: процесс делится на логические шаги: принятие решения → генерация параметров → выполнение.
Давайте подробнее рассмотрим разницу между стандартным вызовом инструментов через Function Calling и предложенным подходом на основе Structured Output.
Традиционный Function Calling
Для начала вспомним, как работает обычный Function Calling.
Возьмем классический пример с погодой.
У нас есть функция, которую LLM может вызвать как инструмент. Эта функция ожидает два параметра: location и необязательный unit.
def get_weather(location: str, unit: str = "celsius") -> str:
temp = 25 if unit == "celsius" else 77
return f"Погода в {location}: {temp}°}, солнечно"
Чтобы LLM могла использовать инструмент, необходимо определить JSON с описанием самой функции и ее параметрами и передать в tools
Реализовываем эту часть, предаем запрос пользователя и описание инструмента.
query = "Какая погода в Нью-Йорке?"
messages = [{"role": "user", "content": query}]
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"description": "Получить погоду для города",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "Название города"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}
},
"required": ["location"]
}
}
}],
tool_choice="auto"
)
После запуска мы увидим, что модель правильно решает использовать функцию get_weather, т.к. мы спросили о погоде и возвращает имя функции и входные аргументы в виде JSON.
[{
"id": "call_12345xyz",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"Нью-Йорк\",\"unit\":\"celsius\"}"
}
}]
Этот JSON затем парсится, и вызывается сама функция get_weather с извлеченными параметрами
tool_call = completion.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
result = get_weather(**args)
Так работает Function Calling, и его главная уязвимость — именно этап генерации параметров. Нет гарантии, что модель вернёт валидный JSON с ожидаемыми полями. Конечно, вероятность успеха с большими коммерческими моделями высока, но при работе с open-source моделями риск получения некорректных данных кратно возрастает.
⚠️ Однако, ключевая гибкость LLM с Function Calling заключается в том, что, например, на запрос вроде «Привет, как дела?» модель не вызывает инструмент, а просто ведёт диалог.
Наш подход с SO должен сохранить эту особенность.
Structured Output как основа для вызова инструментов
Structured Output — это функция, которая заставляет модель всегда генерировать ответы, строго соответствующие предоставленной JSON-схеме.
Как гласит официальная документация OpenAI: Structured Outputs — это эволюция JSON-режима. Хотя оба варианта гарантируют создание валидного JSON, только Structured Outputs гарантируют соблюдение схемы.
Давайте повторим реализацию Function Calling, но теперь через Structured Output.
Расматриваем тот же пример, о погоде, инструмент, который LLM может вызвать если необходимо узнать что-то о погоде. Определяем функцию:
def get_weather(location: str, unit: str = "celsius") -> str:
temp = 25 if unit == "celsius" else 77
return f"Погода в {location}: {temp}°}, солнечно"
Далее, мы хотим получить параметры функции, но в строго структурированном ответе. Поэтому определим Pydantic-схему WeatherParams
для параметров:
class TemperatureUnit(str, Enum):
"""Единицы измерения температуры."""
CELSIUS = "celsius"
FAHRENHEIT = "fahrenheit"
class WeatherParams(BaseModel):
"""Модель параметров для инструмента погоды."""
location: str = Field(description="Город для получения погоды.")
unit: TemperatureUnit = Field(
default=TemperatureUnit.CELSIUS,
description="Единицы измерения температуры (celsius или fahrenheit)."
)
Чтобы LLM генерировала ответ используя Structured Output, мы передаемWeatherParams
в параметр response_format
:
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Какая погода в Нью-Йорке?"],
response_format=WeatherParams,
)
result_json = response.choices[0].message.content
params_response = WeatherParams.model_validate_json(result_json)
После запуска, мы получим ответ в строко структурированном формате, соответствующий WeatherParams:
params = WeatherParams(location='Нью-Йорк', unit='celsius')
Теперь у нас есть всё для безопасного вызова функции:
get_weather(**params.model_dump())
Однако для полноценной замены Function Calling этого еще не достаточно. Если мы оставим все как есть, то модель будет всегда генерировать ответ в заданном формате WeatherParams, даже отвечая на вопрос вроде «Привет, как дела?». Мы же хотим, чтобы модель могла и свободно общаться, и вызывать инструменты по необходимости.
Поэтому мы реализуем двухэтапный процесс:
Этап Решения: LLM анализирует запрос и решает, может ли она ответить сама или ей нужен инструмент. Для этого мы определим базовую структуру ответа
AnswerDecision
.Этап Генерации параметров: Если инструмент выбран, LLM генерирует для него параметры в строго заданном формате. Это шаг, который мы рассмотрели выше.
Принятие решения:
Приступаем к релизации первого этапа. Определим Pydantic-модель AnswerDecision
class AnswerDecision(BaseModel):
reasoning: str = Field(description="Логическое рассуждение агента")
answer: str = Field(description="Ответ пользователю или промежуточный комментарий")
use_tool: Optional[str] = Field(None, description="Название инструмента для вызова (если нужен)")
Она включает в себя три ключевых поля:
reasoning
: рассуждения модели (для прозрачности решений).answer
: ответ пользователю (финальный или промежуточный).use_tool
: опциональное поле с названием инструмента, если требуется вызов.
Теперь создадим системный промпт, который объясняет как необходимо отвечать, и передаем AnswerDecision
в качестве формата ответа.
query = 'Какая погода в Нью-Йорке?'
system_prompt = f"""Ты помощник, который отвечает на любые вопросы.
У тебя есть инструменты, которые могут помочь.
Доступные инструменты:
"name": "get_weather", "description": "Получить погоду для города"
Анализируй ситуацию и решай:
1. Если можешь дать ответ - используй "answer" и оставь "use_tool" пустым
2. Если нужен инструмент - укажи его в "use_tool"
Отвечай в JSON формате:
{{
"reasoning": "Твое логическое рассуждение",
"answer": "Финальный ответ пользователю или промежуточный комментарий",
"use_tool": "Название инструмента или null"
}}
"""
messages = [{"role": "system", "content": system_prompt},
{"role": "user", "content": query}]
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=AnswerDecision,
)
result_json = response.choices[0].message.content
result = AnswerDecision.model_validate_json(result_json)
Теперь, когда мы спрашиваем о погоде, LLM заполняет поле use_tool названием функции. А уже на втором этапе мы просим сгенерировать параметры для этой функции.
AnswerDecision(
reasoning="Пользователь спрашивает о погоде, нужно использовать get_weather",
answer="Я сейчас уточню погоду в Москве",
use_tool="get_weather"
)
Если же мы спросим "Как дела?", поле use_tool останется пустым, а ответ будет в поле answer, который мы и будем выводить пользователю.
AnswerDecision(
reasoning='Пользователь приветствует меня и спрашивает, как у меня дела. Так как я не могу иметь личные чувства, я должен ответить вежливо и нейтрально.'
answer='Здравствуйте! У меня всё хорошо, спасибо за интерес. Чем я могу помочь вам сегодня?'
use_tool=None
)
Таким образом, мы смогли реализовать полноценный аналог Function Calling используя Structured Output .
Анализ подхода: компромиссы и преимущества
Недостатки
Дополнительны вызов LLM: Каждый вызов инструмента требует двух последовательных обращений к LLM вместо одного. Это увеличивает время ответа и стоимость.
Сложность внедрения: Подход требует написания собственной логики управления, так как популярные фреймворки не поддерживают его "из коробки".
Скрытое преимущество: эффективность контекста
На первый взгляд, дополнительный вызов LLM — это явный минус. Однако давайте посмотрим на ситуацию, когда у агента есть доступ к десяткам инструментов (например, API для Jira, Confluence и т.д.), каждый из которых имеет множество сложных параметров.
Классический Function Calling: При каждом вызове в системный промпт необходимо передавать описание всех доступных инструментов и всех их параметров. Это "засоряет" контекст, заставляя модель обрабатывать массу ненужной в данный момент информации.
Наш двухэтапный подход:
На этапе решения модель видит только названия и краткие описания инструментов. Этого достаточно, чтобы сделать выбор.
На этапе генерации параметров модель получает подробную схему только для одного, уже выбранного инструмента.
Этот подход позволяет использовать контекстное окно LLM гораздо эффективнее, так как модель фокусируется на конкретной, детерминированной задаче на каждом шаге.
Заключение
Двухэтапный подход с использованием Structured Output — это мощный и надёжный способ для вызова инструментов, особенно в экосистеме open-source LLM. Он полностью решает проблему невалидного JSON, обеспечивает предсказуемость и упрощает отладку.
Несмотря на компромисс в виде повышенной латентности, выигрыш в надёжности и эффективности использования контекста при большом количестве инструментов делает этот метод крайне привлекательным для построения сложных и отказоустойчивых агентских систем.