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

Строчку в рейтинге агентного бенчмарка часто читают как «модель X умеет решать задачи на N%». Это неверно. Агентный бенчмарк оценивает не модель саму по себе, а систему: модель, программу, которая гоняет её в цикле, набор инструментов, лимиты на шаги и токены, а также стратегию, по которой агент выбирает действия. Если поменять оболочку, у той же модели получится другой результат. Если ничего не говорить о стоимости, «лучшим» может оказаться агент, который в разы дороже соседа и выигрывает у него на пару задач.
В этой статье мы соберём воспроизводимый процесс: зафиксируем единицу сравнения, введём формат журнала прогонов, напишем скрипт, который сводит результаты в таблицу «модель + оболочка» (доля решённых задач с доверительным интервалом, медианное число шагов, токены, стоимость за решённую задачу), и разберём, как читать эту таблицу, ничего лишнего ей не приписывая.
Что на самом деле стоит за строкой рейтинга
Результат агентного прогона удобно записать как функцию от нескольких аргументов:
результат = f(модель, оболочка, инструменты, бюджет, окружение, случайность)
Модель здесь только один аргумент из шести. Такие лидерборды, как Terminal-Bench, где агент решает задачи в терминале внутри контейнера, поэтому и публикуют пары «агент + модель», а не одни названия моделей. Но при пересказе в чатах и презентациях первую половину пары обычно теряют.
Из чего состоит оболочка
Оболочкой (harness, агентным каркасом) называют весь код вокруг вызова модели. На результат обычно влияют:
- Системный промпт и формат действий. Как модели объясняют задачу и в каком виде она должна вернуть команду: JSON, вызов инструмента или свободный текст, который потом парсится.
- Набор инструментов. Сырой shell, редактор файлов с диффами, поиск по репозиторию, отдельный инструмент «запусти тесты». Один и тот же шаг может занять одно действие или пять.
- Управление контекстом. Как обрезается длинный вывод команд, есть ли суммаризация истории, что делается при переполнении окна.
- Обработка ошибок. Повторяются ли вызовы при невалидном формате, получает ли модель сообщение об ошибке парсинга или ход просто теряется.
- Стратегия. Есть ли этап планирования, самопроверка перед завершением, несколько попыток с выбором лучшей.
- Лимиты. Максимум шагов, токенов, времени на задачу, таймауты отдельных команд.
Каждый пункт может сдвинуть итоговую долю решённых задач. Поэтому фразу «модель A лучше модели B» можно обосновать только в одном случае: обе прогнаны в одной и той же оболочке одной версии, с одинаковыми лимитами и на одном наборе задач.
Шаг 1. Зафиксируйте единицу сравнения
Единица сравнения — это не модель, а конфигурация. Минимальный ключ выглядит так:
(model, harness, harness_version)
Для каждой конфигурации отдельно (в конфиге запуска или в README эксперимента) зафиксируйте то, что в ключ не попало, но на результат влияет:
- точный идентификатор модели и параметры сэмплирования (temperature, лимит выходных токенов);
- лимит шагов и таймаут на задачу;
- набор задач и его версию;
- число повторов (trial) на каждую задачу.
Чтобы влияние модели и оболочки можно было разделить, планируйте эксперимент матрицей, а не набором случайных пар. Минимальный осмысленный план: две модели × две оболочки. Тогда вы сможете сравнить модели внутри одной оболочки и оболочки на одной модели, а заодно увидите взаимодействие: бывает, что оболочка помогает одной модели и мешает другой.
Шаг 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 принимает ровно три значения:
solved: проверка задачи пройдена;failed: агент не справился. Сюда же относятся исчерпанный лимит шагов, таймаут агента и невалидный вывод;infra_error: сбой окружения, за который агент не отвечает. Например, контейнер не поднялся или упал сам раннер.
Граница между 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
Таблице можно доверять, если выполнены условия:
- скрипт не вывел предупреждений об исключённых задачах. Если вывел, разберитесь, почему у конфигураций разные наборы;
- в проверке 3 получилось одно значение: у каждой пары «конфигурация + задача» одинаковое число повторов;
- доля
infraмала и примерно одинакова у всех конфигураций; - проверка 4 ничего не вернула;
- вы вручную открыли журнал хотя бы нескольких решённых и проваленных задач для каждой конфигурации и убедились, что статус соответствует происходящему.
Как читать таблицу
Сначала смотрите на интервалы, потом на цифры. Если интервалы двух конфигураций сильно перекрываются, на этом объёме задач вы не можете уверенно сказать, какая лучше. Добавьте повторы или задачи, а не придумывайте объяснение разнице.
Сравнивайте по осям матрицы. Строки с одной оболочкой и разными моделями показывают вклад модели. Строки с одной моделью и разными оболочками показывают вклад оболочки. Если оболочка меняет ранжирование моделей, это и есть прямое доказательство тезиса статьи на ваших собственных данных.
Стройте фронт Парето по качеству и стоимости. Конфигурация доминирована, если другая решает не меньше задач и стоит не дороже за решённую задачу. Доминированные конфигурации можно отбрасывать. Выбор среди оставшихся зависит от ваших ограничений, а не от бенчмарка.
Число шагов — отдельная ось. Две конфигурации с одинаковыми качеством и стоимостью могут сильно различаться по числу действий. В продакшене каждое действие означает латентность, риск побочного эффекта и строку в аудите. Для агента, который работает в реальной инфраструктуре, 12 шагов вместо 40 — самостоятельное преимущество.
Типовые ошибки
- Сравнение строк с разными оболочками как сравнение моделей. Это главная ошибка, ради которой написана статья. Модели можно сравнивать только внутри одной оболочки.
- Таймаут агента записан в
infra_error. Такие прогоны выпадают из знаменателя, и доля успеха растёт искусственно. Таймаут, исчерпанный лимит шагов и неразобранный вывод — это провал агента, то естьfailed. - Разные наборы задач. Одна конфигурация упала на половине прогона, другая дошла до конца. Скрипт сравнивает только по общему набору, но лучше перезапустить эксперимент.
- Один прогон на задачу. Агентные прогоны нестабильны даже при низкой температуре: недетерминированы окружение, сеть и порядок вывода. Без повторов вы не отличите разницу от шума.
- Стоимость без кешированных токенов. Если провайдер тарифицирует кешированный вход отдельно, простая формула завысит или занизит стоимость. Добавьте в журнал поле с кешированными токенами и отдельную цену для них.
- Неназванные лимиты. Конфигурация с лимитом в 100 шагов против конфигурации с лимитом 30 — это сравнение бюджетов, а не оболочек. Указывайте лимиты рядом с таблицей.
- Прогон агента с реальными секретами. Запускайте задачи в изолированных контейнерах без продовых ключей и доступов. Агент выполняет произвольные команды, и бенчмарк не должен стать инцидентом.
Ограничения подхода
- Таблица описывает поведение конфигураций на этом наборе задач. Перенос на ваш рабочий домен — гипотеза, которую нужно проверять на собственных задачах.
- Интервал Уилсона считает прогоны независимыми. Повторы одной задачи коррелированы, поэтому реальная неопределённость шире. Для строгого анализа используйте бутстрэп по задачам.
- Цены у провайдеров меняются. Стоимость в таблице верна только для тарифов, записанных в
prices.jsonна дату расчёта. Храните этот файл в репозитории вместе с результатами. - Медианы скрывают хвосты. Если вам важны худшие случаи, например неуправляемые длинные прогоны, добавьте 90-й перцентиль токенов и шагов.
- Публичные лидерборды обновляются, а задачи могут попасть в обучающие данные моделей. Свой небольшой закрытый набор задач — полезное дополнение к публичным результатам.
Итог
Ответ на вопрос из заголовка прост: агентный бенчмарк запускает не модель, а систему, и в итоговый процент попадает вклад каждой её части. Чтобы сделать из этого практический вывод, сравнивайте конфигурации «модель + оболочка + версия», планируйте эксперимент матрицей, отделяйте сбои инфраструктуры от провалов агента и всегда показывайте рядом с качеством его цену: стоимость за решённую задачу, токены и число действий.
Другие практические материалы по оценке агентов собраны в разделе руководств. Определения терминов из статьи — оболочка, trial, доверительный интервал, фронт Парето — есть в глоссарии.