Evals 2.0: новое поколение оценки LLM-приложений

Agent evals, eval-driven development, multi-turn оценка, автогенерация датасетов, eval в CI/CD. Практики 2025-2026.

Evals 2.0 — это следующее поколение практик оценки LLM-приложений, сформировавшееся в 2025-2026 годах. Если первое поколение eval научило нас оценивать отдельные ответы модели (LLM-as-judge, автометрики, Promptfoo/Braintrust), то Evals 2.0 решает задачи, которые стали критичными с ростом сложности AI-систем: оценка агентных workflow, многоходовых диалогов, автоматическая генерация eval-датасетов и eval-driven development как новая парадигма разработки.

Если вы ещё не знакомы с базовыми концепциями eval (LLM-as-judge, автометрики, eval-наборы), начните со статьи Eval: оценка качества LLM-приложений. Evals 2.0 строится поверх этих основ.

Суть: от Evals 1.0 к 2.0

Evals 1.0 (2023-2024) решали задачу «правильно ли модель ответила на вопрос». Evals 2.0 решают задачу «правильно ли AI-система выполнила сложную задачу».

Без техники
Evals 1.0: статичный набор из 50 вопросов, один промпт → один ответ, ручная разметка эталонов, LLM-as-judge по шкале 1-5. Eval запускается вручную перед деплоем.
С техникой
Evals 2.0: динамические eval-наборы, сгенерированные LLM из production-логов. Оценка агентных цепочек (10+ шагов, tool calls, recovery). Eval встроен в CI/CD и блокирует PR при регрессии. Автоматический мониторинг cost-quality tradeoff.

Ключевые сдвиги:

Evals 1.0Evals 2.0
Статичные тест-наборыДинамическая генерация eval-кейсов
Один промпт → один ответMulti-turn, multi-step, multi-tool
Ручная разметка эталоновLLM генерирует eval-датасеты
Eval перед деплоемEval в каждом PR + непрерывный мониторинг
Качество ответаКачество + стоимость + латентность
Универсальные метрикиDomain-specific eval-фреймворки

Эволюция от 1.0 к 2.0

Почему старых eval стало недостаточно

К 2025 году LLM-приложения перестали быть простыми «вопрос-ответ» системами. Три тренда сделали Evals 1.0 недостаточными:

1. Агентные системы. Приложения стали выполнять многошаговые задачи: агент ищет информацию, вызывает API, принимает решения, корректирует план. Оценивать только финальный ответ — как оценивать шахматиста только по результату партии, не глядя на ходы.

2. Масштаб production. Когда приложение обрабатывает тысячи запросов в день, статичный eval-набор из 50 примеров не покрывает реальное распределение запросов. Нужна автоматическая генерация eval-кейсов из production-данных.

3. Cost-quality tradeoff. С появлением десятков моделей разного размера и стоимости, eval должен помогать выбирать оптимальную модель для каждой задачи: когда хватит мини-модели, а когда нужен Opus.

Agent Evals: оценка агентных workflow

Агентные системы — самая сложная задача для eval. Агент выполняет последовательность действий: планирование, вызов инструментов, обработка ошибок, адаптация стратегии. Нужно оценивать не только финальный результат, но и качество пути.

Три уровня оценки агентов

Метрики для агентов

МетрикаЧто измеряетКак считать
Task completion rateДоля успешно завершённых задачSucceeded / Total
Step efficiencyОтношение минимально необходимых шагов к фактическимOptimal steps / Actual steps
Tool accuracyПравильность выбора и параметров инструментовCorrect tool calls / Total tool calls
Recovery rateДоля успешных восстановлений после ошибокRecovered / Total errors
Cost per taskСтоимость выполнения задачиTotal tokens * price
Time to completionВремя от начала до завершенияEnd time - Start time
Не оценивайте агента только по финальному результату. Агент, который «гуляет» по 20 tool calls и случайно находит ответ — ненадёжен. Завтра он на похожем вопросе уйдёт в бесконечный цикл. Trajectory eval ловит такие проблемы.

Eval-Driven Development (EDD)

Eval-Driven Development — это подход, при котором eval-наборы пишутся до промптов, как TDD (Test-Driven Development) в классическом программировании. EDD стал стандартной практикой в командах, работающих с LLM в production.

