Самое важное из мира AI — в канале MAX AgentLabОтдельные разборы, инструменты и практические схемы Читайте Agent Lab в Telegram Разборы, кейсы и новости о практических AI-агентах без лишней воды.

Почему агентные бенчмарки измеряют модель вместе с оболочкой

Почему агентные бенчмарки измеряют модель вместе с оболочкой
Temporary fallback cover; replace in editorial pass.
Уровень: продвинутый Чтение: до 12 минут

Строчку в рейтинге агентного бенчмарка часто читают как «модель X умеет решать задачи на N%». Это неверно. Агентный бенчмарк оценивает не модель саму по себе, а систему: модель, программу, которая гоняет её в цикле, набор инструментов, лимиты на шаги и токены, а также стратегию, по которой агент выбирает действия. Если поменять оболочку, у той же модели получится другой результат. Если ничего не говорить о стоимости, «лучшим» может оказаться агент, который в разы дороже соседа и выигрывает у него на пару задач.

В этой статье мы соберём воспроизводимый процесс: зафиксируем единицу сравнения, введём формат журнала прогонов, напишем скрипт, который сводит результаты в таблицу «модель + оболочка» (доля решённых задач с доверительным интервалом, медианное число шагов, токены, стоимость за решённую задачу), и разберём, как читать эту таблицу, ничего лишнего ей не приписывая.

Что на самом деле стоит за строкой рейтинга

Результат агентного прогона удобно записать как функцию от нескольких аргументов:

результат = f(модель, оболочка, инструменты, бюджет, окружение, случайность)

Модель здесь только один аргумент из шести. Такие лидерборды, как Terminal-Bench, где агент решает задачи в терминале внутри контейнера, поэтому и публикуют пары «агент + модель», а не одни названия моделей. Но при пересказе в чатах и презентациях первую половину пары обычно теряют.

Из чего состоит оболочка

Оболочкой (harness, агентным каркасом) называют весь код вокруг вызова модели. На результат обычно влияют:

Каждый пункт может сдвинуть итоговую долю решённых задач. Поэтому фразу «модель A лучше модели B» можно обосновать только в одном случае: обе прогнаны в одной и той же оболочке одной версии, с одинаковыми лимитами и на одном наборе задач.

Шаг 1. Зафиксируйте единицу сравнения

Единица сравнения — это не модель, а конфигурация. Минимальный ключ выглядит так:

(model, harness, harness_version)

Для каждой конфигурации отдельно (в конфиге запуска или в README эксперимента) зафиксируйте то, что в ключ не попало, но на результат влияет:

Чтобы влияние модели и оболочки можно было разделить, планируйте эксперимент матрицей, а не набором случайных пар. Минимальный осмысленный план: две модели × две оболочки. Тогда вы сможете сравнить модели внутри одной оболочки и оболочки на одной модели, а заодно увидите взаимодействие: бывает, что оболочка помогает одной модели и мешает другой.

Шаг 2. Введите единый журнал прогонов

Раннеры бенчмарков складывают результаты в разных форматах. Не стройте анализ на их внутренних структурах: напишите небольшой экспорт в общий формат JSONL, где одна строка соответствует одному прогону одной задачи. Ниже пример схемы. Значения в нём синтетические и показывают только формат, это не результаты каких-либо реальных моделей.

{"model": "model-a", "harness": "minimal-shell", "harness_version": "1.2.0", "task_id": "fix-permissions", "trial": 1, "status": "solved", "input_tokens": 41200, "output_tokens": 3100, "steps": 14}
{"model": "model-a", "harness": "planner-editor", "harness_version": "0.9.1", "task_id": "fix-permissions", "trial": 1, "status": "failed", "input_tokens": 88400, "output_tokens": 5900, "steps": 31}
{"model": "model-b", "harness": "minimal-shell", "harness_version": "1.2.0", "task_id": "fix-permissions", "trial": 1, "status": "infra_error", "input_tokens": 0, "output_tokens": 0, "steps": 0}

