Logo
Overview

Structured Output и function calling: как получать надёжный JSON от LLM

August 24, 2026
9 min read

Пятница, 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 для аргументов + логика принятия решения о вызове инструмента.

Два механизма — одна инфраструктура

100%
flowchart TD
  A["Запрос аналитика"] --> B["LLM (GPT-4o / Claude)"]
  B --> C{"Structured Output?"}
  C -->|"Да"| D["JSON Schema ограничивает ответ"]
  C -->|"Нет"| E["Сырой текст + regex-парсинг"]
  D --> F["Валидный структурированный JSON"]
  E --> G["Попытка извлечения JSON"]
  G --> H{"Удалось?"}
  H -->|"Да"| F
  H -->|"Нет"| I["Ошибка / ретрей"]
  D --> J["Интеграция с системой"]
  F --> J
  J --> K["Автоматизация выполнена"]

  style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B fill:#7b68ee,stroke:#5a4db2,color:#fff
  style C fill:#f0a500,stroke:#c88400,color:#fff
  style D fill:#50c878,stroke:#3a9a5c,color:#fff
  style E fill:#e0e0e0,stroke:#999,color:#333
  style F fill:#50c878,stroke:#3a9a5c,color:#fff
  style G fill:#e0e0e0,stroke:#999,color:#333
  style H fill:#f0a500,stroke:#c88400,color:#fff
  style I fill:#e74c3c,stroke:#c0392b,color:#fff
  style J fill:#7b68ee,stroke:#5a4db2,color:#fff
  style K fill:#4a90d9,stroke:#2c5f8a,color:#fff

На диаграмме показан принципиальный развилок: если 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 OutputJSON-схема жёстко задаёт формат, не требуется принятие решений
Модель должна запрашивать реальные данные из внешней системы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 в динамике — когда модель принимает решение о вызове инструмента, а не просто форматирует ответ.

100%
sequenceDiagram
  participant API as REST API (Система)
  participant LLM as LLM (Claude/GPT)
  participant TOOLS as Подключённые инструменты
  participant DB as База данных

  API->>LLM: Запрос с определениями инструментов
  LLM->>LLM: Анализ: нужен ли вызов функции?
  alt Инструмент требуется
      LLM-->>API: function_call и аргументы
      API->>TOOLS: Выполнить вызов инструмента
      TOOLS->>DB: SELECT / UPDATE
      DB-->>TOOLS: Результат запроса
      TOOLS-->>API: Результат работы инструмента
      API->>LLM: Результат работы инструмента в контексте
      LLM-->>API: Финальный ответ с учётом данных
  else Текст без инструмента
      LLM-->>API: Прямой текстовый ответ
  end

  Note right of LLM: Structured Output гарантирует формат function_call и аргументов
  Note left of TOOLS: Инструмент = безопасный выход LLM в реальный мир

  rect rgb(74, 144, 217)
      Note over API: Система-клиент
  end
  rect rgb(123, 104, 238)
      Note over LLM,TOOLS: LLM и вызов инструментов
  end
  rect rgb(80, 200, 120)
      Note over DB: База данных
  end

На диаграмме — полный цикл 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 — это десятки-сотни токенов в системном промпте) и на многошаговые циклы «вызов → ответ → вызов». Без кэширования это может быть дорого.

Несколько практических советов по экономии:

  1. Prompt caching — описания инструментов в системном промпте почти не меняются между запросами. И OpenAI, и Anthropic поддерживают кэширование системного промпта со скидкой 50–90%.
  2. Выносите описания инструментов из каждого запроса — если используете LiteLLM, он кэширует определения инструментов и не отправляет их повторно в каждом вызове.
  3. Ограничивайте количество инструментов — давайте модели 5–7 релевантных инструментов, а не все 40. Меньше токенов на описание — меньше latency — меньше стоимость.
  4. Используйте 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.