Практика · AI-разработка · Code review

Diff-first code review: как сократить стоимость проверки AI-агентом

Уровень: продвинутый Время чтения и практики: 75 минут Результат: два агента и воспроизводимый бенчмарк качества, стоимости и вызовов инструментов

Дорогой code review часто начинается не с большой модели, а с неправильной точки входа. Получив задачу «проверь изменение», AI-агент перечисляет каталоги, читает README, ищет архитектурные документы, открывает десятки соседних модулей и только потом возвращается к изменённым строкам. Его контекст уже заполнен материалом, который не помог подтвердить ни одной новой ошибки. В этом руководстве мы построим две сравнимые версии reviewer-агента: обычную repo-first и управляемую diff-first. Затем измерим их на одном наборе изменений, не подменяя качество впечатлением от красивого отчёта.

Что получится

Итогом будет локальный бенчмарк, который запускает две стратегии на одинаковых Git-изменениях:

  1. Repo-first. Агент свободно исследует репозиторий, а затем проверяет изменение.
  2. Diff-first. Агент начинает с патча, формулирует конкретные гипотезы риска и получает только тот соседний код, который нужен для проверки этих гипотез.

Для каждого запуска стенд сохранит:

  • структурированный список замечаний;
  • входные и выходные токены по данным провайдера или локального runtime;
  • число и типы вызовов инструментов;
  • объём прочитанных файлов и строк;
  • продолжительность запуска;
  • полноту, точность и покрытие критических дефектов;
  • причину каждого дополнительного чтения в diff-first режиме.

Руководство не содержит заранее объявленного победителя и не приписывает стратегиям выдуманные проценты экономии. Вы получите формат, команды и правила оценки, а численные выводы рассчитаете на своих репозиториях и выбранной модели.

Почему исследование всего репозитория дорого

Просмотр кода человеком обычно имеет естественный якорь: pull request показывает изменённые файлы и строки. Reviewer сначала понимает намерение патча, затем открывает определения типов, вызываемые функции, тесты и конфигурацию, если конкретное замечание требует такого перехода.

Агент без явной стратегии часто действует иначе. Общий промпт «изучи репозиторий и проведи тщательное review» превращает полноту в бесконечное исследование. Модель не знает, какой объём знакомства достаточен, поэтому страх пропустить зависимость толкает её к новым поискам.

list_repository
→ read README
→ read package manifest
→ list src
→ search changed symbol globally
→ read several implementations
→ inspect test tree
→ inspect configuration
→ finally read diff
→ repeat searches after context compression

Стоимость растёт сразу по нескольким каналам:

  • Вход модели. Результаты чтения попадают в последующие запросы.
  • Инструменты. Каждый поиск и файл создаёт отдельный цикл принятия решения.
  • Задержка. Последовательные вызовы нельзя полностью распараллелить.
  • Загрязнение контекста. Соседний, но неизменённый код начинает конкурировать с патчем за внимание модели.
  • Повторное чтение. После сокращения истории агент может заново искать уже виденные данные.
  • Ложные замечания. Модель начинает рецензировать старый код, который не относится к изменению.

Важно не запретить исследование репозитория, а сделать его условным. Некоторые дефекты невозможно увидеть в одном diff: изменение сигнатуры может нарушить внешний вызов, новый путь — обойти авторизацию, а перестановка операций — сломать транзакционный инвариант. Поэтому diff-first — не режим «читать только патч». Это режим «сначала патч, затем минимально достаточное доказательство».

Контекст для review должен расширяться по причине, а не по любопытству.

Конкретный кейс: повторная отправка платежа

Рассмотрим синтетическое изменение без привязки к клиенту или реальному продукту. В сервисе появился повтор сетевого запроса при временной ошибке:

diff --git a/src/payments/charge.py b/src/payments/charge.py
index 2a71b10..e6d94b2 100644
--- a/src/payments/charge.py
+++ b/src/payments/charge.py
@@ -38,7 +38,12 @@ def create_charge(order, gateway):
-    return gateway.charge(order.total, order.currency)
+    for attempt in range(3):
+        try:
+            return gateway.charge(order.total, order.currency)
+        except TemporaryGatewayError:
+            if attempt == 2:
+                raise

