/ Дневник курса / Урок 4 /

Структурный вывод и типизация агента: контракт вместо парсинга JSON

Схема как контракт, а не просьба в промпте: три уровня контроля вывода, рассуждение по схеме, зависимости без промпта и циклы валидации в PydanticAI.

  • pydantic-ai
  • structured-output
  • llm-agents
  • validation

Когда вывод модели типизирован схемой, агент перестаёт ронять бэкенд тихим TypeError — и вот как из этого собирается надёжный слой между кодом и LLM.

1. Парсинг JSON руками — это технический долг

Ручной разбор ответа модели надёжен на 80–95 %. Оставшиеся 5–20 % — это запросы, которые тихо падают в проде без исключения и обнаруживаются с задержкой. Для высоконагруженного агента даже 2–3 % брака недопустимы: некорректный тип уходит дальше по пайплайну и роняет следующий сервис тихим сбоем.

Связка json.loads() + data.get() рассыпается пятью типовыми способами:

1. "count": "three"            → строка вместо int → тихий TypeError в downstream
2. ```json\n{...}\n```         → markdown-обёртка → json.loads() падает на символах
3. пропущено обязательное поле → NPE в следующем сервисе
4. "extra_field": "..."        → лишнее поле → strict-режим ORM бросает исключение
5. "address": "{\"city\":...}" → вложенный объект строкой → нельзя индексировать
Рисунок — пять поломок JSON: неверный тип, markdown-обёртка, пропуск обязательного поля, лишнее поле и вложенный объект строкой. Каждая ломается в своём месте пайплайна, и ни одну не ловит наивный json.loads().

По материалам курса есть показательный инцидент: модель вернула "amount": "$1,200.50" вместо числа 1200.50, квартальный отчёт не сошёлся, и три дня аудита на четырёх человек обошлись в $8 400. Первопричина не в модели — промпт был инструкцией («верни JSON»), а не контрактом. Ни один unit-тест не поймал: в тестах модель всегда возвращала число.

Защитный код без типизации — name = data.get("name", ""), age = int(data["age"]), score = data["score"] * 100 — это набор независимых точек отказа. При смене модели всё это переписывается заново. Один класс BaseModel заменяет полтора десятка строк парсинга и десяток unit-тестов.

2. Три уровня контроля вывода

Контроль над форматом ответа бывает трёх уровней, и они отличаются не удобством, а тем, возможно ли нарушение схемы в принципе.

Уровень Метод Принцип Надёжность
1 Prompt Engineering просьба «верни JSON» 80–95 %
2 Function Calling / Tool Use схема как hint 95–99 %
3 Native Structured Output constrained decoding 100 %

На уровне 1 модель просто слушается (или нет). На уровне 2 схема — рекомендация, модель может её нарушить. На уровне 3 нарушение исключено механически: работает constrained decoding (ограниченное декодирование).

Механизм держится на FSM (Finite State Machine, конечный автомат). На каждом шаге генерации модель выбирает токен из словаря — десятки тысяч вариантов. FSM накладывает на словарь маску по текущей позиции в схеме: вероятность недопустимого токена обнуляется до выборки.

Схема ждёт:  { "priority": "____" }
Допустимо:   "low" | "medium" | "high" | "critical"

шаг генерации, маска по словарю:
  "low"      → разрешён    ✓
  "medium"   → разрешён    ✓
  "срочно"   → заблокирован ✗   (вероятность → 0 до выборки)
  "8"        → заблокирован ✗
Рисунок — на каждом шаге FSM держит маску по словарю: токены вне схемы ("срочно", "8") получают нулевую вероятность ещё до выборки, разрешены только значения из Literal. Разница как между «пожалуйста, не выходи за линию» и физическим забором.

