Перейти к содержимому
PD
AI-автоматизация 8 мин чтения

Деплой того агента, который вы прогнали через evals: промпты, тулы и модель как один артефакт

Evals показывают 94%, а в проде хаос. Дело почти никогда не в модели - дело в неотслеживаемом дрейфе между тем, что вы тестировали, и тем, что задеплоили. Разбираю workflow на основе манифеста и хеша.

PD

Pavel Duglas

AI Automation & MVP Architect

В прошлом месяце мне написал клиент: их support-агент “за ночь отупел”. Деплоя не было. Код не меняли, промпт не трогали, инцидентов в логах нет. Eval-сьют по-прежнему выдаёт 94%. Количество жалоб в поддержку выросло втрое.

Причину нашли за сорок минут. Они дергали алиас модели вместо закреплённой версии, а провайдер прокрутил алиас вперёд. Новый снапшот лучше рассуждал и заметно хуже соблюдал формат вывода, на который завязан их парсер. А eval-сьют последний раз прогоняли три недели назад: другая модель, и промпт-файл, в котором стажёр с лучшими намерениями поправил два слова.

Они деплоили не того агента, которого прогоняли через evals. И так делают почти все.

Поверхность дрейфа, которую никто не инвентаризирует

Когда вы деплоите обычный сервис, вы деплоите контейнер с digest. Артефакт - это ровно то, что вы протестировали. Агенты ломают эту модель, потому что их поведение живёт в десятке мест, которых нет в вашей сборке.

Вот полный список того, что я проверяю на каждом аудите:

  • Идентичность модели. gpt-5, claude-sonnet-latest, openrouter/auto - это не версии. Это подписка на чужой релизный календарь.
  • Параметры сэмплинга. Temperature, top_p, лимит выходных токенов, reasoning effort, seed. Обычно захардкожены в одном месте для evals и в другом для прода.
  • Системный промпт. Лежит в .md, который никто не считает кодом, правится прямо на сервере или хранится в prompt-management SaaS, где любой коллега может нажать “Publish”.
  • Схемы тулов. Поменяли описание параметра с “ISO date” на “date” - и модель начинает присылать 12/03/2026. Описания тулов это промпт, а не документация.
  • Реализация тулов. Схема та же, но search_orders теперь возвращает 50 строк вместо 10 и разносит контекстное окно.
  • Состояние retrieval. Индекс, на котором вы гоняли evals, содержал 4 000 документов. В проде их 40 000, включая три противоречащих друг другу PDF с регламентами, которые кто-то залил в пятницу.
  • Сборка контекста. Порядок обрезки, окно истории, порог суммаризации. Почти всегда реализовано дважды: один раз в eval-харнессе, другой раз в реальном рантайме.
  • Роутинг и фоллбэки. Ваш failover уводит 2% трафика на совсем другую модель, которую вообще ни разу не проверяли.
  • Изменения на стороне провайдера. Safety-фильтры, поведение кэша, обновление токенизатора, тихие серверные вставки в промпт “для полезности”.

Каждый пункт из этого списка хотя бы раз ударил лично меня. И лечится это не количеством evals. Лечится тем, что объект тестирования становится физически идентичен объекту в проде.

Агент как один хешированный артефакт

Я складываю всё, что влияет на поведение, в один манифест и считаю от него хеш. Хеш изменился - это новый релиз, ему нужен новый прогон evals. Без исключений.

# agents/support/manifest.yaml
id: support-agent
version: 7

model:
  provider: anthropic
  # закреплённый снапшот, никогда не алиас
  name: claude-sonnet-4-5-20250929
  temperature: 0.2
  max_output_tokens: 1200

fallback:
  # проверяется отдельно, версионируется отдельно
  manifest: agents/support/manifest.fallback.yaml

prompts:
  system: prompts/support/system.v7.md
  refusal: prompts/support/refusal.v3.md

tools:
  - name: search_orders
    schema: tools/search_orders.v2.json
    impl: app.tools.orders:search
    max_rows: 10
  - name: issue_refund
    schema: tools/issue_refund.v1.json
    impl: app.tools.billing:refund
    requires_approval: true

retrieval:
  index: support-kb
  snapshot: 2026-02-14T09:00:00Z
  top_k: 6

context:
  history_turns: 8
  truncation: oldest_first
  hard_token_cap: 24000

Хеш считается по манифесту плюс содержимому всех файлов, на которые он ссылается:

import hashlib, json, yaml
from pathlib import Path

def artifact_hash(manifest_path: Path) -> str:
    manifest = yaml.safe_load(manifest_path.read_text())
    h = hashlib.sha256()
    h.update(json.dumps(manifest, sort_keys=True).encode())
    for rel in sorted(_referenced_files(manifest)):
        h.update(rel.encode())
        h.update(Path(rel).read_bytes())
    return h.hexdigest()[:16]

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

Закрепите модель, запретите алиасы в CI

Одна регулярка в CI закрывает целый класс аварий:

FORBIDDEN = ("latest", "auto", "preview")

def test_model_is_pinned(manifest):
    name = manifest["model"]["name"]
    assert not any(tag in name for tag in FORBIDDEN), \
        f"model must be a pinned snapshot, got {name}"

Да, закрепление означает, что новые модели придётся подключать руками. В этом и смысл. Апгрейд модели - это релиз, а не погода за окном.

Версионируйте контракты тулов как публичный API

search_orders.v2.json - это файл, который меняется только рождением v3. Если вы правите описание на месте, меняется хеш, срабатывает eval-гейт, и вы узнаёте, что аккуратная правка формулировки стоила вам 6 пунктов на парсинге дат. Узнаёте до клиента, а не после.

