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

Инструмент агента — это API с двумя контрактами

Вход и выход инструмента проверяют разное: где кончается гарантия схемы, как упаковать сбой сети в валидный ответ и чем кормить модель при ошибке.

  • tool-calling
  • pydantic
  • contract-first
  • validation
Содержание
  1. Функция, отданная модели, перестаёт быть функцией
  2. Входной контракт: нормализация, типы и то, что типами не выражается
  3. Где кончается гарантия схемы
  4. Что отдать модели, когда вход не прошёл
  5. Выходной контракт: сбой сети — тоже валидный ответ
  6. Когда аргументов ещё нет: диалог вместо одного вызова
  7. Контрактные тесты: чтобы контракт не разъехался молча
  8. Как читать бенчмарк, который меряет именно вызов инструментов
  9. Что видно в проде и с чего начинать
  10. Итог
  11. FAQ
  12. Источники

Клиент пишет в чат банка: «вчера с карты •• 4417 списали 1 200,50 ₽ дважды, верните вторую». Агент поддержки разбирает фразу и зовёт инструмент оформления спора. Под инструментом лежит обычная функция на Python, которую бэкенд-команда написала когда-то для внутренней админки.

В аргументы прилетает то, что модель вычитала из сообщения. Дата «вчера», номер карты с точками и пробелом, сумма строкой «1 200,50 ₽». Функция ждала объект даты, четыре цифры и число. Дальше возможны два исхода, и оба скверные. Либо необработанное исключение обрывает цикл агента посреди диалога, и клиент видит отказ вместо ответа. Либо аргументы проезжают дальше как есть, и в базу уходит спор с датой, которой не существует.

Ошибка тут не в модели. Функцию, отданную агенту, продолжают считать функцией, хотя вызывает её теперь не соседний модуль, а вероятностный генератор текста, читающий сообщения посторонних людей. У инструмента агента контрактов два. Входной чинит и проверяет аргументы, которые породила модель. Выходной отвечает за то, что вернётся обратно в её контекст, включая форму аварии. Посмотрим, что меняется, когда функцию проектируют как API: что именно проверяет входной контракт, что инструмент обязан вернуть при недоступной базе, что положить в сообщение об ошибке, чтобы модель исправилась со второй попытки.

Дневник курса, урок 15. Здесь пригодится разобранное раньше: схема вывода как контракт (урок 4) — оттуда берём Pydantic и цикл исправления, здесь смотрим на схему с другой стороны; каталог инструментов и обрыв контекста (урок 1) — почему инструментов у агента должно быть немного; цикл агента на графе состояний (урок 6) — куда возвращается результат вызова. Пост читается отдельно: все термины вводятся заново.

1. Функция, отданная модели, перестаёт быть функцией

Инструмент для агента обычно берут готовым из бэкенда. Вот метод, который умеет оформлять спор, обернём его декоратором @tool и опишем параметры. Способ разумный, на первых пяти инструментах работает. Ломается он в другом месте. У метода из админки есть подразумеваемый договор с вызывающим кодом: даты приходят датами, коды карт нормализованы, сумма уже прошла форму на фронтенде. У модели такого договора нет. Она порождает аргументы генерацией текста по описанию, и всё, что схемой не запрещено, рано или поздно окажется в вызове.

Отсюда позиция, на которой стоит вся тема. Аргумент, полученный от модели, остаётся недоверенным вводом, пока его не проверил код. Модель границей безопасности не работает, а атакующий, подложивший инструкцию в текст обращения, добирается до бэкенда по цепочке «подсказка → инструмент → исполнение» (разбор угрозы — в уроке 9). Проверять аргументы приходится по тем же правилам, по которым принимают HTTP-запрос от незнакомого клиента.

Подход, при котором интерфейс описывают до реализации и уже под него пишут код, называется contract-first. Для инструмента агента он разворачивается в два разных документа, и вот здесь главное отличие от урока 4. Там схема описывала то, что модель отдаёт наружу. Одна сторона, один контракт. У инструмента сторон две. Он что-то принимает и что-то возвращает, причём возвращает обратно в контекст модели, которая на этот ответ будет опираться на следующем шаге.

   от модели                                       в контекст модели
       │                                                    ▲
       ▼                                                    │
 ┌──────────────┐     ┌─────────────┐     ┌──────────────┐  │
 │  ВХОДНОЙ     │ ──► │  бэкенд:    │ ──► │  ВЫХОДНОЙ    │ ─┘
 │  КОНТРАКТ    │     │  БД, HTTP,  │     │  КОНТРАКТ    │
 │ нормализация │     │  очередь    │     │ статус+данные│
 │ типы         │     └─────────────┘     │ сбой = объект│
 │ межполевые   │            │            │ без трейсбека│
 └──────────────┘            │            └──────────────┘
       │                     ▼                    ▲
       │                исключение ───────────────┘
       ▼                (перехвачено)
  отказ с причиной
  и списком допустимого
Рисунок — у инструмента два контракта: входной чинит и проверяет аргументы модели в трёх фазах (нормализация, типы, межполевые правила) и при отказе отдаёт причину со списком допустимых значений; выходной упаковывает и штатный ответ, и перехваченное исключение бэкенда в один валидный объект без системного трейсбека.

Работает это всё на знакомом кейсе серии. Агент первой линии в банке принимает около 8000 обращений в сутки, средний диалог тянется на пятнадцать ходов, так что за каждую лишнюю повторную попытку вызова на таком потоке платят тысячи раз в день. Инструментов у него немного. Когда каталог разрастается, схемы приходится отбирать в контекст по смыслу запроса и по правам пользователя, но это территория урока 1, а здесь речь пойдёт про один инструмент. open_dispute, оформление спора по списанию. Он касается денег, необратим и требует пяти аргументов, которые человек называет вразброс по диалогу. То есть собирает в себе почти все способы сломаться.

