Structured Output: как заставить LLM выдавать JSON

opensourceaillmstructured-outputjsonit
← Back to Blog

Введение: LLM — это не база данных

LLM генерирует текст токен за токеном. Но что если вам нужна структура? JSON, таблица, объект с полями?

Факт: GPT-4 возвращает невалидный JSON в ~8-15% случаев без явного инструктажа.


Что такое Structured Output?

Проблема

Без structured output:
  User: "Извлеки имена из текста"
  LLM: "Имена: Иван, Петр, Мария"
  ✗ Нет структуры
  ✗ Нельзя парсить надежно
  ✗ Разные форматы каждый раз

Со structured output:
  User: "Извлеки имена из текста"
  LLM: { "names": ["Иван", "Петр", "Мария"] }
  ✓ Предсказуемая структура
  ✓ Легко парсить
  ✓ Валидация на стороне клиента

Почему LLM не генерирует структуру?

LLM — это next-token predictor

Он предсказывает:
  "Сегодня погода хорошая"
  
А не:
  {"weather": "good", "day": "today"}

Структура — это не то, чему LLM обучался
Но можно дообучить или промптнуть

Уровень 1: Prompt-based Structured Output

JSON в промпте

response = openai.chat.completions.create(
    model='gpt-4',
    messages=[
        {
            'role': 'system',
            'content': '''
Ответь ТОЛЬКО валидным JSON. Не используй markdown.
Формат: {"sentiment": "positive|negative|neutral", "score": 0.0-1.0}
'''
        },
        {'role': 'user', 'content': 'Этот продукт отличный!'}
    ]
)

# Результат:
# {"sentiment": "positive", "score": 0.95}

Few-shot Examples

response = openai.chat.completions.create(
    model='gpt-4',
    messages=[
        {
            'role': 'user',
            'content': 'Отлично работает!'
        },
        {
            'role': 'assistant',
            'content': '{"sentiment": "positive", "score": 0.95}'
        },
        {
            'role': 'user',
            'content': 'Ужасный сервис'
        },
        {
            'role': 'assistant',
            'content': '{"sentiment": "negative", "score": 0.1}'
        },
        {
            'role': 'user',
            'content': 'Нормально'
        }
    ]
)

# Результат:
# {"sentiment": "neutral", "score": 0.5}

Уровень 2: OpenAI Response Format

JSON Mode

response = openai.chat.completions.create(
    model='gpt-4-turbo',
    messages=[
        {'role': 'user', 'content': 'Опиши город: Москва'}
    ],
    response_format={
        'type': 'json_schema',
        'json_schema': {
            'name': 'city_info',
            'schema': {
                'type': 'object',
                'properties': {
                    'name': {'type': 'string'},
                    'population': {'type': 'integer'},
                    'country': {'type': 'string'},
                    'facts': {
                        'type': 'array',
                        'items': {'type': 'string'}
                    }
                },
                'required': ['name', 'population', 'country']
            }
        }
    }
)

# Гарантированно валидный JSON
# {
#   "name": "Москва",
#   "population": 13000000,
#   "country": "Россия",
#   "facts": ["Столица", "11 кремлей"]
# }

Strict Mode

response = openai.chat.completions.create(
    model='gpt-4-turbo',
    messages=[...],
    response_format={
        'type': 'json_schema',
        'json_schema': {
            'name': 'exact_schema',
            'schema': {
                'type': 'object',
                'properties': {
                    'age': {'type': 'integer', 'minimum': 0},
                    'name': {'type': 'string'}
                },
                'required': ['name', 'age'],
                'additionalProperties': False  # ТОЛЬКО эти поля!
            },
            'strict': True  # Строгое соответствие
        }
    }
)

Уровень 3: Local LLM Structured Output

Ollama with JSON Mode

import ollama

# Force JSON mode
response = ollama.chat(
    model='llama3.1',
    messages=[{'role': 'user', 'content': 'Извлеки имена'}],
    format='json'  # Принудительный JSON
)

# Результат:
# {"names": ["Иван", "Петр"]}

Ollama with Schema

response = ollama.chat(
    model='llama3.1',
    messages=[{'role': 'user', 'content': 'Извлеки данные'}],
    format={
        "type": "object",
        "properties": {
            "entities": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string"},
                        "type": {"type": "string"}
                    }
                }
            }
        },
        "required": ["entities"]
    }
)