Из патча уже выводится проверяемая гипотеза: повтор небезопасен, если операция не идемпотентна. Чтобы подтвердить или опровергнуть её, агенту не нужен весь репозиторий. Ему нужны ответы на три конкретных вопроса:

  1. Принимает ли gateway.charge ключ идемпотентности?
  2. Передаёт ли вызывающий код устойчивый идентификатор заказа или операции?
  3. Есть ли тест, моделирующий ситуацию «провайдер выполнил списание, но клиент получил временную ошибку»?

Diff-first агент сначала ищет определение или протокол charge, затем ближайшие вызовы и тесты повтора. Если интерфейс поддерживает idempotency_key, но новый код его не передаёт, появляется доказуемое замечание. Если нижний слой сам гарантирует идемпотентность, гипотеза закрывается без замечания.

Как ведёт себя repo-first вариант

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

Как ведёт себя diff-first вариант

Управляемый агент строит короткую цепочку:

изменённая операция
→ риск повторного побочного эффекта
→ контракт gateway.charge
→ место формирования idempotency_key
→ негативный тест сетевой неопределённости
→ подтверждённое замечание или закрытая гипотеза

Такая трасса полезна даже при одинаковом итоговом замечании: она показывает, почему каждый фрагмент кода попал в контекст.

Сначала зафиксируйте контракт хорошего review

Нельзя оптимизировать стоимость, пока неизвестно, что считается полезным результатом. В этом стенде замечание принимается только при выполнении всех условий:

  1. Оно относится к поведению, добавленному или изменённому патчем.
  2. В нём описан конкретный сценарий отказа, а не общее пожелание.
  3. Указано место в изменённом файле.
  4. Есть доказательство из diff, соседнего контракта, вызова, теста или конфигурации.
  5. Предложена проверяемая коррекция или тест, который воспроизводит проблему.
  6. Уверенность отделена от серьёзности.

Структура одного замечания:

{
  "title": "Повтор может создать второе списание",
  "severity": "high",
  "confidence": 0.93,
  "category": "side_effect",
  "file": "src/payments/charge.py",
  "line": 41,
  "changed_line": true,
  "scenario": "Провайдер выполнил первое списание, но соединение оборвалось до ответа; цикл отправляет операцию снова.",
  "evidence": [
    "В новом цикле повторяется gateway.charge",
    "Контракт принимает idempotency_key, но этот вызов его не передаёт"
  ],
  "suggested_check": "Смоделировать успешное списание с TemporaryGatewayError на стороне клиента и проверить единственность операции."
}

Не включайте в основной score замечания о стиле, именовании и рефакторинге, если они не связаны с дефектом. Их можно хранить в отдельном массиве non_blocking_notes, чтобы разговор о предпочтениях не искажал измерение обнаружения ошибок.

Два варианта агента

Вариант A: repo-first

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

Разрешённые действия:

  • получить diff;
  • перечислять каталоги;
  • читать любые файлы рабочей копии;
  • искать строки и символы;
  • запускать разрешённые тесты и статические проверки;
  • возвращать замечания по заданной JSON-схеме.

Repo-first агент нужен не как карикатурный «плохой» baseline. Его инструкция должна требовать такой же формат доказательств и запрещать замечания вне патча. Отличаться должна стратегия получения контекста, а не строгость оценки.

Вариант B: diff-first

Этот агент получает diff первым сообщением. До чтения дополнительных файлов он обязан построить карту изменения:

  • изменённые сущности;
  • новые и удалённые ветви;
  • побочные эффекты;
  • изменения контрактов и форматов;
  • границы доверия и разрешений;
  • изменения конкурентности, времени и порядка операций;
  • недостающие или изменённые проверки.

После этого каждое внешнее чтение оформляется как запрос контекста с причиной:

{
  "hypothesis_id": "H3",
  "question": "Поддерживает ли gateway.charge ключ идемпотентности?",
  "target": "определение символа gateway.charge",
  "stop_when": "найдена сигнатура и реализация адаптера",
  "max_files": 3
}

Если агент не может назвать проверяемый вопрос и условие остановки, дополнительное чтение не разрешается.

Что должно быть одинаковым

Параметр Требование
Модель и версия Одинаковые в парной серии
Параметры генерации Одинаковая температура, лимит вывода и режим рассуждения
Репозиторий Одинаковый закреплённый commit
Diff Одинаковые base и head
Инструменты Одинаковый набор и одинаковые ограничения безопасности
Выходная схема Одинаковая JSON-схема замечаний
Таймаут Одинаковый общий предел
Кэш Либо отключён для всех, либо измеряется отдельно

Алгоритм diff-first по шагам