Без техники
Обычная разработка: написал промпт → попробовал 5 вопросов вручную → «вроде работает» → задеплоил → через неделю нашли баг → починил → сломал другое.
С техникой
EDD: описал требования → написал 30 eval-кейсов → написал промпт → прогнал eval → увидел 72% → улучшил промпт → 89% → добавил edge cases → 85% → итерировал до 92% → задеплоил с уверенностью.

Цикл EDD

EDD особенно эффективен при работе в команде. Eval-набор становится «контрактом»: PM описывает требования, разработчик переводит их в eval-кейсы, и все видят объективную метрику качества вместо субъективных «вроде работает».

Multi-turn Conversation Eval

Оценка многоходовых диалогов — одна из самых сложных задач Evals 2.0. Пользователь задаёт вопрос, уточняет, меняет тему, возвращается — и каждый ответ модели зависит от всей предыдущей истории.

Что оценивать в multi-turn

АспектВопросПример проблемы
ConsistencyНе противоречит ли себе?«Доставка бесплатная» → позже «Доставка 300 руб.»
Context retentionПомнит ли контекст?Пользователь назвал имя в 1 сообщении, в 5-м модель спрашивает «как вас зовут?»
Topic trackingСледит ли за темой?Пользователь переключился с заказа на возврат, модель продолжает про заказ
ClarificationЗадаёт ли уточняющие вопросы?Неоднозначный запрос — модель не уточняет, а угадывает
EscalationЗнает ли, когда передать человеку?Разъярённый клиент — модель продолжает скриптовые ответы

Подход: scenario-based eval

Вместо отдельных вопросов вы описываете сценарии — полные диалоги из 5-10 реплик с ожидаемым поведением на каждом шаге:

ПромптMulti-turn eval scenario
Scenario: «Клиент хочет вернуть товар, потом передумал»

Turn 1 (user): Хочу вернуть ноутбук
Expected: спросить причину и номер заказа

Turn 2 (user): Заказ #12345, не включается
Expected: предложить диагностику ИЛИ оформить возврат

Turn 3 (user): А, подождите, он включился! Не нужен возврат
Expected: подтвердить отмену возврата, предложить помощь

Turn 4 (user): Но экран мерцает
Expected: предложить гарантийный ремонт (не возврат — клиент уже отказался)
Ответ модели

Eval judges each turn: Turn 1: 5/5 — спросил причину и номер Turn 2: 4/5 — предложил возврат, но не спросил про диагностику Turn 3: 5/5 — корректно подтвердил отмену Turn 4: 5/5 — предложил ремонт, учёл контекст

Overall scenario score: 4.75/5 Context retention: PASS Consistency: PASS

Автоматическая генерация eval-датасетов

Ручное создание eval-наборов — бутылочное горлышко. В Evals 2.0 LLM используются для генерации eval-датасетов: из production-логов, из документации, из описания требований.

Три источника генерации

1. Из production-логов. Самый ценный источник. LLM анализирует реальные запросы пользователей и генерирует eval-кейсы с эталонными ответами.

2. Из документации. LLM читает вашу документацию (FAQ, инструкции, политики) и генерирует вопросы, которые пользователи могут задать, с правильными ответами из документа.

3. Из описания требований. Вы описываете, что должно делать приложение — LLM генерирует edge cases, adversarial inputs, пограничные ситуации.

Автогенерация ускоряет создание eval-наборов в 10-20 раз, но не заменяет ручную проверку. Всегда ревьюьте сгенерированные кейсы: LLM может придумать нереалистичные сценарии или некорректные эталонные ответы. Правило: генерируйте 100, ревьюьте и оставляйте 50.

Eval в CI/CD: блокировка деплоев

В Evals 2.0 eval встроен в CI/CD-пайплайн так же, как unit-тесты. Изменение промпта без прогона eval — как изменение кода без прогона тестов.

Два уровня eval в CI/CD

УровеньКогдаОбъёмПорогВремя
Quick evalКаждый PR10-20 кейсов, критичные сценарииScore >= 4.030-60 сек
Full evalMerge в main50-200 кейсов, все сценарииScore >= 4.0, regression < 5%3-10 мин