llama.cpp with Structured Output

from llama_cpp import Llama
import json

llama = Llama(
    model_path="./llama3.gguf",
    logits_all=True
)

# Grammar-based structured output
grammar = r'''
root ::= object
object ::= "{" ws (string ws ":" ws value (ws "," ws value)*)? ws "}"
value ::= object | array | string | number | "null"
...
'''

output = llama.create_chat_completion(
    messages=[{'role': 'user', 'content': 'Извлеки данные'}],
    grammar=grammar,  # Строгая грамматика
    stream=False
)

Уровень 4: Grammar-based Structured Output

Что такое Grammar?

Grammar = формальная грамматика, которая ограничивает вывод LLM

BNF (Backus-Naur Form):
  root ::= "{" ws field ws "}"
  field ::= string ws ":" ws value
  value ::= string | number | array
  array ::= "[" ws (value (ws "," ws value)*)? ws "]"
  ws ::= [ \t\n]*

LLM генерирует ТОЛЬКО валидный JSON!

llama.cpp Grammar Example

import json

# JSON grammar для llama.cpp
json_grammar = """
root ::= object
object ::= "[" value ("," value)* "]"
value ::= string | number | object | bool | null
string ::= "\"" ([^"\\\\]|\\\\.)* "\""
number ::= "-"? ([1-9] [0-9]+ | "0") ("." [0-9]+)? ([eE] [-+]? [0-9]+)?
bool ::= "true" | "false"
null ::= "null"
%import common.WS
%ignore WS
"""

llama = Llama(model_path="./model.gguf", grammar=json_grammar)

Преимущества Grammar

✅ Grammar-based:
- 100% валидный вывод
- Любая структура (не только JSON)
- Работает с local LLM
- Нет халлюцинаций формата

❌ Grammar-based:
- Сложнее писать грамматику
- Может замедлять генерацию
- Не все модели поддерживают

Уровень 5: Pydantic Integration

Pydantic Model

from pydantic import BaseModel, Field, validator
from typing import List, Optional

class Person(BaseModel):
    name: str = Field(..., description="Full name")
    age: int = Field(..., ge=0, le=150)
    email: Optional[str] = None
    skills: List[str] = []
    
    @validator('email')
    def validate_email(cls, v):
        if v and '@' not in v:
            raise ValueError('Invalid email')
        return v

# Schema для LLM
schema = Person.model_json_schema()
# {
#   "properties": {
#     "name": {"description": "Full name", "type": "string"},
#     "age": {"description": "", "maximum": 150, "minimum": 0, "type": "integer"},
#     ...
#   },
#   "required": ["name", "age"],
#   "title": "Person",
#   "type": "object"
# }

Using with OpenAI

from typing import Literal

class Response(BaseModel):
    sentiment: Literal['positive', 'negative', 'neutral']
    score: float = Field(..., ge=0.0, le=1.0)
    language: str

# OpenAI + Pydantic
response = openai.beta.chat.completions.parse(
    model='gpt-4-turbo',
    messages=[
        {'role': 'user', 'content': 'Отличный продукт!'}
    ],
    response_format=Response
)

# result.parsed — валидный Person объект!
result = response.choices[0].message.parsed
# Response(sentiment='positive', score=0.95, language='ru')

Error Handling

response = openai.beta.chat.completions.parse(
    model='gpt-4-turbo',
    messages=[...],
    response_format=Response,
    max_retries=3
)

if response.choices[0].message.refusal:
    print(f"Refusal: {response.choices[0].message.refusal}")
elif response.choices[0].message.parsed is None:
    # Parse failed
    print("Parse failed, fallback to raw")
    raw = json.loads(response.choices[0].message.content)
    parsed = Response(**raw)
else:
    parsed = response.choices[0].message.parsed

Уровень 6: Extraction Patterns

Entity Extraction

class Entity(BaseModel):
    name: str
    type: Literal['person', 'organization', 'location']
    description: Optional[str]

class ExtractionResult(BaseModel):
    entities: List[Entity]
    summary: str