Шаг 1. Подготовьте патч как самостоятельный вход

git diff --no-ext-diff --unified=40 BASE_SHA HEAD_SHA -- > change.diff
git diff --name-status BASE_SHA HEAD_SHA -- > changed-files.txt
git diff --numstat BASE_SHA HEAD_SHA -- > change-numstat.tsv

Сорок строк окружения — стартовое значение, а не универсальная норма. Оно помогает увидеть функцию целиком для небольших изменений. Большие сгенерированные файлы, снимки и lock-файлы лучше пометить отдельно, но не скрывать: агент должен знать, что они изменились.

Шаг 2. Разделите патч на смысловые блоки

Единицей анализа служит не файл, а логическое изменение: новый retry, изменение проверки доступа, перенос записи после сетевого вызова, новое поле схемы, удалённая валидация. Один файл может содержать несколько независимых блоков.

Шаг 3. Сформируйте гипотезы

Для каждого блока агент заполняет таблицу:

Поле Вопрос
risk Что конкретно может сломаться?
trigger При каком входе, состоянии или порядке событий?
evidence_in_diff Какая строка патча породила гипотезу?
missing_fact Какого факта не хватает для решения?
lookup Какой минимальный поиск даст этот факт?
stop_condition Когда исследование по гипотезе завершено?

Шаг 4. Выдайте бюджет на расширение

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

context_policy:
  initial_source: diff
  max_lookup_rounds: 8
  max_files_per_hypothesis: 4
  max_lines_per_read: 240
  max_total_file_lines: 2400
  allow_full_file_when_lines_below: 320
  require_lookup_reason: true
  require_stop_condition: true
  changed_files_first: true
  allow_budget_escape: true

Жёсткий запрет на превышение опасен: сложное изменение действительно может потребовать больше контекста. Поэтому предусмотрите escape hatch. Агент может запросить дополнительный бюджет, но обязан записать:

  • какую гипотезу нельзя закрыть;
  • что уже проверено;
  • какие новые файлы нужны;
  • почему результат влияет на серьёзность или существование замечания.

Шаг 5. Сначала закрывайте гипотезы, потом пишите отчёт

Состояние каждой гипотезы должно стать одним из четырёх:

Confirmed
Есть конкретный сценарий отказа и достаточное доказательство.
Rejected
Соседний контракт или тест показывает, что риск уже закрыт.
Unresolved
Доступного контекста недостаточно; вывод нельзя выдавать за дефект.
Out of scope
Проблема существует, но не создана и не усилена данным diff.

В итоговые замечания попадают только подтверждённые гипотезы. Неопределённые вопросы можно вернуть отдельным разделом open_questions.

Структура воспроизводимого стенда

review-bench/
├── config/
│   ├── common.json
│   ├── repo-first.json
│   └── diff-first.json
├── prompts/
│   ├── common-contract.md
│   ├── repo-first.md
│   └── diff-first.md
├── schemas/
│   ├── review-output.schema.json
│   └── trace-event.schema.json
├── cases/
│   ├── manifest.jsonl
│   ├── case-001/
│   │   ├── change.diff
│   │   ├── expected.json
│   │   └── notes.md
│   └── case-002/
├── repos/
│   └── README.md
├── scripts/
│   ├── run_case.py
│   ├── validate_output.py
│   ├── match_findings.py
│   └── summarize.py
└── runs/
    └── .gitkeep

Репозитории не обязательно копировать внутрь стенда. В manifest.jsonl можно хранить абсолютный или разрешённый относительный путь к локальному checkout. Для переносимости лучше указывать URL отдельно, а запуск выполнять только после явного получения репозитория и проверки commit.

{"case_id":"retry-side-effect-001","repo_path":"../fixtures/payment-service","base_sha":"BASE_COMMIT","head_sha":"HEAD_COMMIT","expected_path":"cases/case-001/expected.json","languages":["python"],"tags":["retry","side_effect"],"max_seconds":900}

Замените BASE_COMMIT и HEAD_COMMIT настоящими идентификаторами из своего тестового репозитория. Не используйте плавающие ветки вроде main в сохранённом запуске.

Инструкции для агентов

Общий контракт

Ты проводишь code review изменения между заданными base и head.

Ищи только дефекты, созданные или усиленные изменением.
Не сообщай о стиле и необязательном рефакторинге.
Для каждого замечания укажи:
- файл и изменённую строку;
- серьёзность и уверенность;
- конкретный сценарий отказа;
- доказательства;
- проверяемую коррекцию или тест.

