Как тестировать 5 LLM-агентов одним набором тестов: capability-based подход

от автора

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

ссылка на оригинал статьи https://habr.com/ru/articles/1050252/