AIREITER

Дрейф описаний инструментов Agent: пусть декларация станет единственным источником истины

Последнее обновление: 2026-07-31 07:28:34

Добавили в Agent инструмент вроде «получить публичные посты платформы». Две недели он спокойно работает в продакшене. Затем вы меняете значение по умолчанию у limit с 25 на 20, добавляете новый вариант в enum sort, тесты проходят, изменения попадают в main.

Через три дня в продакшене изредка начинают появляться ошибки. Модель вызывает инструмент со значением enum, которое вы удалили на прошлой неделе; runtime-валидация отклоняет запрос, а stack trace ведёт в слой диспетчеризации. Вы полчаса изучаете dispatch-код — и он безупречен. Проблема вообще в другом месте: сигнатуру функции вы обновили, а описание инструмента, по которому ориентируется модель, осталось старым. Модель по-прежнему опирается на прежнюю схему и формирует вызовы в старом формате, который больше не соответствует реальному интерфейсу.

Это и есть дрейф описаний инструментов. В Agent-разработке это самый распространённый и один из самых мучительных в поиске классов багов: симптом и первопричина находятся в разных частях системы. Ошибка проявляется на исполнении, а её источник лежит в JSON-файле, который никто не думает открыть. Решение — не напоминать команде «не забывайте синхронизировать». Нужно убрать саму возможность расхождения: оставить один источник данных вместо двух копий.

Откуда берётся рассинхронизация

Причина проста: вы одновременно поддерживаете два источника истины.

Первый — исполняемый код: сигнатура функции, валидация аргументов, значения по умолчанию, ограничения enum. Это жёсткий контракт. Ошибка здесь быстро и громко проявится.

Второй — описание инструмента, которое видит модель: name, description и JSON Schema в parameters. Это мягкий контракт. Если он устарел, ничего не падает сразу: модель лишь строит некорректный вызов, а сбой возникает позже, в исполняющем слое.

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

Единый источник истины: декларация и есть интерфейс

Главный сдвиг в подходе: отдельный JSON с описанием инструмента вам вообще не нужен.

Декларация парсера функции вместе с docstring уже содержит всё, что требуется для описания инструмента. Например, обычная декларация на argparse:

subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
    "--sort",
    choices=("hot", "new", "top", "rising", "controversial"),
    default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)

help задаёт краткое описание команды. choices определяет допустимые значения enum. default хранит значение по умолчанию. type — тип параметра, а позиционный аргумент означает обязательное поле. Всё, что модели нужно для вызова инструмента, уже находится здесь: назначение команды, параметры, обязательность, допустимые значения и дефолты. Более того, эту же декларацию runtime использует для парсинга и валидации. Она не может разойтись с логикой исполнения, потому что сама является частью этой логики.

Поэтому не пишите второе описание инструмента. Считайте, что такого документа не существует. Есть только код; когда требуется описание инструмента, оно проецируется из кода. Направление у этой проекции одно — от кода к описанию, но никогда наоборот.

Как собрать весь каталог из деклараций

Если декларация — это интерфейс, описания инструментов не должны создаваться вручную. Их должен строить генератор.

Его задача полностью механическая: пройти по всем контекстам платформ, импортировать их парсеры и преобразовать список action-объектов argparse в три неизменяемые структуры: Platform, Command и Parameter. Каждый Parameter содержит имя, тип, признак обязательности, значения enum, default и текст help. Так получается read-model интерфейса, целиком производный от кода.

После этого любые форматы вывода становятся вторичными. Команда describe --format json отдаёт полный машиночитаемый интерфейс для выбора инструментов Agent. render_skill() формирует каталог возможностей, понятный человеку и модели. Число команд в таком каталоге не пишется вручную: оно считается на лету как sum(len(platform.commands)). Сейчас это 22 контекста платформ и 241 команда — и ни одна из них не была вручную внесена в каталог.

Это даёт очень удобное свойство. Добавляете платформу — добавляете её контекст, а каталог сам подхватывает все команды. Меняете параметр — правите декларацию парсера, и связанный enum или default в каталоге обновляется автоматически. Больше не бывает ситуаций «написал новую команду и забыл зарегистрировать» или «изменил параметр, а каталог устарел»: отдельного действия регистрации просто нет. Каталог вычисляется, а не поддерживается вручную.