Ещё одну вещь стоит развести сразу, потому что её постоянно склеивают. Протокол MCP переносит описания инструментов от сервера к агенту и избавляет от ручных JSON-манифестов, но управлением вероятностями токенов он не занимается. Ограничение генерации живёт отдельным слоем, на стороне провайдера или движка инференса. Транспорт схемы и ограничение декодирования — разные механизмы, и путать их дорого. Из «у нас MCP» никак не следует «аргументы гарантированно валидны».

2. Входной контракт: нормализация, типы и то, что типами не выражается

Первое, что хочется сделать с аргументами от модели, — прогнать их через проверку типов и отклонить всё, что не подошло. Часть отказов при этом окажется несправедливой. «Вчера» — корректная дата, просто записанная по-человечески. «•• 4417» — корректный код карты в оформлении, которое модель скопировала из сообщения клиента. Проверка данных распадается на три разных действия: на каждом отсеивается свой класс проблем.

{"card_last4": "•• 4417", "operation_date": "вчера", "amount": "1 200,50 ₽"}
      │
      ▼   before — сырые строки, типов ещё нет
{"card_last4": "4417", "operation_date": "2026-08-22", "amount": "1200.50"}
      │
      ▼   core — приведение к типам Python
card_last4="4417"   operation_date=date(2026, 8, 22)  amount=Decimal("1200.50")
      │
      ▼   after — объект собран, видно все поля разом
reason="duplicate", а original_operation_id пуст → ValueError
Рисунок — три фазы входного контракта на одном вызове: before чинит человеческую запись («вчера» → 2026-08-22, «1 200,50 ₽» → 1200.50), core приводит значения к типам Python, after проверяет то, что видно только на собранном объекте, — спор с причиной «дубль» без ссылки на исходную операцию.

Фаза before работает с тем, что пришло, и типов там ещё нет. На вход валидатора попадает произвольный объект, а не date, поэтому здесь живут регулярки, разбор относительных дат и приведение регистра. Фаза core — обычная проверка типов Pydantic. Фаза after получает уже собранный экземпляр и видит все поля сразу, поэтому только в ней выражаются правила вида «А несовместимо с Б». Ни одна система типов не скажет, что причина «дубль» требует ссылки на исходную операцию. По отдельности оба поля безупречны.

import re
from datetime import date, timedelta
from decimal import Decimal
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator

Reason = Literal["duplicate", "fraud", "not_received", "wrong_amount"]
RELATIVE_DAYS = {"сегодня": 0, "вчера": 1, "позавчера": 2}

class OpenDisputeInput(BaseModel):
    card_last4: str = Field(description="Последние 4 цифры карты, только цифры")
    operation_date: date = Field(description="Дата операции, YYYY-MM-DD")
    amount: Decimal = Field(gt=0, description="Сумма операции в рублях")
    reason: Reason = Field(description="Причина спора")
    original_operation_id: Optional[str] = Field(
        None,
        description="Идентификатор исходной операции; "
                    "обязателен при reason='duplicate'",
    )

    @field_validator("card_last4", mode="before")
    @classmethod
    def keep_digits(cls, v: object) -> object:
        if not isinstance(v, str):
            return v
        return "".join(ch for ch in v if ch.isdigit())[-4:]

    @field_validator("operation_date", mode="before")
    @classmethod
    def parse_relative_day(cls, v: object) -> object:
        key = v.strip().lower() if isinstance(v, str) else None
        if key in RELATIVE_DAYS:
            return date.today() - timedelta(days=RELATIVE_DAYS[key])
        return v

    @field_validator("amount", mode="before")
    @classmethod
    def parse_amount(cls, v: object) -> object:
        if not isinstance(v, str):
            return v
        # выбрасываем ₽, «руб.» и разрядные пробелы, запятую делаем точкой
        return re.sub(r"[^\d.,]", "", v).rstrip(".,").replace(",", ".")

    @field_validator("reason", mode="before")
    @classmethod
    def lower_reason(cls, v: object) -> object:
        return v.strip().lower() if isinstance(v, str) else v

    @model_validator(mode="after")
    def duplicate_needs_origin(self) -> "OpenDisputeInput":
        if self.reason == "duplicate" and not self.original_operation_id:
            raise ValueError("reason='duplicate' требует original_operation_id")
        return self

Пара деталей, на которых спотыкаются и которых нет в типовых примерах. Валидатор не должен бросать ValidationError сам. Документация Pydantic ждёт от него ValueError или AssertionError, а финальный объект ошибки с путём до поля соберёт библиотека. Режимов у валидаторов больше двух. У @field_validator их четыре (before, after, plain, wrap), у @model_validator три, режима plain там нет. Самый недооценённый из них — wrap: он оборачивает внутреннюю валидацию и позволяет перехватить её ошибку, подставив запасное значение вместо отказа.

И совсем неочевидное — порядок применения. Валидаторы before и wrap выполняются справа налево, after слева направо, а объявленные декораторами конвертируются во внутреннюю форму и добавляются последними. Пока валидатор один, это незаметно. Как только на поле висят и очистка от мусора, и разбор относительной даты, порядок начинает определять результат. Выяснять его на проде неприятно.

Разницу между двумя классами проверок стоит назвать прямо. Схема закрывает синтаксис: тип, обязательность, диапазон, принадлежность перечислению. Межполевые правила закрывают семантику, то есть согласованность полей между собой и с состоянием системы. Первое можно поручить провайдеру. Второе нельзя ни при каких настройках, потому что бизнес-правил вашего банка он не знает.

3. Где кончается гарантия схемы

В уроке 4 три уровня контроля вывода заканчивались обещанием: на строгом режиме нарушение схемы физически невозможно, потому что маска на словаре обнуляет недопустимые токены ещё до выборки. Здесь тот же механизм смотрится с другой стороны. Вопрос уже не «как получить валидный JSON», а «на что можно опереться, когда строишь контракт». И вот тут выясняется, что стопроцентная формулировка требует оговорок. Причём выписаны они самим провайдером.