Не утверждай, что проблема существует, если доступных доказательств
недостаточно. В таком случае добавь вопрос в open_questions.

Верни только JSON, соответствующий review-output.schema.json.

Добавка для repo-first

Исследуй репозиторий в объёме, который считаешь необходимым.
Ты можешь получать diff, читать файлы, искать символы и запускать
разрешённые проверки. Сам выбери порядок действий.

Добавка для diff-first

Начни с предоставленного diff. До чтения других файлов:
1. Раздели diff на логические изменения.
2. Сформируй гипотезы конкретных отказов.
3. Для каждого дополнительного чтения укажи hypothesis_id,
   вопрос, цель и условие остановки.
4. Читай минимальный диапазон строк.
5. Сначала проверяй изменённые файлы, определения затронутых
   символов, непосредственных вызывающих и связанные тесты.
6. Не перечисляй дерево репозитория без гипотезы.
7. Заверши гипотезу как confirmed, rejected, unresolved
   или out_of_scope.
8. Если бюджета недостаточно, запроси budget_escape с причиной.

Схема ответа должна быть одинаковой. Иначе один вариант может выглядеть дешевле просто потому, что возвращает менее подробный результат.

{
  "type": "object",
  "required": ["findings", "open_questions", "review_summary"],
  "properties": {
    "findings": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "title", "severity", "confidence", "category",
          "file", "line", "changed_line", "scenario",
          "evidence", "suggested_check"
        ]
      }
    },
    "open_questions": {"type": "array"},
    "review_summary": {"type": "string"},
    "hypotheses": {"type": "array"}
  }
}

Как учитывать инструменты, прочитанный код и токены

Финальный текст агента не позволяет восстановить стоимость исследования. Все операции нужно проводить через шлюз, который пишет событие до или после каждого разрешённого действия. Это часть наблюдаемости стенда.

Событие вызова инструмента

{"ts":"2026-07-27T10:00:00Z","run_id":"retry-side-effect-001__diff-first__r1","seq":7,"tool":"read_file","target":"src/payments/gateway.py","line_start":1,"line_end":180,"returned_lines":180,"returned_bytes":6240,"hypothesis_id":"H3","reason":"Проверить поддержку idempotency_key","status":"ok","duration_ms":31}

Минимальные поля трассы:

  • run_id и последовательный seq;
  • имя инструмента;
  • нормализованная цель без секретных параметров;
  • размер возвращённых данных;
  • продолжительность и статус;
  • идентификатор гипотезы для diff-first;
  • причина чтения;
  • признак повторного чтения того же диапазона.

Какие операции считать отдельно

Группа Примеры Почему выделять
Навигация list_files, tree Показывает широкое исследование без чтения содержания
Поиск search_text, find_symbol Один поиск может вернуть очень большой результат
Чтение read_file, read_range Даёт объём строк и байтов, попавших в контекст
Git show_diff, show_file_at_revision Отделяет патч от остального кода
Выполнение run_tests, run_linter Время и вычисления несопоставимы с простым чтением

Учёт токенов

Используйте значения input_tokens, output_tokens, cached_input_tokens и другие поля, которые реально возвращает выбранный API или runtime. Не оценивайте стоимость делением символов на условный коэффициент, если провайдер даёт фактический usage.

Сохраняйте сырые значения каждого модельного вызова:

{"run_id":"retry-side-effect-001__diff-first__r1","model_call":4,"model":"PINNED_MODEL_ID","input_tokens":8120,"cached_input_tokens":0,"output_tokens":940,"price_snapshot_id":"local-2026-07-27","estimated_cost":null}

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

Как собрать набор задач и эталон

Бенчмарк должен содержать не только удобные локальные ошибки. Включите изменения разного профиля:

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

Используйте два источника случаев

  1. Исторические изменения. Возьмите исправленные дефекты, для которых известны исходный патч, последующее исправление и тест. Удалите из задания сообщения коммитов, раскрывающие ответ.
  2. Контролируемые мутации. Внесите одно заранее описанное нарушение в тестовую ветку: удалите проверку, поменяйте порядок операций, отбросьте параметр или ослабьте условие. Помечайте такие случаи как синтетические.

Мутации дают чистую причинность, но не полностью отражают реальные ошибки. Исторические случаи естественнее, однако их эталон часто неполон. Отчёт должен показывать результаты по двум группам отдельно.