Eval-diff в PR

Ключевая идея Evals 2.0: в PR отображается не просто «eval passed/failed», а diff — на каких кейсах стало лучше, на каких хуже. Разработчик видит трейдоффы и принимает осознанное решение.

Cost-Quality Tradeoff Eval

С появлением десятков моделей (от GPT-4.1 nano до Claude Opus) eval должен помогать выбирать оптимальную модель. Cost-quality eval сравнивает модели на вашем eval-наборе по трём осям: качество, стоимость, латентность.

Quality vs Cost: модели на типичном eval-наборе (условные единицы)
GPT-4.1 nano — качество68%
GPT-4.1 nano — стоимость5%
Claude Sonnet — качество88%
Claude Sonnet — стоимость30%
GPT-4.1 — качество90%
GPT-4.1 — стоимость50%
Claude Opus — качество95%
Claude Opus — стоимость100%

Вывод часто неочевиден: на 80% запросов мини-модель справляется не хуже, и можно сэкономить 90% бюджета, маршрутизируя только сложные запросы на большую модель. Eval позволяет найти эту границу.

Domain-Specific Eval

Универсальные метрики (correctness, helpfulness) недостаточны для специализированных приложений. В 2025-2026 появились domain-specific eval-фреймворки:

ДоменСпецифичные метрикиИнструменты
МедицинаКлиническая точность, contraindication detection, guideline complianceMedEval, HealthBench
ЮриспруденцияТочность ссылок на законы, применимость нормы, risks identificationLegalBench
ФинансыЧисловая точность, compliance, risk disclosureFinEval
КодКомпилируемость, прохождение тестов, code style, securitySWE-bench, HumanEval+
Customer supportResolution rate, empathy, escalation accuracy, SLA complianceКастомные
Если вы строите приложение для конкретного домена — инвестируйте в domain-specific eval раньше, чем в UI. Даже идеальный интерфейс не спасёт, если модель даёт неточные медицинские или юридические советы.

Human-in-the-Loop Eval

LLM-as-judge не заменяет человека, но масштабируется. Зрелые команды комбинируют оба подхода: LLM оценивает 100% запросов, люди — выборку для калибровки.

Процесс HITL eval

Inter-rater agreement

KappaИнтерпретацияДействие
0.8+Отличное согласиеLLM-judge надёжен, можно уменьшить выборку
0.6-0.8Хорошее согласиеПриемлемо, точечная калибровка
0.4-0.6УмеренноеНужна серьёзная калибровка промпта судьи
< 0.4СлабоеLLM-judge ненадёжен, пересмотреть подход

Реализация

Agent eval: оценка траекторий

from dataclasses import dataclass
from enum import Enum


class StepType(Enum):
    THINK = "think"
    TOOL_CALL = "tool_call"
    TOOL_RESULT = "tool_result"
    RESPONSE = "response"
    ERROR = "error"


@dataclass
class AgentStep:
    type: StepType
    content: str
    tool_name: str | None = None
    tool_args: dict | None = None
    duration_ms: int = 0
    tokens_used: int = 0


@dataclass
class AgentTrajectory:
    task: str
    steps: list[AgentStep]
    final_result: str
    success: bool
    total_cost: float


