Сдвиг от «LLM-чат-бота» к «LLM-системе, которая делает работу» происходит на уровне структурированных выходов и вызова функций. Здесь LLM перестаёт просто производить прозу и начинает производить данные, принимать решения о действиях и интегрироваться с остальной инфраструктурой.
В продакшене «структурированный выход» не означает «режим JSON сработал один раз в моём тесте». Это означает устойчивый конвейер, который справляется с вариативностью модели, некорректными выходами, частичными отказами, эволюцией схем и неприглядной реальностью, где LLM не всегда следуют инструкциям.
Эта статья посвящена паттернам, которые реально держатся в продакшене. Мы предполагаем, что вы знаете основы (использовали tool_choice у OpenAI, вызов инструментов (tool use) у Anthropic, ограничения JSON Schema). Идём глубже — туда, где эти системы становятся надёжными.
Два режима
Две связанные, но различимые возможности:
Структурированный выход: LLM производит ответ, соответствующий схеме (обычно JSON). Используется, когда вам нужен ответ LLM в программно обрабатываемом формате.
Вызов функций/инструментов: LLM получает набор функций, которые может вызывать, решает, какую вызвать (если вообще нужно), формирует параметры. Хост-система выполняет функцию и возвращает результат. LLM может вызвать следующие функции или сформировать финальный ответ.
API моделей обычно выставляют это через:
- Параметр
response_format(или его аналог), принимающий JSON-схему, для обычных структурированных выходов — Structured Outputs у OpenAI,responseSchemaу Google Gemini и т. д. - Массив
tools, описывающий доступные функции, плюс ответ с вызовом инструмента при срабатывании. API вызова инструментов (tool use) у Anthropic заодно служит способом ограничить выход схемой — вы определяете один инструмент с нужной схемой и заставляете модель его вызвать.
Оба работают; они родственны. «Вызов функции» — по сути структурированный выход, где схема и есть сигнатура функции.
Паттерн 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 (никакого дрейфа в свободную форму).
- Включает описания, которые работают как встроенные промпты (модель их использует).
- Требует полей (вы не получите частичных ответов).
- Запрещает лишние поля (никаких случайных выдуманных ключей).
В продакшене у каждого поля схемы должно быть описание. Каждое перечисление enum должно быть явным. Каждое обязательное поле должно быть помечено. Это «схема как промпт» — ваша схема занимается промпт-инжинирингом.
Паттерн 2: генерация с ограничениями
Крупные провайдеры теперь поддерживают генерацию с ограничениями — модель ограничивается на уровне декодирования, чтобы производить только валидные выходы.
- OpenAI:
response_format: { type: "json_schema", json_schema: { ..., strict: true } }. - Anthropic: инструменты со строгими схемами.
- Открытые решения:
outlines,lm-format-enforcer,jsonformer, декодирование на основе грамматик в vLLM.
Пользуйтесь этим. Всегда. Это убирает целый класс отказов (некорректный JSON, выдуманные поля, пропущенные обязательные поля). Накладные расходы по производительности пренебрежимо малы.
Когда генерация с ограничениями недоступна (некоторые открытые модели, некоторые конфигурации), запасной вариант — валидация и повтор (см. Паттерн 4).
Паттерн 3: версионирование схем
Схемы эволюционируют. Вы добавляете поля. Помечаете поля как устаревшие. Меняете перечисления enum.
Изменение схемы — это изменение кода. Оно должно быть:
- Версионировано. Помечайте каждую схему номером версии.
- Протестировано. Набор для оценки прогоняется по новой схеме до развёртывания.
- Согласовано. Нижестоящие потребители выхода знают об изменении.
- По возможности обратно совместимо. Добавляйте новые опциональные поля; не убирайте обязательные.
Паттерн, который хорошо работает: хранить схемы как 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: валидация и повтор
Даже с генерацией с ограничениями валидируйте выход до использования:
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
Промпт для повтора должен включать исходные инструкции, предыдущий вывод модели и конкретное описание того, что пошло не так:
В вашем предыдущем ответе была ошибка валидации:
{error message}
Ваш предыдущий вывод:
{previous output}
Исправьте проблему и выдайте корректный ответ.
Повторы работают на удивление хорошо — обычно одного повтора хватает, чтобы исправиться после ошибки модели.
Ограничения: не повторяйте бесконечно (максимум 2–3 раза). Не повторяйте при ошибках, не связанных с валидацией (лимиты запросов, контент-фильтр и т. п.). Логируйте повторы, чтобы следить за их частотой (растущая доля повторов сигнализирует о дрейфе модели или проблемах с промптом).
Паттерн 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 иногда вызывают один и тот же инструмент дважды или повторяют уже успешные вызовы. Без идемпотентности вы получаете дубликаты: два возврата средств, два письма, две записи.
Паттерны идемпотентности:
Ключи идемпотентности. Каждый вызов инструмента получает уникальный ключ (генерируется на стороне клиента, передаётся в вызов). Нижестоящий API или ваша обёртка вокруг инструмента использует ключ, чтобы обнаружить дубликаты и вернуть уже существующий результат.
Семантика «найти или создать». Инструменты, создающие записи, сначала выполняют поиск. «Создать клиента с email X» сперва проверяет, есть ли клиент с email X; если есть, возвращает существующего вместо создания дубликата.
Журналы операций. Инструменты логируют каждую операцию. Обёртка проверяет журнал перед выполнением; если уже сделано — возвращает кэшированный результат.
Консервативный дизайн инструментов. Инструменты, выполняющие значимые действия, спроектированы так, чтобы требовать явного подтверждения или одобрения человеком. LLM не может случайно дёрнуть их в плотном цикле.
Для любого инструмента с побочными эффектами проектируйте под идемпотентность. Пропуск этого шага — крупный источник продакшен-багов.
Паттерн 7: наблюдаемость вызовов инструментов
Вы должны знать, что происходит с вызовами инструментов. Для каждого вызова логируйте:
- Метку времени.
- Имя инструмента и аргументы.
- Результат (или ошибку).
- Длительность.
- Пользователя или сессию, которым он принадлежит.
- Цепочку вызовов в этом ходе (был ли этот вызов частью более длинной цепочки?).
Стройте дашборды на этих данных. Типичные разрезы:
- Объём вызовов по инструментам.
- Доля ошибок по инструментам.
- Средняя длительность по инструментам.
- Паттерны последовательностей («какие инструменты часто вызываются вместе?»).
- Вызовы несуществующих инструментов (LLM попыталась вызвать инструмент, которого нет).
Это показывает, где ваша система валится и где она обходится дорого.
Паттерн 8: выдуманные аргументы
LLM иногда выдумывают значения для параметров инструмента. Они вызовут search_customers(email="...") с адресом, не соответствующим реальному вопросу пользователя. Или вызовут book_meeting(date="...") с датой, которая нигде не упоминалась.
Способы смягчения:
Строгая схема с описаниями. «user_id должен быть упомянут ранее в диалоге. Не выдумывайте ID.»
Валидация в обёртке инструмента. Если значение неправдоподобно (user_id не существует, дата в прошлом), инструмент возвращает структурированную ошибку и модель пересматривает решение.
Рефлексия. «Перед вызовом этого инструмента подтвердите, что используемые значения опираются на диалог.»
Ограниченные описания инструментов. Инструменты, оперирующие конкретными сущностями, выставляют только ID сущностей, полученные ранее в диалоге. Не выставляйте прямой поиск.
Журналы аудита. Отлавливайте паттерны выдуманных аргументов и подстраивайте промпты и схемы.
Паттерн 9: аккуратная деградация
Инструменты падают. API падают. Срабатывают лимиты запросов. Правильным ответом редко является «скажи пользователю, что ничего не работает».
Паттерны:
Кэш или устаревшие данные. Если живой источник недоступен, верните кэшированные данные с пометкой, что они устарели.
Частичное выполнение. Если 3 из 5 подзадач удались — сообщите, что сделано, а что нет.
Запасные пути. Если основной инструмент падает, модель знает про резервный. Например, если search_documents падает, переходим на search_web с соответствующими оговорками.
Видимые пользователю состояния ошибок. Если инструмент реально не может завершиться, модель формирует понятное сообщение об ошибке для пользователя — а не выдуманный успех.
Модель должна знать об этих паттернах. Документируйте их в системном промпте:
Если инструмент вернул ошибку:
- Попробуйте запасной инструмент, если он есть.
- Ясно сообщите о частичных результатах, если пользователь уже предоставил информацию.
- Никогда не заявляйте об успехе, когда инструмент вернул ошибку.
Паттерн 10: потоковая передача структурированных выходов
Ради удобства пользователя стриминг частичного структурированного вывода — это здорово: пользователь видит, как результат формируется в реальном времени, а не ждёт.
Реализация:
- Большинство современных API моделей стримят JSON-выход токен за токеном.
- Парсите частичный JSON инкрементально (библиотеки вроде
partial-json-parserили собственный небольшой потоковый парсер). - Обновляйте интерфейс по мере появления полей.
Особенно хорошо это работает для выходов с несколькими секциями. Длинное описание продукта, аналитика с несколькими инсайтами, код-ревью с несколькими находками — все они кажутся куда более отзывчивыми, когда стримятся.
Оговорка: не принимайте решений по частичному выходу. Стримьте для отображения; ждите завершения, прежде чем действовать по структурированному результату.
Паттерн 11: вызов функций против явных «decide»-вызовов
Нативный вызов функций удобен — модель «решает», когда вызвать инструмент. Но в некоторых рабочих процессах явный «decide»-вызов надёжнее.
Пример: рабочий процесс клиентской поддержки, где модель должна выбрать между несколькими действиями.
Подход с нативным вызовом функций: дайте модели 5 инструментов (refund, send_article, escalate_to_human, ask_clarifying_question, close_ticket). Пусть решает сама.
Подход с явным decide-вызовом: сначала вызовите модель с единственным инструментом decide_action, у которого один параметр — какое действие. Затем на основании решения вызовите модель снова — уже только с релевантным инструментом.
Явный подход медленнее и многословнее, но надёжнее. Модель собраннее на каждом шаге. У хост-системы больше контроля над рабочим процессом.
Для высокорисковых рабочих процессов явный подход часто выигрывает. Для разведывательных или простых нативный вызов функций вполне сгодится.
Паттерн 12: форматирование результата инструмента
То, как вы возвращаете результат инструмента, имеет значение. Модель читает результат; формат важен.
Плохо:
{"id": "cus_123", "n": "John", "p": "12345"}
Лучше:
{
"customer_id": "cus_123",
"name": "John Doe",
"phone": "+1-555-0123",
"tier": "premium",
"open_tickets": 0
}
Лучше всего (для некоторых случаев):
Найден клиент:
- ID: cus_123
- Имя: John Doe
- Уровень: Premium
- Телефон: +1-555-0123
- Открытых обращений: 0
Этот клиент на уровне premium, открытых обращений нет.
Форма «лучше всего» — человекочитаемая, включает контекст, и модели проще использовать её в последующей генерации. Форма «лучше» — более структурирована и машиночитаема. Используйте тот формат, с которым модель лучше справляется на ваших последующих задачах (проверяйте на практике).
Для некоторых инструментов хорошо работает возврат и того, и другого («Вот результат: [нарратив]. Сырые данные: [JSON]»).
Паттерн 13: повторы с учётом схемы
Некоторые ошибки валидации непоправимы (модель фундаментально не поняла задачу). Другие — простые правки.
Полезный паттерн: классифицировать ошибку и реагировать соответственно.
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: модель компьютерного зрения извлекает текст из PDF.
- Шаг извлечения: вызов LLM со схемой выше, генерация с ограничениями включена.
- Шаг валидации: Pydantic валидирует. Ошибки валидации запускают один повтор с обратной связью.
- Шаг кросс-проверки: вызов инструмента, ищущий поставщика в нашей системе. Если
vendor_nameсовпадает с известным — прикрепляетсяvendor_id. Если нет — ставитсяneeds_review=true. - Шаг проверки арифметики: проверяем
sum(line_items.total) ≈ subtotalиsubtotal + tax ≈ total. Если нет —needs_review=true. - Шаг проверки уверенности: если
confidenceнизкий или у какой-то позиции низкая уверенность —needs_review=true. - Маршрутизация: если
needs_review=true, отправляем в очередь на проверку человеком. Иначе — в бухгалтерскую систему. - Логирование: каждый шаг логируется со входом, выходом, длительностью, ошибками.
Обрабатываемые режимы отказа:
- Некорректный JSON: генерация с ограничениями предотвращает; повтор ловит граничные случаи.
- Выдуманные поля: схема строгая.
- Арифметические ошибки: проверены.
- Неизвестные поставщики: помечены.
- Низкая уверенность: помечена.
- Ошибки инструмента: явная обработка.
Производительность в продакшене: ~95% счетов проходят насквозь; 5% уходят на проверку. Среди автообработанных доля ошибок <0,5% (хорошо в пределах допустимого). Среди очереди на проверку ~80% подтверждаются корректными, 20% требуют правок.
Вот так выглядит продакшен-уровень структурированных выходов. Не «режим JSON сработал один раз», а конвейер, который обрабатывает реальные режимы отказа.
Типичные ошибки
Несколько паттернов, которые мы видим раз за разом:
Ошибка 1: нет валидации. Pydantic, zod — что угодно — просто валидируйте. Не доверяйте модели.
Ошибка 2: размытые описания. «category: string» модели не помогает. «category: одно из billing, technical, account_access, где billing охватывает…» — помогает.
Ошибка 3: слишком много инструментов. Доступно 30 инструментов — модель выбирает не те. Курируйте до <10 релевантных на вызов.
Ошибка 4: нет повтора при ошибке валидации. Один некорректный выход убивает весь поток. Делайте один повтор с обратной связью.
Ошибка 5: нет наблюдаемости. Когда вызовы инструментов валятся в продакшене, без трассировок вы не сможете поставить диагноз.
Ошибка 6: нет идемпотентности на инструментах с побочными эффектами. Дубликаты возвратов, дубликаты писем. Предсказуемый баг.
Ошибка 7: доверие к аргументам, выбранным LLM, без валидации. Выдуманные идентификаторы пользователей, выдуманные даты. Валидируйте аргументы перед выполнением.
Ошибка 8: пропуск версионирования схем. Изменения схемы ломают нижестоящих потребителей. Версионируйте.
От демо к продакшен-системе
Структурированные выходы и вызов функций — это мост от «LLM, которая болтает» к «LLM, которая делает работу». Сделано хорошо — открывает ИИ в продакшене. Сделано плохо — ломается интересно и дорого.
Важные паттерны: жёсткие схемы, генерация с ограничениями, валидация с повтором, рефлексия над результатами инструментов, идемпотентность, аккуратная деградация, обработка ошибок с учётом схемы и сквозная наблюдаемость.
Каждый из этих паттернов отделяет демо от продакшен-системы. Закладывайте их с самого начала.