(Этот принцип — выводить данные, а не сопровождать их копии, — работает и при проверке того, что реально мигрировало между языками через операции над множествами. Об этом подробнее — в материале о миграции между языками.)

CI должен ловить дрейф до merge

Генерация решает проблему «новая команда автоматически попадает в каталог», но остаётся один сценарий. Кто-то меняет декларацию парсера, забывает перезапустить генератор и не коммитит обновлённый каталог. В репозитории снова остаётся устаревшая копия — рассинхронизация возвращается через чёрный ход.

Последний барьер ставится в CI, и его основа — одна проверка:

docs-check:
	$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
	  path = Path("skill/SKILL.md"); \
	  assert path.read_text(encoding="utf-8") == render_skill(), \
	  "skill/SKILL.md is out of sync with the code; run make docs"'

Проверка берёт каталог, закоммиченный в репозиторий, и побайтно сравнивает его с каталогом, заново сгенерированным из текущего кода. Отличается хотя бы один символ — CI падает и сообщает, что каталог устарел и нужно выполнить make docs.

Ценность этой строки в том, что она переносит момент обнаружения дрейфа. Раньше это был призрак рантайма: ошибка всплывала в продакшене через две недели, а stack trace указывал не туда. Теперь это красный крестик ещё при коммите. Pull request остановлен, сообщение прямо говорит, что каталог устарел, а исправление сводится к его регенерации. Рассинхронизация превращается из «самого сложного для диагностики бага» в ошибку сборки, устраняемую одной командой. Так замыкается цикл interface-as-code: декларация — исходник, каталог возможностей — артефакт сборки, CI — проверка типов. Вы не стали бы вручную поддерживать build-артефакт или терпеть его расхождение с исходником. С описаниями инструментов нужно поступать так же.

Что фиксировать в коде, а что оставить модели

Генерация и CI гарантируют точность описания интерфейса. Но до этого нужно принять ещё одно решение: какую возможность оформить как фиксированный код, а какую оставить модели для оркестрации в момент запроса. Если ошибиться с этой границей, точный интерфейс уже не спасёт.

Полезно разделить возможности на три уровня.

Низкоуровневый примитив читает один тип данных или выполняет одно ясное действие. У него стабильный ввод, структурированный вывод, и его можно тестировать отдельно. Это чистый код, не требующий рассуждений. Здесь находится подавляющее большинство из 241 команд.

Детерминированный workflow — строго упорядоченный процесс внутри одной платформы, где есть общее состояние и ясное условие успеха. Например, creative pipeline creative-pipeline последовательно выполняет поиск возможностей, затем Top Ads, затем подбор авторов, затем creative brief и preflight генерации. Порядок и зависимости между шагами заранее определены. Этот уровень тоже нужно фиксировать в коде: если последовательность уже известна, заставлять модель каждый раз заново её планировать и медленнее, и менее стабильно. Для маркировки достаточно одной строки: назначить команде set_defaults(_command_level="workflow"). Это единственная такая строка в кодовой базе, и именно благодаря ей каталог показывает workflows и primitives как два разных уровня.

Оркестрация Agent — это межплатформенное исследование, выбор между актуальными компромиссами и смена маршрута после сбоя. Именно это стоит оставить модели: следующий запрос зависит от результата предыдущего, и заранее описать все варианты невозможно.

Критерий достаточно понятен. Если возможности нужны устойчивые статусы этапов, общий контекст или побочные эффекты генерации — фиксируйте её в коде. Если в ней есть расширение запроса, проверка по нескольким платформам или смена маршрута после ошибки — оставляйте модели. Ошибки в обе стороны дороги. Зашить исследовательскую гипотезу в клиент — значит переусложнить фиксацию, и после изменения платформы снова придётся редактировать код. А поручить модели каждый раз собирать заранее известную последовательность — значит недофиксировать процесс: вы экономите одно решение модели, но покупаете нестабильность на каждом запуске.

