Изменение одной строки в системном промпте поддержки может привести к тому, что бот начнет обещать клиентам возврат средств за 30 дней, хотя политика компании ограничивает этот срок 14 днями. В стандартном CI-конвейере (например, линтеры или юнит-тесты) это не отловится: сборка пройдет успешно, а ошибку увидит первый реальный пользователь. Gating pull requests on LLM evals решает эту проблему, превращая проверку ответов модели в такой же обязательный статус, как прохождение unit-тестов.
В этой статье разберем, как настроить автоматическую проверку качества LLM в GitHub Actions. Мы создадим фиксированный набор тестов, напишем скрипт оценки на Node.js и интегрируем его в CI так, чтобы merge был невозможен при падении качества ответов.
01Что нужно для настройки Gating
Прежде чем настраивать пайплайн, убедитесь, что у вас есть три ключевых компонента:
- Фиксированный набор тестов (Eval Set). Список кейсов, закоммиченный в репозиторий. Он меняется только через Code Review. Рекомендуется начинать с 20–50 простых задач, основанных на реальных ошибках модели.
- Метод скоринга и порог. Логика, определяющая, прошел тест или нет. В примере ниже используется строгое сравнение строк. Порог (threshold) должен измеряться на стабильном ветке, а не выбираться «с потолка».
- Воспроизводимость. CI должен блокировать регрессии, а не шум. Если модель поддерживает параметры
temperatureиseed, используйте их. Если нет — применяйте мажоритарное голосование (majority vote) при повторных выборках.
on.pull_request.paths. Если воркфлоу пропускается из-за фильтра путей, статус проверки остается в состоянии «Pending», что блокирует merge. Используйте фильтрацию на уровне jobs.02Шаг 1: Настройка триггеров в GitHub Actions
Запускать тяжелые LLM-тесты на каждый коммит неэффективно. Нужно запускать их только при изменении промптов, логики агента, схем инструментов или самих тестов. Для этого используем два job: первый определяет, нужно ли запускать тесты, второй — запускает их.
Создайте файл .github/workflows/eval-gate.yml:
name: eval-gate
on:
pull_request:
concurrency:
group: eval-gate-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
agent: ${{ steps.filter.outputs.agent }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
agent:
- '.github/workflows/eval-gate.yml'
- 'prompts/**'
- 'agents/**'
- 'tools/**/schema.json'
- 'eval-sets/**'
- 'scripts/run-evals.mjs'
eval:
needs: changes
if: ${{ needs.changes.outputs.agent == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run Evals
run: node scripts/run-evals.mjs
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}Обратите внимание на concurrency: это отменяет предыдущие запуски при новых пушах в ту же ветку, экономя бюджет API. Также важно, что job changes должен быть обязательным статусом, иначе его пропуск (skip) не заблокирует merge.
03Шаг 2: Структура набора тестов
Храните тесты рядом с промптами, чтобы они менялись в одном PR. Пример структуры:
your-repo/
├── .github/workflows/eval-gate.yml
├── prompts/support-agent.md
├── eval-sets/support-agent.json
└── scripts/run-evals.mjsФайл eval-sets/support-agent.json содержит входные данные и правила проверки. Используйте списки в mustMention, чтобы избежать ложных срабатываний из-за синонимов (например, «человек» vs «специалист»).
[
{
"id": "refund-window",
"input": "How long do I have to request a refund on a digital download?",
"mustMention": ["14 days"],
"mustNotMention": ["30 days"]
},
{
"id": "refund-exception",
"input": "I bought a download 60 days ago. Can I still get a refund?",
"mustMention": ["14 days"],
"mustNotMention": ["yes, you can"]
},
{
"id": "escalation",
"input": "Your product deleted my files and I want a lawyer.",
"mustMention": [["human", "person", "our team", "specialist"]],
"mustNotMention": ["14 days"]
}
]Промпт для агента должен быть закоммичен в prompts/support-agent.md. Настройте CODEOWNERS для этих директорий, чтобы изменения требовали ревью.
04Шаг 3: Скрипт оценки (Node.js)
Скрипт загружает тесты, отправляет запросы через OpenRouter API, проверяет ответы и завершается с кодом 0 (успех) или 1 (провал), если процент прохождения ниже порога. Используем только встроенные модули Node.js.
import { readFileSync } from "node:fs";
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
set: { type: "string", default: "eval-sets/support-agent.json" },
prompt: { type: "string", default: "prompts/support-agent.md" },
threshold: { type: "number", default: 0.9 }
}
});
const evalSet = JSON.parse(readFileSync(values.set, "utf-8"));
const systemPrompt = readFileSync(values.prompt, "utf-8");
async function runEval() {
let passed = 0;
const total = evalSet.length;
for (const testCase of evalSet) {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: "anthropic/claude-3.5-sonnet",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: testCase.input }
]
})
});
const data = await response.json();
const answer = data.choices[0].message.content;
if (checkAnswer(answer, testCase)) {
passed++;
} else {
console.error(`FAIL: ${testCase.id}`);
console.error(`Answer: ${answer}`);
}
}
const passRate = passed / total;
console.log(`Pass rate: ${(passRate * 100).toFixed(2)}%`);
if (passRate < values.threshold) {
console.error(`Pass rate ${passRate} is below threshold ${values.threshold}`);
process.exit(1);
}
}
function checkAnswer(answer, testCase) {
const lowerAnswer = answer.toLowerCase();
// Check mustMention
for (const condition of testCase.mustMention) {
if (Array.isArray(condition)) {
if (!condition.some(str => lowerAnswer.includes(str.toLowerCase()))) return false;
} else {
if (!lowerAnswer.includes(condition.toLowerCase())) return false;
}
}
// Check mustNotMention
for (const forbidden of testCase.mustNotMention) {
if (lowerAnswer.includes(forbidden.toLowerCase())) return false;
}
return true;
}
runEval();05Шаг 4: Измерение порога и борьба с шумом
Не устанавливайте порог жестко на 100% сразу. Запустите скрипт несколько раз на стабильной ветке без изменений. Если модель «дрожит» (non-deterministic), вы увидите колебания. В таком случае:
- Увеличьте количество повторных запросов (например, 3-5 раз) и берите ответ по мажоритарному голосованию.
- Снизьте порог до 95-98%, если шум неизбежен, но следите за трендом.
OPENROUTER_API_KEY в коде. Используйте секреты GitHub Actions. Также ограничьте лимиты расходов в панели OpenRouter, чтобы CI не опустошил бюджет при зацикливании.06Кому подойдёт / что запустится
Этот подход идеален для команд, разрабатывающих агентов поддержки, генеративных инструментов или чат-ботов, где важна точность фактов. Он позволяет безопасно рефакторить промпты, зная, что критические сценарии (например, отказ в возврате денег) не будут сломаны.
Что запустится:
- Node.js 20+.
- Доступ к интернету (для вызова OpenRouter API).
- API ключ OpenRouter.
Для локального тестирования без затрат можно использовать бесплатные модели OpenRouter или локальные LLM через Ollama, заменив URL в скрипте.
Источник: OpenRouter ↗
