В [прошлой статье](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/