Документация Anthropic перечисляет три случая, в которых вывод строгого режима схеме не соответствует. Модель может отказаться отвечать, и тогда приходит stop_reason: "refusal" с кодом 200, а текст отказа вытесняет структуру. Генерация может упереться в max_tokens и оборваться на середине объекта. Третий случай самый коварный. Регистр значений перечисления не гарантируется. Модель вернёт "Duplicate" вместо "duplicate", запрос завершится нормально, без ошибки и без особого признака остановки, а ваш Literal этот вариант отклонит. Рекомендация из тех же доков — сравнивать значения перечислений без учёта регистра. То есть ровно тот before-валидатор, который стоит в коде выше.

Четвёртый способ разойтись со схемой тише остальных, и дело тут уже не в поведении модели. Дело в том, что до неё доезжает не вся схема. Ограниченная генерация понимает не любую JSON Schema. За бортом остаются числовые границы (minimum, maximum, multipleOf), длина строк (minLength, maxLength), рекурсивные схемы, внешние $ref, сложные типы в перечислениях и ограничения массивов, кроме minItems со значением 0 или 1.

SDK для Python, TypeScript, Ruby и PHP такие схемы преобразуют автоматически: неподдерживаемое ограничение вырезается, его смысл дописывается в текстовое описание поля, а ответ сверяется уже на клиенте с исходной схемой. Пример из документации ровно про наш случай — поле с minimum: 100 уезжает к модели обычным целым, в описание дописывается «Must be at least 100», и проверку делает ваш код.

Усиливать это утверждение сверх источника не надо. Ограничение не теряется, официальный SDK его проверяет, и на выходе вы получаете то, что написали. Опасность в другом. Если вы ходите в API напрямую по HTTP, пишете на языке, для которого такого SDK нет, или отключили сверку ответа, строка "minimum": 0 в схеме выглядит гарантией неотрицательности, а фактически её никто не проверяет. Контракт инструмента живёт в двух местах сразу. Часть его исполняет декодер, часть обязан исполнять ваш код, и граница между ними проходит не там, где кажется. Отсюда и довод в пользу model_validator(mode="after") на входе инструмента вместо надежды на строгий режим.

Дальше начинаются лимиты, и они жёстче, чем ожидаешь. В одном запросе допускается не более 20 инструментов со strict: true, суммарно 24 опциональных параметра по всем строгим схемам и 16 параметров с union-типами. Последние дороги тем, что дают экспоненциальную стоимость компиляции. Пример из документации показывает, как в потолок упираются незаметно: четыре инструмента по шесть необязательных параметров, и лимит выбран целиком, хотя ни один инструмент сложным не выглядит. За превышением приходит 400 с текстом «Schema is too complex for compilation», а последней страховкой стоит таймаут компиляции в 180 секунд.

Anthropic OpenAI
порядок полей в ответе сначала обязательные, потом опциональные как в схеме
pattern (регулярка) декодером не поддержан, проверяется SDK на клиенте поддержан
необязательные поля считаются в лимит 24 на запрос запрещены, эмулируются union с null
корень схемы обязан быть объектом, anyOf нельзя
потолок размера 20 строгих инструментов, 16 union-полей 5000 свойств, 10 уровней вложенности, 1000 значений перечислений

Строка про порядок полей выглядит формальностью, а бьёт по конкретному приёму. В уроке 4 разбирался SGR, рассуждение по схеме, где поле reasoning ставят перед решением, чтобы модель сначала разобрала случай и только потом вынесла вердикт. Если такое поле объявлено необязательным, у Anthropic оно уедет вниз, за все обязательные, и рассуждение превратится в объяснение задним числом. У OpenAI порядок ваш, зато необязательных полей нет вовсе: все они обязаны стоять в required, а опциональность выражается union-типом с null. Одно слово strict у двух провайдеров означает разные вещи, и SGR-схема между ними не переносится один в один.

За строгий режим платят дважды. Сначала системным промптом: при включённом структурированном выводе модель получает дополнительное объяснение ожидаемого формата, и оно тарифицируется как обычные входные токены. Потом компиляцией грамматики. Скомпилированные грамматики кэшируются на 24 часа от последнего использования, так что первый запрос с новой схемой платит компиляцией, а смена набора инструментов кэш сбрасывает. Есть и более тонкая граница. Грамматика структурированного вывода применяется к прямому тексту модели, но не к вызовам инструментов, их результатам и блокам рассуждения. За строгость аргументов отвечает отдельный режим strict tool use, который включается на определении инструмента.

Практический вывод из всего перечня простой. Строгий режим снимает синтаксический класс ошибок и делает это хорошо. Валидатора на своей стороне он не отменяет: четыре документированных способа разойтись со схемой и потолки сложности никуда не деваются, а бизнес-правила в грамматику не компилируются вовсе.

4. Что отдать модели, когда вход не прошёл

Раз отказы неизбежны, инструмент обязан уметь их объяснять. Общепринятый совет звучит так: сырой трейсбек Python модели не отдают, ей отдают аккуратный машинный путь до сломанного поля, ['data']['reason'], и она чинит точечно, не трогая остальные аргументы. Совет рабочий. Вот только работает он не по той причине, по которой его обычно объясняют.

Препринт «Structured Feedback Improves Repair in an LLM Agent Loop» (Ray и Goyal, июль 2026) разбирает обратную связь на три составляющие: место ошибки, наблюдённое значение и перечень допустимых значений. Каждое слагаемое меряли порознь, на парных играх TextWorld, где агенту давали не больше четырёх вызовов инструмента. Разбор по слагаемым тут и есть главное. Почти весь прирост даёт список допустимого, а сообщение, где стоят только место и присланное значение, держится около уровня сырого трейсбека.

терминальный успех, 50 игр TextWorld, лимит 4 вызова инструмента

                             Qwen2.5-Coder-14B     Llama-3.1-8B
сырой трейсбек                  14/50  ███          8/50  ██
только место + значение         около того ███      около того ██
+ перечень допустимых           36/50  ████████     29/50  ███████

