Практика · тестирование агентов
Воспроизводимые мультиагентные сценарии и property-based testing

Property-based testing проверяет не отдельный заранее придуманный пример, а общее свойство системы на множестве сгенерированных входов. Для мультиагентных сценариев этот подход особенно полезен: ошибки в формулах, порядке событий и обработке конфликтов часто проявляются только на редких комбинациях состояний.
Проблема начинается с безобидной демонстрации. Агент выбирает действие через случайность, несколько агентов одновременно меняют общее состояние, а итоговый лог зависит от времени или порядка обхода коллекции. Повторный запуск с тем же входом уже не гарантирует тот же результат. В такой системе трудно отличить исправление от случайного удачного прогона.
Цель статьи — собрать минимальный детерминированный движок: все источники вариативности контролируются, состояние обновляется через явные события, а тесты проверяют инварианты. Код ниже — учебный пример архитектуры, а не утверждение о конкретном production-стеке.
1. Зафиксируйте модель выполнения
Начните с контракта, который не зависит от конкретной LLM или фреймворка:
- вход сценария содержит начальное состояние, список агентов, seed и лимит шагов;
- агент получает снимок состояния и возвращает действие;
- движок валидирует действие и применяет его в одном месте;
- каждый шаг добавляет событие в журнал;
- результат содержит финальное состояние и полный журнал.
Важно разделить принятие решения и изменение состояния. Агент не должен напрямую увеличивать баланс, удалять другого агента или менять глобальный объект. Он предлагает команду, а движок решает, допустима ли она.
type Scenario = {
seed: number
maxSteps: number
state: State
agents: Agent[]
}
type Action =
| { type: "move"; agentId: string; dx: number; dy: number }
| { type: "transfer"; from: string; to: string; amount: number }
| { type: "wait"; agentId: string }
type Event = {
step: number
agentId: string
action: Action
accepted: boolean
reason?: string
}
2. Сделайте случайность явной
Не вызывайте глобальный генератор случайных чисел из логики агента. Передайте ему объект генератора, созданный из seed. Тогда seed становится частью входа, а не скрытой настройкой окружения.
function createRng(seed: number) {
let value = seed >>> 0
return {
next(): number {
value = (value * 1664525 + 1013904223) >>> 0
return value / 0x100000000
},
pick<T>(items: T[]): T {
if (items.length === 0) {
throw new Error("Cannot pick from an empty list")
}
return items[Math.floor(this.next() * items.length)]
}
}
}
Алгоритм здесь показан только как пример простого детерминированного генератора. Для production-системы выбирайте генератор осознанно и фиксируйте его реализацию или версию. Нельзя считать seed достаточным, если в коде остаются вызовы системного времени, UUID, сетевые ответы или обход неупорядоченной коллекции.
Практический приём — передавать агенту не только основной генератор, но и независимый поток случайности. Например, поток для выбора действия не должен сдвигаться из-за добавленного debug-логирования.
const rng = createRng(scenario.seed)
for (const agent of orderedAgents(scenario.agents)) {
const action = agent.decide(snapshot, rng)
pending.push(action)
}
3. Определите порядок событий
Одновременность в симуляции нужно описать явно. Один из безопасных вариантов — двухфазный шаг:
- движок строит неизменяемый снимок состояния;
- все агенты предлагают действия на основе этого снимка;
- действия сортируются по детерминированному ключу;
- движок последовательно проверяет и применяет их;
- в журнал записываются как принятые, так и отклонённые действия.
function runStep(state: State, agents: Agent[], step: number, rng: Rng) {
const snapshot = deepFreeze(clone(state))
const proposals = agents
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.map(agent => ({
agentId: agent.id,
action: agent.decide(snapshot, rng)
}))
const events: Event[] = []
for (const proposal of proposals) {
const result = applyAction(state, proposal.action)
events.push({
step,
agentId: proposal.agentId,
action: proposal.action,
accepted: result.accepted,
reason: result.reason
})
}
return events
}
Функция applyAction должна быть единственной точкой, где меняется состояние. Это упрощает аудит: если баланс стал отрицательным, искать нарушение нужно в валидаторе или в этой функции, а не в каждом агенте.
4. Сформулируйте инварианты
Инвариант — условие, которое должно оставаться истинным после каждого шага и после завершения сценария. Хорошие инварианты короткие, проверяемые и не зависят от конкретного ожидаемого лога.
- баланс каждого агента неотрицателен;
- сумма балансов не меняется при переводе без комиссии;
- координаты находятся внутри заданного поля;
- в журнале нет событий с неизвестным агентом;
- отклонённое действие не меняет состояние;
- один и тот же seed и вход дают одинаковый результат;
- число шагов не превышает
maxSteps.
Проверяйте инварианты не только в конце. Если ошибка появилась на третьем шаге, проверка после завершения может показать лишь косвенное следствие.
function assertInvariants(state: State, events: Event[]) {
for (const account of Object.values(state.accounts)) {
if (account.balance < 0) {
throw new Error("Negative balance")
}
}
const knownAgents = new Set(state.agents.map(agent => agent.id))
for (const event of events) {
if (!knownAgents.has(event.agentId)) {
throw new Error("Unknown agent in event log")
}
}
}
5. Добавьте property-based testing
Обычный example-based тест отвечает на вопрос: «работает ли этот конкретный сценарий?». Property-based тест отвечает на вопрос: «сохраняется ли свойство для множества допустимых сценариев?». Он генерирует состояния, действия и параметры, а при сбое пытается свести вход к минимальному контрпримеру.
Библиотека зависит от языка. Поэтому ниже важен не конкретный API, а структура теста:
property("replay is deterministic", arbitraryScenario(), scenario => {
const first = run(scenario)
const second = run(scenario)
expect(second).toEqual(first)
})
property("rejected actions do not mutate state", arbitraryScenario(), scenario => {
const before = clone(scenario.state)
const result = run(scenario)
for (const event of result.events) {
if (!event.accepted) {
// В реальном тесте сравнивайте снимок до и после
// конкретной операции применения.
expect(event.reason).toBeDefined()
}
}
expect(result.state).toSatisfy(assertInvariants)
})
property("total balance is conserved", arbitraryScenario(), scenario => {
const before = totalBalance(scenario.state)
const result = run(scenario)
const after = totalBalance(result.state)
expect(after).toBe(before)
})
Генераторы должны создавать только валидные входы, если тест проверяет бизнес-свойство. Отдельно полезно иметь генераторы повреждённых или пограничных данных для проверки отказоустойчивости: нулевые суммы, пустые списки, повторяющиеся идентификаторы и превышение лимита шагов.
Когда тест падает, сохраняйте seed и минимизированный сценарий. Seed позволяет повторить сбой, а уменьшенный вход помогает понять формулу или переход состояния.
6. Воспроизводимые шаги запуска
Ниже приведён безопасный шаблон команд. Он не устанавливает пакеты глобально, не обращается к внешним сервисам и предполагает, что команды проекта уже описаны в его конфигурации.
# 1. Установить зависимости в локальное окружение проекта
npm ci
# 2. Запустить обычные тесты
npm test
# 3. Запустить property-based тесты с фиксированным seed
npm run test:properties -- --seed=20261101
# 4. Повторить один найденный контрпример
npm run test:properties -- --seed=20261101 --case=failed-case.json
Если проект использует другой менеджер пакетов, замените команды на эквивалентные из документации репозитория. Не вставляйте в команды токены, ключи API и содержимое конфигурации с секретами. Для тестового движка лучше использовать полностью локальные заглушки: фиксированные агенты, заранее заданные ответы и локальный журнал событий.
Хороший CI-пайплайн запускает два режима:
- быстрый режим с небольшим числом примеров на каждый коммит;
- расширенный режим с большим числом примеров по расписанию или перед релизом.
В обоих режимах при падении нужно выводить seed, номер примера, входной сценарий и первый шаг, на котором нарушился инвариант.
7. Проверка результата
Детерминированный движок можно считать проверенным, если выполнены все четыре проверки:
- одинаковый вход запускается дважды и даёт побайтно одинаковый сериализованный результат;
- изменение seed меняет только ожидаемую случайную часть поведения;
- каждый шаг проходит проверку инвариантов;
- ошибка воспроизводится по сохранённому seed и контрпримеру.
Сериализацию тоже фиксируйте. Сортируйте ключи объектов или используйте формат, который гарантирует стабильный порядок. Иначе два одинаковых состояния могут отличаться только порядком полей, а сравнение результата будет давать ложный сигнал.
const output = stableStringify({
state: result.state,
events: result.events
})
writeFileSync("artifacts/run.json", output + "\n")
Для диагностики полезно разделять три отпечатка: входной сценарий, состояние после каждого шага и итоговый результат. Так проще локализовать расхождение между двумя прогонами.
Типовые ошибки
Seed есть, но результат всё равно меняется
Проверьте время, UUID, сетевые ответы, параллельные задачи и порядок обхода коллекций. Случайность часто скрыта не в агенте, а в вспомогательном коде.
Тест сравнивает только финальное состояние
Два разных пути могут привести к одному финалу. Сравнивайте журнал событий, если порядок и причины решений важны для сценария.
Агенты видят изменения друг друга внутри шага
Это создаёт зависимость от порядка вызовов. Передавайте всем агентам один снимок, а действия применяйте после фазы предложений.
Проверяется слишком много деталей реализации
Жёсткие ожидания по каждому случайному выбору делают тесты хрупкими. Оставьте точные сценарии для критических примеров, а общие свойства проверяйте инвариантами.
Случайные тесты не сохраняют контрпример
Без seed и входных данных падение превращается в «иногда не работает». Сохраняйте минимальный контрпример как артефакт CI.
Ограничения подхода
Детерминизм не доказывает корректность модели. Он лишь делает поведение повторяемым. Если инвариант сформулирован неверно, система будет стабильно проходить неправильную проверку.
Property-based testing также не заменяет тестирование интеграций, производительности и реальных отказов внешних систем. Его сильная сторона — исследование пространства состояний локального движка. Для LLM-агентов дополнительно придётся контролировать модель, параметры генерации, версии промптов и ответы инструментов. Иначе seed приложения не сделает внешний вызов детерминированным.
Наконец, слишком сложный генератор может скрывать саму причину сбоя. Генераторы должны быть читаемыми, а свойства — независимыми и небольшими.
Итог
Воспроизводимые мультиагентные сценарии и property-based testing начинаются не с выбора библиотеки, а с архитектурного контракта: явный seed, изолированная случайность, снимок состояния, детерминированный порядок событий и единая точка изменения состояния.
Когда поверх этого контракта появляются инварианты и сохранённые контрпримеры, мультиагентное демо превращается в проверяемый движок. Ошибка перестаёт быть случайным наблюдением: её можно повторить, локализовать и исправить.