Эталон одного случая

{
  "case_id": "retry-side-effect-001",
  "provenance": "synthetic_mutation",
  "expected_findings": [
    {
      "finding_id": "F1",
      "category": "side_effect",
      "severity": "high",
      "files": ["src/payments/charge.py"],
      "line_ranges": [[39, 45]],
      "behavior": "Повтор запроса возможен без устойчивого ключа идемпотентности.",
      "required_evidence": [
        "повторяется операция charge",
        "ключ идемпотентности не передан или не гарантирован нижним слоем"
      ]
    }
  ],
  "acceptable_alternatives": [],
  "known_non_findings": [
    "Само число попыток не является дефектом без сценария повторного побочного эффекта."
  ]
}

Не составляйте эталон после просмотра ответов сравниваемых агентов: это создаёт смещение. Сначала два reviewer-а независимо описывают ожидаемые дефекты, затем разрешают разногласия. Если согласие не достигнуто, случай помечается как спорный и не участвует в основном score.

Добавьте отрицательные случаи

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

Как сопоставлять замечания с эталоном

Сравнение по точному тексту не работает: два корректных review могут описывать один дефект разными словами. Используйте двухэтапную процедуру.

Этап 1. Детерминированные кандидаты

Пара считается кандидатом на совпадение, если:

  • категории совпадают или входят в заранее заданную таблицу эквивалентности;
  • файл совпадает с одним из ожидаемых;
  • строка находится в ожидаемом диапазоне или на ближайшей изменённой строке;
  • описанный сценарий относится к тому же наблюдаемому поведению.

Этап 2. Слепая проверка человеком

Reviewer не должен знать, какой вариант агента сформировал замечание. Он выбирает один статус:

  • true_positive — замечание соответствует эталонному дефекту;
  • false_positive — дефект не подтверждается;
  • duplicate — повтор уже сопоставленного замечания;
  • new_valid_finding — полезная проблема вне исходного эталона;
  • uncertain — требуется дополнительная экспертиза.

Новые подтверждённые проблемы добавляйте в расширенный эталон только после завершения основной парной серии. Исходный score сохраните неизменным, а перерасчёт опубликуйте отдельной версией набора.

Не считайте дубликаты отдельными находками

Если агент трижды описал одну причину на соседних строках, это один true positive и два дубликата. Иначе многословный вариант искусственно получит преимущество.

Повторяемый запуск

Удобный адаптер запускает любой агентный CLI, который принимает JSON через стандартный ввод, возвращает структурированный ответ и пишет трассу в заданный файл. Имя конкретного продукта не является частью методики.

{
  "agent_command": ["YOUR_AGENT_ADAPTER"],
  "model": "PINNED_MODEL_ID",
  "temperature": 0,
  "max_output_tokens": 5000,
  "timeout_seconds": 900,
  "repetitions": 3,
  "environment_allowlist": ["PATH", "LANG"],
  "network": false
}

Не добавляйте ключи API в конфигурацию или артефакты. Адаптер получает необходимые секреты из защищённого окружения, но не переносит их в дочерние инструменты и трассу.

Каркас runner-а

from __future__ import annotations

import hashlib
import json
import os
import subprocess
import time
from pathlib import Path


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for block in iter(lambda: stream.read(65536), b""):
            digest.update(block)
    return digest.hexdigest()