форма подачи: проза ≈ JSON-запись (различий не обнаружено)
Рисунок — вклад слагаемых обратной связи: место ошибки и присланное значение держат результат около сырого трейсбека (14/50 и 8/50), а перечень допустимых значений поднимает его до 36/50 и 29/50; подача прозой и машинной записью дала сопоставимый результат.

Вторая половина того же разбора бьёт по ещё одному расхожему правилу. Считается, что ошибку надо отдавать строго машинным форматом, иначе модель её «не разберёт». Авторы подали ту же полную информацию о починке прозой вместо ключей JSON и получили практически тот же успех. Свидетельств в пользу того, что сам синтаксис JSON улучшает исправление, нет. Формат сообщения остаётся вопросом удобства парсинга на вашей стороне, рычагом качества он не работает.

Отсюда рабочее правило. Путь до поля говорит, что чинить, и полезен как адресация. Список допустимых значений говорит, на что менять, и именно он делает работу. Если в сообщение кладётся что-то одно, класть надо второе. В Pydantic всё нужное лежит в объекте ошибки: errors() отдаёт loc (путь), input (что прислали), msg, type и ctx, словарь с параметрами ограничения, откуда достаются границы и разрешённые варианты.

def repair_hint(exc: ValidationError, schema: type[BaseModel]) -> str:
    lines = []
    for err in exc.errors():
        path = ".".join(str(p) for p in err["loc"])
        allowed = err.get("ctx", {}).get("expected")           # варианты из Literal/enum
        if allowed is None:
            allowed = describe_field(schema, err["loc"])       # границы, формат, пример
        lines.append(
            f"{path}: прислано {err['input']!r}, не подходит ({err['msg']}). "
            f"Допустимо: {allowed}"
        )
    return "\n".join(lines)

Второй источник, «Self-Reflective APIs» (июнь 2026), меряет то же самое со стороны API. Сервис при отказе валидации возвращает машиночитаемую сводку, достаточную для починки запроса без внешних рассуждений. На моделях Anthropic доля выполненных задач выросла на 36,7–40,0 процентного пункта против диагностики обычным текстом, и на каждый успех уходило в 1,8–2,2 раза меньше токенов. Авторы честно оговариваются, что на gpt-4o-mini прирост незначим, а само сравнение держится только после аудита двух недокументированных классов утечки ответов в бенчмарках.

Почему петля вообще работает — вопрос отдельный, и ответ на него объясняет, чего в неё класть нельзя. Валидатор внешний по отношению к модели, он проверяет по правилам, которых модель не видит и не может под себя подстроить. Замкнутая на саму себя проверка ведёт себя иначе. Работа «LLMs Cannot Self-Correct Reasoning Yet» (ICLR 2024) показала, что без внешнего сигнала качество после «самокоррекции» нередко падает: модель принимает сомнение за указание на ошибку и начинает её изобретать. Граница эта измерена с обеих сторон, и с внешним признаком правильности та же петля результат, наоборот, поднимает — разбор обоих замеров (урок 16).

Self-Correction Bench добавляет неожиданную деталь. На 14 открытых моделях без режима рассуждения слепое пятно составило 64,5%. Чужие ошибки они исправляют, точно такие же собственные пропускают. Способность есть, она не включается. Причина в составе данных дообучения, где почти нет последовательностей с исправлением ошибок: 5306 таких примеров срезают слепое пятно на 76,0%, а простое дописывание слова «Wait» — на 89,3%.

Сколько попыток давать? Честного замера найти не удалось. Косвенные ориентиры сходятся на малом числе. В эксперименте выше лимит стоял на четырёх вызовах, рабочий дефолт retries=3 разбирался в уроке 4, и форма кривой везде одна. Первая повторная попытка чинит большинство, вторая заметно меньше, третья почти ничего. Единой цифры со ссылкой в природе нет, и выдумывать её не стоит.

5. Выходной контракт: сбой сети — тоже валидный ответ

Про вход думают все. Выход инструмента обычно оставляют как есть: функция вернёт что вернёт, а если база недоступна, вылетит исключение. В обычном бэкенде это приемлемо, там необработанная ошибка превращается в 500, и на этом всё заканчивается. У агента она стоит дороже. Цикл рассуждения обрывается на середине, оплаченный контекст диалога выбрасывается, возможности объяснить клиенту, что произошло, не остаётся.

Правило звучит так: любой инфраструктурный сбой перехватывается внутри инструмента и возвращается как валидный объект выходной схемы. Наружу уходит объект со статусом ошибки, безопасными значениями по умолчанию и полями, по которым рантайм примет решение.

class OpenDisputeOutput(BaseModel):
    status: Literal["accepted", "rejected", "error"]
    case_id: Optional[str] = None
    review_deadline: Optional[date] = None
    error: Optional[ProblemDetails] = None

def open_dispute(inp: OpenDisputeInput) -> OpenDisputeOutput:
    try:
        resp = disputes_api.create(inp.model_dump(), timeout=3.0)
        return OpenDisputeOutput(status="accepted", case_id=resp["id"],
                                 review_deadline=resp["deadline"])
    except httpx.ConnectTimeout:
        return OpenDisputeOutput(status="error", error=ProblemDetails(
            type="https://bank.example/errors/disputes-unavailable",
            title="Сервис споров не отвечает",
            detail="Заявка не создана, повторная отправка безопасна.",
            retryable=True, retry_after=30, owner_action_required=False,
        ))

Форму ошибки изобретать не нужно, она стандартизована. RFC 9457 Problem Details (июль 2023, отменяет устаревший RFC 7807) описывает пять базовых членов — type со ссылкой на документацию ошибки, status, title, detail, instance — и тип содержимого application/problem+json. Расширять его своими полями разрешено: клиент, который их не знает, просто игнорирует.

