В этой статье мы рассмотрим альтернативный подход вызова инструментов 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, обеспечивает предсказуемость и упрощает отладку.
Несмотря на компромисс в виде повышенной латентности, выигрыш в надёжности и эффективности использования контекста при большом количестве инструментов делает этот метод крайне привлекательным для построения сложных и отказоустойчивых агентских систем.
Комментарии (6)
Wiggin2014
29.06.2025 14:14А что произойдёт, если при валидации ответа по схеме Пайдантика произойдёт эксепшен? Ну то есть, если промт не смог уговорить модель отдать валидный джейсон, то как тут поможет просто валидация? Ответ-то уже сгенерирован.
DmitriiFilippov Автор
29.06.2025 14:14Если мы используем Structured Output (SO) — такого просто не быть. В этом и заключается суть подхода: мы не «уговариваем» модель сгенерировать корректный JSON, а заставляем её. Это достигается за счёт механизма, называемого ограниченное декодирование (constrained decoding).
В этом контексте, ограниченное декодирование — это метод, который манипулирует процессом генерации токенов генеративной модели, чтобы ограничить ее предсказания следующих токенов только теми токенами, которые не нарушают требуемую структуру выходных данных.Rion333
29.06.2025 14:14Подскажите, пожалуйста, если используется строгое ограниченное декодирование, то зачем задавать LLM схему ответа внутри промпта на естественном языке?
DmitriiFilippov Автор
29.06.2025 14:14По моему опыту это улучшает генерацию ответа.
И во многих фреймворках для локального инференса это упоминяется, например в vLLM https://docs.vllm.ai/en/v0.8.4/features/structured_outputs.html#:~:text=While not strictly necessary%2C normally it´s better to indicate in the prompt that a JSON needs to be generated and which fields and how should the LLM fill them. This can improve the results notably in most cases.
Но это, дейтсвительно, не обязательно, ответ все равно придет в нужном формате.
Hopenolis
У google gemini в их библиотеке google-genai весь сложный механизм вызова функций спрятан под капотом, тебе надо только написать сами функции с хорошими докстрингами, и передать их список в запрос, просто список имен функций, всю инфу он сам вытянет из самодокументации питона, достанет докстринг и описание функции из __.
Эти функции могут вызываться по много раз подряд в разных комбинациях анализируя результаты и корректируя следующие вызовы. Например если есть функция для рисования мермейд графиков и она умеет возвращать текстовое описание ошибки то джемини будет вызывать ее много раз подряд пока не получит требуемый результат или решит что не получится его получить.
Давно ищу какой-нибудь аналог для всех остальных моделей, хотя бы с однократным запуском функций но чтоб не надо было это всё вручную делать. Может знает кто такой.
DmitriiFilippov Автор
Ты говоришь о ReAct агенте. Есть реализованный такой агент на LangGraph https://langchain-ai.github.io/langgraph/agents/agents/