def run_once(case, variant, repetition, common):
    run_id = f"{case['case_id']}__{variant}__r{repetition}"
    run_dir = Path("runs") / run_id
    run_dir.mkdir(parents=True, exist_ok=False)

    diff_path = Path("cases") / case["case_id"] / "change.diff"
    trace_path = run_dir / "trace.jsonl"
    response_path = run_dir / "review.json"

    request = {
        "run_id": run_id,
        "variant": variant,
        "repo_path": case["repo_path"],
        "base_sha": case["base_sha"],
        "head_sha": case["head_sha"],
        "diff": diff_path.read_text(encoding="utf-8"),
        "trace_path": str(trace_path.resolve()),
        "common_prompt_path": "prompts/common-contract.md",
        "variant_prompt_path": f"prompts/{variant}.md",
        "output_schema_path": "schemas/review-output.schema.json",
        "limits": common["limits"]
    }

    safe_env = {
        key: os.environ[key]
        for key in common["environment_allowlist"]
        if key in os.environ
    }

    started = time.monotonic()
    completed = subprocess.run(
        common["agent_command"],
        input=json.dumps(request),
        text=True,
        capture_output=True,
        timeout=case["max_seconds"],
        env=safe_env,
        check=False
    )
    duration_ms = round((time.monotonic() - started) * 1000)

    (run_dir / "stdout.txt").write_text(
        completed.stdout, encoding="utf-8"
    )
    (run_dir / "stderr.txt").write_text(
        completed.stderr, encoding="utf-8"
    )

    if completed.returncode != 0:
        status = "agent_error"
    else:
        response_path.write_text(completed.stdout, encoding="utf-8")
        status = "completed"

    metadata = {
        "run_id": run_id,
        "status": status,
        "exit_code": completed.returncode,
        "duration_ms": duration_ms,
        "diff_sha256": sha256(diff_path),
        "base_sha": case["base_sha"],
        "head_sha": case["head_sha"],
        "model": common["model"],
        "temperature": common["temperature"]
    }
    (run_dir / "metadata.json").write_text(
        json.dumps(metadata, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

Этот каркас намеренно не предполагает конкретный API. Адаптер отвечает за вызов модели, реализацию разрешённых инструментов, запись usage и выдачу JSON. Сам runner отвечает за одинаковые входы, таймаут, окружение и неизменяемые артефакты.

Порядок серии

  1. Проверьте, что рабочая копия соответствует head_sha.
  2. Пересоздайте diff из base_sha..head_sha и сравните его SHA-256 с сохранённым.
  3. Случайно перемешайте порядок вариантов внутри каждого случая.
  4. Выполните не менее нескольких повторов, если модель или оркестратор недетерминированы.
  5. Не запускайте варианты одновременно на одной рабочей копии, если инструменты создают кэш или временные файлы.
  6. Валидируйте JSON сразу после запуска.
  7. Заморозьте артефакты до ручной разметки.
python scripts/run_case.py \
  --manifest cases/manifest.jsonl \
  --variants repo-first diff-first \
  --repetitions 3

python scripts/validate_output.py runs/
python scripts/match_findings.py --runs runs/ --cases cases/
python scripts/summarize.py --runs runs/ --output runs/summary.json

Метрики качества, стоимости и поведения

Качество

Для каждого запуска вычисляйте:

  • TP — уникальные эталонные дефекты, найденные агентом;
  • FP — неподтверждённые замечания;
  • FN — эталонные дефекты, которые агент пропустил;
  • Precision = TP / (TP + FP);
  • Recall = TP / (TP + FN);
  • Critical recall — полнота только для дефектов с критической серьёзностью;
  • Clean-patch accuracy — доля корректных патчей, где агент не выдал ложное замечание;
  • Evidence completeness — доля замечаний с проверяемым сценарием и доказательством.

Если пропуск дефекта дороже ложного замечания, добавьте F2-меру, которая сильнее учитывает recall:

F2 = 5 × precision × recall / (4 × precision + recall)

Не объединяйте все серьёзности без дополнительного отчёта. Пять мелких находок не компенсируют пропуск одного критического нарушения авторизации.

Стоимость

  • суммарные входные токены;
  • некэшированные входные токены;
  • выходные токены;
  • число модельных вызовов;
  • денежная оценка по зафиксированному снимку тарифов;
  • медианная и хвостовая продолжительность запуска.

Инструменты и контекст

  • общее число вызовов инструментов;
  • число поисков, чтений и запусков команд;
  • уникальные прочитанные файлы;
  • всего возвращённых строк и байтов;
  • доля строк из изменённых файлов;
  • повторно прочитанные диапазоны;
  • число запросов дополнительного бюджета;
  • доля чтений diff-first с заполненной причиной и гипотезой.

Эффективность, а не просто дешевизна

Полезно считать стоимость одной уникальной подтверждённой находки:

tokens_per_true_positive = total_tokens / max(TP, 1)
tool_calls_per_true_positive = tool_calls / max(TP, 1)

Но эта метрика не заменяет recall. Агент, который прочитал только diff, нашёл одну очевидную ошибку и пропустил остальные, может выглядеть очень эффективным на одну находку. Поэтому решение принимается через ограниченную оптимизацию:

Сначала задайте минимально допустимое качество, затем сравнивайте стоимость стратегий, которые этот порог прошли.

Шаблон итоговой таблицы

Метрика Repo-first Diff-first Разница
Precision измерить измерить рассчитать
Recall измерить измерить рассчитать
Critical recall измерить измерить рассчитать
Входные токены измерить измерить рассчитать
Вызовы инструментов измерить измерить рассчитать
Прочитанные строки измерить измерить рассчитать
Продолжительность измерить измерить рассчитать
Ложные замечания на чистый патч измерить измерить рассчитать

Сохраняйте результаты по каждому случаю, а не только среднее. Агрегат может скрыть, что diff-first выигрывает на локальных изменениях, но систематически теряет дефекты в межмодульных контрактах.

Проверка честности и воспроизводимости

1. Проверьте идентичность входов

git -C ../fixtures/payment-service rev-parse HEAD
git -C ../fixtures/payment-service diff \
  --no-ext-diff --unified=40 BASE_COMMIT HEAD_COMMIT \
  | sha256sum

sha256sum cases/retry-side-effect-001/change.diff

Хеши патча должны совпасть, а HEAD — соответствовать манифесту.

2. Проверьте трассу

jq -s '
  {
    events: length,
    missing_reason: [
      .[]
      | select(.tool != "show_diff")
      | select((.reason // "") == "")
    ] | length,
    missing_hypothesis: [
      .[]
      | select(.tool != "show_diff")
      | select((.hypothesis_id // "") == "")
    ] | length
  }
' runs/CASE__diff-first__r1/trace.jsonl

Для diff-first оба счётчика должны быть нулевыми, кроме заранее описанных служебных операций.

3. Найдите повторное чтение

jq -r '
  select(.tool == "read_file" or .tool == "read_range")
  | [.target, .line_start, .line_end]
  | @tsv
' runs/CASE__VARIANT__r1/trace.jsonl \
  | sort \
  | uniq -c \
  | sort -nr

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

4. Проверьте выходную схему

Отдельный валидатор должен отклонять отсутствующие строки, неизвестные уровни серьёзности, значения уверенности вне диапазона и свободный текст вокруг JSON. Ошибка формата считается не нулём найденных проблем, а техническим провалом запуска.

5. Проведите слепую оценку

Перед ручной разметкой удалите из представления поля variant, run_id, порядок запуска и сведения о стоимости. Перемешайте замечания и присвойте временные идентификаторы. Иначе reviewer может бессознательно оценивать предпочитаемую стратегию мягче.

6. Повторите на другом порядке

Чередуйте, какой вариант запускается первым. Если инструменты используют общий индекс, кэш сборки или прогретый файловый кэш, порядок способен повлиять на задержку. Токены при этом могут не измениться, поэтому время и модельную стоимость анализируйте отдельно.

7. Проверьте стабильность

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

Типичные провалы и способы их обнаружить

Diff-first превращается в diff-only

Агент делает уверенный вывод по изменённым строкам, не проверяя контракт вызываемой функции. Результат — ложные замечания и пропуск межмодульных дефектов.

Проверка: добавьте отрицательный случай, где подозрительное поведение защищено нижним слоем. Хороший агент должен прочитать этот слой и отклонить гипотезу.

Бюджет становится целью

Агент избегает нужного чтения, чтобы уложиться в лимит инструментов. Дешёвый запуск выглядит успешным до сравнения с эталоном.

Исправление: оставьте запрос дополнительного бюджета и оценивайте его обоснованность. Порог качества важнее числа вызовов.

Repo-first получает более удобную инструкцию

Если baseline просят «тщательно изучить весь репозиторий», а diff-first — «найти конкретные дефекты», сравнение измеряет качество промптов, а не порядок получения контекста.

Исправление: вынесите критерии замечаний, схему ответа и ограничения области в общий контракт.

Один поиск возвращает половину репозитория

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

Исправление: измеряйте не только calls, но и возвращённые строки, байты и число уникальных файлов.

Запуски тестов неограниченны

Агент несколько раз запускает весь набор, и вычислительная стоимость начинает доминировать над модельной.

Исправление: разделите быстрые целевые проверки и полный прогон. Для каждого запуска команды сохраняйте причину, продолжительность и код возврата.

Эталон знает только одну формулировку

Корректное замечание признаётся ложным, потому что использует другой термин или указывает соседнюю строку.

Исправление: сопоставляйте наблюдаемое поведение, категорию, файл и доказательство, а не текстовое сходство.

Исторический патч раскрывает ответ

Сообщение коммита, имя ветки, тест test_prevent_double_charge или комментарий напрямую называют дефект.

Исправление: аудитируйте утечки ответа до включения случая. Сохраняйте исходное происхождение отдельно от данных, видимых агенту.

Агент рецензирует старый код

Технически верная проблема не создана изменением и не должна блокировать этот review.

Исправление: требуйте привязку к изменённой строке и объяснение причинной связи с патчем. Старые проблемы сохраняйте отдельно.

Стоимость сравнивается по текущей цене

После изменения тарифов старый отчёт начинает показывать новые денежные значения.

Исправление: первичными метриками оставляйте токены и вызовы, а цену рассчитывайте по версионированному снимку.

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

Diff-first лучше всего работает, когда изменение имеет ясную локальную поверхность и репозиторий позволяет быстро переходить от символа к определению, вызовам и тестам. Есть классы задач, где широкий контекст нужен раньше:

  • массовая архитектурная миграция;
  • изменение глобального протокола или схемы событий;
  • удаление инфраструктурного компонента;
  • генерация кода, где diff не отражает источник изменения;
  • поведение, распределённое между несколькими репозиториями;
  • изменение сборки, упаковки или deployment-контура;
  • ошибка, возникающая только при полном системном тесте;
  • репозиторий с динамическими связями, которые не находятся обычным поиском.

В таких случаях полезен адаптивный режим: diff остаётся точкой входа, но классификатор изменения заранее повышает бюджет и разрешает чтение архитектурной карты, публичных контрактов или связанных репозиториев.

Ещё одно ограничение — неполнота эталона. Code review не имеет абсолютно полного списка возможных дефектов. Человеческая проверка новых находок остаётся обязательной, особенно для безопасности, конкурентности и доменных инвариантов.

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

Как перенести стратегию в рабочий review

После бенчмарка оформите diff-first review как явный рабочий процесс, а не как совет внутри длинной инструкции:

  1. CI сохраняет base, head, diff и список изменённых файлов.
  2. Предварительный этап классифицирует размер и тип изменения.
  3. Reviewer получает diff и строит гипотезы.
  4. Шлюз разрешает чтение только внутри репозитория и ведёт трассу.
  5. Превышение мягкого бюджета требует обоснования, но не блокируется автоматически.
  6. Ответ валидируется по схеме.
  7. Замечания без изменённой строки или сценария отказа отправляются в неблокирующий раздел.
  8. Метрики стоимости агрегируются по типу изменения, языку и размеру diff.
  9. Выборочная человеческая проверка отслеживает деградацию precision и recall.

Маршрутизация по размеру и риску

Тип изменения Стартовая стратегия Расширение
Локальный патч, один модуль Diff-first с малым бюджетом Определения, вызовы, ближайшие тесты
Изменение публичного контракта Diff-first с повышенным бюджетом Все прямые потребители и проверки совместимости
Авторизация, платежи, данные Diff-first с обязательными risk-checklist Политики, границы доверия, негативные тесты
Архитектурная миграция Hybrid Архитектурная карта до детального review
Сгенерированные артефакты Review источника генерации Проверка воспроизводимости результата

Практический критерий принятия

Не вводите diff-first только потому, что он прочитал меньше файлов. Зафиксируйте до эксперимента:

  • минимально допустимый общий recall;
  • отдельный порог critical recall;
  • максимальный уровень ложных замечаний на чистых патчах;
  • желаемое снижение некэшированных входных токенов;
  • желаемое снижение вызовов и прочитанных строк;
  • правила автоматического перехода в расширенный режим.

Если стратегия дешевле, но не проходит порог качества, её нельзя использовать как единственного reviewer-а. Если качество сохраняется только на локальных патчах, применяйте маршрутизацию вместо глобальной замены.

Короткий чек-лист повторения

  1. Закрепите модель, base SHA, head SHA и параметры генерации.
  2. Сохраните diff и его SHA-256.
  3. Подготовьте общий контракт замечаний.
  4. Создайте repo-first и diff-first инструкции.
  5. Пропустите все инструменты через журналируемый шлюз.
  6. Соберите исторические, синтетические и чистые случаи.
  7. Разметьте эталон до запуска агентов.
  8. Перемешайте порядок парных запусков.
  9. Сохраните сырые usage, трассы и ответы.
  10. Проведите слепое сопоставление замечаний.
  11. Сравните качество до стоимости.
  12. Изучите результаты по типам изменений, а не только общий итог.
  13. Настройте escape hatch для случаев, которым нужен широкий контекст.
  14. Повторите серию после изменения модели, промпта или набора инструментов.

← Все руководства · Лабораторный словарь →