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

«Sometimes не работает как ожидается» — так выглядел наш баг-репорт на LLM-агента. Агент пропускал обязательные шаги сценария, застревал на переходах, молча менял поведение — без единого изменения с нашей стороны. Это, конечно, не баг-репорт, а пожелание призраку.

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

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

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

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

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

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

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

Наивный путь: на каждого агента — свой файл тестов. 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.

greeting

jailbreak

brevity

sms-consent

photo-upload

human-handoff

tool-silence

loan-agent

+

+

+

+

+

+

insurance-agent

+

+

+

+

+

+

Шаг 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 проверок».

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

Было

Стало

«Sometimes не работает»

human-handoff упал — конкретный тест-кейс

5 агентов × 8 проверок = копипаста

Один спек, развёрнутый на всех

Деградация тонет в среднем

Дрейф балла по каждому агенту отдельно

«Напишите тесты для нового агента»

«Отметьте способности в реестре»

Для меня как для 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 подход из этой статьи там разобран отдельным модулем, со всеми паттернами (траектории, флоу, передача человеку, «тихие» инструменты, память диалога).

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы сейчас тестируете несколько LLM-агентов?
0%Пишу отдельные тесты на каждого агента0
0%Пытался обобщить, но вышло громоздко0
0%Есть единый фреймворк (реестр способностей)0
0%Пока один агент / агентов не тестирую0
Никто еще не голосовал. Воздержавшихся нет.