Структурированные выходы и function calling: продакшен-паттерны
Структурированные выходы и function calling — это мост от «LLM, которая генерирует текст» к «системе, которая делает работу». В продакшене важны паттерны вокруг схем, обработки ошибок, идемпотентности и аккуратной деградации — а не просто JSON mode.
Сдвиг от «LLM-чатбот» к «LLM-системе, которая делает работу» происходит на уровне структурированных выходов и function calling. Здесь LLM перестаёт просто производить прозу и начинает производить данные, принимать решения о действиях и интегрироваться с остальной инфраструктурой.
В продакшене «структурированный выход» не означает «JSON mode сработал один раз в моём тесте». Это означает устойчивый конвейер, который обрабатывает дисперсию модели, кривые выходы, частичные отказы, эволюцию схем и грязную реальность LLM, которые не всегда следуют инструкциям.
Эта статья посвящена паттернам, которые реально держатся в продакшене. Мы предполагаем, что вы знаете основы (использовали tool_choice у OpenAI, tool use у Anthropic, ограничения JSON schema). Идём глубже — туда, где эти системы становятся надёжными.
Два режима
Две связанные, но различимые возможности:
Структурированный выход: LLM производит ответ, соответствующий схеме (обычно JSON). Используется, когда вам нужен ответ LLM в программно обрабатываемом формате.
Function/tool calling: LLM получает набор функций, которые может вызывать, решает, какую вызвать (если вообще нужно), формирует параметры. Хост-система выполняет функцию и возвращает результат. LLM может вызвать следующие функции или сформировать финальный ответ.
API моделей обычно выставляют это через:
- Параметр
response_format(или его аналог), принимающий JSON-схему, для обычных структурированных выходов — Structured Outputs у OpenAI,responseSchemaу Google Gemini и т. д. - Массив
tools, описывающий доступные функции, плюс ответ с tool-call при срабатывании. Tool-use API у Anthropic заодно служит способом ограничить выход схемой — вы определяете один tool с нужной схемой и заставляете модель его вызвать.
Оба работают; они родственны. «Вызов функции» — по сути структурированный выход, где схема и есть сигнатура функции.
Паттерн 1: жёсткие, явные схемы
Самый большой выигрыш в надёжности — в ваших схемах.
Размашистая схема:
{
"type": "object",
"properties": {
"category": { "type": "string" },
"priority": { "type": "string" }
}
}Жёсткая схема:
{
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "feature_request", "complaint"],
"description": "The ticket category. Use 'technical' for product bugs and 'account' for login/password issues."
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "Use 'urgent' only for outages or business-critical impact. 'High' for blocking issues on important customers. 'Medium' for standard impact. 'Low' for nice-to-haves."
}
},
"required": ["category", "priority"],
"additionalProperties": false
}Жёсткая версия:
- Ограничивает значения известным enum (никакого free-form-дрейфа).
- Включает описания, которые работают как inline-промпты (модель их использует).
- Требует полей (вы не получите частичных ответов).
- Запрещает лишние поля (никаких случайных нагаллюцинированных ключей).
В продакшене у каждого поля схемы должно быть описание. Каждый enum должен быть явным. Каждое обязательное поле должно быть помечено. Это «schema as prompt» — ваша схема занимается prompt engineering.
Паттерн 2: constrained generation
Крупные провайдеры теперь поддерживают constrained generation — модель ограничивается на уровне декодирования, чтобы производить только валидные выходы.
- OpenAI:
response_format: { type: "json_schema", json_schema: { ..., strict: true } }. - Anthropic: tools со строгими схемами.
- Open-source:
outlines,lm-format-enforcer,jsonformer, grammar-based декодирование в vLLM.
Пользуйтесь этим. Всегда. Это убирает целый класс отказов (кривой JSON, нагаллюцинированные поля, пропущенные обязательные поля). Накладные расходы по производительности пренебрежимы.
Когда constrained generation недоступен (некоторые открытые модели, некоторые конфигурации), запасной вариант — валидация + ретрай (см. Паттерн 4).
Паттерн 3: версионирование схем
Схемы эволюционируют. Вы добавляете поля. Депрекейтите поля. Меняете enum-ы.
Изменение схемы — это изменение кода. Оно должно быть:
- Версионировано. Помечайте каждую схему номером версии.
- Протестировано. Eval-набор гоняется по новой схеме до деплоя.
- Согласовано. Downstream-потребители выхода знают об изменении.
- По возможности обратно совместимо. Добавляйте новые опциональные поля; не убирайте обязательные.
Паттерн, который работает: хранить схемы как TypeScript-типы или Pydantic-модели, версионировать их в системе контроля версий, генерировать JSON Schema из них. Типы обслуживают и API модели, и код вашего приложения.
class TicketClassificationV2(BaseModel):
category: Literal["billing", "technical", "account", "feature_request", "complaint"]
priority: Literal["low", "medium", "high", "urgent"]
confidence: float = Field(ge=0, le=1, description="Confidence in this classification, 0-1")
needs_human_review: bool = Field(description="True if any field has low confidence or unusual signal")
reasoning: str = Field(description="Brief reasoning for the classification, especially for non-obvious cases")Pydantic-модель определяет схему, валидирует выход и служит типом в вашем Python-коде. Один источник правды.
Паттерн 4: валидация и ретрай
Даже с constrained generation валидируйте выход до использования:
from pydantic import ValidationError
def call_with_validation(prompt, schema, max_retries=2):
for attempt in range(max_retries + 1):
response = llm_call(prompt, response_format=schema)
try:
parsed = schema.model_validate_json(response.content)
return parsed
except ValidationError as e:
if attempt < max_retries:
prompt = build_retry_prompt(prompt, response.content, e)
continue
raiseПромпт для ретрая должен включать исходные инструкции, предыдущий вывод модели и конкретное описание того, что пошло не так:
Your previous response had a validation error:
{error message}
Your previous output:
{previous output}
Please correct the issue and produce a valid response.Ретраи работают на удивление хорошо — обычно одного ретрая хватает, чтобы исправиться после ошибки модели.
Ограничения: не ретрайте бесконечно (максимум 2-3). Не ретрайте при ошибках, не связанных с валидацией (rate limits, content filter и т. п.). Логируйте ретраи, чтобы мониторить частоту (растущий rate ретраев сигнализирует о дрейфе модели или проблемах с промптом).
Паттерн 5: рефлексия над результатами
Для высокорисковых вызовов функций заставляйте модель рефлексировать над результатами инструмента до их использования.
Голый цикл:
1. Call LLM with tools available.
2. Model decides to call tool X.
3. Execute X.
4. Pass result back to model.
5. Model produces final response.Цикл с рефлексией:
1. Call LLM with tools available.
2. Model decides to call tool X.
3. Execute X.
4. Pass result back to model.
5. Model evaluates: does this result match what I expected? Should I act on it?
6. If yes, model produces final response. If no, model calls another tool or asks for clarification.Так ловятся случаи вроде:
- Инструмент вернул 0 результатов, когда должен был вернуть данные → модель распознаёт пустой случай.
- Инструмент вернул ошибку → модель обрабатывает её явно, а не игнорирует.
- Инструмент вернул неожиданные данные → модель замечает и адаптируется.
Реализация: просите модель явно оценивать результаты инструмента, возможно через структурированный паттерн «оценить, потом действовать».
Это добавляет латентность и токены. Для высокорисковых действий (отправить письмо, провести платёж, изменить запись) оно того стоит. Для низкорискового извлечения информации — пропускайте.
Паттерн 6: идемпотентность
LLM иногда вызывают один и тот же инструмент дважды или повторяют уже успешные вызовы. Без идемпотентности вы получаете дубликаты: два возврата, два письма, две записи.
Паттерны идемпотентности:
Idempotency-ключи. Каждый вызов инструмента получает уникальный ключ (генерируется на стороне клиента, передаётся в вызов). Downstream-API или ваша обёртка вокруг инструмента использует ключ, чтобы детектировать дубликаты и вернуть существующий результат.
Семантика get-or-create. Инструменты, создающие записи, сначала ищут. «Создать клиента с email X» сперва проверяет, есть ли клиент с email X; если есть, возвращает существующего вместо создания дубликата.
Логи операций. Инструменты логируют каждую операцию. Обёртка проверяет лог перед выполнением; если уже сделано — возвращает кэшированный результат.
Консервативный дизайн инструментов. Инструменты, выполняющие значимые действия, спроектированы так, чтобы требовать явного подтверждения или одобрения человеком. LLM не может случайно дёрнуть их в плотном цикле.
Для любого инструмента с побочными эффектами проектируйте под идемпотентность. Пропуск этого шага — крупный источник продакшен-багов.
Паттерн 7: наблюдаемость tool-вызовов
Вы должны знать, что происходит с tool-вызовами. Для каждого вызова логируйте:
- Timestamp.
- Имя инструмента и аргументы.
- Результат (или ошибку).
- Длительность.
- Пользователя/сессию, которым он принадлежит.
- Цепочку вызовов в этом ходе (был ли этот tool-вызов частью более длинной цепочки?).
Стройте дашборды на этих данных. Типичные виды:
- Объём tool-вызовов по инструментам.
- Error rate по инструментам.
- Средняя длительность по инструментам.
- Паттерны последовательностей («какие инструменты часто вызываются вместе?»).
- Нагаллюцинированные tool-вызовы (LLM пыталась вызвать несуществующий инструмент).
Это показывает, где ваша система валится и где она дорогая.
Паттерн 8: нагаллюцинированные аргументы
LLM иногда выдумывают значения для параметров инструмента. Они вызовут search_customers(email="...") с email-ом, не соответствующим реальному вопросу пользователя. Или вызовут book_meeting(date="...") с датой, которая нигде не упоминалась.
Смягчения:
Строгая схема с описаниями. «user_id должен быть упомянут ранее в диалоге. Не выдумывайте ID.»
Валидация в обёртке инструмента. Если значение неправдоподобно (user_id не существует, дата в прошлом), инструмент возвращает структурированную ошибку и модель переосмысливает.
Рефлексия. «Перед вызовом этого инструмента подтвердите, что используемые значения опираются на диалог.»
Ограниченные описания инструментов. Инструменты, оперирующие конкретными сущностями, выставляют только ID сущностей, полученные ранее в диалоге. Не выставляйте сырой поиск.
Аудит-логи. Ловите паттерны нагаллюцинированных аргументов и подстраивайте промпты/схемы.
Паттерн 9: аккуратная деградация
Инструменты падают. API ложатся. Срабатывают rate limits. Правильным ответом редко является «скажи пользователю, что ничего не работает».
Паттерны:
Кэш или устаревшие данные. Если живой источник недоступен, верните кэшированные данные с пометкой, что они устарели.
Частичное выполнение. Если 3 из 5 подзадач удались — сообщите, что сделано, а что нет.
Запасные пути. Если основной инструмент падает, модель знает про резервный. Например, если search_documents падает, переходим на search_web с соответствующими оговорками.
Видимые пользователю состояния ошибок. Если инструмент реально не может завершиться, модель формирует понятное сообщение об ошибке для пользователя — а не нагаллюцинированный успех.
Модель должна знать об этих паттернах. Документируйте их в системном промпте:
If a tool returns an error:
- Try the alternate tool if one exists.
- Report partial results clearly if the user has already provided information.
- Never claim success when a tool returned an error.Паттерн 10: стриминг структурированных выходов
Для UX стриминг частичного структурированного вывода — это здорово: пользователь видит, как результат формируется в реальном времени, а не ждёт.
Реализация:
- Большинство современных API моделей стримят JSON-выход токен за токеном.
- Парсите частичный JSON инкрементально (библиотеки вроде
partial-json-parserили собственный маленький стриминговый парсер). - Обновляйте UI по мере появления полей.
Особенно хорошо работает для выходов с несколькими секциями. Длинное описание продукта, аналитика с несколькими инсайтами, code review с несколькими находками — все они кажутся куда более отзывчивыми, когда стримятся.
Оговорка: не принимайте решений по частичному выходу. Стримьте для отображения; ждите завершения перед действиями по структурированному результату.
Паттерн 11: function calling vs явные «decide»-вызовы
Нативный function calling удобен — модель «решает», когда вызвать инструмент. Но в некоторых воркфлоу явный «decide»-вызов надёжнее.
Пример: воркфлоу клиентской поддержки, где модель должна выбрать между несколькими действиями.
Подход с нативным function calling: дайте модели 5 инструментов (refund, send_article, escalate_to_human, ask_clarifying_question, close_ticket). Пусть решает сама.
Подход с явным decide-вызовом: сначала вызовите модель с единственным инструментом decide_action, у которого один параметр — какое действие. Затем на основании решения вызовите модель снова — уже только с релевантным инструментом.
Явный подход медленнее и многословнее, но надёжнее. Модель собраннее на каждом шаге. У хост-системы больше контроля над воркфлоу.
Для высокорисковых воркфлоу явный подход часто выигрывает. Для разведывательных или простых нативный function calling сгодится.
Паттерн 12: форматирование результата инструмента
То, как вы возвращаете результат инструмента, имеет значение. Модель читает результат; формат важен.
Плохо:
{"id": "cus_123", "n": "John", "p": "12345"}Лучше:
{
"customer_id": "cus_123",
"name": "John Doe",
"phone": "+1-555-0123",
"tier": "premium",
"open_tickets": 0
}Лучшее (для некоторых случаев):
Customer found:
- ID: cus_123
- Name: John Doe
- Tier: Premium
- Phone: +1-555-0123
- Open tickets: 0
This customer is in the premium tier and has no open tickets.«Лучшая» форма — человекочитаема, включает контекст, и модели проще использовать её в последующей генерации. «Лучшая» в смысле «лучше» — более структурирована и машиночитаема. Используйте тот формат, с которым модель лучше справляется на ваших downstream-задачах (тестируйте).
Для некоторых инструментов хорошо работает возврат и того, и другого («Вот результат: [нарратив]. Сырые данные: [JSON]»).
Паттерн 13: schema-aware ретраи
Некоторые ошибки валидации непоправимы (модель фундаментально не поняла задачу). Другие — простые правки.
Полезный паттерн: классифицировать ошибку и реагировать соответственно.
def handle_validation_error(error):
if "missing required field" in str(error):
return retry_with_message("You omitted required field X. Please include it.")
elif "value not in enum" in str(error):
return retry_with_message("Value X is not in the allowed set. Choose from: ...")
elif "type mismatch" in str(error):
return retry_with_message("Field X must be a number, not a string.")
else:
# Unknown error — single generic retry
return retry_with_message("There was an error in your response. Please try again.")Заточенные под ошибку ретраи срабатывают чаще, чем универсальные.
Паттерн 14: композиция
Инструменты должны компоноваться. Маленькие сфокусированные инструменты, делающие одно дело, модель может комбинировать в сложные воркфлоу.
Монолитный process_customer_request(query), делающий всё, — это чёрный ящик. Модель не может ни наблюдать, ни рулить внутренней логикой.
Набор сфокусированных инструментов — search_customer(email), get_recent_orders(customer_id), check_subscription_status(customer_id), escalate_to_human(reason) — модель может скомпоновать в правильный поток под каждую ситуацию.
Проектируйте инструменты с правильной гранулярностью. Каждый делает одно дело. Инструменты компонуются в воркфлоу.
Паттерн 15: схема для «не знаю»
Тонкий паттерн: явно моделируйте неопределённость в схеме.
class CustomerInfo(BaseModel):
name: str
name_confidence: Literal["high", "medium", "low"]
needs_clarification: bool
clarification_question: Optional[str] = NoneМодель может вернуть «низкую уверенность» с уточняющим вопросом вместо того, чтобы фантазировать.
Это намного лучше, чем модель, которая всегда уверенно заполняет поля — иногда нагаллюцинированными данными.
Разобранный пример: обработка счетов
Чтобы собрать всё вместе, вот продакшен-уровневая система обработки счетов.
Вход: PDF-счёт во вложении к письму. Цель: извлечь структурированные данные, направить в бухгалтерскую систему.
Схема:
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
total: float
confidence: Literal["high", "medium", "low"]
class Invoice(BaseModel):
vendor_name: str
vendor_id: Optional[str] = None # null if not found in our records
invoice_number: str
invoice_date: date
due_date: Optional[date] = None
line_items: List[LineItem]
subtotal: float
tax: float
total: float
currency: str # ISO 4217
confidence: Literal["high", "medium", "low"]
needs_review: bool
review_reasons: List[str] # Specific reasons review is neededВоркфлоу:
- Шаг OCR: vision-модель извлекает текст из PDF.
- Шаг извлечения: вызов LLM со схемой выше, constrained generation включён.
- Шаг валидации: Pydantic валидирует. Ошибки валидации запускают один ретрай с feedback-ом.
- Шаг кросс-проверки: tool-вызов, ищущий вендора в нашей системе. Если
vendor_nameсовпадает с известным — прикрепляетсяvendor_id. Если нет — ставитсяneeds_review=true. - Шаг проверки арифметики: проверяем
sum(line_items.total) ≈ subtotalиsubtotal + tax ≈ total. Если нет —needs_review=true. - Шаг проверки уверенности: если
confidenceнизкий или у какой-то позиции низкий confidence —needs_review=true. - Маршрутизация: если
needs_review=true, отправляем в очередь к человеку. Иначе — в бухгалтерскую систему. - Логирование: каждый шаг логируется со входом, выходом, длительностью, ошибками.
Обрабатываемые режимы отказа:
- Кривой JSON: constrained generation предотвращает; ретрай ловит граничные случаи.
- Нагаллюцинированные поля: схема строга.
- Арифметические ошибки: проверены.
- Неизвестные вендоры: помечены.
- Низкий confidence: помечен.
- Ошибки инструмента: явная обработка.
Производительность в продакшене: ~95% счетов проходят насквозь; 5% уходят на ревью. Среди автообработанных error rate <0,5% (хорошо в пределах допустимого). Среди очереди на ревью ~80% подтверждаются корректными, 20% требуют правок.
Вот так выглядит продакшен-уровень структурированных выходов. Не «JSON mode сработал один раз», а конвейер, который обрабатывает реальные режимы отказа.
Типичные ошибки
Несколько паттернов, которые мы видим раз за разом:
Ошибка 1: нет валидации. Pydantic, zod — что угодно — просто валидируйте. Не доверяйте модели.
Ошибка 2: размытые описания. «category: string» модели не помогает. «category: одно из billing, technical, account_access, где billing охватывает...» — помогает.
Ошибка 3: слишком много инструментов. Доступно 30 инструментов — модель выбирает не те. Курируйте до <10 релевантных на вызов.
Ошибка 4: нет ретрая при ошибке валидации. Один кривой выход убивает весь поток. Делайте один ретрай с feedback-ом.
Ошибка 5: нет наблюдаемости. Когда tool-вызовы валятся в продакшене, без трейсов вы не сможете поставить диагноз.
Ошибка 6: нет идемпотентности на side-effect-инструментах. Дубликаты возвратов, дубликаты писем. Предсказуемый баг.
Ошибка 7: доверие к выбранным LLM аргументам без валидации. Нагаллюцинированные user ID, нагаллюцинированные даты. Валидируйте аргументы перед выполнением.
Ошибка 8: пропуск версионирования схем. Изменения схемы ломают downstream-потребителей. Версионируйте.
Главное
Структурированные выходы и function calling — это мост от «LLM, которая болтает» к «LLM, которая делает работу». Сделано хорошо — даёт продакшен AI. Сделано плохо — ломается интересно и дорого.
Важные паттерны: жёсткие схемы, constrained generation, валидация с ретраем, рефлексия над результатами инструментов, идемпотентность, аккуратная деградация, schema-aware обработка ошибок и сквозная наблюдаемость.
Это не опциональная полировка. Это разница между демо и продакшен-системой. Закладывайте всё это с самого начала.