def eval_trajectory(
    trajectory: AgentTrajectory,
    optimal_steps: int,
    required_tools: list[str],
    max_cost: float,
) -> dict:
    """Evaluate agent trajectory on multiple dimensions."""
    actual_steps = len([
        s for s in trajectory.steps
        if s.type == StepType.TOOL_CALL
    ])

    # Step efficiency: optimal / actual (capped at 1.0)
    step_efficiency = min(optimal_steps / max(actual_steps, 1), 1.0)

    # Tool accuracy: did the agent use the right tools?
    used_tools = {
        s.tool_name for s in trajectory.steps
        if s.type == StepType.TOOL_CALL and s.tool_name
    }
    tool_coverage = len(
        set(required_tools) & used_tools
    ) / max(len(required_tools), 1)

    # Recovery: errors that were handled
    errors = [s for s in trajectory.steps if s.type == StepType.ERROR]
    recoveries = 0
    for i, step in enumerate(trajectory.steps):
        if step.type == StepType.ERROR and i + 1 < len(trajectory.steps):
            if trajectory.steps[i + 1].type != StepType.ERROR:
                recoveries += 1
    recovery_rate = recoveries / max(len(errors), 1) if errors else 1.0

    # Cost efficiency
    cost_ok = trajectory.total_cost <= max_cost

    return {
        "task_completed": trajectory.success,
        "step_efficiency": round(step_efficiency, 2),
        "tool_coverage": round(tool_coverage, 2),
        "recovery_rate": round(recovery_rate, 2),
        "cost_within_budget": cost_ok,
        "actual_cost": trajectory.total_cost,
        "total_steps": len(trajectory.steps),
        "score": round(
            (
                (1.0 if trajectory.success else 0.0) * 0.4
                + step_efficiency * 0.2
                + tool_coverage * 0.2
                + recovery_rate * 0.2
            ),
            2,
        ),
    }

Multi-turn eval: scenario runner

import anthropic
import json

client = anthropic.Anthropic()

MULTI_TURN_JUDGE_PROMPT = """You are evaluating a multi-turn conversation.
For each assistant turn, rate on a 1-5 scale:
- Relevance: does the response address the user's message?
- Consistency: does it contradict previous responses?
- Context retention: does it remember earlier context?
- Helpfulness: does it move toward resolving the user's need?

Conversation:
{conversation}

Return JSON array with one object per assistant turn:
[{{"turn": 1, "relevance": N, "consistency": N,
   "context_retention": N, "helpfulness": N, "notes": "..."}}]"""


@dataclass
class ConversationTurn:
    role: str  # "user" or "assistant"
    content: str
    expected_behavior: str | None = None  # for eval


def run_multi_turn_eval(
    system_prompt: str,
    scenario: list[ConversationTurn],
    model: str = "claude-sonnet-4-20250514",
    judge_model: str = "claude-opus-4-20250514",
) -> dict:
    """Run a multi-turn scenario and evaluate each turn."""
    messages = []
    actual_conversation = []

    for turn in scenario:
        if turn.role == "user":
            messages.append({"role": "user", "content": turn.content})
            actual_conversation.append(f"User: {turn.content}")

            # Get model response
            response = client.messages.create(
                model=model,
                max_tokens=1024,
                system=system_prompt,
                messages=messages,
            )
            assistant_msg = response.content[0].text
            messages.append({"role": "assistant", "content": assistant_msg})
            actual_conversation.append(f"Assistant: {assistant_msg}")

            if turn.expected_behavior:
                actual_conversation.append(
                    f"[Expected: {turn.expected_behavior}]"
                )

    # Judge the full conversation
    judge_response = client.messages.create(
        model=judge_model,
        max_tokens=2048,
        messages=[{
            "role": "user",
            "content": MULTI_TURN_JUDGE_PROMPT.format(
                conversation="\n".join(actual_conversation)
            ),
        }],
    )

    text = judge_response.content[0].text
    start = text.index("[")
    end = text.rindex("]") + 1
    scores = json.loads(text[start:end])

    avg_score = sum(
        sum(s[k] for k in ["relevance", "consistency",
                            "context_retention", "helpfulness"]) / 4
        for s in scores
    ) / len(scores)

    return {
        "scenario_score": round(avg_score, 2),
        "turn_scores": scores,
        "conversation": actual_conversation,
        "passed": avg_score >= 4.0,
    }

Автогенерация eval-датасетов

EVAL_GEN_PROMPT = """You are generating eval test cases for an AI application.

Application description: {app_description}

Generate {count} diverse test cases. For each test case provide:
1. input: the user's message
2. expected: what a correct response should contain
3. category: type of test (typical, edge_case, adversarial, out_of_scope)
4. difficulty: easy, medium, hard

Rules:
- 40% typical cases, 30% edge cases, 20% adversarial, 10% out of scope
- Include multilingual inputs if the app supports it
- Include ambiguous inputs that require clarification
- Include inputs that try to manipulate the system

Return JSON array:
[{{"input": "...", "expected": "...",
   "category": "...", "difficulty": "..."}}]"""