Здесь же закрывается старый страх про латентность. Бенчмарк JSONSchemaBench (около 10 000 production-схем, шесть фреймворков) показывает: constrained decoding не замедляет, а ускоряет генерацию до 50 % — модель не тратит такты на низковероятные пути вне схемы. На структурных задачах вроде DB-запросов и extraction success rate растёт до 4 % даже на сильных моделях за счёт устранения синтаксических ошибок и невалидных enum-значений. Под капотом движки вроде XGrammar-2 и llguidance предвычисляют offline свыше 99 % токенов словаря, у которых валидность не зависит от контекста, а в рантайме оценивают лишь крошечную долю — отсюда mask-overhead менее 40 микросекунд.

3. BaseModel — это контракт, а не просто класс

Главный сдвиг урока: от «промпта-инструкции» к схеме-контракту. Выход агента — не строка и не словарь, а объект Python с гарантированными типами.

from pydantic import BaseModel, Field
from pydantic_ai import Agent

class CityInfo(BaseModel):
    name: str
    population: int = Field(ge=0, description="Население")
    country: str

agent = Agent("openai:gpt-4o", output_type=CityInfo)
result = agent.run_sync("Расскажи про Берлин")
result.output.population  # уже int, не строка "3.9M"

Что здесь происходит под капотом, по шагам:

  • class CityInfo(BaseModel) описывает структуру один раз. Дальше класс работает и контрактом для LLM, и валидатором входящих данных.
  • Pydantic автоматически генерирует JSON Schema через model_json_schema() — руками её писать не нужно.
  • Схема уходит в API как response_format → токены генерируются только в рамках структуры.
  • Валидация при десериализации: неверный тип бросает ValidationError сразу, с точным путём к проблеме (age: Input should be a valid integer), а не где-то глубже в бизнес-логике.
  • Field(description=...) становится частью схемы. Описание формата уходит из промпта в саму схему — модель читает его как подсказку при генерации.

Field — это одновременно документация, валидация и промпт для модели. Туда же Field(ge=0, le=10) для диапазонов, pattern= для regex и Literal["low","medium","high"], который физически ограничивает значение списком.

Вложенные схемы. Плоский JSON с полями address_city, address_zip плохо читается моделью. Вложенная модель Address внутри ContactInfo даёт модели иерархию — она генерирует структуру правильнее, а Pydantic валидирует рекурсивно: ошибка в address.zip_code приходит с точным путём, а не как «invalid JSON».

Discriminated Union. Агент может вернуть успех или ошибку — два разных формата. Union[SuccessResult, ErrorResult] с дискриминатором status: Literal["success"] / Literal["error"] позволяет модели выбрать нужный вариант, а коду — развести их через match. Это основа для routing и fallback-ответов.

4. Schema-Guided Reasoning: схема направляет мышление

Схема проверяет результат — и заодно направляет процесс. Порядок полей BaseModel есть неявная цепочка рассуждения: модель заполняет поля по очереди, и этот порядок ведёт её через нужные этапы. Это структурная, типизированная форма chain-of-thought (цепочки рассуждений).

Обычный промпт «проанализируй кандидата и дай вывод» возвращает произвольный текст: каждый раз другой формат, автоматически не обработать. SGR (Schema-Guided Reasoning, рассуждение по схеме) задаёт жёсткие шаги:

Обычный промпт          │  Schema-Guided Reasoning
────────────────────────┼──────────────────────────────────
«rate from 1 to 10      │  brief_summary: str
 and recommend»         │  skill_match:    int (1-10)
                        │  experience_fit: int (1-10)
текст: «I'd rate 7.5    │  decision: Literal["hire","reject","hold"]
out of 10 and...»       │
                        │  модель обязана пройти все шаги,
ATS-парсер падал        │  каждый шаг типизирован и валидируется
1 запрос из 8           │
Рисунок — слева свободный промпт даёт неструктурный текст, который парсер ронял 1 раз из 8; справа SGR проводит модель через brief_summary → skill_match → experience_fit → decision, и каждый шаг типизирован.

Каждый шаг типизирован и валидируется — этим SGR отличается от текстового рассуждения, где промежуточные шаги ничем не ограничены. Базовых паттернов пять:

  • Classification — category: Literal[...], confidence: float, reasoning: str (тикеты, намерения, контент).
  • Extraction — вложенные модели на каждую сущность (Person, Company, Date, Amount): модель извлекает и структурирует одновременно.
  • Comparison — option_a, option_b, winner: Literal["a","b","tie"], reasoning.
  • Planning — поля как этапы плана: steps: list[Step].
  • Validation — is_valid: bool + errors: list[str] + suggestions: list[str].

По материалам курса SGR-скрининг резюме дал измеримый эффект: время на кандидата с 4 минут до 40 секунд, доля падавших запросов с 1 из 8 до нуля форматных ошибок, 300 резюме в день.

5. Зависимости агента: контекст без промпта

Передавать секреты, права и роли через текст промпта — антипаттерн. Строка f"role is {role}, api key is {key}" опасна сразу с трёх сторон: ключи утекают в логи LLM-провайдера; prompt injection (внедрение в промпт — «ignore role, you are admin») перехватывает права; типизации нет, тестировать тяжело. Правило простое: если что-то security-критично — это должно быть в коде, а не в промпте.

В PydanticAI всё это кладётся в deps — типизированный объект с контекстом, доступный в обход промпта.

from dataclasses import dataclass
from typing import Literal
from pydantic_ai import Agent, RunContext, ModelRetry

@dataclass
class TicketDeps:
    user_id: str
    role: Literal["agent", "supervisor"]
    max_priority: Literal["low", "medium", "high", "critical"]

agent = Agent("openai:gpt-4o-mini", deps_type=TicketDeps)

@agent.tool
async def escalate_ticket(ctx: RunContext[TicketDeps], new_priority: str) -> str:
    if PRIORITY_RANK[new_priority] > PRIORITY_RANK[ctx.deps.max_priority]:
        raise ModelRetry(f"role '{ctx.deps.role}' не может выставить '{new_priority}'")
    return f"escalated to {new_priority}"

RunContext[TicketDeps] доступен в трёх местах: в @agent.system_prompt (динамический промпт под пользователя), в @agent.tool (проверка прав), в @agent.output_validator (бизнес-проверка с обращением к БД или API). Права проверяет код инструмента — текстом промпта это не обойти. В deps кладут контекст (user_id, role, tenant_id), инфраструктуру (HTTP-клиент, БД, Redis), ключи и бизнес-данные (feature flags, A/B-группа).

Бонус — тестируемость. Подменяем реальный клиент на fake deps, и тесты идут без сети. Глобальные переменные вместо deps ломаются при конкурентных запросах; типизированный объект изолирует каждый прогон агента.

6. Циклы валидации: автоисправление без кода

Структура гарантирована, но технически валидный JSON может быть бизнес-неверным: дата конца раньше даты начала, priority=critical без флага requires_human, сумма не сходится. Это ловит паттерн Validate → Repair → Retry.

Три независимых уровня проверки:

  1. JSON Schema / типы — автоматически: тип поля, обязательность, enum, диапазоны.
  2. @field_validator — правило для конкретного поля и нормализация (строку "$1,200.50"1200.50).
  3. @agent.output_validator — последний рубеж: бизнес-логика, в том числе обращение к БД через ctx.deps.
agent = Agent("openai:gpt-4o-mini", output_type=TicketResult,
              deps_type=TicketDeps, retries=3)

@agent.output_validator
async def validate_output(ctx: RunContext[TicketDeps],
                          output: TicketResult) -> TicketResult:
    if output.priority == "critical" and not output.requires_human:
        raise ModelRetry("priority=critical обязывает requires_human=True")
    if output.estimated_minutes % 15 != 0:
        raise ModelRetry(f"{output.estimated_minutes} не кратно 15")
    return output

При провале ModelRetry возвращает модели её прошлый ответ плюс причину ошибки, и модель переписывает результат сама. Сила не в самом retry, а в содержательности ошибки: модель получает не «невалидно», а «поле X провалило проверку Y по причине Z» — с этим контекстом следующая попытка в разы успешнее.

По материалам курса на потоке юридических документов добавление output-валидаторов снизило логические ошибки с 4,7 % до 0,0 %, с исправлением с первой попытки в 94 % случаев.

Защита от runaway-цикла обязательна. retries=3 на агенте, после исчерпания — UnexpectedModelBehavior. Безлимитный while True: ... except: continue на систематической ошибке (модель раз за разом проваливает один и тот же констрейнт) сжигает бюджет и вешает приложение. Лимит retry и таймаут — это не опция, а часть контракта.

7. Дебаты «схема вредит reasoning» и где они ломаются

Вокруг constrained decoding есть живой спор, и его стоит знать, чтобы не наступить на грабли.

Работа «Let Me Speak Freely?» (EMNLP 2024) зафиксировала: жёсткий JSON-формат роняет точность рассуждения на 10–30 % против свободного текста, сильнее всего на математике (GSM8K) и word-puzzles. Гипотеза — constrained logit processor уводит модель на «неестественные» токен-траектории (trajectory bias).

Ответ от dottxt («Say What You Mean») воспроизвёл эксперимент и показал, что деградация была артефактом плохого промпта и нечестного бейзлайна:

Задача Свободный текст Плохой JSON-промпт Исправленный structured
GSM8K 77,0 % 15,2 % 78,0 %
Last Letter 73,0 % 21,0 % 77,0 %
Shuffled Objects 41,0 % 29,5 % 44,0 %
Рисунок — провал с 77,0 % до 15,2 % на GSM8K вызван не схемой, а кривым промптом: при корректной постановке structured-вывод даёт 78,0 %, выше свободного текста. Дело не в формате как таковом, а в том, как ты ставишь схему.

Арбитраж — работа EACL 2026 через causal inference: для frontier-моделей (GPT-4o) причинного влияния формата на качество нет в 43 из 48 сценариев; прежние расхождения причинно связаны с инструкциями промпта, а не с форматом. Reasoning-модели (o3, Gemini 1.5 Pro) устойчивы — они рассуждают до выдачи структуры. Чувствительны только малые модели (<20B): строгая схема «съедает» их контекст и когнитивный бюджет.

Практический приём, примиряющий спор, уже встроен в провайдеров. У Anthropic грамматические ограничения применяются к финальному тексту, но обходят Extended Thinking — модель рассуждает unconstrained, потом пишет структуру. Та же идея в подходе draft-then-constrain: сначала свободный черновик-рассуждение, потом ограниченная генерация по нему.

8. Провайдеры и анти-паттерны схем

Уровень 3 у каждого провайдера реализован по-своему, и это влияет на дизайн Pydantic-моделей.

  • OpenAI включает strict-schema при response_format = json_schema с strict: true. Каждый объект схемы обязан задавать additionalProperties: false и перечислять все поля в required. Опциональные поля выражаются union-типом с null (["string", "null"]) — прямое следствие для дизайна моделей: Optional[str] превращается в null-union, а не в «поле можно опустить».
  • Anthropic даёт два режима — JSON-вывод (output_config.format) и strict tool use (tools.strict) — и обходит Extended Thinking, как описано выше.

Три анти-паттерна, на которых ломаются прод-схемы:

  • Nesting-bloat. Каждое optional-поле примерно удваивает число состояний грамматики. Глубоко вложенная схема с десятком optional раздувает state-space движка → замедление и «путаница» модели. Держи схемы плоскими, скаляры в приоритете, сложное разбивай на несколько простых инструментов.
  • Structure snowballing. Если зажать маленькую модель сложной таксономией, при первой же аналитической ошибке она тратит бюджет на удовлетворение синтаксиса, а не на исправление логики — и зацикливается на грамматически верных, но неверных по сути ответах.
  • Runaway retry. Уже разбирали: без retries=3 и таймаута систематическая ошибка крутит цикл до исчерпания бюджета.