Поле status принимает ровно три значения:

Граница между failed и infra_error принципиальна, и о ней мы ещё поговорим в разделе ошибок.

Цены не храните в журнале, вынесите их в отдельный файл. Заполните его актуальными тарифами вашего провайдера на дату прогона. Ниже заготовка, значения в ней намеренно пустые:

{
  "model-a": {"input_per_mtok": null, "output_per_mtok": null},
  "model-b": {"input_per_mtok": null, "output_per_mtok": null}
}

Скрипт откажется работать, пока цены не заполнены. Так в таблицу не попадёт стоимость «по памяти».

Шаг 3. Скрипт сводной таблицы

Подготовьте рабочую папку. Команды ниже ничего не устанавливают и не обращаются к сети:

mkdir -p harness-eval && cd harness-eval
# положите сюда runs.jsonl, prices.json и summarize.py
python3 --version

Сохраните скрипт как summarize.py. Он использует только стандартную библиотеку Python.

#!/usr/bin/env python3
"""Сводная таблица «модель + оболочка» по журналу прогонов runs.jsonl."""
import json
import math
import statistics
import sys
from collections import defaultdict

STATUSES = {"solved", "failed", "infra_error"}


def wilson(k, n, z=1.96):
    """95% доверительный интервал Уилсона для доли k из n."""
    if n == 0:
        return 0.0, 0.0
    p = k / n
    denom = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return max(0.0, centre - half), min(1.0, centre + half)


def load_runs(path):
    groups = defaultdict(list)
    seen = set()
    with open(path, encoding="utf-8") as f:
        for line_no, line in enumerate(f, 1):
            if not line.strip():
                continue
            r = json.loads(line)
            if r["status"] not in STATUSES:
                sys.exit(f"{path}:{line_no}: неизвестный status {r['status']!r}")
            key = (r["model"], r["harness"], r["harness_version"])
            run_id = key + (r["task_id"], r["trial"])
            if run_id in seen:
                sys.exit(f"{path}:{line_no}: дубликат прогона {run_id}")
            seen.add(run_id)
            groups[key].append(r)
    if not groups:
        sys.exit(f"{path}: журнал пуст")
    return groups


def main(runs_path, prices_path):
    with open(prices_path, encoding="utf-8") as f:
        prices = json.load(f)
    groups = load_runs(runs_path)

    # Сравниваем только на задачах, которые есть у всех конфигураций.
    task_sets = {k: {r["task_id"] for r in rs} for k, rs in groups.items()}
    common = set.intersection(*task_sets.values())
    for k, tasks in task_sets.items():
        extra = sorted(tasks - common)
        if extra:
            print(f"ВНИМАНИЕ: {k} исключены задачи вне общего набора: {extra}",
                  file=sys.stderr)

    cols = ["model", "harness", "version", "valid", "solved", "rate", "ci95",
            "infra", "med_steps", "med_tokens", "cost", "cost_per_solved"]
    print("\t".join(cols))
    for (model, harness, version), rs in sorted(groups.items()):
        price = prices.get(model) or {}
        pin, pout = price.get("input_per_mtok"), price.get("output_per_mtok")
        if pin is None or pout is None:
            sys.exit(f"заполните цены для {model} в {prices_path}")

        rs = [r for r in rs if r["task_id"] in common]
        valid = [r for r in rs if r["status"] != "infra_error"]
        solved = sum(r["status"] == "solved" for r in valid)
        infra = len(rs) - len(valid)
        lo, hi = wilson(solved, len(valid))
        rate = solved / len(valid) if valid else 0.0
        # Стоимость считаем по всем прогонам: потраченные токены не возвращаются.
        cost = sum(r["input_tokens"] * pin + r["output_tokens"] * pout
                   for r in rs) / 1e6
        med_steps = statistics.median(r["steps"] for r in valid) if valid else 0
        med_tokens = (statistics.median(r["input_tokens"] + r["output_tokens"]
                                        for r in valid) if valid else 0)
        per_solved = f"{cost / solved:.4f}" if solved else "—"
        print("\t".join(map(str, [
            model, harness, version, len(valid), solved, f"{rate:.3f}",
            f"{lo:.2f}–{hi:.2f}", infra, med_steps, med_tokens,
            f"{cost:.4f}", per_solved,
        ])))


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("использование: summarize.py runs.jsonl prices.json")
    main(sys.argv[1], sys.argv[2])

