Пятница, 18:42. Вы написали промпт: «Верни JSON с перечнем API-эндпоинтов для системы заказов». LLM старательно выдаёт вам объект… но в поле method написано GET,POST (а не массив), где-то потеряна запятая, а в конце красуется философское «Это примерная структура, уточните при необходимости». Вы сидите с regex-ом и думаете, стоило ли оно того. Спойлер: не стоило.
Проблема не в том, что LLM не может выдать JSON. Она его выдаёт — но без гарантий. Ваш пайплайн try { JSON.parse(...) } catch { retry } — это костыль. В 2026 году этот костыль не нужен. Два механизма — Structured Output и function calling — позволяют получать от LLM строго валидный JSON и вызывать внешние системы, вообще не заглядывая в сырой текст ответа.
В этой статье разберём оба механизма: как устроены, чем отличаются, когда что применять и как встроить в ежедневную работу аналитика.
Что такое Structured Output
Structured Output — это режим, при котором вы передаёте LLM JSON-схему, а модель обязана вернуть ответ, соответствующий этой схеме. Не «постарается», не «вероятно» — жёстко, на уровне логитов, модель просто не может сгенерировать токен, нарушающий схему.
С точки зрения провайдера это выглядит так:
{ "model": "gpt-4o", "messages": [...], "response_format": { "type": "json_schema", "json_schema": { "name": "api_endpoints", "schema": { "type": "object", "properties": { "endpoints": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }, "description": { "type": "string" } }, "required": ["path", "method", "description"] } } }, "required": ["endpoints"] } } }}Модель физически не может вернуть method: "GET,POST" — потому что схема говорит: enum из конкретных строк. Не может забыть поле description — оно в required. Не может обернуть ответ в «Вот что у меня получилось: { … }» — ответ должен начинаться с \{, иначе это не object.
Примечательно, что Structured Output доступен не только в OpenAI. Claude 3.5+ через Anthropic API тоже поддерживает response_format с JSON Schema. Gemini — через response_schema в конфигурации. Так что выбор провайдера не привязывает вас к костылям.
Что такое function calling (tool use)
Function calling (в терминологии Anthropic — tool use) — это механизм, при котором вы описываете LLM набор доступных функций, а модель сама решает, вызвать ли функцию, с какими аргументами и в каком порядке. Вы не просите JSON — вы даёте набор инструментов и говорите: «решай сам, когда какой дёрнуть».
Описание инструмента выглядит так:
{ "name": "get_user_orders", "description": "Получить список заказов пользователя по его ID", "parameters": { "type": "object", "properties": { "user_id": { "type": "integer", "description": "ID пользователя" }, "status": { "type": "string", "enum": ["active", "completed", "cancelled"], "description": "Фильтр по статусу" } }, "required": ["user_id"] }}Когда пользователь пишет «Покажи мои активные заказы», LLM не отвечает текстом. Она возвращает:
{ "tool_calls": [ { "name": "get_user_orders", "arguments": { "user_id": 42, "status": "active" } } ]}Ваш код выполняет реальный SQL-запрос, получает настоящие данные и отправляет результат обратно в LLM — уже с контекстом. И модель формирует человекочитаемый ответ: «У вас три активных заказа: №105 на 14 500 руб., №108…».
Обратите внимание: аргументы функции — это тоже JSON, и они тоже валидируются схемой. По сути function calling = Structured Output для аргументов + логика принятия решения о вызове инструмента.
Два механизма — одна инфраструктура
На диаграмме показан принципиальный развилок: если Structured Output включён — ответ сразу попадает в интеграцию. Без него — начинается танец с бубном: regex, поиск первой фигурной скобки, попытка распарсить. Иногда удаётся, иногда нет, и тогда ретрей. Стоит отметить, что даже один ретрей на токенах может стоить дороже, чем использование Structured Output с первой попытки.
Возможности без кода: Structured Output в AI SDK и LangChain
Если вы не пишете прямые HTTP-запросы, а используете AI SDK или LangChain — там оба механизма обёрнуты в удобные абстракции. Пример с Vercel AI SDK:
import { generateObject } from 'ai';import { openai } from '@ai-sdk/openai';import { z } from 'zod';
const { object } = await generateObject({ model: openai('gpt-4o'), schema: z.object({ endpoints: z.array(z.object({ path: z.string(), method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']), description: z.string() })) }), prompt: 'Спроектируй REST API для корзины покупок'});// object — строго типизированный, валидный, без парсингаТри строчки — и у вас на выходе не «возможно-JSON», а строго типизированный объект. Компилятор TypeScript проверяет обращения к полям, схема Zod валидирует на рантайме, провайдер гарантирует соответствие схеме на уровне модели. Тройная защита.
Когда Structured Output, а когда function calling
| Ситуация | Механизм | Почему |
|---|---|---|
| Нужен структурированный ответ фиксированного формата — таблица, список, словарь | Structured Output | JSON-схема жёстко задаёт формат, не требуется принятие решений |
| Модель должна запрашивать реальные данные из внешней системы | Function calling | Модель сама определяет момент и параметры вызова |
| Нужен статический анализ текста без выхода во внешний мир | Structured Output | Достаточно валидации формата, инструменты избыточны |
| Агентный сценарий: модель ходит в Jira, БД, API по цепочке | Function calling | Многошаговые вызовы — модель планирует последовательность |
| Есть и то и другое: ответ — плюс модель может уточнить данные | Оба | Structured Output для финального ответа, function calling — в процессе |
Эмпирическое правило: если ваша задача сводится к «распарси это и верни как объект» — берите Structured Output. Если к «пойми, что нужно пользователю, сходи в систему и ответь» — function calling.
Практический пример: протокол встречи в требования за один вызов
Представьте: вы провели интервью со стейкхолдером. У вас есть транскрипт. Задача — извлечь функциональные требования, привязать к ролям и оценить приоритет.
Structured Output-схема для этого:
{ "name": "requirements_extraction", "schema": { "type": "object", "properties": { "stakeholder": { "type": "string", "description": "Роль участника" }, "functional_requirements": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "pattern": "^FR-\\\\d{3}$" }, "actor": { "type": "string" }, "action": { "type": "string" }, "priority": { "type": "string", "enum": ["critical", "high", "medium", "low"] }, "acceptance_criteria": { "type": "array", "items": { "type": "string" } } }, "required": ["id", "actor", "action", "priority"] } }, "non_functional_requirements": { "type": "array", "items": { "type": "object", "properties": { "category": { "type": "string", "enum": ["performance", "security", "availability", "usability"] }, "metric": { "type": "string" }, "target_value": { "type": "string" } }, "required": ["category", "metric", "target_value"] } } }, "required": ["functional_requirements"] }}Результат: за один вызов — структурированный набор ФТ с ролями, действиями и приоритетами, плюс НФТ с метриками. Ни одного потерянного поля, ни одной забытой запятой. Дальше этот JSON можно отдать в Confluence-шаблон ТЗ. (Кстати, связка LLM + Confluence через MCP делает ровно это — пример есть в статье про MCP для аналитика.)
function calling: LLM вызывает реальный мир
Теперь посмотрим, как работает function calling в динамике — когда модель принимает решение о вызове инструмента, а не просто форматирует ответ.
На диаграмме — полный цикл function calling. Система передаёт LLM запрос и определения инструментов. Модель анализирует, нужен ли внешний вызов. Если да — возвращает function_call с аргументами. Система исполняет его (запрос в БД, вызов API, чтение файла), результат возвращается в контекст модели, и LLM формулирует финальный ответ. Если контекста достаточно — модель отвечает текстом, не дёргая инструменты.
Важный нюанс: вы контролируете, что именно модель может вызвать. Она не получает доступ к БД напрямую — она генерирует запрос, а ваш код его исполняет с проверкой прав и ограничением LIMIT 100. LLM — мозг, а руки остаются у вас.
Сколько это стоит и как не разориться
Structured Output и function calling увеличивают latency и расход токенов.
Structured Output — модель тратит токены на подгонку под схему. В OpenAI это фиксированная наценка: response_format: json_schema добавляет ~10–15% к стоимости ответа. В Claude — примерно сопоставимо. Но экономит токены ретреев: один неудачный запрос с regex-парсингом — и вы уже переплатили больше.
Function calling — токены уходят на описание инструментов (каждый definition — это десятки-сотни токенов в системном промпте) и на многошаговые циклы «вызов → ответ → вызов». Без кэширования это может быть дорого.
Несколько практических советов по экономии:
- Prompt caching — описания инструментов в системном промпте почти не меняются между запросами. И OpenAI, и Anthropic поддерживают кэширование системного промпта со скидкой 50–90%.
- Выносите описания инструментов из каждого запроса — если используете LiteLLM, он кэширует определения инструментов и не отправляет их повторно в каждом вызове.
- Ограничивайте количество инструментов — давайте модели 5–7 релевантных инструментов, а не все 40. Меньше токенов на описание — меньше latency — меньше стоимость.
- Используйте
tool_choice— если вы знаете, что инструмент точно нужен, укажите его принудительно. Модель не тратит ресурсы на принятие решения.
Structured Output vs валидация на стороне клиента: почему не regex
Часто спрашивают: «А чем Structured Output лучше, чем просто попросить JSON и прогнать через joi/zod на клиенте?» Ответ: наличием контракта на уровне провайдера. Если схема задана в response_format, модель не генерирует невалидный JSON. Вы не доходите до стадии catch — его просто нет.
Попробуйте попросить модель без Structured Output: «верни JSON-массив». В 5–8% случаев (по данным OpenAI) в ответе будет лишний текст до или после JSON-блока. С ростом сложности схемы процент ошибок растёт. С Structured Output — 0% ошибок формата. Не «почти ноль» — статистический ноль, проверенный на миллионах запросов.
Для пайплайнов, где один сбой ломает всю цепочку (например, CI/CD-генерация конфигов или автоматическая приоритизация бэклога), эта разница критична.
Итог: два инструмента — одна философия
Structured Output и function calling — это не про «сделать красиво». Это про предсказуемость. Вы перестаёте гадать, что вернёт модель, и начинаете проектировать интеграцию как с обычным API: есть схема, есть контракт, есть гарантия.
Structured Output — для задач, где важен формат ответа. Function calling — где важно взаимодействие с внешним миром. Вместе они закрывают практически все сценарии интеграции LLM в рабочие процессы аналитика: от извлечения требований из стенограмм до агентов, самостоятельно бегающих по Jira и БД.
И да — тот самый пятничный regex для парсинга JSON из LLM. Вы больше не напишете его никогда.
Если вы только начинаете путь интеграции LLM в рабочие процессы, рекомендую заглянуть в обзорную статью про MCP — там про то, как связать модель с реальными системами аналитика. А про единый API-слой для работы с разными провайдерами — в разборе LiteLLM.