def generate_eval_dataset(
    app_description: str,
    count: int = 50,
    model: str = "claude-opus-4-20250514",
) -> list[dict]:
    """Generate eval dataset from app description."""
    response = client.messages.create(
        model=model,
        max_tokens=8192,
        messages=[{
            "role": "user",
            "content": EVAL_GEN_PROMPT.format(
                app_description=app_description,
                count=count,
            ),
        }],
    )

    text = response.content[0].text
    start = text.index("[")
    end = text.rindex("]") + 1
    dataset = json.loads(text[start:end])

    return dataset


def generate_from_logs(
    production_logs: list[dict],
    model: str = "claude-opus-4-20250514",
) -> list[dict]:
    """Generate eval cases from production logs."""
    prompt = f"""Analyze these production logs and generate eval test cases.

Focus on:
- Queries that received low user ratings
- Queries where the model refused to answer
- Queries with unusual patterns
- Common query types that should always work

Logs (sample):
{json.dumps(production_logs[:20], ensure_ascii=False, indent=2)}

Generate 20 eval test cases based on patterns you see.
Return JSON array with input, expected, category, difficulty."""

    response = client.messages.create(
        model=model,
        max_tokens=8192,
        messages=[{"role": "user", "content": prompt}],
    )

    text = response.content[0].text
    start = text.index("[")
    end = text.rindex("]") + 1
    return json.loads(text[start:end])

Eval в CI/CD: GitHub Actions + eval-diff

# .github/workflows/eval.yml
name: LLM Eval

on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'src/llm/**'
      - 'src/agents/**'

jobs:
  quick-eval:
    name: Quick Eval
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - run: pip install -r requirements.txt

      - name: Run quick eval
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: python scripts/run_eval.py --suite quick --output results.json

      - name: Generate eval diff
        run: python scripts/eval_diff.py --current results.json --baseline baseline.json --output diff.md

      - name: Post eval diff to PR
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          path: diff.md

      - name: Check thresholds
        run: |
          python scripts/check_thresholds.py results.json \
            --min-score 4.0 \
            --max-regression 0.05 \
            --max-cost-increase 0.20
# scripts/eval_diff.py
import json
import sys


def generate_diff(current_file: str, baseline_file: str, output_file: str):
    with open(current_file) as f:
        current = json.load(f)
    with open(baseline_file) as f:
        baseline = json.load(f)

    lines = ["## Eval Results\n"]

    # Overall metrics
    curr_score = current["avg_score"]
    base_score = baseline["avg_score"]
    delta = curr_score - base_score
    sign = "+" if delta >= 0 else ""

    lines.append(f"| Metric | Baseline | Current | Delta |")
    lines.append(f"|--------|----------|---------|-------|")
    lines.append(
        f"| Avg Score | {base_score:.2f} | {curr_score:.2f} "
        f"| {sign}{delta:.2f} |"
    )
    lines.append(
        f"| Cost | ${baseline['total_cost']:.3f} "
        f"| ${current['total_cost']:.3f} "
        f"| {sign}${current['total_cost'] - baseline['total_cost']:.3f} |"
    )

    # Regressions
    regressions = []
    improvements = []
    for c_case in current.get("details", []):
        case_id = c_case["input"]["question"]
        b_case = next(
            (b for b in baseline.get("details", [])
             if b["input"]["question"] == case_id),
            None,
        )
        if b_case:
            c_score = c_case["scores"]["overall"]
            b_score = b_case["scores"]["overall"]
            if c_score < b_score - 0.5:
                regressions.append((case_id, b_score, c_score))
            elif c_score > b_score + 0.5:
                improvements.append((case_id, b_score, c_score))

    if regressions:
        lines.append("\n### Regressions\n")
        for q, b, c in regressions:
            lines.append(f"- **{q}**: {b:.1f} -> {c:.1f}")

    if improvements:
        lines.append("\n### Improvements\n")
        for q, b, c in improvements:
            lines.append(f"- **{q}**: {b:.1f} -> {c:.1f}")

    with open(output_file, "w") as f:
        f.write("\n".join(lines))


if __name__ == "__main__":
    generate_diff(sys.argv[1], sys.argv[2], sys.argv[3])

Cost-quality eval: model comparison

