Один агент, разные обвязки: тестируем HarnessLab

Модель та же, задачи те же, а результат меняется: кто-то переписал системный промпт, добавил шаг планирования или повторные попытки при ошибках инструментов. Всё это части обвязки (harness), то есть кода и конфигурации вокруг модели. От обвязки зависит, чем агент закончит работу, причём часто сильнее, чем от выбора модели. Беда в том, что такие изменения обычно оценивают «на глаз»: прогнали пару задач, стало вроде лучше, смёржили.
В этой статье мы собираем HarnessLab, небольшой стенд для A/B-сравнения двух конфигураций обвязки. На выходе получается повторяемый бенчмарк. По каждой конфигурации он показывает успешность, стоимость, время и трассировки, а для каждой пары прогонов находит первую точку расхождения, то есть шаг, на котором поведение агентов разошлось.
Что именно мы сравниваем
Конфигурация обвязки складывается из пяти осей. Удобно описывать каждую явно, чтобы diff между A и B был виден целиком:
- Промпт: системные инструкции и шаблоны сообщений.
- Планирование: есть ли отдельный шаг плана, как часто план пересматривается.
- Инструменты: набор, схемы аргументов, формат ответов, лимиты вывода.
- Повторные попытки: что считается ошибкой, сколько ретраев, есть ли backoff.
- Верификация: проверяет ли агент свой результат (тесты, линтер, самопроверка) перед тем, как объявить задачу выполненной.
Главное правило: одно сравнение — одна ось. Если A и B отличаются и промптом, и ретраями, вы узнаете, что B лучше или хуже, но не узнаете почему.
Шаг 1. Структура стенда
harnesslab/
configs/
A.json # базовая конфигурация
B.json # A + одно изменение
tasks/
task-01.json # формулировка + путь к фикстуре + команда проверки
task-02.json
fixtures/
repo-01/ # исходное состояние рабочей директории для задачи
prices.json # ваши текущие цены за токены (заполняете сами)
runner.py # ваша точка входа в агента (контракт ниже)
compare.py # агрегирование и поиск расхождений
runs/ # результаты, создаются при прогоне
Пример пары конфигураций, которые отличаются только верификацией:
// configs/A.json
{
"model": "claude-sonnet-5-5",
"temperature": 0,
"max_steps": 40,
"prompt": "prompts/system-v1.md",
"planning": "none",
"tools": ["read_file", "write_file", "run_command"],
"retries": { "max": 2, "on": ["tool_error"] },
"verify": "none"
}
// configs/B.json — то же самое, кроме:
"verify": "run_task_check_before_finish"
Проверить, что конфигурации действительно отличаются одной осью, можно безопасной командой, которая только читает файлы:
diff <(jq -S . configs/A.json) <(jq -S . configs/B.json)
Шаг 2. Задачи с детерминированной проверкой
Успешность должен определять внешний проверяющий, а не отчёт агента. Подходит любая команда с кодом возврата 0 или не 0: тесты, сравнение с эталонным файлом, проверка схемы.
// tasks/task-01.json (пример)
{
"id": "task-01",
"fixture": "fixtures/repo-01",
"instruction": "Исправь падающий тест в tests/test_parse.py, не меняя сам тест.",
"check": "python -m pytest -q tests/test_parse.py",
"timeout_s": 600
}
Задач нужен хотя бы десяток, и они должны различаться по сложности. На двух-трёх задачах разница между обвязками тонет в шуме.
Шаг 3. Контракт прогона и формат трассы
runner.py пишете вы, он оборачивает вашего агента. Стенду нужно, чтобы он соблюдал контракт:
- Скопировать фикстуру во временную директорию. Агент никогда не работает в исходной фикстуре.
- Запустить агента с указанной конфигурацией и записывать каждое событие строкой JSON в
runs/<cfg>/<task>/rep-<n>.jsonl. - После завершения запустить команду
checkв той же временной директории и записать итог вrep-<n>.result.json.
Формат события в трассе (пример):
{"step": 3, "type": "tool_call", "name": "run_command",
"args": {"cmd": "python -m pytest -q"}, "tokens_in": 4120, "tokens_out": 96, "t": 12.4}
Формат итога:
{"task": "task-01", "config": "B", "rep": 2, "success": true,
"wall_s": 84.2, "tokens_in": 51230, "tokens_out": 3310, "steps": 17, "stop": "finished"}
Поле stop (finished, max_steps, timeout, crash) пригодится позже: оно отделяет плохое решение от упавшего стенда.
Шаг 4. Прогон
Каждую пару «задача × конфигурация» гоняем несколько раз: даже при temperature: 0 поведение агента не гарантированно детерминировано. Чтобы на результат не влияло время суток и нагрузка API, конфигурации чередуются внутри одного цикла, а не идут подряд блоками:
for r in 1 2 3 4 5; do
for t in tasks/*.json; do
for cfg in A B; do
python runner.py --config "configs/$cfg.json" --task "$t" --rep "$r" --out "runs/$cfg"
done
done
done
Перед полным прогоном запустите одну задачу с одним повтором и вручную посмотрите трассу. Это дешевле, чем найти ошибку в контракте после сотни прогонов.
Шаг 5. Агрегирование и первая точка расхождения
Цены не зашиты в код. Заполните prices.json по актуальному прайс-листу своего провайдера:
{ "input_per_mtok": 0.0, "output_per_mtok": 0.0 }
Пример compare.py:
import hashlib
import json
import statistics
from pathlib import Path
PRICES = json.loads(Path("prices.json").read_text())
def cost(r):
return (r["tokens_in"] * PRICES["input_per_mtok"]
+ r["tokens_out"] * PRICES["output_per_mtok"]) / 1_000_000
def load_results(cfg):
return [json.loads(p.read_text())
for p in sorted(Path("runs", cfg).glob("*/rep-*.result.json"))]
def step_key(ev):
# Сравниваем действие, а не текст: тип, инструмент и хеш аргументов.
args = json.dumps(ev.get("args", {}), sort_keys=True, ensure_ascii=False)
return (ev["type"], ev.get("name"), hashlib.sha256(args.encode()).hexdigest()[:12])
def load_trace(path):
lines = path.read_text().splitlines()
return [step_key(json.loads(l)) for l in lines if l.strip()]
def first_divergence(a, b):
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
return i, x, y
if len(a) != len(b):
return min(len(a), len(b)), None, None
return None
def summary(cfg):
rs = load_results(cfg)
ok = [r for r in rs if r["success"]]
return {
"runs": len(rs),
"success_rate": round(len(ok) / len(rs), 3) if rs else None,
"median_wall_s": statistics.median(r["wall_s"] for r in rs) if rs else None,
"median_cost": statistics.median(cost(r) for r in rs) if rs else None,
"cost_per_success": sum(cost(r) for r in rs) / len(ok) if ok else None,
"stops": {s: sum(r["stop"] == s for r in rs) for s in {r["stop"] for r in rs}},
}
if __name__ == "__main__":
for cfg in ("A", "B"):
print(cfg, json.dumps(summary(cfg), ensure_ascii=False))
for a_path in sorted(Path("runs/A").glob("*/rep-*.jsonl")):
b_path = Path("runs/B", a_path.parent.name, a_path.name)
if not b_path.exists():
print(f"{a_path.parent.name}/{a_path.stem}: нет пары в B")
continue
d = first_divergence(load_trace(a_path), load_trace(b_path))
where = "идентичны" if d is None else f"шаг {d[0]}: A={d[1]} B={d[2]}"
print(f"{a_path.parent.name}/{a_path.stem}: {where}")
Запуск только читает файлы в runs/:
python compare.py | tee report.txt
Как читать первую точку расхождения
Если A и B меняют только верификацию, трассы должны совпадать вплоть до момента, когда агент собирается закончить работу. Раннее расхождение, например на шаге 0 или 1, значит, что в сравнение просочилось лишнее отличие: другой промпт, другой порядок инструментов в схеме, недетерминизм окружения. Сначала разберитесь с ним, потом с метриками. Позднее расхождение там, где ему и положено быть, — признак того, что эксперимент чистый.
Проверка результата
Бенчмарк можно считать готовым, когда выполняются все пункты:
- Санити-прогон A против A. Сравните конфигурацию саму с собой, подставив копию A вместо B. Метрики должны совпадать в пределах шума. Если «A лучше A», шум стенда больше эффекта, который вы хотите измерить.
- У каждой пары «задача × повтор» есть и трасса, и итог, в отчёте нет строк «нет пары».
- Доля
stop: crashиtimeoutмала и примерно одинакова в A и B. Если она не мала, вы измеряете стенд, а не обвязку. - Повторный запуск
compare.pyна тех жеruns/даёт тот же отчёт байт в байт. - Успешность сравнивается и попарно по задачам. Полезно видеть, какие задачи B решает, а A нет, и наоборот, а не только общий процент.
Типовые ошибки
- Несколько изменений сразу. Результат есть, объяснения нет. Делите на серию A→B→C.
- Агент сам решает, успешен ли он. Фраза «Готово, тесты проходят» в трассе ничего не доказывает. Засчитывайте только код возврата внешней проверки.
- Общая рабочая директория. Один прогон оставляет файлы, следующий их находит. Всегда работайте в свежей копии фикстуры.
- Сравнение медианной стоимости без успешности. Обвязка, которая быстро сдаётся, дешёвая. Смотрите на
cost_per_success. - Нечестные ретраи. Если B повторяет весь прогон до успеха, а A нет, вы сравниваете pass@k с pass@1. Ретраи внутри шага и ретраи всего прогона учитывайте раздельно.
- Сравнение сырого текста. Трассы почти никогда не совпадают по тексту ответов модели. Ищите расхождение по действиям: инструменту и аргументам.
- Нестабильные проверки. Флаки-тест в
checkвыглядит как недетерминизм агента. Прогоните проверку на эталонном решении несколько раз, прежде чем включать задачу в набор.
Ограничения подхода
- Небольшой набор задач и пять повторов дают ориентир, но не статистически строгий вывод. Разницу в несколько процентных пунктов на таком объёме лучше считать неразличимой.
- Хеширование аргументов — грубый критерий. Одинаковые по смыслу команды с разными пробелами разойдутся. Если это мешает, нормализуйте аргументы перед хешированием.
- Бенчмарк показывает поведение на ваших задачах и ваших фикстурах. На другие типы задач результаты переносятся только после отдельной проверки.
- Провайдер может незаметно обновить модель или API. Записывайте версию модели и дату прогона в итог и не сравнивайте прогоны, разнесённые во времени, как одну серию.
- Если агенту доступна сеть или он совершает внешние действия, прогон перестаёт быть изолированным и воспроизводимым. Для бенчмарка инструменты должны работать только внутри временной директории.
Что дальше
Когда стенд проходит санити-проверку A против A, его можно добавить в CI: каждое изменение обвязки приходит с отчётом compare.py, где для каждой задачи видна точка расхождения. Методику изоляции прогонов и проверки агентов по действиям мы разбираем в гайдах. Термины из этой статьи (обвязка, трасса, pass@k, верификация) собраны в глоссарии.