Инженерия AI-агентов
Воспроизводимый replay AI-агента: как расследовать сбой без повторного вызова внешних систем
Повторный запуск агентной сессии — это не воспроизведение. Он заново читает изменившиеся данные, обращается к модели и способен повторно отправить письмо, создать задачу или списать ресурс. Для расследования нужен журнал фактического выполнения и режим, в котором внешние эффекты заменены записанными результатами.
1. Что именно воспроизводить
AI-агент в рабочей сессии принимает вход, строит запрос к модели, интерпретирует ответ, вызывает инструменты и обновляет локальное состояние. Ошибка может возникнуть на любой границе. Поэтому одного лога сообщений недостаточно: он не объяснит, какой набор инструментов был доступен, что вернул API и какое состояние агент увидел перед решением.
Полезно разделить выполнение на два слоя:
- Детерминированная оболочка: маршрутизация, проверка аргументов, редьюсер состояния, лимиты и обработка ошибок. Ее можно выполнять повторно.
- Недетерминированные границы: модель, HTTP API, база данных, часы, генератор случайных значений, файловая система и очереди. Их результаты нужно записывать и подставлять при replay.
Цель replay — не получить «похожий» ответ новой модели, а провести тот же поток через оболочку с теми же наблюдаемыми входами. Внешний вызов во время воспроизведения считается ошибкой изоляции.
2. Подготовьте формат журнала событий
Храните события в JSON Lines: одно завершенное событие на строку. Такой файл можно дописывать без перезаписи целого документа, читать потоково и обрабатывать стандартными утилитами. Следующая схема — пример, а не обязательный стандарт:
{
"schema_version": 1,
"session_id": "sess_example_01",
"seq": 17,
"event_id": "evt_example_17",
"parent_event_id": "evt_example_16",
"kind": "tool.result",
"recorded_at": "2026-01-15T10:24:31.482Z",
"logical_time_ms": 842,
"actor": "tool:issue_tracker.get",
"correlation_id": "call_example_04",
"payload": {
"status": "ok",
"output": {
"issue_id": "EXAMPLE-42",
"state": "open"
}
},
"error": null,
"meta": {
"agent_build": "git-sha-or-build-id",
"model_route": "configured-model-alias",
"tool_contract_hash": "sha256:example",
"payload_hash": "sha256:example"
}
}
Идентификаторы и значения выше демонстрационные. В рабочей системе не подставляйте в учебный файл реальные токены, адреса клиентов или содержимое закрытых задач.
Обязательные поля
schema_version- Версия формата, чтобы старые записи можно было мигрировать явно.
session_idиseq- Идентификатор сессии и монотонный порядковый номер. Порядок нельзя восстанавливать только по часам.
kind- Тип события: вход, запрос или ответ модели, вызов или результат инструмента, изменение состояния, ошибка либо финальный результат.
correlation_id- Связывает запрос с результатом. Особенно важен при параллельных инструментах.
payload- Данные, которые действительно увидел следующий этап, уже после нормализации адаптером.
meta- Версия сборки, конфигурации, контракта инструмента и контрольные суммы значимых данных.
Минимальная последовательность типов событий:
session.started
input.received
model.requested
model.responded
tool.requested
tool.responded
state.updated
output.produced
session.completed | session.failed
Для потоковой модели фиксируйте либо все чанки, либо уже собранный ответ, который реально поступил парсеру. Не смешивайте подходы внутри одной версии схемы.
Отделите журнал от секретов
Редактирование чувствительных данных выполняйте до записи на диск, но сохраняйте структурную форму аргументов. Например, заголовок авторизации заменяется маркером, а не удаляется целиком:
{
"headers": {
"authorization": {"redacted": true},
"content-type": "application/json"
}
}
Если значение влияет на ветвление, одной маски недостаточно. Сохраните стабильный HMAC или категорию, например "tenant_tier": "enterprise", при этом ключ HMAC держите вне журнала. Обычный SHA-256 для коротких предсказуемых секретов не обеспечивает надежной защиты от перебора.
3. Запишите рабочую сессию на границах
Не размазывайте логирование по бизнес-логике. Оберните каждую недетерминированную зависимость единым recorder-адаптером:
async function invokeRecorded(ctx, boundary, args) {
const callId = ctx.ids.next();
await ctx.journal.append({
kind: `${boundary.kind}.requested`,
correlation_id: callId,
payload: {
name: boundary.name,
args: redact(args)
}
});
try {
const raw = await boundary.invoke(args);
const normalized = boundary.normalize(raw);
await ctx.journal.append({
kind: `${boundary.kind}.responded`,
correlation_id: callId,
payload: {
name: boundary.name,
status: "ok",
output: redact(normalized)
}
});
return normalized;
} catch (cause) {
const error = normalizeError(cause);
await ctx.journal.append({
kind: `${boundary.kind}.responded`,
correlation_id: callId,
payload: {
name: boundary.name,
status: "error"
},
error
});
throw restoreDomainError(error);
}
}
Это псевдокод. Существенны три свойства: запрос записан до вызова, результат нормализован так же, как в рабочем выполнении, а исключение сохранено в переносимом формате — с типом, кодом, сообщением и признаком повторяемости.
Отдельно перехватите скрытые источники расхождения:
- текущее время и таймзону;
- случайные числа, UUID и seed;
- порядок результатов параллельных операций;
- список доступных инструментов и их JSON Schema;
- системный промпт, параметры модели и правила маршрутизации;
- начальное состояние памяти, feature flags и лимиты;
- тайм-ауты, отмену операции и политику повторных попыток.
После каждой записи вычисляйте хеш канонического представления события. Для защиты от удаления или перестановки строк добавьте цепочку previous_hash. Хеш помогает обнаружить изменение, но сам по себе не доказывает происхождение файла; для этого требуется подпись или доверенное неизменяемое хранилище.
4. Реализуйте изолированный режим replay
Replay-адаптер не вызывает настоящую зависимость. Он извлекает следующее ожидаемое событие, сопоставляет имя операции и аргументы, затем возвращает записанный результат или воспроизводит записанную ошибку.
class ReplayBoundary {
constructor(tape) {
this.tape = tape;
}
async invoke(actualRequest) {
const expected = this.tape.takeNext();
assertEqual(expected.name, actualRequest.name);
assertCanonicalEqual(expected.args, redact(actualRequest.args));
if (expected.status === "error") {
throw restoreDomainError(expected.error);
}
return deepClone(expected.output);
}
}
Сопоставляйте вызовы строго, но осмысленно. Перед сравнением канонизируйте JSON: сортируйте ключи объектов, приводите даты к одному формату и исключайте только заранее объявленные нестабильные поля. Не игнорируйте аргументы целиком — иначе replay «успешно» подставит ответ от другого запроса.
Конфигурация с закрытой сетью
Пример конфигурации процесса воспроизведения:
mode: replay
session_file: ./fixtures/session-example.jsonl
boundaries:
model: tape
tools: tape
clock: tape
random: tape
network:
policy: deny
allow_hosts: []
writes:
workspace: ./tmp/replay
external_datastores: deny
matching:
order: strict
arguments: canonical-json
unconsumed_events: error
Флаг mode: replay недостаточен, если продуктивный клиент все еще можно создать через контейнер зависимостей. Сборка replay должна получать только заглушки. Дополнительно запрещайте сеть на уровне процесса или контейнера.
Безопасный локальный запуск
Ниже — пример команды для проекта, в котором уже существует собственный исполняемый файл agent-replay. Имена файла и параметров условны:
mkdir -p ./tmp/replay
env -i \
PATH="$PATH" \
AGENT_MODE=replay \
REPLAY_NETWORK=deny \
REPLAY_TAPE=./fixtures/session-example.jsonl \
REPLAY_WORKDIR=./tmp/replay \
./bin/agent-replay --strict
env -i уменьшает риск случайно передать процессу рабочие учетные данные. Перед применением проверьте, какие переменные действительно нужны вашему рантайму. Не добавляйте API-ключи в replay-окружение даже «на всякий случай».
Для контейнеризированного инструмента эквивалентная защита обычно выглядит так:
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
-v "$PWD/fixtures:/replay/fixtures:ro" \
-v "$PWD/tmp/replay:/replay/output:rw" \
agent-replay-image:local \
--tape /replay/fixtures/session-example.jsonl \
--output /replay/output \
--strict
Это пример безопасного шаблона, а не готовая команда для неизвестного образа. Используйте локально собранный и проверенный образ; не подставляйте случайный образ из публичного реестра.
5. Проверьте, что сбой действительно воспроизведен
Успешный replay — не просто завершение с тем же кодом. Зафиксируйте проверяемые критерии:
- Журнал полностью потреблен. Не осталось результатов, которые новая траектория не запросила.
- Все обращения совпали. Имя, порядок и канонические аргументы каждого вызова соответствуют записи.
- Внешних обращений не было. Сетевая политика не зафиксировала попыток соединения, а продуктивные адаптеры не создавались.
- Состояние совпало. Хеш финального нормализованного состояния равен ожидаемому.
- Сбой совпал семантически. Сравниваются доменный тип, код и точка возникновения, а не нестабильный стек или абсолютные пути.
Пример отчета, который должен выдавать ваш runner:
REPLAY RESULT: reproduced
session: sess_example_01
events consumed: 28/28
boundary calls matched: 6/6
unexpected network attempts: 0
final state hash: matched
failure: ToolOutputValidationError at seq=24
exit code: 0
Здесь код 0 означает, что расследуемый сбой был ожидаемо воспроизведен. Если runner не различает «сбой агента воспроизведен» и «сломался сам replay», автоматизация будет давать ложные тревоги. Удобно определить отдельные коды: например, несовпадение ленты, нарушение изоляции и внутренняя ошибка runner. Конкретные значения — решение вашего проекта.
Контрольная негативная проверка
В копии фикстуры измените один безопасный демонстрационный аргумент, например идентификатор тестовой сущности. Строгий replay обязан остановиться на первом несовпадении и показать ожидаемое и фактическое значения после редактирования секретов. Если выполнение продолжается, механизм сопоставления слишком мягкий.
Типовые ошибки
Повторный вызов модели в режиме replay
Даже при температуре 0 результат нельзя считать гарантированно идентичным: могут измениться версия модели, инфраструктура, системные инструкции или маршрутизация. Записывайте ответ модели, который получил парсер. Отдельный «re-simulation» с новой моделью полезен для экспериментов, но это не детерминированный replay.
Записан ответ API, но не записан запрос
Тогда невозможно доказать, что ответ относится к тому же вызову. Сохраняйте нормализованные аргументы и связывайте пару через correlation_id.
Mock возвращает идеальный объект
Реальный адаптер мог вернуть отсутствующее поле, неожиданный порядок, частичный ответ или доменную ошибку. Replay должен отдавать фактически записанный нормализованный результат, включая дефекты, повлиявшие на выполнение.
Параллельность сведена к последовательному списку
Причиной сбоя может быть гонка. Сохраняйте начало и завершение каждого вызова, причинные связи и логическое время. Для первого расследования можно воспроизвести наблюдавшийся порядок завершения; проверку других допустимых порядков проводите отдельно.
Секреты удалены после записи
Это оставляет окно утечки в логах и резервных копиях. Редактируйте данные до сериализации, ограничивайте доступ к журналам и задавайте срок хранения.
Replay незаметно обращается к сети
Запрет должен быть многоуровневым: заглушки в контейнере зависимостей, отсутствие учетных данных и сетевая изоляция процесса. Один условный оператор в коде не является достаточной границей безопасности.
Фикстура обновляется автоматически
Автоматическое перезаписывание способно превратить регрессию в «новую норму». Обновляйте ленту только явной командой, показывайте diff и проводите ревью изменений.
Ограничения подхода
- Replay доказывает поведение на одном записанном пути, но не корректность агента для других входов.
- Если нужное внутреннее состояние не попало в журнал, восстановить его задним числом невозможно.
- Записанный успешный ответ не проверяет, работает ли внешняя интеграция сейчас. Для этого нужны отдельные контрактные и интеграционные проверки в безопасной среде.
- Изменение схемы инструмента или редьюсера может сделать старую ленту несовместимой. Нужны версионированные миграции либо запуск старой сборки.
- Побочные эффекты, выполненные до сбоя, replay не откатывает. Их расследуют по идентификаторам идемпотентности и журналам самой интеграции.
- Полная запись повышает стоимость хранения и риск утечки данных. Объем, редактирование и срок хранения следует определить в модели угроз.
Краткий план внедрения
- Определите все недетерминированные границы агента.
- Введите версионированный JSONL-формат и строгую последовательность событий.
- Записывайте запрос и нормализованный результат каждой границы.
- Редактируйте секреты до записи и добавьте контроль целостности.
- Создайте replay-адаптеры, которые физически не умеют вызывать внешние системы.
- Запретите сеть и передачу рабочих учетных данных на уровне окружения.
- Проверяйте совпадение вызовов, полное потребление ленты и финальное состояние.
- Храните фикстуры неизменяемо и обновляйте их только через ревью.
После этого расследование перестает зависеть от текущего состояния интеграций: команда может повторить исходную траекторию, локализовать первое расхождение и проверить исправление на той же записи. Другие материалы по проектированию и эксплуатации агентов собраны в руководствах, а определения терминов — в глоссарии.