Эти самые поля и превращают текст ошибки в решение. retryable отвечает на вопрос, имеет ли смысл повтор вообще. retry_after говорит, через сколько. owner_action_required отделяет случаи, где ни повтор, ни другая формулировка не помогут и нужен человек. Cloudflare, выкативший такие ответы для агентов, сформулировал разницу лучше всех: обычные страницы ошибок дают агенту улики, а не инструкции. Вместо «вы заблокированы» агент читает «сработало ограничение частоты, подождите 30 секунд и повторите с экспоненциальной задержкой». Заодно там намерили сокращение объёма ответа и расхода токенов более чем на 98% против HTML-страницы. Цифра красивая, но это один замер на одном коде ошибки, а не средняя экономия по системе.

Что бывает, когда выходного контракта нет, отлично видно на бенчмарке. В агентной части BFCL V4 запросы к веб-страницам намеренно ломают: каждый вызов с некоторой вероятностью получает одну из шести типовых ошибок, от 503 и 429 до таймаута чтения и обрыва связи. Типичный режим отказа при этом выглядит совсем не как падение. Модель находит правильный источник, спотыкается о блокировку и переключается на другой сайт с неверной или двусмысленной информацией. Хуже того, при полностью отключённой функции загрузки часть моделей продолжает отвечать верно на заметную долю вопросов, и авторы предполагают, что дело в опоре на внутренние знания или в галлюцинации. Невнятный сбой инструмента не роняет агента. Он тихо портит ответ.

Отдельная обязанность выходного контракта — не выпускать наружу лишнее. Системный трейсбек, строка подключения к базе, сырой ответ платёжного шлюза с чужими персональными данными. Всё это уезжает в контекст модели, а оттуда с изрядной вероятностью в текст ответа клиенту. Полезно держать в голове и разграничение статусов из инженерной практики. Штатный бизнес-исход, скажем, отклонённый по регламенту спор, — это успешный ответ с полем причины, а коды 4xx/5xx резервируются за системными сбоями. Если «отказано по правилам платёжной системы» отдавать пятисоткой, мониторинг начнёт считать нормальную работу авариями, и настоящая авария утонет в шуме.

6. Когда аргументов ещё нет: диалог вместо одного вызова

Схема требует пять аргументов, а клиент в первом сообщении назвал два. Прогнать неполный набор через валидатор — значит получить честный отказ по обязательным полям, за которым не стоит никакой реальной проблемы. Данные не плохие, они просто ещё не все. Средний диалог у нашего агента идёт пятнадцать ходов, и аргументы в нём накапливаются постепенно, как в обычном разговоре с оператором.

Приём называется slot filling, заполнение слотов, и держится он на разведении двух моментов, сбора и проверки. Аргументы копятся в состоянии сессии как отдельные значения, объект схемы не собирается вовсе, и только когда набор полон, он уходит в валидатор целиком. Строгость никуда не девается, она применяется в правильный момент.

ход 1: «вчера дважды списали 1 200,50 с карты •• 4417»
       слоты: card_last4=4417, operation_date=2026-08-22, amount=1200.50
       не хватает: reason, original_operation_id
       вопрос: «Похоже на повторное списание. Подтвердите — обе операции
                прошли по одной покупке?»

ход 2: «да, обе в кофейне»
       слоты: + reason=duplicate
       не хватает: original_operation_id → подтягиваем из выписки
       валидация: полный набор → OpenDisputeInput → вызов
Рисунок — сбор аргументов по ходам диалога: слоты накапливаются в состоянии сессии, недостающие превращаются в вопрос по смыслу поля, и только полный набор из пяти значений уходит в схему и в вызов инструмента.

Ломается это место предсказуемо. Чаще всего виновато разрушающее слияние: новая порция аргументов от модели затирает то, что клиент назвал три хода назад, потому что в текущем ходе модель вернула по этим полям None. Пустые значения записывать в состояние нельзя. Второе больное место — вопрос по имени поля вместо смысла, когда клиент читает «укажите значение original_operation_id» вместо «какая из двух операций была первой». Третье — время жизни слотов. Клиент вернулся к теме через полчаса, а система помнит отменённое намерение. Рабочая комбинация: таймаут как страховка плюс сброс по явной смене темы, а неявные признаки лучше пускать не на сброс, а на уточняющий вопрос.

Отдельно стоит случай, когда новая реплика противоречит уже заполненному слоту. Граница проходит по обратимости действия, к которому слот ведёт. Для необратимого надо переспрашивать, показывая старое и новое значение. Для остального перезаписывать, но сообщать об этом в ответе («понял, меняю сумму на 1 340»). Молча не делать никогда: тихая подмена суммы спора и есть тот класс сбоев, который обнаруживается только по жалобе.

Способность задать уточняющий вопрос вместо выдумывания значения, кстати, меряется. В BFCL есть категории Multi Turn Miss Param и Multi Turn Miss Func, где у модели намеренно отбирают часть данных и смотрят, спросит она или сочинит. Тот же slot filling, вынесенный в бенчмарк, и результаты там заметно ниже общего балла.

7. Контрактные тесты: чтобы контракт не разъехался молча

Оба контракта написаны, валидаторы стоят, ошибка объясняется. Остаётся вопрос, который в обычной разработке решён давно. Как узнать, что контракт всё ещё соблюдается, когда бэкенд-команда меняет своё поле amount с целого на строку, а вы узнаёте об этом из жалоб? У инструмента агента дрейф схемы неприятнее вдвойне, потому что схема одновременно работает описанием для модели.

Сразу оговорка: прямых работ, применяющих контрактное тестирование к инструментам LLM-агента с замером, найти не удалось. Всё, что ниже, — перенос практики по аналогии, и относиться к нему стоит соответственно.

Переносят же вот что. В обычной разработке две команды страхуются от расхождения контрактными тестами в стиле Pact: потребитель API записывает ответ, которого ждёт от поставщика, поставщик прогоняет эту запись у себя в CI и краснеет, как только формат разошёлся. Отсюда и название consumer-driven контракты — форму диктует тот, кто API потребляет, а не тот, кто его отдаёт.