response = openai.beta.chat.completions.parse(
    model='gpt-4-turbo',
    messages=[
        {
            'role': 'system',
            'content': 'Извлеки сущности из текста'
        },
        {'role': 'user', 'content': 'Apple купила стартап в Берлине'}
    ],
    response_format=ExtractionResult
)

# ExtractionResult(
#   entities=[
#     Entity(name="Apple", type="organization", description=None),
#     Entity(name="Берлин", type="location", description=None)
#   ],
#   summary="Apple acquired a startup in Berlin"
# )

Summarization with Structure

class StructuredSummary(BaseModel):
    title: str
    key_points: List[str]
    sentiment: Literal['positive', 'negative', 'neutral']
    action_items: List[str]
    entities_mentioned: List[str]

response = openai.beta.chat.completions.parse(
    model='gpt-4-turbo',
    messages=[
        {'role': 'user', 'content': article_text}
    ],
    response_format=StructuredSummary
)

Уровень 7: Multi-Model Comparison

Structured Output Accuracy

Модель          | JSON валидность | Schema adherence
---------------|-----------------|------------------
gpt-4-turbo    | 95%             | 92%
gpt-3.5-turbo  | 85%             | 78%
llama3.1 70B   | 88%             | 80%
mistral-large  | 90%             | 85%
codellama      | 97%             | 95%

codellama лучше всего для JSON!

Code Models for Structured Output

# CodeLLaMA отлично генерирует JSON
response = ollama.chat(
    model='codellama',
    messages=[{'role': 'user', 'content': 'JSON: {"key": "value"}'}],
    format='json'
)

# codellama дает ~97% валидный JSON
# против ~88% для llama3.1

Уровень 8: Post-processing

JSON Repair

import json_repair

raw_output = '''
{
  "name": "Москва",
  "population": 13000000,
  "facts": ["Столица", "Большой город"]
  // missing comma above
}
'''

# json_repair исправляет ошибки
result = json_repair.loads(raw_output)
# {'name': 'Москва', 'population': 13000000, 'facts': [...]}

Regex Extraction

import re

def extract_json_from_markdown(text: str) -> dict:
    # LLM часто оборачивает в ```json ... ```
    match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
    if match:
        json_str = match.group(1)
    else:
        # Ищем первый {
        start = text.find('{')
        end = text.rfind('}')
        json_str = text[start:end+1]
    
    return json.loads(json_str)

Уровень 9: Streaming Structured Output

Streaming с валидацией

import json
from sseclient import SSEClient

def stream_structured_output(messages, schema):
    """Streaming с постепенной валидацией"""
    partial = ""
    in_json = False
    
    for chunk in stream_response:
        partial += chunk.delta.content
        in_json |= '{' in chunk.delta.content
        
        if in_json:
            try:
                parsed = json.loads(partial)
                yield parsed  # Валидный partial result
            except json.JSONDecodeError:
                pass  # Ждем больше данных

Delta Updates

# OpenAI streaming с structured output
response = openai.beta.chat.completions.parse(
    model='gpt-4-turbo',
    messages=[...],
    response_format=Response,
    stream=True
)

for chunk in response:
    if chunk.choices[0].delta.parsed:
        print(f"Partial: {chunk.choices[0].delta.parsed}")

Уровень 10: Best Practices

Schema Design

✅ Schema Design:
- Используй descriptions для каждого поля
- Добавляй constraints (min, max, pattern)
- Используй enum вместо строк
- Разбивай сложные схемы на части
- Тестируй на edge cases

❌ Common Mistakes:
- Слишком сложные nested schemas
- Отсутствие required полей
- Ambiguous descriptions
- Нет валидации на своей стороне

Production Checklist

Production Structured Output:
1. Всегда валидируй на своей стороне
2. Используй fallback parsing
3. Логгируй raw output для дебага
4. Добавляй retries при ошибках
5. Мониторь rate of parse failures
6. Используй strict mode когда возможно
7. Consider codellama для JSON-heavy tasks

Заключение

Structured Output — это must-have для production LLM apps.

Ключевые выводы:

  1. OpenAI response_format — самый простой путь
  2. Pydantic — лучший способ определения схем
  3. Grammar-based — самый надежный для local LLM
  4. Code models (CodeLLaMA) — лучшие для JSON
  5. Всегда валидируйте на своей стороне

Ресурсы: