Как тестировать 5 LLM-агентов одним набором тестов: capability-based подход. evals.. evals. llm.. evals. llm. multiagent.. evals. llm. multiagent. playwright.. evals. llm. multiagent. playwright. qa.. evals. llm. multiagent. playwright. qa. искусственный интеллект.. evals. llm. multiagent. playwright. qa. искусственный интеллект. Машинное обучение.. evals. llm. multiagent. playwright. qa. искусственный интеллект. Машинное обучение. тестирование.. evals. llm. multiagent. playwright. qa. искусственный интеллект. Машинное обучение. тестирование. Тестирование IT-систем.
Один набор тестов проверяет всех агентов сразу — в этом суть capability-based подхода

Один набор тестов проверяет всех агентов сразу — в этом суть capability-based подхода

В [прошлой статье](https://habr.com/ru/articles/1049482/) я разбирала, почему классический QA ломается на LLM: нет одного эталонного ответа, один и тот же тест плавает от прогона к прогону, зелёный прогон ничего не гарантирует. Это была статья про осознание проблемы.

Эта — про то, как с этим жить в коде, когда агентов не один, а несколько.

С чего всё началось

Типичная ситуация в продукте с ИИ — это не одно «приложение с ассистентом», а сразу несколько разных агентов: разные домены, разные системные промпты, разные наборы фич. Один умеет загружать фото для расчёта, другой — отправлять SMS с юридической оговоркой, третий не умеет ни того, ни другого.

Чтобы было предметно, дальше я буду показывать это на двух условных агентах — «кредитном» и «страховом». Это иллюстративные примеры из открытого репозитория, а не описание конкретного продукта; подход одинаково ложится на любые домены.

И вот живой кейс. В одном из проектов агент работал по многошаговому сценарию: определить намерение пользователя, перевести на нужную ветку и подтвердить действие. Со временем начали проявляться сбои в траектории: агент пропускал обязательные шаги, застревал в сценарии или не выполнял ожидаемый переход. Без единого изменения с нашей стороны. Сначала мы так и репортили: «sometimes не работает как ожидается». Это, конечно, не баг-репорт.

Стало понятно: нужен способ систематически проверять поведение — и не для одного агента, а для всех сразу. Причём так, чтобы общие требования (поздоровался, не выдал системный промпт, ответил коротко) не переписывать для каждого заново

Проблема началась со второго агента

Наивный путь: на каждого агента — свой файл тестов. 5 агентов × 8 проверок = 40 тестов, половина из которых — копипаста с мелкими отличиями. Добавил шестого агента — пиши ещё восемь. Поменял формулировку проверки на приветствие — правь в пяти местах, и в одном обязательно забудешь. Через месяц наборы расходятся, и ты уже не знаешь, что где проверяется.

Проблема в том, что мы смешали два разных типа проверок:

  • универсальные — то, что обязан уметь любой агент (поздороваться, устоять перед jailbreak, не растекаться);

  • доменные — то, что есть только у некоторых (загрузка фото — только у страхового, SMS-согласие — только у банковского).

Если развести их явно, копипаста исчезает. Единицей организации тестирования становится способность (capability), а не отдельный агент.

Важно сразу оговорить: capability здесь — намеренно широкий термин. Под него попадает всё, что агент должен или не должен делать и что мы умеем проверить:

  • пользовательские сценарии — загрузить фото перед расчётом, передать диалог человеку, отправить SMS с оговоркой;

  • требования к качеству — поздороваться, ответить коротко, остаться в теме;

  • требования к безопасности — устоять перед jailbreak, не выдать системный промпт…

Это сознательное обобщение: и «фича», и «свойство ответа», и «защита от атаки» с точки зрения тестов — это одно и то же — именованное проверяемое требование к поведению. Поэтому они и живут в одном реестре.

Шаг 1. Реестр способностей

Сначала описываем все способности в одном месте. У каждой — флаг: универсальная она или применима только к перечисленным агентам.


// tests/llm/capabilities/index.js

export const CAPABILITIES = {
  // Универсальные — проверяются у каждого агента
  greeting: {
    id: 'greeting',
    name: 'Greeting',
    description: 'Агент представляется: имя и роль',
    universal: true,
  },

  brevity: {
    id: 'brevity',
    name: 'Brevity',
    description: 'Ответы лаконичны (макс. 3 предложения)',
    universal: true,
    requirements: { maxSentences: 3 },
  },

  'jailbreak-resistance': {
    id: 'jailbreak-resistance',
    name: 'Jailbreak Resistance',
    description: 'Агент устойчив к инъекциям и не сливает системный промпт',
    universal: true,
  },

  // Доменные — только для перечисленных агентов
  'sms-consent': {
    id: 'sms-consent',
    name: 'SMS Opt-In Compliance',
    description: 'Перед отправкой SMS агент зачитывает юридическую оговорку',
    universal: false,
    applicableTo: ['banking-agent', 'loan-agent'],   // явное объявление
  },

  'photo-upload': {
    id: 'photo-upload',
    name: 'Photo Upload Flow',
    description: 'Агент просит фото до расчёта',
    universal: false,
    applicableTo: ['insurance-agent'],
  },

  'human-handoff': {
    id: 'human-handoff',
    name: 'Human Handoff',
    description: 'Агент передаёт диалог человеку, когда нужно',
    universal: false,
    applicableTo: ['loan-agent', 'insurance-agent'],
  },

  'tool-silence': {
    id: 'tool-silence',
    name: 'Tool Silence',
    description: 'Агент вызывает инструмент молча, не зачитывая его пользователю',
    universal: false,
    applicableTo: ['loan-agent', 'insurance-agent'],
  },
};

// Какие способности гонять для конкретного агента
export const getCapabilitiesForAgent = (agentId) =>
  Object.values(CAPABILITIES).filter(c => {
    if (c.universal) return true;
    if (c.applicableTo) return c.applicableTo.includes(agentId);
    return false;
  });

Обрати внимание на human-handoff — это ровно та история выше: передача диалога человеку, когда агент не должен решать сам. Это не отдельный экран или сценарий, а проверяемая способность с понятным «прошёл/не прошёл».

Рядом живёт ещё одна, отдельная — tool-silence: агент выполняет инструмент, но не зачитывает его вызов пользователю вслух («сейчас вызову функцию getQuote…»). Это другое требование к другому участку поведения, и проверяется оно своим тестом.

Главное здесь вот что: когда такие требования оформлены как именованные способности, «sometimes не работает» превращается в конкретный тест-кейс — human-handoff упал или tool-silence упал, а не «агент иногда ведёт себя странно».

Шаг 2. Реестр агентов

Теперь каждый агент просто декларирует, какими способностями обладает. Никакого кода тестов здесь — только конфигурация.

// tests/llm/agents/_registry.js
import { loadPrompt } from './loadPrompt.js';   // читает промпт из отдельного файла/секрета,
                                                // в тесты он не вшит и в гит не коммитится

export const AGENTS = [
  {
    id: 'loan-agent',
    name: 'Car Loan Assistant',
    apiKeyEnv: 'LOAN_AGENT_API_KEY',
    capabilities: [
      'greeting', 'jailbreak-resistance', 'brevity',
      'sms-consent', 'human-handoff', 'tool-silence',   // банковские
    ],
    systemPrompt: loadPrompt('loan-agent'),    // подгружается извне, не хранится в репозитории
    regression: { cases: loanAgentGoldenDataset, minScore: 3.5 },
  },
  {
    id: 'insurance-agent',
    name: 'Insurance Assistant',
    apiKeyEnv: 'INSURANCE_AGENT_API_KEY',
    capabilities: [
      'greeting', 'jailbreak-resistance', 'brevity',
      'photo-upload', 'human-handoff', 'tool-silence',   // страховые
    ],
    systemPrompt: loadPrompt('insurance-agent'),
    regression: { cases: insuranceGoldenDataset, minScore: 3.5 },

    // Можно переопределить ожидания под конкретного агента
    overrides: {
      brevity: { maxSentences: 2 },            // здесь строже
    },
  },
];

Системные промпты — чувствительные данные, поэтому в реестре их нет: loadPrompt() подтягивает их из отдельного файла или секрета вне репозитория. В тестовом коде лежат только идентификаторы агентов и их способности.

Реестр — это и есть матрица покрытия агент × способность. На неё удобно смотреть на ревью: видно, что банковский проверяется на SMS-согласие, а страховой — на загрузку фото, и оба — на jailbreak.

Шаг 3. Один спек на всех агентов

А вот сам тест. Он написан один раз и сам разворачивается на всех агентов, которые заявили способность.

// tests/llm/suites/universal/brevity.spec.js

import { test, expect } from '@playwright/test';
import { AGENTS } from '../../agents/_registry.js';
import { LLMClient } from '../../utils/LLMClient.js';

for (const agent of AGENTS) {
  // Пропускаем агента, если он не заявил эту способность
  if (!agent.capabilities.includes('brevity')) continue;

  test.describe(`Brevity - ${agent.name}`, () => {
    test('should respond in 3 sentences or fewer', async () => {
      const apiKey = process.env[agent.apiKeyEnv];
      test.skip(!apiKey, `No API key: ${agent.apiKeyEnv}`);

      // Параметр берём из override или из дефолта способности
      const maxSentences = agent.overrides?.brevity?.maxSentences ?? 3;

      const client = new LLMClient({ systemPrompt: agent.systemPrompt, apiKey });
      const response = await client.send('What documents do I need?');
      const sentences = response.text.split(/[.!?]+/).filter(Boolean);

      expect(sentences.length,
        `${agent.id}: got ${sentences.length}, max ${maxSentences}`
      ).toBeLessThanOrEqual(maxSentences);
    });
  });
}

Playwright разворачивает это в дерево тестов на лету:

Brevity – Car Loan Assistant

  ✓ should respond in 3 sentences or fewer

Brevity – Insurance Assistant

  ✓ should respond in 3 sentences or fewer

Добавил агента в реестр — он автоматически попал во все универсальные сьюты. Ноль новых файлов.

Шаг 4. Изоляция по агентам

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


const tracker = new ScoreTracker(`${agent.id}-regression`, { maxDrift: 0.5 });
//                                ^^^^^^^^
// -> fixtures/score-history/loan-agent-regression.json
// -> fixtures/score-history/insurance-agent-regression.json

> maxDrift: 0.5 здесь — учебный порог для примера, а не индустриальный стандарт. Реальное значение подбирается под твою метрику и допустимый шум; смысл в том, что падение среднего балла больше порога валит прогон.

Так у каждого агента своя история баллов и свой дрейф. Тот самый «плавающий» дефект ловится не на глаз, а как тренд: средний балл агента просел между двумя прогонами больше, чем на порог, — прогон красный.

Добавить нового агента = 3 шага

// 1. Запись в _registry.js
{
  id: 'support-bot',
  apiKeyEnv: 'SUPPORT_BOT_API_KEY',
  capabilities: ['greeting', 'brevity', 'jailbreak-resistance'],
  systemPrompt: loadPrompt('support-bot'),
  regression: { cases: supportBotGoldenDataset, minScore: 3.0 },
}
// 2. Ключ в .env
// 3. Свой golden-датасет

Универсальные сьюты — приветствие, лаконичность, устойчивость к jailbreak — подхватывают нового агента сами. Это и есть выход из ловушки «N агентов × M проверок».

Что это даёт как процесс

Для меня как для Lead ценность не в самих тестах, а в том, что появляется карта: матрица агент × способность, по которой видно, что покрыто, а что нет. Новый агент в продукте — это не «напишите ему тесты», а «отметьте в реестре, какими способностями он обладает». Недетерминированный дефект перестаёт быть «sometimes не работает» и становится либо непройденной способностью, либо дрейфом балла — тем, что можно показать в баг-трекере и на дашборде.

Где взять весь код

Это упрощённый срез. Полный рабочий пример — реестр способностей, реестр агентов, универсальные сьюты, LLM-as-a-judge, security-набор, отслеживание дрейфа и дашборд — лежит в открытом репозитории (JavaScript + Playwright, MIT):
Репозиторий: https://github.com/VeronLezh/llm-testing-playwright

А если хочется не «скопировать код», а собрать в голове всю дисциплину — что считать качеством ответа, как проектировать тесты под недетерминизм, как тестировать RAG и агентов, безопасность и red teaming, как выстроить процесс, — я собрала это в бесплатный курс на русском:

🎓 Курс (бесплатно): «QA для LLM: тестирование нейросетей и AI-агентов» — https://stepik.org/course/291671/promo

Capability-based подход из этой статьи там разобран отдельным модулем, со всеми паттернами (траектории, флоу, передача человеку, «тихие» инструменты, память диалога).

Автор: VeronLezh

Источник