Практика из мира API Переносится? Почему
классический Pact с детерминированными моками нет ожидания фиксируют точный ответ, а вывод модели меняется от прогона к прогону
сверка со спецификацией интерфейса в CI: OpenAPI (описание HTTP-API), JSON Schema да проверяет форму, а не конкретный текст; ловит дрейф типов до выката
закон Хайрума про наблюдаемое поведение да, как предупреждение агент опирается на недокументированные особенности ответа так же, как чужой код

На практике из этого выходит набор проверок, которые пишутся один раз на инструмент.

Снапшот схемы в репозитории. OpenDisputeInput.model_json_schema() даёт ровно тот JSON, который уедет провайдеру. Его кладут в файл рядом с кодом и сравнивают в CI. Любое изменение состава полей, перечислений или описаний всплывает в диффе ещё до выката. Заодно этот же артефакт удобно скармливать команде бэкенда как контракт со своей стороны.

Мок транспортного сбоя. Тест подменяет HTTP-клиент так, чтобы тот бросил таймаут соединения, и проверяет не текст сообщения, а форму ответа: статус error, отсутствие case_id, выставленный retryable. Проверять здесь надо именно контракт, потому что смысл упражнения в том, что цикл агента переживёт аварию бэкенда.

def test_timeout_becomes_valid_output(monkeypatch):
    monkeypatch.setattr(disputes_api, "create",
                        lambda *a, **kw: (_ for _ in ()).throw(httpx.ConnectTimeout("db")))
    out = open_dispute(valid_input)
    assert out.status == "error"
    assert out.case_id is None
    assert out.error.retryable is True

def test_enum_case_is_normalized():
    inp = OpenDisputeInput(**{**raw, "reason": "Duplicate",
                             "original_operation_id": "op-1"})
    assert inp.reason == "duplicate"

Тест на грязный вход. Те самые формы, в которых модель реально присылает аргументы: перечисление с заглавной буквы, код карты с точками, дата словом, сумма с пробелом и знаком рубля. Это проверка не Pydantic, а ваших нормализаторов, и она регулярно краснеет после безобидного рефакторинга валидаторов, где кто-то поменял их порядок.

Фикстуры для таких тестов полезно брать из продовых трейсов. Сочинённые слабее по скучной причине: человек пишет число числом и перечисление в нижнем регистре, и это уже подводило нас в уроке 4, где ни один unit-тест не поймал инцидент с суммой строкой. В записанных вызовах аргументы лежат ровно в том виде, в каком их присылает модель, и такой корпус злее любого выдуманного.

8. Как читать бенчмарк, который меряет именно вызов инструментов

Контракты написаны, тесты стоят, но всё это работает тем лучше, чем чаще модель попадает в схему с первой попытки. Общие лидерборды на такой вопрос не отвечают. В уроке 1 разбирались SWE-bench, OSWorld и τ-bench, и все они меряют решение задач целиком. Для вызова инструментов есть свой бенчмарк, Berkeley Function Calling Leaderboard, он же BFCL, живущий в лаборатории Gorilla в Беркли и добравшийся до четвёртой версии.

Мерит он двумя способами, и разница между ними существенна. AST-метрика разбирает сгенерированный вызов питоновским модулем ast и сверяет имя функции и аргументы со схемой, быстро и дёшево. Executable-метрика реально запускает вызов против живого или симулированного бэкенда. Она нужна там, где один и тот же результат достижим разными синтаксическими формами, например в REST или SQL. Категории росли вместе с версиями: от простого и параллельного вызова в V1 через многоходовые сценарии в V3 к агентной части в V4, где появились веб-поиск, память и чувствительность к формату.

Здесь же полезно поправить пересказ, который гуляет по обучающим материалам. Категорию Multiple Function часто описывают как выбор нужного инструмента из полусотни кандидатов. В блоге бенчмарка сказано другое: это вопрос, требующий одного вызова, при 2–4 документациях функций на входе. Деградация при разрастании каталога реальна, и мы её разбирали в уроке 1, но меряет её не эта категория и не этот эксперимент.

Ранг Модель Режим Общий балл Multi-turn Стоимость прогона
1 Claude-Opus-4-5 function calling 77,47% 68,38% $86,55
2 Claude-Sonnet-4-5 function calling 73,24% 61,37% $43,73
3 Gemini-3-Pro-Preview prompt 72,51% 60,75% $298,47
4 GLM-4.6 function calling, с рассуждением 72,38% 68,00% $4,64
7 Gemini-3-Pro-Preview function calling 68,14% 63,12% $224,69
16 GPT-5.2 function calling 55,87% 28,12% $85,65
50 Llama-4-Maverick-17B function calling 37,29% $18,25

Снимок лидерборда от 12 апреля 2026 года. Ни один полезный вывод из него не читается по колонке ранга.

Prompt-режим против нативного. У Gemini-3-Pro режим, где схемы описаны текстом в промпте, стоит на третьем месте, а нативный вызов функций того же семейства на седьмом. То же самое у линейки Qwen3. Тезис «нативный вызов надёжнее» верен про формат, потому что провайдер гарантирует структуру ответа. Про итоговое качество работы с инструментами он неверен, и проверять это надо на своей задаче.

Общий балл против многоходового. У GPT-5.2 общий балл 55,87% при 28,12% на многоходовых сценариях. Синтаксическая гарантия и компетентность в диалоге, где нужно помнить прошлые вызовы и уточнять недостающее, — разные способности. Агенту поддержки с диалогом на пятнадцать ходов важнее второе число.

Цена прогона против ранга. Открытая GLM-4.6 берёт 72,38% за $4,64 против $86,55 у лидера. Разрыв в стоимости прогона почти в девятнадцать раз при разнице в пять пунктов, а по многоходовым сценариям GLM-4.6 идёт вровень с первым местом. Одна оговорка: строка снята в режиме с включённым рассуждением, и $4,64 за прогон уже включают его токены.

