Главная/Блог/Аналитика/Gating PR на LLM-тестах: как не сломать…
Аналитика5 мин чтения · 1 октября 2026 г.

Gating PR на LLM-тестах: как не сломать поддержку в CI

Настройка обязательной проверки качества LLM в GitHub Actions. Защита от регрессий в системных промптах с помощью фиксированных наборов тестов и скриптов на Node.js.

Gating PR на LLM-тестах: как не сломать поддержку в CI

Изменение одной строки в системном промпте поддержки может привести к тому, что бот начнет обещать клиентам возврат средств за 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) при повторных выборках.
💡
Важно про GitHub Actions. Никогда не фильтруйте запуск воркфлоу на уровне on.pull_request.paths. Если воркфлоу пропускается из-за фильтра путей, статус проверки остается в состоянии «Pending», что блокирует merge. Используйте фильтрацию на уровне jobs.

02Шаг 1: Настройка триггеров в GitHub Actions

Запускать тяжелые LLM-тесты на каждый коммит неэффективно. Нужно запускать их только при изменении промптов, логики агента, схем инструментов или самих тестов. Для этого используем два job: первый определяет, нужно ли запускать тесты, второй — запускает их.

Создайте файл .github/workflows/eval-gate.yml:

terminalyaml
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. Пример структуры:

terminalbash
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 «специалист»).

terminaljson
[
  {
    "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.

terminaljavascript
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), вы увидите колебания. В таком случае:

  1. Увеличьте количество повторных запросов (например, 3-5 раз) и берите ответ по мажоритарному голосованию.
  2. Снизьте порог до 95-98%, если шум неизбежен, но следите за трендом.
⚠️
Предупреждение по безопасности. Никогда не храните OPENROUTER_API_KEY в коде. Используйте секреты GitHub Actions. Также ограничьте лимиты расходов в панели OpenRouter, чтобы CI не опустошил бюджет при зацикливании.

06Кому подойдёт / что запустится

Этот подход идеален для команд, разрабатывающих агентов поддержки, генеративных инструментов или чат-ботов, где важна точность фактов. Он позволяет безопасно рефакторить промпты, зная, что критические сценарии (например, отказ в возврате денег) не будут сломаны.

Что запустится:

  • Node.js 20+.
  • Доступ к интернету (для вызова OpenRouter API).
  • API ключ OpenRouter.

Для локального тестирования без затрат можно использовать бесплатные модели OpenRouter или локальные LLM через Ollama, заменив URL в скрипте.

Источник: OpenRouter ↗