Шесть статусов этапа для осмысленной деградации

Чтобы оркестрационный уровень мог принимать решения, результаты нижнего уровня должны быть понятны модели. Непрозрачного boolean-статуса успеха или ошибки недостаточно. Передайте модели success: false — и ей останется лишь гадать, что делать дальше.

Поэтому каждый этап workflow возвращает не boolean, а статус этапа. Всего их шесть: completed, empty, ready, skipped, unavailable и blocked. Главное — различать ситуации, в которых этап не продолжился:

  • skipped означает, что оператор намеренно отключил этот шаг — например, выставил лимит одного из путей сбора в 0. Это не ошибка, и модели не нужно повторять попытку.

  • unavailable означает временную недоступность зависимости этапа: например, интерфейс вернул ошибку или отсутствует сессия. Модель может обойти этот этап и продолжить либо запросить новую сессию и вернуться позднее.

  • blocked означает, что не выполнено предусловие: например, нет исследовательских данных или не пройден preflight. Модель не должна принудительно запускать следующий шаг. Ей нужно вернуться и собрать недостающие данные.

Возьмём тот же creative pipeline. Он отдельно проверяет «готовность platform preflight» и «готовность исследовательских данных», а затем вычисляет итоговое значение ready = platform_ready and research_ready. Если не проходит любая из проверок, этап генерации возвращает blocked вместе со списком blockers, объясняющим причину. А когда все результаты коммерческого поиска пусты, задача генерации просто не отправляется.

Почему такой дизайн удобен именно модели? Оркестрационная модель, увидев seedance_generation: blocked и blockers: [research_evidence_empty], понимает: нужно вернуться за данными, а не повторять отправку задачи. Увидев organic_discovery: skipped, она понимает, что это намерение пользователя, а не сбой, и ничего не предпринимает. Статус unavailable говорит, что этап можно временно обойти. Когда вы разделяете «намеренно отключено», «временно недоступно» и «не выполнено предусловие», модель способна выбрать правильный путь деградации. Сведите все три случая к false — и даже сильная модель начнёт топтаться на месте.

Как распределить модели по слоям

На каждом из описанных уровней к модели предъявляются разные требования. (Материал о reverse engineering в четыре этапа описывает ту же четырёхуровневую таблицу применительно к обратной разработке; здесь она перенесена на Agent stack.) Распределяйте модели по задачам — и не будете зря тратить возможности дорогих моделей:

Задача в Agent stack

Нужная способность

Выбор

model id

Загрузить в контекст json из describe для 241 команды и выбрать инструмент

Длинный контекст, чтение всего каталога за один проход

Kimi K3

kimi-k3

Оркестрация: прочитать статусы этапов и blockers, решить — деградировать, сменить маршрут или продолжить

Сильное рассуждение, верное решение на основе статуса

Claude Opus 5

claude-opus-5

Массово генерировать из docstrings понятные модели тексты описаний инструментов

Низкая стоимость, сотни вызовов при высокой параллельности

Claude Sonnet 5

claude-sonnet-5

Установить причину ошибки tool call: прочитать ошибку и декларацию, определить дрейф это или внешнее изменение

Средний уровень рассуждений, объяснение через конкретные поля

GPT-5.6 Sol

gpt-5.6-sol

Особенно важен оркестрационный слой. Интерпретация blocked и skipped для выбора следующего действия — единственный этап в этой схеме, где смена модели заметно меняет результат. Здесь проверяется именно способность верно трактовать статус. Более слабая модель воспринимает skipped как ошибку и повторяет запрос или видит blocked, но всё равно отправляет задачу. Сильная рассуждающая модель читает blockers и точно меняет маршрут. Разрыв похож на то, действительно ли раздел с контрдоказательствами спорит с собственным выводом в материале о fingerprinting: кандидата сгенерировать может каждый, сложность в принятии верного решения.

