Structured extraction: как заставить LLM возвращать чистый JSON из HTML
Practical guide: function calling, JSON Schema и валидация — три техники, которые убирают галлюцинации и битый JSON из ответов LLM.

Когда LLM используется для извлечения данных из HTML, конечная цель — не текст, а структурированный объект: {"price": 1999, "currency": "RUB", "in_stock": true}. Разница между «модель обычно возвращает похожий на JSON текст» и «модель гарантированно возвращает валидный JSON по нашей схеме» — это разница между прототипом и продакшн-системой.
Проблема: LLM генерирует текст, а не данные
По умолчанию языковая модель — это генератор следующего токена. Попросив «верни JSON», вы получаете JSON в большинстве случаев, но:
- иногда модель добавляет пояснение до или после объекта («Вот запрошенные данные:
{...}»); - иногда обрезает ответ на лимите токенов, оставляя незакрытую скобку;
- иногда меняет типы полей (
"1999"вместо1999) или добавляет несуществующие в схеме ключи.
Для одной статьи в блоге это забавный баг. Для пайплайна, который парсит 10 000 страниц в день, это процент неизбежных падений, который нужно свести к минимуму.
Решение 1: Function calling / Tool use
Большинство современных LLM API поддерживают режим, при котором модель не «пишет JSON текстом», а заполняет параметры объявленной функции — формат гарантированно валиден на уровне API.
import json
from openai import OpenAI
client = OpenAI()
schema = {
"name": "extract_product",
"description": "Извлечь структурированные данные о товаре",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["RUB", "USD", "EUR"]},
"in_stock": {"type": "boolean"},
},
"required": ["title", "price", "currency", "in_stock"],
},
}
response = client.chat.completions.create(
model="gpt-4.1",
messages=[{"role": "user", "content": f"Извлеки данные о товаре:\n{page_text}"}],
tools=[{"type": "function", "function": schema}],
tool_choice={"type": "function", "function": {"name": "extract_product"}},
)
data = json.loads(response.choices[0].message.tool_calls[0].function.arguments)Модель физически не может вернуть текст вне схемы — API-слой сам ограничивает генерацию допустимыми токенами по грамматике JSON Schema.
Решение 2: Validation-слой поверх ответа
Даже со structured outputs стоит проверять данные явно — схема гарантирует форму, но не смысл.
from pydantic import BaseModel, field_validator
class Product(BaseModel):
title: str
price: float
currency: str
in_stock: bool
@field_validator("price")
@classmethod
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError("price должен быть положительным")
return v
try:
product = Product.model_validate(data)
except Exception as e:
# retry с уточняющим промптом или отправка в очередь на ручную проверку
log_extraction_failure(url, data, e)Совет
Держите схему валидации (Pydantic/Zod) как единственный источник правды — из неё же генерируйте JSON Schema для function calling. Так схема данных и схема для модели никогда не разойдутся.
Решение 3: Retry с обратной связью об ошибке
Если валидация упала, не выбрасывайте попытку — отправьте модели конкретную причину ошибки и попросите исправить именно её, а не начинать заново.
def extract_with_retry(page_text, schema, max_attempts=3):
last_error = None
for attempt in range(max_attempts):
prompt = page_text
if last_error:
prompt += f"\n\nПредыдущая попытка не прошла валидацию: {last_error}. Исправь именно эту проблему."
raw = call_llm(prompt, schema)
try:
return Product.model_validate(raw)
except Exception as e:
last_error = str(e)
raise ExtractionFailed(last_error)Сравнение подходов
| Подход | Гарантия формата | Гарантия смысла | Сложность внедрения |
|---|---|---|---|
| Просто попросить "верни JSON" | Низкая | Низкая | Минимальная |
| Function calling / structured outputs | Высокая | Средняя | Средняя |
| + Pydantic/Zod валидация | Высокая | Высокая (для проверяемых полей) | Средняя |
| + Retry с обратной связью | Высокая | Высокая | Высокая |
Заключение
Structured extraction — это не одна техника, а комбинация трёх слоёв: ограничение формата на уровне API (function calling), проверка смысла на уровне вашего кода (валидация схемы) и цикл обратной связи при сбое (retry с указанием причины). Пропуск любого из слоёв работает в 90% случаев на демо и ломается в проде на оставшихся 10%.
Частые вопросы
Почему LLM иногда возвращает невалидный JSON?+
Модель генерирует текст токен за токеном без гарантии синтаксической валидности — она может "забыть" закрыть скобку, добавить пояснение до/после JSON или обрезать ответ на лимите токенов. Function calling / structured outputs решают это на уровне API, ограничивая генерацию так, чтобы результат всегда соответствовал схеме.
Structured outputs гарантируют смысловую точность данных?+
Нет — они гарантируют только синтаксическую валидность (JSON соответствует схеме). Модель всё ещё может "придумать" значение поля, если не уверена. Для смысловой точности нужна отдельная валидация: сверка с исходным текстом, contrastive проверка, human-in-the-loop для важных полей.
Что делать, если провайдер LLM не поддерживает structured outputs?+
Используйте промпт с явным JSON Schema в system-сообщении, просите модель отвечать ТОЛЬКО JSON без пояснений, и оборачивайте парсинг в retry-логику с повторным запросом при невалидном ответе.
Похожие материалы

AI-агенты для парсинга данных: как LLM меняет извлечение информации
Разбираем, чем агентный подход к скрапингу отличается от селекторов, и когда LLM-агент действительно экономит время инженеров.

AI Sales Radar: автоматизация поиска и квалификации лидов
Как AI-агент находит компании с сигналами покупательского намерения — вакансии, новости, технологии на сайте — и оценивает готовность к покупке.

OCR и компьютерное зрение: когда текста на странице недостаточно
Что делать, когда нужные данные — это изображение, скан или текст, встроенный в canvas/WebGL, а не обычный HTML.