from dataclasses import dataclass


@dataclass
class ModelConfig:
    name: str
    cost_per_1k_input: float
    cost_per_1k_output: float
    avg_latency_ms: int


MODELS = [
    ModelConfig("gpt-4.1-nano", 0.0001, 0.0004, 200),
    ModelConfig("claude-haiku", 0.00025, 0.00125, 300),
    ModelConfig("claude-sonnet", 0.003, 0.015, 800),
    ModelConfig("gpt-4.1", 0.002, 0.008, 600),
    ModelConfig("claude-opus", 0.015, 0.075, 2000),
]


def cost_quality_eval(
    eval_dataset: list[dict],
    models: list[ModelConfig],
    run_fn,  # (model_name, input) -> (output, tokens_in, tokens_out)
    judge_fn,  # (input, expected, actual) -> score
) -> list[dict]:
    """Compare models on cost, quality, and latency."""
    results = []

    for model in models:
        scores = []
        total_cost = 0.0

        for case in eval_dataset:
            output, tok_in, tok_out = run_fn(model.name, case["input"])
            score = judge_fn(case["input"], case["expected"], output)
            cost = (
                tok_in / 1000 * model.cost_per_1k_input
                + tok_out / 1000 * model.cost_per_1k_output
            )
            scores.append(score)
            total_cost += cost

        avg_score = sum(scores) / len(scores)
        results.append({
            "model": model.name,
            "avg_score": round(avg_score, 2),
            "total_cost": round(total_cost, 4),
            "cost_per_case": round(total_cost / len(eval_dataset), 5),
            "avg_latency_ms": model.avg_latency_ms,
            "quality_per_dollar": round(
                avg_score / max(total_cost, 0.0001), 1
            ),
        })

    return sorted(
        results, key=lambda x: x["quality_per_dollar"], reverse=True
    )

Promptfoo: agent eval конфигурация

# promptfooconfig.yaml — agent eval
description: "Agent eval: customer support with tools"

providers:
  - id: anthropic:messages:claude-sonnet-4-20250514
    config:
      max_tokens: 2048
      tools:
        - name: search_orders
          description: "Search customer orders by email or order ID"
          parameters:
            type: object
            properties:
              query:
                type: string
            required: [query]
        - name: create_refund
          description: "Create a refund for an order"
          parameters:
            type: object
            properties:
              order_id:
                type: string
              amount:
                type: number
              reason:
                type: string
            required: [order_id, reason]

prompts:
  - id: agent-v2
    raw: |
      You are a customer support agent. Use tools to look up orders
      and process refunds. Always verify the order exists before
      processing a refund. If the customer is upset, acknowledge
      their frustration before proceeding.

tests:
  # Outcome eval
  - vars:
      question: "I want a refund for order #12345"
    assert:
      - type: is-valid-openai-tools-call
      - type: llm-rubric
        value: "Agent searched for the order before processing refund"
      - type: llm-rubric
        value: "Agent asked for refund reason"
      - type: cost
        threshold: 0.05

  # Recovery eval
  - vars:
      question: "Refund my order #99999"
    provider:
      config:
        toolOverrides:
          search_orders:
            output: '{"error": "Order not found"}'
    assert:
      - type: llm-rubric
        value: "Agent gracefully handled the missing order"
      - type: not-contains
        value: "error"

  # Trajectory eval
  - vars:
      question: "I bought a laptop last week and it's broken"
    assert:
      - type: javascript
        value: |
          // Check that agent used tools efficiently
          const toolCalls = output.tool_calls || [];
          return toolCalls.length <= 3;
      - type: llm-rubric
        value: "Agent gathered necessary info before suggesting solution"
Храните eval-baseline в репозитории (eval/baseline.json). При каждом merge в main автоматически обновляйте baseline. Так каждый PR сравнивается с актуальной production-версией, а не с устаревшим снимком.

Проверьте себя

Проверьте себя

1. Чем Evals 2.0 принципиально отличается от Evals 1.0?

2. Что такое trajectory eval для агентов?

3. В чём суть Eval-Driven Development (EDD)?

4. Когда Cohen's Kappa между LLM-judge и людьми ниже 0.4, что это означает?

Связанные темы

Источники