JSON, XML, Pydantic + LLM, Function Calling, Constrained Decoding — надёжный вывод данных
Языковые модели по природе своей генерируют свободный текст. Но большинство реальных приложений — от парсеров резюме до агентов автоматизации — нуждаются в предсказуемой структуре данных, которую можно надёжно обработать программно. Структурированный вывод — это набор техник и инструментов, которые позволяют получать от LLM данные в заданном формате.
Представьте задачу: извлечь из неструктурированного текста вакансии список требуемых навыков, зарплатный диапазон и тип занятости. Если модель вернёт связный текст, вам придётся парсить его с помощью регулярных выражений или дополнительных эвристик — и это ненадёжно. Стоит модели изменить формулировку, и парсер сломается.
Структурированный вывод решает эту проблему: модель возвращает данные в формате JSON, XML или YAML, которые программа может обработать напрямую. Это критично для:
Самый простой способ — явно попросить в промпте:
Извлеки из текста вакансии следующую информацию и верни строго в формате JSON:
- title (string): название должности
- skills (array of strings): список требуемых технических навыков
- salary_min (integer | null): минимальная зарплата в рублях
- salary_max (integer | null): максимальная зарплата в рублях
- remote (boolean): возможна ли удалённая работа
Верни только JSON без дополнительного текста.
Текст вакансии:
{vacancy_text}
Добавление "верни только JSON без дополнительного текста" — важная деталь. Без неё модели часто оборачивают JSON в объяснения вроде "Вот результат:" или добавляют markdown-блоки с ```json.
Самый базовый подход — явно описать ожидаемый формат:
Классифицируй отзыв пользователя.
Верни JSON с полями:
- sentiment: "positive", "negative" или "neutral"
- confidence: число от 0 до 1
- reason: одно предложение с обоснованием
Более надёжный вариант — показать пример целевого JSON:
Классифицируй отзыв. Пример ответа:
{"sentiment": "positive", "confidence": 0.92, "reason": "Пользователь хвалит скорость работы приложения"}
Теперь классифицируй:
"Загрузка занимает 10 секунд, это невыносимо"
Few-shot примеры значительно повышают точность соблюдения формата, особенно для сложных вложенных структур.
OpenAI и Anthropic предоставляют специальные механизмы на уровне API:
OpenAI JSON mode:
response = client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[{"role": "user", "content": "Верни JSON с полем 'result'"}]
)JSON mode гарантирует, что ответ будет валидным JSON-объектом, но не контролирует его схему.
OpenAI Structured Outputs (более строгий вариант):
from pydantic import BaseModel
class JobInfo(BaseModel):
title: str
skills: list[str]
remote: bool
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[...],
response_format=JobInfo,
)
job = response.choices[0].message.parsedConstrained decoding работает на уровне генерации токенов: модель в каждый момент может выбирать только те токены, которые соответствуют грамматике JSON (или заданной схеме). Это гарантирует структуру на математическом уровне, а не просто "просит" модель соответствовать ей.
Когда вы просите JSON только через промпт (без JSON mode или constrained decoding), возникает ряд типичных проблем:
Hallucinated fields — модель добавляет поля, которых не было в схеме:
{"title": "Python Developer", "skills": ["Python", "Django"], "years_required": 3}Поле years_required не запрашивалось, но модель "решила", что оно полезно.
Wrong types — числа в виде строк, булевы как "yes"/"no":
{"remote": "yes", "salary_min": "от 100000"}Trailing text — текст после JSON:
{"sentiment": "positive"}
Я также хотел бы отметить, что...
Невалидный JSON — незакрытые скобки, одинарные кавычки вместо двойных, trailing commas.
Nested structure errors — при глубокой вложенности модель нередко теряет структуру или смешивает уровни.
Популярный паттерн в Python: описать схему через Pydantic и передать её описание в промпт.
from pydantic import BaseModel, Field
from typing import Optional
class VacancyInfo(BaseModel):
title: str = Field(description="Название должности")
skills: list[str] = Field(description="Список технических навыков")
salary_min: Optional[int] = Field(None, description="Минимальная зарплата в рублях, null если не указана")
salary_max: Optional[int] = Field(None, description="Максимальная зарплата в рублях, null если не указана")
remote: bool = Field(description="True если возможна удалённая работа")
# Генерируем JSON Schema и включаем в промпт
schema = VacancyInfo.model_json_schema()
prompt = f"""
Извлеки информацию из вакансии и верни JSON по схеме:
{schema}
Текст вакансии:
{vacancy_text}
"""
# Парсим ответ с валидацией
raw_json = llm_response.strip()
vacancy = VacancyInfo.model_validate_json(raw_json)Это даёт немедленную валидацию и правильную типизацию. Если модель вернёт невалидные данные — Pydantic выбросит исключение, которое можно обработать и повторить запрос.
XML исторически хорошо знаком языковым моделям по обучающим данным (HTML, XML документация). Anthropic активно использует XML-теги в промптах для Claude:
Верни ответ в следующем формате:
<analysis>
<sentiment>positive</sentiment>
<key_points>
<point>Быстрая загрузка</point>
<point>Интуитивный интерфейс</point>
</key_points>
</analysis>
XML удобен для потоковой обработки (streaming) — можно начать обработку до получения полного ответа.
YAML используют реже, но он удобен для конфигурационных данных и более читаем при работе с вложенными структурами. Минус — модели чаще ошибаются с отступами.
В большинстве случаев предпочтителен JSON как стандарт де-факто для API и самый распространённый формат в обучающих данных.
Function Calling (OpenAI) и Tool Use (Anthropic) — это специальный механизм API, при котором модель вместо текстового ответа "вызывает функцию" с типизированными аргументами.
tools = [{
"type": "function",
"function": {
"name": "save_vacancy_info",
"description": "Сохранить извлечённую информацию о вакансии",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"skills": {"type": "array", "items": {"type": "string"}},
"remote": {"type": "boolean"}
},
"required": ["title", "skills", "remote"]
}
}
}]
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": vacancy_text}],
tools=tools,
tool_choice={"type": "function", "function": {"name": "save_vacancy_info"}}
)
args = json.loads(response.choices[0].message.tool_calls[0].function.arguments)Ключевые отличия Function Calling от "верни JSON":
| Параметр | Prompt JSON | Function Calling |
|---|---|---|
| Гарантия формата | Нет | Да (JSON Schema validation) |
| Типизация | Слабая | Строгая |
| Вероятность hallucinated fields | Высокая | Низкая |
| Поддержка в API | Везде | Зависит от провайдера |
| Потоковая обработка | Сложнее | Нативная |
Надёжная система работы с LLM всегда включает обработку ошибок парсинга:
import json
from pydantic import ValidationError
def parse_llm_response(raw: str, schema: type[BaseModel], max_retries: int = 2) -> BaseModel:
for attempt in range(max_retries + 1):
try:
# Попытка извлечь JSON из ответа (модель могла добавить markdown)
text = raw.strip()
if text.startswith("```"):
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
data = json.loads(text)
return schema.model_validate(data)
except (json.JSONDecodeError, ValidationError) as e:
if attempt == max_retries:
raise
# Retry с явным указанием ошибки в промпте
raw = ask_llm_to_fix(raw, str(e))Паттерн "retry с ошибкой" — эффективный способ получить валидный ответ: вы возвращаете модели её ответ + описание ошибки парсинга, и она, как правило, исправляет проблему.
Извлеки из текста статьи:
- все упомянутые компании (company_mentions: list[string])
- основную тему (topic: string)
- тональность (sentiment: "positive" | "negative" | "neutral")
Верни JSON. Текст: {article}
Классифицируй запрос в службу поддержки по категории:
- "billing" — вопросы об оплате и подписке
- "technical" — технические проблемы
- "feature_request" — запросы на новые функции
- "other" — всё остальное
Верни: {"category": "...", "confidence": 0.0-1.0, "summary": "одно предложение о сути запроса"}
Преобразуй неструктурированный адрес в стандартный формат:
{"street": "...", "city": "...", "postal_code": "...", "country": "..."}
Если поле не определяется — используй null.
Адрес: {address_text}
Структурированный вывод — обязательный навык для построения надёжных LLM-приложений. Выбор техники зависит от требований к надёжности:
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.