Содержание
- Парсинг JSON руками — это технический долг
- Три уровня контроля вывода
- BaseModel — это контракт, а не просто класс
- Schema-Guided Reasoning: схема направляет мышление
- Зависимости агента: контекст без промпта
- Циклы валидации: автоисправление без кода
- Дебаты «схема вредит reasoning» и где они ломаются
- Провайдеры и анти-паттерны схем
- Что показывает типизированный агент в проде
- Итог
- FAQ
- Источники
Поле amount приходит от модели строкой "$1,200.50". Код спокойно кладёт значение в отчёт, отчёт уезжает дальше, и через два шага падает сервис, который этот отчёт считает. В стектрейсе стоит чужая функция, в которой всё написано правильно.
Виноват тут не парсер и не модель, а способ договариваться. Формат ответа попросили словами в промпте, а слова никто не проверяет. Модель просьбу услышала, но выполнила по-своему, и узнали мы об этом через два сервиса и полтора часа.
Посмотрим, как схема перестаёт быть просьбой и становится контрактом, который проверяется до того, как данные попадут в код, а на строгом режиме и вовсе не даёт модели написать неверный тип.
Дневник курса, урок 4. Пригодится разобранное в соседних постах: описания инструментов и их схемы (урок 1) — почему контракт вызова решает больше, чем выбор модели; каскадный роутинг по уверенности (урок 2) — там дешёвая модель обязана вернуть свою уверенность машиночитаемо, и это ровно то, о чём здесь; цикл агента на графе состояний (урок 6) — куда типизированный результат едет дальше. Пост читается отдельно: все термины вводятся заново.
1. Парсинг JSON руками — это технический долг
Агент разбирает входящие тикеты и отдаёт на выходе категорию, приоритет и оценку времени в минутах. В промпте написано «верни JSON с полями category, priority, estimated_minutes», в коде стоит json.loads() и десяток data.get(). На тестовых тикетах всё сходится, на первой тысяче в проде тоже.
Ручной разбор ответа модели попадает в цель на 80–95 %. Это сводный ориентир по прод-внедрениям, а не результат одного замера. Разброс в пятнадцать пунктов задают сама модель, длина схемы и то, насколько настойчиво промпт напоминает про формат. Оставшиеся 5–20 % приходятся на запросы, которые тихо падают без исключения и обнаруживаются с задержкой. Дальше цифру можно улучшать. Аккуратные примеры в промпте, ретраи, постобработка ответа сбивают брак до пары процентов. Но и пара процентов — не победа. На потоке в сто тысяч запросов в сутки это две-три тысячи объектов с неверным типом, каждый из которых уезжает дальше по пайплайну и роняет следующий сервис тихим сбоем.
Устроена эта тишина просто. Строка "45 минут" — совершенно законное значение для Python. json.loads() её примет, data.get("estimated_minutes") вернёт как есть, и возразить некому. Ошибка проявится там, где значение впервые попробуют сложить, сравнить или записать в типизированную колонку, а это уже другая функция, чаще другой сервис, а если между ними стоит очередь, то и другой процесс через час. Трассировка стека укажет на пострадавшего. Разбирательство начнётся с невиновного кода.
Разберём, как именно рассыпается связка 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().Громко падает из этих пяти ровно одна, markdown-обёртка, на которой парсер спотыкается сразу. Четыре остальных проходят разбор успешно и уносят проблему дальше. Лишнее поле дождётся строгого режима ORM, пропущенное всплывёт при первом обращении к нему, а вложенный объект строкой сломается на попытке индексации. Дорогими такие баги делает именно зазор между появлением ошибки и её обнаружением.
Показательный инцидент из практики внедрений. Модель вернула "amount": "$1,200.50" вместо числа 1200.50, квартальный отчёт не сошёлся, и три дня аудита на четырёх человек обошлись в $8 400. Первопричина не в модели. Промпт был инструкцией («верни JSON»), а не контрактом. Разница между этими двумя словами вполне техническая, потому что инструкцию некому проверить. Фраза «верни JSON без пояснений» лежит в том же тексте, что и содержимое тикета, системная роль и история диалога, конкурирует с ними за внимание модели, а на выходе её соблюдение никто не сверяет. Ни один unit-тест инцидент не поймал по скучной причине. Фикстуры пишет человек, а человек пишет число числом.
Защитный код без типизации — name = data.get("name", ""), age = int(data["age"]), score = data["score"] * 100 — это набор независимых точек отказа. Каждая строка знает про формат что-то своё, договорённости между ними нигде не записаны, и при смене модели или правке промпта всё это переписывается заново. Один класс BaseModel заменяет полтора десятка строк парсинга и десяток unit-тестов.
2. Три уровня контроля вывода
В такой ситуации первым делом советуют дописать в промпт «отвечай только валидным JSON, без markdown и пояснений». Совет рабочий, процент брака действительно падает. Однако он же и объясняет, почему проблема живёт годами. Улучшение заметное, и кажется, что дальше достаточно ещё немного поработать над формулировкой.
Где же потолок? Пройдём по трём уровням контроля над форматом ответа. Отличаются они не удобством, а тем, возможно ли нарушение схемы в принципе.
| Уровень | Метод | Принцип | Надёжность |
|---|---|---|---|
| 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 (ограниченное декодирование).
От первого уровня второй отличается не дисциплиной модели. Схема инструмента уходит провайдеру отдельным полем запроса, а не строкой внутри промпта, и модель дообучена такие описания соблюдать. Отсюда и скачок к 99 %. Генерация аргументов при этом остаётся свободной. Модель по-прежнему выбирает токены сама, схема лишь смещает её предпочтения. На потоке в сто тысяч запросов в сутки оставшийся процент — это тысяча испорченных объектов.
Третий уровень убирает саму возможность промаха. Держится он на FSM (Finite State Machine, конечный автомат). На каждом шаге генерации модель выбирает токен из словаря, а вариантов там десятки тысяч. FSM накладывает на словарь маску по текущей позиции в схеме, и вероятность недопустимого токена обнуляется до выборки.
Схема ждёт: { "priority": "____" }
Допустимо: "low" | "medium" | "high" | "critical"
шаг генерации, маска по словарю:
"low" → разрешён ✓
"medium" → разрешён ✓
"срочно" → заблокирован ✗ (вероятность → 0 до выборки)
"8" → заблокирован ✗
"срочно", "8") получают нулевую вероятность ещё до выборки, разрешены только значения из Literal. Разница как между «пожалуйста, не выходи за линию» и физическим забором.Автомат работает рельсами, а не контролёром на выходе. Когда схема ждёт открывающую фигурную скобку, допустимый токен ровно один, и выбирать модели не из чего; на значении priority допустимых веток четыре. Валидация и генерация перестают быть разными этапами. Проверять готовый ответ незачем, потому что невалидный ответ невозможно было произвести.
Здесь же закрывается старый страх про задержку. Бенчмарк JSONSchemaBench (около 10 000 production-схем, шесть фреймворков) показывает, что constrained decoding не замедляет, а ускоряет генерацию до 50 %, ведь модель не тратит такты на низковероятные пути вне схемы. На структурных задачах вроде DB-запросов и extraction success rate растёт до 4 % даже на сильных моделях за счёт устранения синтаксических ошибок и невалидных enum-значений.
Откуда берётся ускорение, видно на устройстве XGrammar-2. Свыше 99 % токенов словаря имеют валидность, не зависящую от контекста схемы, поэтому их размечают offline, а в рантайме оценивают лишь оставшуюся долю. Отсюда и mask-overhead менее 40 микросекунд. Другие движки решают ту же задачу иначе, и llguidance, на котором работает строгий режим OpenAI, обходится без предкомпиляции автомата.
Интуиция, что ограничение обязано стоить времени, здесь просто не срабатывает. Ограничение снимает с модели работу, а не добавляет. Закрывающие скобки, кавычки и имена полей схема диктует однозначно, и половину служебного синтаксиса движок дописывает сам, не запуская модель.
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"], который физически ограничивает значение списком. Разница между priority: str с пояснением в описании и priority: Literal[...] принципиальная. В первом случае вы надеетесь на аккуратность модели, во втором на уровне 3 значение вне списка не может быть сгенерировано.
Вложенные схемы. Плоский 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-ответов. Полезно тут то, что развилка перестаёт быть догадкой на стороне кода. Вместо «попробуем прочитать поле error, вдруг оно есть» появляется явное значение дискриминатора, по которому ветвление проверяется статическим анализатором.
4. Schema-Guided Reasoning: схема направляет мышление
Промпт «оцени кандидата от 1 до 10 и дай рекомендацию» возвращает связный, разумный, полезный для человека текст. Проблема в том, что каждый раз он приходит в новом виде. То «7.5 из 10», то «скорее да, чем нет», то с оценкой в середине третьего абзаца. Регулярка, которая вытаскивает из этого число, живёт до первой смены модели.
Схема проверяет результат и заодно направляет сам ход работы. Порядок полей BaseModel образует неявную цепочку рассуждения. Модель заполняет поля по очереди, и порядок ведёт её через нужные этапы. Получается структурная, типизированная форма chain-of-thought (цепочки рассуждений).
Возьмём конкретный случай — скрининг резюме, где вывод модели уходит не человеку, а в ATS (Applicant Tracking System — система, в которой рекрутер ведёт кандидатов по воронке). Обычный промпт «проанализируй кандидата и дай вывод» возвращает произвольный текст, каждый раз в другом формате. Автоматически такое не обработать, и парсер на входе в ATS спотыкался на каждом восьмом ответе. 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, и каждый шаг типизирован.Порядок полей в правой колонке подобран не для красоты. Модель генерирует их слева направо, каждое следующее значение обусловлено уже написанными, и decision в конце пишется, когда обе численные оценки уже стоят в контексте. Переставьте 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].
Промежуточные оценки остаются в ответе отдельными полями, и решение агента становится разбираемым. Видно и вердикт «reject», и что skill_match был 8, а experience_fit — 3. По свободному тексту такую статистику не собрать. По типизированным полям хватит обычного запроса к таблице.
В одном из разобранных внедрений SGR-скрининг резюме дал измеримый эффект: время на кандидата с 4 минут до 40 секунд, доля падавших запросов с 1 из 8 до нуля форматных ошибок, 300 резюме в день.
5. Зависимости агента: контекст без промпта
В поддержке на несколько десятков клиентов у каждого сотрудника своя роль. Рядовой оператор не имеет права поднимать тикет до critical, супервизор имеет. Короче всего это выражается подстановкой роли в системный промпт: «пользователь в роли agent, повышать приоритет выше high ему нельзя». И ведь почти всегда работает.
Передавать секреты, права и роли через текст промпта — антипаттерн. Строка f"role is {role}, api key is {key}" опасна сразу с трёх сторон: ключи утекают в логи LLM-провайдера; prompt injection (внедрение в промпт — «ignore role, you are admin») перехватывает права; типизации нет, тестировать тяжело. Правило простое. Всё, что security-критично, живёт в коде, а не в промпте.
Средняя из трёх угроз ломает саму модель разграничения доступа. Для языковой модели ваша фраза про роль и текст тикета от пользователя — один поток токенов. Ограничение прав живёт в тексте, и переписать его можно тоже текстом. Хитрости тут не нужно. Достаточно, чтобы в тикете нашлась фраза убедительнее вашей.
В уроке 2 инъекции отсекал роутер на входе, и вопрос стоял так: как не пустить вредный запрос к «мозгу». Здесь вопрос другой. Что удержит агента, когда инъекция фильтр уже прошла? Ответ сводится к одному. Права проверяет код, а не текст, и убедить if нечем. Как из этого собирают полный контур проверок, разбирается в посте про безопасность агентов.
В 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}"
Обратите внимание, где именно стоит проверка. Модель по-прежнему вольна решить, что тикет нужно эскалировать до critical, и вызвать инструмент с таким аргументом, ей это никто не запрещает. Дальше управление уходит в обычную функцию Python, которая сравнивает запрошенный приоритет с потолком роли и отказывает. Убедить if текстом невозможно, и в этом весь смысл переноса. Решение о правах принимает код, а модели остаётся роль заявителя.
Здесь у контракта проявляется вторая половина. output_type описывает, что агент обязан вернуть наружу; deps_type описывает, что агенту обязаны дать внутрь. Обе стороны объявлены типами, обе проверяются кодом, обе ловятся статическим анализатором ещё до запуска — mypy и pyright увидят неверный набор зависимостей на сборке, а не в проде. Промпту при этом остаётся ровно то, для чего он и нужен: формулировка задачи. Не хранилище прав и не сейф для ключей.
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, а саму модель на TestModel из pydantic_ai.models.test: он возвращает заранее заданный объект нужного типа, не обращаясь ни к какому провайдеру. Вместе они дают полную изоляцию — тест проверяет, что инструмент правильно посмотрел в ctx.deps.role и отказал роли readonly, и делает это без единого сетевого вызова, без ключей и без недетерминизма.
from pydantic_ai.models.test import TestModel
fake_deps = TicketDeps(user_id="u1", role="agent", max_priority="high")
result = await agent.run("подними до critical", deps=fake_deps, model=TestModel())
assert "не может выставить" in result.output
Мокировать сам LLM-провайдер при этом не нужно — и это заметная разница с тем, как обычно тестируют код вокруг модели. Мок провайдера воспроизводит протокол, TestModel подставляется на место модели целиком, и вся петля агента с валидаторами и ретраями отрабатывает по-настоящему.
Глобальные переменные вместо deps ломаются при конкурентных запросах, а типизированный объект изолирует каждый прогон агента. Разница вылезает не на нагрузочном тесте, а в проде под параллельными запросами: два пользователя с разными ролями, один глобальный current_role, и права одного применяются к запросу другого.
6. Циклы валидации: автоисправление без кода
Тикет приходит с priority="critical" и requires_human=False. Типы верные, обязательные поля на месте, схема пройдена, а по внутреннему регламенту критичный тикет обязан уйти человеку. Структура гарантирована, но технически валидный JSON может быть бизнес-неверным: дата конца раньше даты начала, priority=critical без флага requires_human, сумма не сходится. Это ловит паттерн Validate → Repair → Retry.
Разложим проверки на три независимых уровня.
- JSON Schema / типы — автоматически: тип поля, обязательность, enum, диапазоны.
@field_validator— правило для конкретного поля и нормализация (строку"$1,200.50"→1200.50).@agent.output_validator— последний рубеж: бизнес-логика, в том числе обращение к БД черезctx.deps.
Граница между вторым и третьим уровнем проходит по тому, сколько полей видит проверка. @field_validator смотрит на одно значение и про соседей не знает; @agent.output_validator получает собранный объект целиком, а с ним и зависимости. Только там выйдет сверить два поля между собой или сходить в базу за актуальным лимитом.
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», и с этим контекстом следующая попытка в разы успешнее.
Сравните с обычным повтором запроса. Слепой ретрай отправляет тот же вход и берёт выборку из того же распределения. Модель не знала про правило кратности пятнадцати, поэтому второй прогон даст 47 вместо 43. Ретрай с диагностикой меняет сам вход. В контексте появляются прошлый ответ и текст ошибки, и задача из «классифицируй тикет» превращается в «поправь одно поле, вот что с ним не так». Вторая заметно проще первой, отсюда и доля исправлений с первой попытки.
В другом внедрении, на потоке юридических документов, добавление output-валидаторов снизило логические ошибки с 4,7 % до 0,0 %, с исправлением с первой попытки в 94 % случаев.
Ограничитель цикла обязателен. retries=3 на агенте, а после исчерпания приходит UnexpectedModelBehavior. Безлимитный while True: ... except: continue на систематической ошибке (модель раз за разом проваливает один и тот же констрейнт) сжигает бюджет и вешает приложение. Лимит retry и таймаут — это не опция, а часть контракта. Отдельно полезно ловить сам факт исчерпания попыток. Три провала подряд на одном констрейнте обычно означают не каприз модели, а противоречие в схеме или правило, которого нет в описании полей.
7. Дебаты «схема вредит reasoning» и где они ломаются
Предложение перевести агента на строгую схему почти всегда встречает одно возражение: под жёстким форматом модель хуже думает. Возражение не выдумано, за ним стоит конкретная статья с цифрами. А вокруг статьи живой спор, который стоит знать целиком, чтобы не наступить на грабли ни с одной из двух сторон. Разберём его по кругам.
Работа «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 % до 15,2 % означает отказ решать задачу, а не «стало чуть хуже». Такая цифра сама намекает, что сломалась постановка, а не рассуждение. Так и оказалось. Модели не дали внятного места, где рассуждать, и потребовали ответ сразу в поле.
Спор рассудила работа EACL 2026, где влияние формата проверяли методами causal inference. Для frontier-моделей (GPT-4o) причинного влияния формата на качество нет в 43 из 48 сценариев, а прежние расхождения причинно связаны с инструкциями промпта, а не с форматом. Reasoning-модели (o3, Gemini 1.5 Pro) устойчивы, потому что рассуждают до выдачи структуры. Чувствительны только малые модели (<20B), у которых строгая схема «съедает» контекст и когнитивный бюджет.
Общий знаменатель у всех трёх работ один. Проблема возникает, когда модель обязана думать и оформлять одновременно. У крупной бюджета хватает на обе задачи, а у маленькой нет, и уходит он на синтаксис.
Практический приём, примиряющий спор, уже встроен в провайдеров. У Anthropic грамматические ограничения применяются к финальному тексту, но обходят Extended Thinking — тот самый скрытый кусок рассуждения, который тарифицируется отдельно от ответа (разбор глубины рассуждения — урок 2). Модель рассуждает без ограничений, а схему соблюдает только на выходе. Та же идея в подходе draft-then-constrain: сначала свободный черновик-рассуждение, потом ограниченная генерация по нему. А если провайдер такого режима не даёт, роль черновика берёт на себя первое текстовое поле схемы, тот самый reasoning: str перед решением из паттернов SGR.
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, как описано выше.
Требование перечислять все поля в required кажется формальностью ровно до первого расхождения. «Поля нет» и «поле есть со значением null» — разные события. Первое означает, что модель про него не подумала, а второе, что подумала и решила оставить пустым. Строгий режим оставляет только второй вариант, и код на стороне потребителя это упрощает, а вот привычка описывать необязательность через отсутствие ключа перестаёт работать.
Посмотрим на три анти-паттерна, на которых ломаются прод-схемы.
- Nesting-bloat. Каждое optional-поле примерно удваивает число состояний грамматики. Глубоко вложенная схема с десятком optional раздувает state-space движка → замедление и «путаница» модели. Держи схемы плоскими, скаляры в приоритете, сложное разбивай на несколько простых инструментов.
- Structure snowballing. Если зажать маленькую модель сложной таксономией, при первой же аналитической ошибке она тратит бюджет на удовлетворение синтаксиса, а не на исправление логики — и зацикливается на грамматически верных, но неверных по сути ответах.
- Runaway retry. О нём уже говорили: без
retries=3и таймаута систематическая ошибка крутит цикл до исчерпания бюджета.
Второй пункт неприятен тем, что снаружи выглядит здоровым. Ответы приходят валидные, схема соблюдена, алерты молчат, зато модель раз за разом обосновывает первую же неверную догадку, потому что менять её означает переписать половину заполненных полей. Отсюда практическое следствие. Чем меньше модель, тем проще должна быть схема, и развесистую таксономию ей давать не нужно.
Отдельная ловушка — раздутая схема в промпте. Длинный автогенерированный JSON Schema с typing-метаданными ест контекст и размывает инструкции. Лечится короткими именами полей и содержательными description в Field, которые и так компилируются в схему.
9. Что показывает типизированный агент в проде
Схема, зависимости и валидаторы — это то, что видно в коде на ревью. В эксплуатации у них появляется наблюдаемая сторона: каждый прогон агента оставляет после себя числа, по которым понятно, держится контракт или уже трещит.
Точек съёма две, и обе встроены. result.usage отдаёт токены и стоимость прогона, ctx.retry — номер текущей попытки, доступный прямо внутри инструмента и валидатора. Из них собираются три группы показателей.
Качество вывода. validation_errors_per_run — сколько раз за прогон сработала валидация. retry_count_per_run — сколько раз модель переписывала ответ. fallback_rate — доля прогонов, ушедших в запасной сценарий после исчерпания попыток; выше 5% это уже не шум. unexpected_behavior_rate — доля прогонов, закончившихся исключением UnexpectedModelBehavior.
Стоимость. tokens_per_run раздельно по входу и выходу, cost_per_completion и, интереснее прочих, retry_overhead_tokens — токены, ушедшие не на работу, а на исправление собственных ошибок. Их доля в общем расходе и есть цена неточной схемы, выраженная в деньгах.
Задержка. total_run_time_ms, time_per_retry_ms, tool_call_latency и хвост распределения. При retries=3 p99 выше тридцати секунд означает, что худшие запросы проходят полный цикл исправлений: модель регулярно не попадает в контракт с первой попытки.
Порог алерта здесь стоит непривычно низко — retry_count_per_run > 1. Одна попытка исправления это нормальный рабочий режим, ради которого ModelRetry и придуман. А вот две подряд на одном и том же констрейнте почти никогда не означают каприз модели. Означают они противоречие внутри схемы либо правило, которого нет в описании полей: валидатор требует кратности пятнадцати, а в Field(description=...) про это не сказано ни слова, и модель угадывает. Метрика диагностирует ваш контракт, а не поведение модели.
Собирать это руками не нужно. Logfire от команды Pydantic интегрирован с PydanticAI нативно и снимает перечисленное из коробки, вместе с трейсом каждого прогона — с попытками и текстами ModelRetry внутри. Если телеметрия по токенам уже собирается на шлюзе, как разбиралось в уроке 2, эти метрики ложатся рядом, но отвечают на другой вопрос. Там смотрят, сколько агент потратил; здесь — на что именно ушла разница.
Сводится всё в чек-лист из четырёх уровней, и проверяется каждый отдельно:
СХЕМА BaseModel с Field(description) на каждый вывод
(контракт) Discriminated Union для success / error
Literal вместо свободных строк в перечислениях
DEPS @dataclass с типами; ни одного секрета в промпте
(контекст) fake deps + TestModel — тесты без сети
VALIDATION @field_validator — нормализация значений
(защита) @output_validator — межполевые и внешние проверки
retries=3 и явный fallback после исчерпания
OBSERVABILITY result.usage — токены и стоимость прогона
(мониторинг) ctx.retry — номер попытки
алерт при retry_count_per_run > 1BaseModel с Field(description), discriminated union, Literal), зависимости (типизированный @dataclass, секреты вне промпта, fake deps и TestModel в тестах), валидация (@field_validator, @output_validator, retries=3 с fallback) и наблюдаемость (result.usage, ctx.retry, алерт при retry_count_per_run > 1).Итог
- Схема перестаёт быть просьбой в промпте и становится контрактом: на уровне 3 (constrained decoding) нарушение формата физически невозможно, а задержка при этом не растёт, а падает.
BaseModelзаменяет ручной парсинг: типы гарантированы,ValidationErrorприходит с точным путём,Fieldсовмещает документацию, валидацию и подсказку модели.- Порядок полей в схеме — это SGR, неявная типизированная цепочка рассуждения; контекст и права идут через
deps, а не через промпт; бизнес-логику добивают output-валидаторы сModelRetry. - Контракт двусторонний:
output_typeфиксирует, что агент обязан вернуть,deps_type— что ему обязаны дать. Обе стороны проверяются кодом и обе тестируются без сети, через fake deps иTestModel. - В проде контракт наблюдаем:
result.usageиctx.retryдаютvalidation_errors_per_run,retry_count_per_run,fallback_rateиretry_overhead_tokens. Алерт ставят наretry_count_per_run > 1— две попытки подряд диагностируют не модель, а схему. - Спор «схема вредит reasoning» решён: для frontier-моделей эффекта формата почти нет, провал из старых статей — это кривой промпт. Чувствительны малые модели; лечится приёмом «сначала рассуждение, потом структура».
- Главный результат — агент отдаёт типизированный результат, который не развалит следующий сервис. Чего он всё ещё не умеет, так это пережить перезапуск процесса: состояние, чекпоинты и возврат к прерванному шагу — в посте про графы состояний.
FAQ
Замедляет ли constrained decoding генерацию?
Нет, при правильной реализации ускоряет. Бенчмарк JSONSchemaBench на ~10 000 схем показал ускорение токен-генерации до 50 %, потому что модель не тратит такты на низковероятные пути вне схемы, а движки размечают заранее всё, что можно разметить заранее. У XGrammar-2 это свыше 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 с содержательным описанием, и модель исправляется сама.
Какие метрики снимать с PydanticAI-агента в проде?
Три группы, обе точки съёма встроены: result.usage даёт токены и стоимость прогона, ctx.retry — номер попытки. Качество вывода — validation_errors_per_run, retry_count_per_run, fallback_rate (выше 5% это сигнал) и unexpected_behavior_rate. Стоимость — tokens_per_run, cost_per_completion и retry_overhead_tokens, то есть токены, потраченные на исправление собственных ошибок. Задержка — total_run_time_ms, time_per_retry_ms и p99, где при retries=3 порог выше тридцати секунд означает регулярный полный цикл исправлений. Алерт ставят на retry_count_per_run > 1; собрать всё это из коробки умеет Logfire.
Как тестировать агента без вызовов LLM?
Через TestModel из pydantic_ai.models.test плюс fake deps. TestModel подставляется на место модели целиком и возвращает заранее заданный объект нужного типа, а зависимости передаются обычным экземпляром dataclass с моками HTTP-клиента и базы. Провайдер при этом мокировать не нужно вовсе, и вся петля агента — инструменты, валидаторы, ретраи — отрабатывает по-настоящему, только детерминированно и без сети.
Как избежать бесконечного цикла валидации?
Поставить явный бюджет, то есть 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 сек) приведены как иллюстрация и независимо не верифицировались.