Проверить разницу можно самостоятельно:

  1. Возьмите реальный ответ одного из ваших workflows с его stages и blockers либо создайте ответ со статусом blocked и blockers: [research_evidence_empty].

  2. Передайте этот ответ, свой каталог возможностей — json из describe — и инструкцию выбрать следующее действие отдельно в claude-opus-5 и gpt-5.6-sol.

  3. Смотрите на одно: корректно ли следующее действие различает blocked (вернуться за данными), skipped (намерение пользователя, не трогать) и unavailable (получить сессию или обойти), либо модель повторяет skipped, будто это ошибка.

  4. Доля правильных путей деградации и будет критерием выбора. От неё зависит, начнёт ли ваш Agent зацикливаться при реальном сбое или самостоятельно найдёт обходной путь.

Главная проблема — цена переключения

Эти четыре модели поставляются тремя вендорами, а при function calling цена смены особенно высока. У OpenAI используются tools / tool_calls, у Anthropic — tool_use / tool_result; это два разных формата. Захотите поставить в оркестрационный слой модель с более точными решениями — придётся переписывать весь путь dispatch и разбора ошибок. Именно поэтому большинство в итоге фиксируется на одной модели для оркестрации, даже если она регулярно неверно читает статусы этапов.

AIReiter убирает этот слой сложности. Один ключ, один OpenAI-совместимый интерфейс, все четыре модели за ним — а переключение сводится к изменению поля model в теле запроса.

# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
  }'

# Generate tool-description text in bulk: change the model field, leave the rest
#   "model": "claude-sonnet-5"
# Error attribution:
#   "model": "gpt-5.6-sol"

Для нативного function calling достаточно добавить массив tools: протокол инструментов OpenAI проходит через этот интерфейс без изменений, поэтому смена модели всё так же требует правки одного поля. Если вы уже используете OpenAI SDK, укажите для base_url адрес https://aireiter.com/api/v1 и больше ничего не меняйте. В Anthropic SDK используйте POST /api/v1/messages с тем же ключом.

По цене модели Claude идут со скидкой 30% от листовой цены, GPT — за половину стоимости, а Kimi K3 доступна по тому же ключу. Для этого стека скидка приходится на самые затратные места. Каждый следующий шаг оркестрационного слоя — ещё один вызов модели уровня reasoning, а значит, это самый частый и самый дорогой слой во всём Agent; скидка на Claude действует именно здесь. Массовая генерация описаний инструментов из 241 docstring — тоже высокопараллельная работа Sonnet со скидкой. На эти две задачи приходится основная часть расходов. Вызовов GPT-5.6 для атрибуции ошибок значительно меньше.

  • Получить API-ключ

  • Попробовать без регистрации: вручную проведите несколько раундов, передайте один и тот же ответ blocked обеим моделям и сами посмотрите, какая из них правильно деградирует, прежде чем подключать её к оркестрационному слою.

Итог

Дрейф описаний инструментов невозможно вылечить напоминанием «не забывайте синхронизировать». Такой подход лишь маскирует структурный дефект требованиями к личной дисциплине. Настоящее решение — убрать архитектуру с двумя источниками: декларация парсера и docstring остаются единственным источником, каталог возможностей становится производным артефактом сборки, а одна CI-проверка играет роль type check. Дрейф перестаёт быть призраком рантайма и становится красным крестиком ещё при коммите.

Но генерация гарантирует лишь точность описания. Она не отвечает на вопрос, правильно ли вы разделили уровни. От того, какие возможности вы фиксируете в коде, а какие отдаёте модели на оркестрацию, и от шести статусов этапов, позволяющих модели отличать повтор от деградации, зависит способность Agent работать самостоятельно. В этой схеме модель выполняет две конкретные задачи: принимает решения в оркестрационном слое и определяет причину сбоя tool call. А вопрос, стоит ли фиксировать возможность, и выбор пути деградации определяются разработанными вами статусами этапов и CI-проверками, а не моделью.

Это тот же подход, что и в материалах о сверке миграций через множества и об отказе от единой response Model: AI сокращает время одного шага, а итоговое решение остаётся внутри заданных вами ограничений. Когда вся система начинает работать гладко, единственным заметным трением остаётся переключение моделей — инфраструктурная задача, которую решает единый интерфейс.