Structured Output: как заставить LLM выдавать JSON
opensourceaillmstructured-outputjsonit
Введение: 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.
Ключевые выводы:
- OpenAI response_format — самый простой путь
- Pydantic — лучший способ определения схем
- Grammar-based — самый надежный для local LLM
- Code models (CodeLLaMA) — лучшие для JSON
- Всегда валидируйте на своей стороне
Ресурсы: