Когда вывод модели типизирован схемой, агент перестаёт ронять бэкенд тихим 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.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" → заблокирован ✗
"срочно", "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 │
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.
Три независимых уровня проверки:
- JSON Schema / типы — автоматически: тип поля, обязательность, enum, диапазоны.
@field_validator— правило для конкретного поля и нормализация (строку"$1,200.50"→1200.50).@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 % |
Арбитраж — работа 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 или алерт.
Источники
- JSONSchemaBench: Generating Structured Outputs from Language Models — бенчмарк на ~10 000 схем; цифры по ускорению до 50 % и росту success rate.
- Let Me Speak Freely? (Tam et al., EMNLP 2024) — каноническая работа про падение reasoning на 10–30 % под строгим форматом.
- Say What You Mean — ответ от dottxt — воспроизводимая таблица GSM8K 15,2 % → 78,0 %: дело в промпте, не в схеме.
- Quantifying the Impact of Structured Output Format (Findings of ACL: EACL 2026) — causal-арбитраж спора: 43/48 без эффекта формата для frontier-моделей.
- XGrammar-2 (MLC-AI) — устройство engine-слоя: предвычисление 99 % токенов, mask-overhead <40 µs.
- Structured model outputs — OpenAI API — strict-schema,
additionalProperties: false, null-union для optional. - Structured outputs — Claude API Docs — два режима и обход Extended Thinking; рекомендация против nesting-bloat.
Числовые ориентиры из текста (надёжность уровней, ускорение, success rate, кейс-цифры курса) зависят от профиля нагрузки, модели и корпуса — это порядки величин, а не константы. Кейс-числа из материалов курса ($8 400; 4,7 %→0,0 %; 4 мин→40 сек) приведены как иллюстрация и независимо не верифицировались.