Та же дисциплина на стороне реализации. max_rows живёт в манифесте, а не в теле функции, потому что размер ответа - это параметр поведения, а не деталь имплементации.

Снапшотьте слой retrieval

Пункт, который пропускают чаще всего, и при этом самый тихий источник дрейфа. Если ваша база знаний мутабельна, а evals гоняются по “тому, что сейчас в индексе”, то ваши цифры - это шум. Я использую append-only индексы с фильтром по времени, чтобы манифест мог сказать “документы на 14 февраля 09:00”, и eval с продом видели один и тот же корпус. Для небольших проектов версионированная папка с markdown-файлами в git абсолютно достаточна.

Гейт на деплой по хешу, а не по ощущениям

Правило в CI короткое: у каждого хеша артефакта должен быть проходной отчёт evals не ниже текущего baseline.

HASH=$(python -m agentkit hash agents/support/manifest.yaml)

if ! agentkit evals report --hash "$HASH" --exists; then
  echo "Нет прогона evals для артефакта $HASH. Запустите: agentkit evals run"
  exit 1
fi

agentkit evals compare --hash "$HASH" --baseline main --max-regression 2

Две детали, без которых это не выживает в реальной жизни:

  1. Eval-харнесс обязан грузить манифест. Если ваш харнесс сам собирает промпт и сам создаёт клиента, вы снова тестируете другого агента. Один загрузчик на оба пути, иначе весь ритуал бессмысленен.
  2. Оставьте бюджет на регрессию. Требование нулевой регрессии по шумной метрике приводит к тому, что тесты начинают отключать. Допуск в 2 пункта плюс жёсткий порог по критичным проверкам (валидность tool-call, соблюдение формата, поведение при отказе) - рабочая схема.

Проверяйте на старте, а не в три ночи

CI недостаточно, потому что у прода своё окружение. Я добавляю startup-check, который валит контейнер, если реальность не совпала с манифестом.

def assert_runtime_parity(manifest):
    # провайдер реально отдаёт закреплённый снапшот
    models = {m.id for m in client.models.list()}
    assert manifest["model"]["name"] in models

    # каждый заявленный тул существует и его схема совпадает с файлом
    for tool in manifest["tools"]:
        fn = import_impl(tool["impl"])
        assert schema_of(fn) == load_json(tool["schema"])

    # снапшот retrieval доступен
    assert index_has_snapshot(manifest["retrieval"]["snapshot"])

Контейнер, который отказался стартовать, - это неприятный вторник. Агент, который тихо работает без одного тула и галлюцинирует его результаты, - это очередь на возвраты.

Канарейка и прогретая предыдущая версия

Поскольку артефакт - это единый хешированный блок, раскатка становится тривиальной. Я роутлю по хешу: 5% сессий на новый артефакт на пару часов, обе версии загружены в одном процессе. Сравниваю четыре вещи между когортами: долю ошибочных tool-call, средние токены на решённую сессию, долю эскалаций на человека и p95 по латентности. Оценки от LLM-судьи полезны, но медленные. Эти четыре операционных числа ловят большинство регрессий в течение часа.

Роллбэк - это флаг в конфиге, который переводит трафик на предыдущий хеш. Без пересборки и без археологии по промптам.

Версия для соло-основателя

Если вы один человек и пилите MVP, не надо строить платформу. Сделайте на этой неделе четыре вещи:

  1. Замените все алиасы моделей на закреплённые снапшоты. Десять минут.
  2. Вынесите промпты и схемы тулов в файлы в git, и пусть рантайм читает их с диска, а не из строковых литералов или хостед prompt-UI.
  3. Напишите функцию хеша выше и логируйте хеш с каждым прогоном агента.
  4. Держите 30-50 реальных кейсов в JSONL и скрипт, который прогоняет их через тот же загрузчик, что и прод. Это уже настоящий eval-сьют.

Это примерно полдня работы. И она превращает “агент как-то хуже стал” из вопроса без ответа в diff между двумя хешами. Всё остальное в этой статье - надстройка над этим фундаментом.

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

FAQ

Вопросы и ответы

Закрепление модели означает, что я буду сидеть на устаревшем снапшоте. Как не отстать?

Апгрейд модели становится обычной задачей в бэклоге, а не случайным событием. Я держу в CI отдельную nightly-джобу, которая гоняет тот же eval-сьют на новых доступных снапшотах и пишет отчёт в чат. Если новый снапшот выигрывает, я меняю одну строку в манифесте, получаю новый хеш, прогоняю канарейку и раскатываю. Разница в том, что переход происходит когда я к нему готов, а не в ночь с пятницы на субботу по решению провайдера.

Что делать, если база знаний обновляется каждый час и снапшотить её нереально?

Снапшот нужен не всей базе, а eval-прогону. Минимальный вариант: добавьте в документы поле ingested_at и научите retrieval принимать фильтр as_of. Тогда манифест фиксирует момент времени, evals воспроизводимы, а прод работает с актуальным индексом. Дополнительно я гоняю отдельный набор кейсов по свежему индексу раз в сутки: он ловит не регрессии агента, а мусор в контенте, вроде двух противоречащих регламентов. Это два разных вида проверок, не смешивайте их.

У меня Telegram-бот и пара BAS-сценариев, а не мультиагентная платформа. Это не оверинжиниринг?

Полный манифест с fallback и канарейкой - да, для бота это перебор. Но три вещи стоят своих тридцати минут даже на самом маленьком проекте: закреплённый снапшот модели, промпты и схемы тулов в git-файлах вместо литералов, и логирование хеша артефакта рядом с каждым ответом. Именно третий пункт спасает, когда через два месяца пользователь пришлёт скриншот странного ответа: вы сразу увидите, какая версия агента его породила, вместо того чтобы гадать по датам коммитов.

Похожие статьи