Продвинутая практика
Детерминированное воспроизведение сбоев AI-агента по журналу событий
Многошаговый агент падает сегодня, а завтра тот же запрос проходит: модель обновилась, API вернул другие данные, часы ушли вперёд, состояние памяти изменилось. Исправлять такой сбой по одному стек-трейсу почти бесполезно. Нужна запись исполнения, которую можно повторить без живых зависимостей.
Что именно должно воспроизводиться
Детерминированное воспроизведение — это повторный запуск, при котором агент получает зафиксированные входы и наблюдения окружения, проходит те же переходы состояния и выдаёт проверяемую последовательность событий.
Цель replay-контура — не заставить современную модель дословно повторить старый ответ. Это часто невозможно даже при одинаковых параметрах. Цель — отделить уже состоявшееся исполнение от изменчивых зависимостей и локализовать точку расхождения.
Для каждого шага полезно сохранять четыре слоя:
- Вход: запрос пользователя, системные инструкции, контекст и версия схемы.
- Решение: ответ модели или структурированный вызов инструмента.
- Наблюдение: точный ответ инструмента, код ошибки или тайм-аут.
- Состояние: состояние до и после редьюсера, а также их контрольные суммы.
Если сохранить только сообщения модели, агент останется невоспроизводимым: решение следующего шага обычно зависитело от скрытого состояния, порядка результатов, времени и ответов внешних систем.
Минимальная архитектура replay-контура
пользовательский вход
│
▼
оркестратор агента ──────► журнал JSONL
│ input / model / tool / state
▼
адаптер инструментов
│
├── record: вызывает реальный инструмент и пишет результат
└── replay: возвращает записанный результат без сети
Оркестратор не должен напрямую обращаться к HTTP-клиенту, часам, генератору UUID или файловой системе. Каждую такую зависимость следует пропустить через адаптер с двумя режимами: record и replay.
Шаг 1. Введите неизменяемый формат события
Ниже приведён пример формата, а не обязательный стандарт. JSONL удобен тем, что каждое событие занимает отдельную строку и журнал можно дописывать последовательно.
{
"schema_version": 1,
"run_id": "run-example-001",
"seq": 7,
"kind": "tool.result",
"step_id": "step-03",
"recorded_at": "2026-01-15T12:00:00Z",
"payload": {
"call_id": "call-02",
"tool": "inventory.lookup",
"arguments": {"sku": "EXAMPLE-42"},
"result": {"available": 3},
"error": null
},
"prev_event_hash": "sha256:…",
"event_hash": "sha256:…"
}
Поле seq задаёт порядок независимо от точности часов. step_id связывает решение, вызов и изменение состояния. schema_version позволяет мигрировать старые записи. Цепочка хешей помогает обнаружить удаление, перестановку или изменение событий, но сама по себе не подтверждает происхождение журнала.
Перед хешированием объект нужно сериализовать канонически: одинаковая кодировка UTF-8, стабильный порядок ключей и единое представление чисел. Хеш от обычного JSON.stringify нельзя считать переносимым контрактом, пока правила сериализации явно не зафиксированы.
Шаг 2. Записывайте границы недетерминизма
Сделайте явными все значения, способные измениться между запусками:
- ответы модели, включая структурированные вызовы и причину завершения;
- ответы, статусы и нормализованные ошибки инструментов;
- текущее время, часовой пояс и рассчитанные дедлайны;
- UUID, случайные числа и выбранные ветви;
- результаты поиска, порядок элементов и пагинацию;
- версии промптов, моделей, инструментов и схем;
- начальное состояние памяти и состояние после каждого перехода.
Секреты записывать нельзя. До сохранения удаляйте токены, cookie, заголовки авторизации, персональные данные и неограниченные тела ответов. Лучше применять разрешающий список полей, чем пытаться найти все опасные значения постфактум.
{
"capture": {
"request_fields": ["method", "path", "query", "body"],
"response_fields": ["status", "body"],
"max_body_bytes": 262144
},
"redaction": {
"drop_headers": [
"authorization",
"cookie",
"set-cookie",
"x-api-key"
],
"replace_json_paths": [
"$.access_token",
"$.refresh_token",
"$.password"
]
}
}
Это пример конфигурации. Реальная схема должна соответствовать вашему прокси или адаптеру. После редактирования конфигурации проверьте её на искусственном запросе с заведомо фиктивным маркером секрета.
Шаг 3. Разделите модель на запись и воспроизведение
В режиме record шлюз модели сохраняет нормализованный запрос и полный ответ, необходимый оркестратору. В режиме replay он не вызывает провайдера, а извлекает следующее подходящее событие из журнала.
async function callModel(request, ctx) {
const key = stableHash(normalizeModelRequest(request));
if (ctx.mode === "replay") {
return ctx.tape.consume({
kind: "model.response",
requestHash: key
}).payload.response;
}
const response = await ctx.liveModel.call(request);
await ctx.journal.append({
kind: "model.response",
stepId: ctx.stepId,
payload: {
requestHash: key,
response: normalizeModelResponse(response)
}
});
return response;
}
Сопоставлять запись только по порядковому номеру рискованно: лишний вызов сдвинет весь журнал. Проверяйте одновременно тип события, идентификатор шага и хеш нормализованного запроса. При несовпадении replay должен немедленно завершаться ошибкой расхождения, а не брать «похожий» ответ.
Шаг 4. Подмените инструменты записанными ответами
Адаптер инструмента применяет тот же принцип. Ключ вызова строится из имени инструмента и канонизированных аргументов. Воспроизводить нужно также ошибки и тайм-ауты: они являются наблюдениями агента, а не шумом инфраструктуры.
async function invokeTool(name, args, ctx) {
const callKey = stableHash({name, args: canonicalize(args)});
if (ctx.mode === "replay") {
const event = ctx.tape.consume({
kind: "tool.result",
callKey
});
if (event.payload.error) {
throw restoreRecordedError(event.payload.error);
}
return event.payload.result;
}
try {
const result = await ctx.tools[name](args);
await recordToolResult(ctx, callKey, name, args, result, null);
return result;
} catch (error) {
const safeError = normalizeError(error);
await recordToolResult(ctx, callKey, name, args, null, safeError);
throw restoreRecordedError(safeError);
}
}
В replay-режиме запретите исходящую сеть на уровне процесса или контейнера. Тогда случайный обход адаптера проявится сразу, а не вызовет реальное действие. Не воспроизводите команды с побочными эффектами против рабочих систем.
Шаг 5. Сделайте переходы состояния проверяемыми
Полезно представить изменение состояния чистой функцией:
nextState = reduce(previousState, event)
После каждого перехода записывайте хеш состояния. Полный снимок можно хранить реже, например через каждые 20 событий, а между снимками — только события и контрольные суммы. Это пример компромисса: подходящая частота зависит от размера состояния и стоимости диагностики.
{
"kind": "state.transition",
"step_id": "step-03",
"payload": {
"cause_seq": 7,
"before_hash": "sha256:…",
"after_hash": "sha256:…",
"reducer_version": "agent-state-v4"
}
}
Не включайте в логический хеш поля, которые не влияют на поведение: время записи лога, длительность операции, локальный путь или идентификатор процесса. Иначе корректное воспроизведение будет выглядеть как расхождение.
Шаг 6. Зафиксируйте окружение
Один журнал не компенсирует изменение кода. Для каждого запуска сохраните ссылку на точную ревизию приложения и декларативное описание окружения:
{
"code_revision": "EXAMPLE_COMMIT_SHA",
"runtime": "node-example-version",
"lockfile_hash": "sha256:…",
"prompt_bundle_hash": "sha256:…",
"tool_schema_hash": "sha256:…",
"locale": "ru-RU",
"timezone": "UTC",
"feature_flags": {
"planner_v2": true
}
}
Значения с префиксом EXAMPLE здесь являются заполнителями. Не подставляйте в журнал секретные переменные окружения. Для зависимостей храните lock-файл и его хеш; для контейнеров предпочтительнее фиксировать неизменяемый digest образа, а не плавающий тег.
Шаг 7. Запустите replay в изолированном режиме
Интерфейс запуска должен требовать явного режима и пути к журналу. Следующие команды иллюстрируют безопасную локальную проверку и не обращаются к внешним системам сами по себе:
export AGENT_MODE=replay
export AGENT_NETWORK=disabled
export AGENT_CLOCK=recorded
./agent-debug replay \
--journal ./fixtures/run-example-001.jsonl \
--strict \
--verify-hash-chain \
--fail-on-unused-events
Флаги в примере условные: реализуйте эквивалентные проверки в своём раннере. Перед запуском убедитесь, что указанный файл не содержит производственных секретов и что процесс действительно лишён сетевого доступа.
Строгий режим должен остановить выполнение при первом несовпадении:
Replay divergence at seq=12
expected: tool.result callKey=sha256:aaa…
actual: tool.call callKey=sha256:bbb…
state before: sha256:ccc…
step: step-05
Такой отчёт полезнее финального сообщения «результаты различаются»: он показывает первую причинную границу, после которой остальные отличия могут быть лишь следствиями.
Проверка результата
Контур можно считать работоспособным, если выполняются все следующие условия:
- Replay завершается при отключённой сети.
- Ни модель, ни реальные инструменты не получают запросов.
- Проверка цепочки хешей проходит для исходного журнала.
- Все записанные события потреблены ровно один раз.
- Хеш каждого логического состояния совпадает с записью.
- Искусственное изменение одного аргумента вызывает остановку на соответствующем шаге.
- Искусственное удаление события определяется до завершения исполнения.
Последние две проверки проводите только на копии журнала. Например, создайте отдельный fixture, измените безопасное тестовое поле и убедитесь, что раннер сообщает конкретный seq, ожидаемое событие и фактический запрос.
Как локализовать настоящий дефект
После точного replay запустите сравнительные режимы по одному, не меняя всё одновременно:
- Полный replay: записаны и модель, и инструменты. Проверяет оркестратор и переходы состояния.
- Новая модель, старые инструменты: показывает, изменилось ли решение модели на тех же наблюдениях.
- Старая модельная запись, новые инструменты: выявляет изменение контрактов и данных инструментов.
- Новая версия кода, старый журнал: проверяет исправление регрессии.
Второй и третий режимы уже не являются строго детерминированным воспроизведением. Это контролируемые эксперименты, в которых изменяется ровно одна группа зависимостей.
Типовые ошибки
Записывать только финальный ответ
Финал не показывает, какое наблюдение изменило план. Записывайте границы каждого шага и состояние до и после него.
Повторно вызывать внешние API
Такой запуск проверяет текущее окружение, а не воспроизводит прошлое. В строгом replay сетевые адаптеры должны быть недоступны.
Сопоставлять вызовы только по порядку
Один новый вызов смещает всю ленту. Добавляйте хеш нормализованных аргументов, тип события и идентификатор шага.
Считать seed полной гарантией
Seed полезен только в среде, которая обещает соответствующую воспроизводимость. Версия модели, оборудование или реализация семплирования могут измениться. Для точного replay используйте записанный ответ модели.
Хешировать нестабильные поля
Случайный UUID или время логирования создадут ложное расхождение. Разделяйте логическое состояние и диагностические метаданные.
Без ограничений сохранять тела запросов
Журнал быстро превращается в хранилище секретов и персональных данных. Введите разрешённые поля, редактирование, лимит размера и срок хранения до включения записи.
Молча пропускать неизвестное событие
Это скрывает несовместимость схем. Неизвестная версия или тип события должны приводить к явной ошибке либо проходить через проверенную миграцию.
Ограничения
Replay подтверждает поведение на зафиксированной траектории, но не доказывает корректность агента на других входах. Он также не воспроизводит гонки потоков автоматически: для конкурентного агента придётся фиксировать порядок планирования, блокировок и доставки сообщений.
Ответы нельзя безопасно переиспользовать бесконечно. Старый журнал может содержать данные, которые уже нельзя хранить по внутренним правилам. Нужны срок жизни, контроль доступа, аудит чтения и процедура удаления.
При изменении схем инструментов старые записи могут стать несовместимыми. Миграция должна быть версионированной и сохранять исходный fixture. Если преобразование меняет смысл данных, корректнее оставить старый раннер рядом со старым журналом.
Наконец, детерминированный replay не заменяет трассировку рабочего контура, тестирование контрактов и оценку качества модели. Он отвечает на более узкий, но важный вопрос: «Почему именно этот запуск прошёл именно по этой ветви?»
Короткий рабочий чек-лист
- Единый JSONL-журнал с версией схемы и последовательными номерами.
- Адаптеры
record/replayдля модели, инструментов, часов и случайности. - Канонизация данных перед вычислением хешей.
- Снимки или контрольные суммы состояния после каждого перехода.
- Ревизия кода, lock-файл, версии промптов и схем инструментов.
- Редактирование секретов до записи, а не после неё.
- Строгий replay без сети и с остановкой на первом расхождении.
- Отчёт с номером события, шагом и ожидаемым запросом.
Другие практики проектирования и проверки агентов собраны в разделе «Руководства». Определения терминов, используемых в наблюдаемости и агентных системах, доступны в глоссарии.