Запуск с выравниванием колонок:

python3 summarize.py runs.jsonl prices.json | column -t -s $'\t'

Что означают колонки

КолонкаСмыслЗачем нужна
validПрогоны без инфраструктурных сбоевЗнаменатель доли успеха
rateДоля решённых среди валидныхГлавная метрика качества
ci95Интервал УилсонаПоказывает, различимы ли конфигурации на таком объёме задач
infraЧисло сбоев окруженияЕсли сбоев много, сравнение ненадёжно
med_stepsМедианное число действийОтражает стратегию оболочки и косвенно латентность
med_tokensМедиана входных и выходных токеновПоказывает, сколько контекста съедает оболочка
costСуммарная стоимость всех прогоновСколько стоил эксперимент
cost_per_solvedСтоимость, делённая на число решённых задачГлавная экономическая метрика

Шаг 4. Проверка результата

Не верьте таблице, пока не проверили её входные данные. Все команды ниже только читают файлы.

# 1. Каждая строка — валидный JSON
jq -e . runs.jsonl > /dev/null && echo "JSONL OK"

# 2. Распределение статусов по конфигурациям
jq -r '[.model, .harness, .harness_version, .status] | @tsv' runs.jsonl \
  | sort | uniq -c

# 3. Число прогонов на задачу одинаково у всех конфигураций
jq -r '[.model, .harness, .task_id] | @tsv' runs.jsonl \
  | sort | uniq -c | awk '{print $1}' | sort | uniq -c

# 4. Подозрительные «успехи» с нулём шагов или токенов
jq -c 'select(.status == "solved" and (.steps == 0 or .output_tokens == 0))' runs.jsonl

Таблице можно доверять, если выполнены условия:

Как читать таблицу

Сначала смотрите на интервалы, потом на цифры. Если интервалы двух конфигураций сильно перекрываются, на этом объёме задач вы не можете уверенно сказать, какая лучше. Добавьте повторы или задачи, а не придумывайте объяснение разнице.

Сравнивайте по осям матрицы. Строки с одной оболочкой и разными моделями показывают вклад модели. Строки с одной моделью и разными оболочками показывают вклад оболочки. Если оболочка меняет ранжирование моделей, это и есть прямое доказательство тезиса статьи на ваших собственных данных.

Стройте фронт Парето по качеству и стоимости. Конфигурация доминирована, если другая решает не меньше задач и стоит не дороже за решённую задачу. Доминированные конфигурации можно отбрасывать. Выбор среди оставшихся зависит от ваших ограничений, а не от бенчмарка.

Число шагов — отдельная ось. Две конфигурации с одинаковыми качеством и стоимостью могут сильно различаться по числу действий. В продакшене каждое действие означает латентность, риск побочного эффекта и строку в аудите. Для агента, который работает в реальной инфраструктуре, 12 шагов вместо 40 — самостоятельное преимущество.

Типовые ошибки

Ограничения подхода

Итог

Ответ на вопрос из заголовка прост: агентный бенчмарк запускает не модель, а систему, и в итоговый процент попадает вклад каждой её части. Чтобы сделать из этого практический вывод, сравнивайте конфигурации «модель + оболочка + версия», планируйте эксперимент матрицей, отделяйте сбои инфраструктуры от провалов агента и всегда показывайте рядом с качеством его цену: стоимость за решённую задачу, токены и число действий.

Другие практические материалы по оценке агентов собраны в разделе руководств. Определения терминов из статьи — оболочка, trial, доверительный интервал, фронт Парето — есть в глоссарии.