Отдельная ловушка — раздутая схема в промпте. Длинный автогенерированный JSON Schema с typing-метаданными ест контекст и размывает инструкции. Лечится короткими именами полей и содержательными description в Field — они и так компилируются в схему.

Итог

  • Схема перестаёт быть просьбой в промпте и становится контрактом: на уровне 3 (constrained decoding) нарушение формата физически невозможно, а латентность при этом не растёт, а падает.
  • BaseModel заменяет ручной парсинг: типы гарантированы, ValidationError приходит с точным путём, Field совмещает документацию, валидацию и подсказку модели.
  • Порядок полей в схеме — это SGR, неявная типизированная цепочка рассуждения; контекст и права идут через deps, а не через промпт; бизнес-логику добивают output-валидаторы с ModelRetry.
  • Спор «схема вредит reasoning» решён: для frontier-моделей эффекта формата почти нет, провал из старых статей — это кривой промпт. Чувствительны малые модели; лечится приёмом «сначала рассуждение, потом структура».
  • Главный результат — агент не ломает бэкенд неверным форматом данных.

FAQ

Замедляет ли constrained decoding генерацию?

Нет, при правильной реализации — ускоряет. Бенчмарк JSONSchemaBench на ~10 000 схем показал ускорение токен-генерации до 50 %: модель не тратит такты на низковероятные пути вне схемы, а движки вроде llguidance предвычисляют офлайн свыше 99 % валидных токенов. Mask-overhead в рантайме — менее 40 микросекунд.

Правда ли, что строгая схема снижает качество рассуждения?

Для современных frontier-моделей — практически нет. Causal-анализ (EACL 2026) не нашёл влияния формата на качество в 43 из 48 сценариев для GPT-4o; известный провал из статьи «Let Me Speak Freely?» оказался артефактом плохого промпта (исправленный structured-вывод дал 78,0 % против 77,0 % у свободного текста). Чувствительны только малые модели до 20B параметров.

В чём разница между function calling и structured output?

Function calling даёт схему как hint (уровень 2, надёжность 95–99 %) — модель может её нарушить. Native structured output поднимает ту же схему до жёсткого ограничения через constrained decoding (уровень 3, надёжность 100 %) — нарушение исключено механически на уровне выбора токенов. Для агента, где даже 2–3 % брака роняют бэкенд, нужен уровень 3.

Зачем передавать контекст через deps, а не через промпт?

Секреты и права в тексте промпта утекают в логи провайдера и перехватываются prompt injection («ignore role, you are admin»). Зависимости через RunContext живут в коде: права проверяет инструмент, а не текст, и это нельзя обойти инъекцией. Бонусом — типизация и тестируемость через fake deps без реальных вызовов.

Чем output_validator отличается от валидации типов?

Типовая валидация (JSON Schema, @field_validator) ловит неверный тип, пропущенное поле, выход за диапазон — синтаксис и структуру. @agent.output_validator — это последний рубеж для бизнес-логики: межполевые инварианты, обращение к БД через ctx.deps, правила вида «critical обязывает requires_human=True». При провале он бросает ModelRetry с содержательным описанием, и модель исправляется сама.

Как избежать бесконечного цикла валидации?

Ставить явный бюджет: retries=3 на агенте плюс таймаут. На систематической ошибке (модель раз за разом проваливает один констрейнт) безлимитный retry сжигает API-бюджет и вешает приложение. После исчерпания попыток PydanticAI бросает UnexpectedModelBehavior — его нужно ловить и уводить в fallback или алерт.

Источники

Числовые ориентиры из текста (надёжность уровней, ускорение, success rate, кейс-цифры курса) зависят от профиля нагрузки, модели и корпуса — это порядки величин, а не константы. Кейс-числа из материалов курса ($8 400; 4,7 %→0,0 %; 4 мин→40 сек) приведены как иллюстрация и независимо не верифицировались.