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

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

Один агент, разные обвязки: тестируем HarnessLab
Temporary fallback cover; replace in editorial pass.

Уровень: продвинутый · Чтение: до 12 минут · Опубликовано: 24 октября 2026

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

В этой статье мы собираем HarnessLab, небольшой стенд для A/B-сравнения двух конфигураций обвязки. На выходе получается повторяемый бенчмарк. По каждой конфигурации он показывает успешность, стоимость, время и трассировки, а для каждой пары прогонов находит первую точку расхождения, то есть шаг, на котором поведение агентов разошлось.

Что именно мы сравниваем

Конфигурация обвязки складывается из пяти осей. Удобно описывать каждую явно, чтобы diff между A и B был виден целиком:

Главное правило: одно сравнение — одна ось. Если 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 пишете вы, он оборачивает вашего агента. Стенду нужно, чтобы он соблюдал контракт:

  1. Скопировать фикстуру во временную директорию. Агент никогда не работает в исходной фикстуре.
  2. Запустить агента с указанной конфигурацией и записывать каждое событие строкой JSON в runs/<cfg>/<task>/rep-<n>.jsonl.
  3. После завершения запустить команду 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, его можно добавить в CI: каждое изменение обвязки приходит с отчётом compare.py, где для каждой задачи видна точка расхождения. Методику изоляции прогонов и проверки агентов по действиям мы разбираем в гайдах. Термины из этой статьи (обвязка, трасса, pass@k, верификация) собраны в глоссарии.