Отдельная часть V4 меряет то, о чём редко думают при выборе, — чувствительность к формату. Пять измерений (формат возврата, формат документации функций, теги вызова, формат и стиль промпта) дали 26 конфигураций на 200 тест-кейсах для 39 моделей. Результат отрезвляющий. Несколько моделей, дообученных специально под вызов инструментов, проваливаются до нулевой точности, если сменить формат возврата или потребовать теги вокруг вызова. У промптовых моделей разброс поменьше и тоже заметен: максимальная дельта 8,5 пункта у Gemini-3-Pro-Preview и 13,0 у Grok-4-0709. Практический смысл прямой. Модель, обученная под ваш формат, и модель, устойчивая к формату, — разные вещи, и переезд на другой рантайм иногда стоит дороже переезда на другую модель.

Забавная деталь, которую стоит унести отдельно от цифр. В том же материале текст раздела говорит, что точность выше всего на JSON, ниже на XML и ниже всего на Python, а подпись под графиком выстраивает порядок иначе: JSON, потом Python, потом XML. Первоисточник противоречит сам себе. Даже канонический бенчмарк приходится вычитывать целиком, прежде чем на него ссылаться.

Есть и вторая сторона вопроса «сколько стоит формат». Свежая работа «The Format Tax» (UCSD, апрель 2026) определяет налог на формат как разницу между качеством при свободном ответе и при ответе по схеме, и локализует, откуда он берётся. Основная часть потерь приходится на промпт: сама инструкция «отвечай по схеме» роняет точность ещё до того, как включается ограничение декодирования, а на долю смещения выборки приходится меньшая часть. Обычные лечения не помогают, примеры в промпте и подробные описания схемы оставляют большую часть просадки на месте. Помогает другое. Дать модели порассуждать свободно, а формат навести вторым проходом или режимом расширенного рассуждения.

Величины при этом скромнее ходовых пересказов и очень неровные. У qwen3-8b в среднем −9,9 пункта, у nemotron3-nano −4,3. Сильнее всего эффект на математических задачах, слабее на логических головоломках, на GPQA почти не виден. А три свежие модели через API (claude-haiku-4.5, grok-4.1-fast и gpt-5.4-nano) показывают около нуля или плюс. Отсюда правило, которое стоит держать рядом с решением о развёртывании. Если инструменты вызывает открытая модель на своём железе, рассуждение и форматирование разводят на два прохода: сначала свободный разбор случая, потом укладка результата в схему. Передовым закрытым моделям этот приём уже не нужен.

Налог на формат и есть цена, которую платит входной контракт, когда рассуждение и форматирование идут одним проходом. Схема инструмента — такая же инструкция «отвечай по форме», как и любая другая, и просадка на ней той же природы. Два прохода эту цену снижают, а самого контракта не трогают.

9. Что видно в проде и с чего начинать

Все перечисленные границы дают одно наблюдаемое следствие, долю вызовов, где аргументы прошли валидацию с первой попытки. Считается она в одну строку по трейсам, и в лекциях её иногда подают как отраслевой стандарт с порогами «выше 90% отлично, ниже 70% деградация». Отраслевого стандарта за этим нет, внешнего источника таких порогов найти не удалось. Относиться к цифре стоит как к рабочему приёму: полезна она движением на своём сервисе после смены схемы или модели, а абсолютное значение сравнивать не с чем.

Рядом ложатся метрики типизированного агента из урока 4: retry_count_per_run, retry_overhead_tokens и fallback_rate. Смотреть их полезно вместе, потому что отвечают они на разные вопросы. Доля успеха с первой попытки говорит, насколько описания полей понятны модели. Накладные токены на исправления переводят непонятность в деньги. Доля ухода в запасной сценарий показывает, сколько диалогов до цели вообще не дошло.

Точка контроля Что проверяет Где чинить
вход, синтаксис типы, перечисления, границы, обязательность схема и before-валидаторы
вход, семантика согласованность полей между собой model_validator(mode="after")
обратная связь понятность отказа для модели перечень допустимых значений
выход, штатный форма ответа, уходящего в контекст выходная схема без трейсбеков
выход, аварийный поведение при сбое бэкенда RFC 9457, retryable, retry_after
дрейф расхождение схемы с бэкендом снапшот схемы в CI
Рисунок — шесть точек контроля вокруг одного инструмента: две на входе (синтаксис и семантика), одна на обратной связи при отказе, две на выходе (штатный ответ и авария) и одна в CI против дрейфа схемы.

Порядок внедрения ровно такой же, и переписывать агента целиком он не требует. Берётся один инструмент, самый денежный или самый необратимый. На него пишется входная схема с нормализацией и межполевыми правилами. Затем выходная, вместе с перехватом исключений бэкенда. Затем сообщение об ошибке, где обязательно стоит список допустимых значений. Затем два теста, мок транспортного сбоя и снапшот схемы. Дальше следующий инструмент, по одному.

Вернёмся к клиенту из начала. Он пишет то же самое, «вчера с карты •• 4417 списали 1 200,50 ₽ дважды». Фаза before превращает «вчера» в дату, выбрасывает точки из номера карты и опускает регистр причины, так что пришедшее от модели "Duplicate" становится законным duplicate. Фаза after замечает, что для дубля не указана исходная операция, и агент задаёт единственный уточняющий вопрос вместо отказа. Сервис споров в этот момент отвечает таймаутом, и инструмент возвращает объект со статусом ошибки и пометкой, что повтор безопасен через тридцать секунд. Клиент видит «заявка ещё не создана, пробуем ещё раз» вместо оборванного диалога. Разница между двумя версиями этой сцены лежит не в модели, модель та же самая.

Итог

  • Всё сводится к одной фразе. Инструмент агента не падает и не молчит. На входе он чинит починимое и объясняет остальное, на выходе всегда возвращает объект, даже когда за ним ничего не работает. Отсюда и два контракта: входной чинит человеческую запись, приводит типы и ловит несогласованность полей, выходной обещает форму ответа, включая форму аварии.
  • Строгий режим провайдера снимает синтаксический класс ошибок, но гарантия не абсолютна. Отказ модели с кодом 200, обрыв по лимиту токенов и негарантированный регистр перечислений документированы самим провайдером, а числовые границы и длины строк декодер вообще не видит: их вырезают из схемы и проверяют уже на клиенте. Бизнес-правила в грамматику не компилируются вовсе.
  • В сообщении об ошибке главное — перечень допустимых значений, а не путь до сломанного поля; форма подачи, проза или машинная запись, различий в замере не дала. Петля исправлений работает только от внешнего арбитра, замкнутая на себя самопроверка ухудшает ответ.
  • Транспортный сбой возвращается как валидный объект по RFC 9457 с полями retryable, retry_after и owner_action_required. Так рантайм получает решение вместо текста, а цикл агента переживает аварию бэкенда.
  • Контракт проверяется в CI: снапшот model_json_schema(), мок таймаута и тесты на грязный вход из реальных трейсов. Прямых замеров по переносу контрактного тестирования на инструменты агента нет, это аналогия, но дрейф схемы она ловит. Пригодность самой модели меряет BFCL, и читать его по рангу бесполезно: prompt-режим порой обгоняет нативный вызов, у GPT-5.2 общий балл расходится с многоходовым вдвое, а открытая модель отстаёт на пять пунктов и стоит почти в девятнадцать раз дешевле.
  • Наш агент поддержки после этого урока оформляет спор, переживая и кривые аргументы, и недоступный бэкенд. Чего он всё ещё не умеет — пережить падение процесса на середине диалога и вернуться к тем же слотам. Что попадает в снимок состояния, что после сбоя переигрывается заново и сколько такой снимок стоит на каждом ходу, решается уровнем ниже, на графе.

FAQ

Чем контракт инструмента отличается от схемы структурированного вывода?

Схема структурированного вывода описывает одну сторону, то, что модель отдаёт наружу. У инструмента сторон две: входной контракт проверяет аргументы, пришедшие от модели, выходной описывает, что инструмент вернёт обратно в контекст. Вторую половину чаще всего забывают, и именно через неё утекают системные трейсбеки, а необработанные исключения бэкенда обрывают цикл агента.

Гарантирует ли strict mode соответствие ответа схеме?

Не полностью. Anthropic документирует три случая, в которых ответ модели расходится со схемой. Отказ модели приходит с кодом 200 и вытесняет структуру. Генерация может оборваться по лимиту токенов. Регистр строковых значений перечислений не гарантируется, и модель вернёт Duplicate вместо duplicate без ошибки и без особого признака остановки. К ним добавляется расхождение другого рода, где дело уже не в поведении модели: часть ключевых слов схемы (числовые границы, длины строк, рекурсия, внешние $ref) ограниченная генерация не поддерживает: SDK вырезает их, переносит смысл в описание поля и сверяет ответ с исходной схемой уже на клиенте. Плюс существуют потолки сложности: до 20 строгих инструментов на запрос, 24 опциональных параметра и 16 union-полей суммарно.

Что писать в сообщении об ошибке валидации, чтобы модель исправилась?

Перечень допустимых значений для сломанного поля. Разбор по слагаемым в препринте arXiv:2607.14167 показал, что именно он даёт почти весь прирост: сообщение, содержащее только путь до поля и присланное значение, держится около уровня сырого трейсбека. Путь полезен как адресация правки, но сам по себе работу не делает. Свидетельств того, что сам синтаксис JSON улучшает исправление, в этом замере нет: та же информация, поданная прозой, дала сопоставимый успех.

Нужно ли пробрасывать исключение инструмента наружу, в рантайм агента?

Нет. Инфраструктурные сбои (таймауты, недоступность базы, ошибки авторизации) перехватываются внутри инструмента и возвращаются как валидный объект выходной схемы со статусом ошибки. Иначе обрывается цикл рассуждения, теряется оплаченный контекст диалога и исчезает возможность мягкой деградации. Форму такого объекта задаёт RFC 9457 с расширениями retryable, retry_after и owner_action_required.

Почему цикл самоисправления работает от валидатора, но ломается от вопроса «ты уверен?»

Валидатор внешний по отношению к модели, он проверяет по правилам, которых модель не видит и не может под себя подстроить. Самопроверка замкнута на ту же модель с теми же слабостями. Работа ICLR 2024 показала, что без внешнего сигнала качество после самокоррекции нередко падает: модель принимает сомнение за указание на ошибку и начинает её изобретать.

Как читать Berkeley Function Calling Leaderboard?

По колонке ранга читать бесполезно. Полезны три сравнения: prompt-режим против нативного вызова функций (первый порой выше, то есть «нативный надёжнее» верно про формат, но не про качество), общий балл против многоходовых сценариев (у GPT-5.2 55,87% против 28,12%), стоимость прогона против позиции (GLM-4.6 даёт 72,38% за $4,64 против $86,55 у лидера). Отдельно стоит посмотреть часть про чувствительность к формату: часть моделей, дообученных под вызов инструментов, падает до нуля при смене формата возврата.

Работает ли контрактное тестирование в стиле Pact для инструментов агента?

Частично, и работ с замерами на эту тему найти не удалось. Pact — инструмент контрактного тестирования: потребитель API записывает ответ, которого ждёт от поставщика, а поставщик прогоняет запись у себя в CI (отсюда и название consumer-driven, «продиктованные потребителем»). Такие контракты с детерминированными моками на инструмент агента не переносятся: они фиксируют точный ответ, а вывод модели меняется от прогона к прогону. Переносится проверка спецификации: снапшот model_json_schema() в репозитории плюс сравнение в CI ловят дрейф схемы до выката. Полезны также мок транспортного сбоя и тесты на грязный вход, собранные из реальных трейсов.

Источники

Числовые ориентиры из текста (баллы бенчмарка, величины налога на формат, прирост от структурной обратной связи) сняты на конкретных наборах задач и моделях и зависят от профиля нагрузки, домена и версии схемы. Сокращение объёма ошибки более чем на 98% измерено Cloudflare на одном коде ошибки против HTML-страницы. Пороги доли успеха с первой попытки внешнего подтверждения не имеют и приведены как рабочий приём.