Практика · AI-разработка · Code review
Diff-first code review: как сократить стоимость проверки AI-агентом
Дорогой code review часто начинается не с большой модели, а с неправильной точки входа. Получив задачу «проверь изменение», AI-агент перечисляет каталоги, читает README, ищет архитектурные документы, открывает десятки соседних модулей и только потом возвращается к изменённым строкам. Его контекст уже заполнен материалом, который не помог подтвердить ни одной новой ошибки. В этом руководстве мы построим две сравнимые версии reviewer-агента: обычную repo-first и управляемую diff-first. Затем измерим их на одном наборе изменений, не подменяя качество впечатлением от красивого отчёта.
Что получится
Итогом будет локальный бенчмарк, который запускает две стратегии на одинаковых Git-изменениях:
- Repo-first. Агент свободно исследует репозиторий, а затем проверяет изменение.
- 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
Из патча уже выводится проверяемая гипотеза: повтор небезопасен, если операция не идемпотентна. Чтобы подтвердить или опровергнуть её, агенту не нужен весь репозиторий. Ему нужны ответы на три конкретных вопроса:
- Принимает ли
gateway.chargeключ идемпотентности? - Передаёт ли вызывающий код устойчивый идентификатор заказа или операции?
- Есть ли тест, моделирующий ситуацию «провайдер выполнил списание, но клиент получил временную ошибку»?
Diff-first агент сначала ищет определение или протокол charge, затем ближайшие вызовы и тесты повтора. Если интерфейс поддерживает idempotency_key, но новый код его не передаёт, появляется доказуемое замечание. Если нижний слой сам гарантирует идемпотентность, гипотеза закрывается без замечания.
Как ведёт себя repo-first вариант
Свободный агент может сначала прочитать структуру проекта, модели заказов, настройки провайдера, общие исключения, документацию по запуску, остальные платёжные операции и весь каталог тестов. Часть данных потенциально полезна, но причинная связь между чтением и проверяемым риском не фиксируется.
Как ведёт себя diff-first вариант
Управляемый агент строит короткую цепочку:
изменённая операция
→ риск повторного побочного эффекта
→ контракт gateway.charge
→ место формирования idempotency_key
→ негативный тест сетевой неопределённости
→ подтверждённое замечание или закрытая гипотеза
Такая трасса полезна даже при одинаковом итоговом замечании: она показывает, почему каждый фрагмент кода попал в контекст.
Сначала зафиксируйте контракт хорошего review
Нельзя оптимизировать стоимость, пока неизвестно, что считается полезным результатом. В этом стенде замечание принимается только при выполнении всех условий:
- Оно относится к поведению, добавленному или изменённому патчем.
- В нём описан конкретный сценарий отказа, а не общее пожелание.
- Указано место в изменённом файле.
- Есть доказательство из diff, соседнего контракта, вызова, теста или конфигурации.
- Предложена проверяемая коррекция или тест, который воспроизводит проблему.
- Уверенность отделена от серьёзности.
Структура одного замечания:
{
"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 с малой смысловой сложностью.
Используйте два источника случаев
- Исторические изменения. Возьмите исправленные дефекты, для которых известны исходный патч, последующее исправление и тест. Удалите из задания сообщения коммитов, раскрывающие ответ.
- Контролируемые мутации. Внесите одно заранее описанное нарушение в тестовую ветку: удалите проверку, поменяйте порядок операций, отбросьте параметр или ослабьте условие. Помечайте такие случаи как синтетические.
Мутации дают чистую причинность, но не полностью отражают реальные ошибки. Исторические случаи естественнее, однако их эталон часто неполон. Отчёт должен показывать результаты по двум группам отдельно.
Эталон одного случая
{
"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 отвечает за одинаковые входы, таймаут, окружение и неизменяемые артефакты.
Порядок серии
- Проверьте, что рабочая копия соответствует
head_sha. - Пересоздайте diff из
base_sha..head_shaи сравните его SHA-256 с сохранённым. - Случайно перемешайте порядок вариантов внутри каждого случая.
- Выполните не менее нескольких повторов, если модель или оркестратор недетерминированы.
- Не запускайте варианты одновременно на одной рабочей копии, если инструменты создают кэш или временные файлы.
- Валидируйте JSON сразу после запуска.
- Заморозьте артефакты до ручной разметки.
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 как явный рабочий процесс, а не как совет внутри длинной инструкции:
- CI сохраняет base, head, diff и список изменённых файлов.
- Предварительный этап классифицирует размер и тип изменения.
- Reviewer получает diff и строит гипотезы.
- Шлюз разрешает чтение только внутри репозитория и ведёт трассу.
- Превышение мягкого бюджета требует обоснования, но не блокируется автоматически.
- Ответ валидируется по схеме.
- Замечания без изменённой строки или сценария отказа отправляются в неблокирующий раздел.
- Метрики стоимости агрегируются по типу изменения, языку и размеру diff.
- Выборочная человеческая проверка отслеживает деградацию precision и recall.
Маршрутизация по размеру и риску
| Тип изменения | Стартовая стратегия | Расширение |
|---|---|---|
| Локальный патч, один модуль | Diff-first с малым бюджетом | Определения, вызовы, ближайшие тесты |
| Изменение публичного контракта | Diff-first с повышенным бюджетом | Все прямые потребители и проверки совместимости |
| Авторизация, платежи, данные | Diff-first с обязательными risk-checklist | Политики, границы доверия, негативные тесты |
| Архитектурная миграция | Hybrid | Архитектурная карта до детального review |
| Сгенерированные артефакты | Review источника генерации | Проверка воспроизводимости результата |
Практический критерий принятия
Не вводите diff-first только потому, что он прочитал меньше файлов. Зафиксируйте до эксперимента:
- минимально допустимый общий recall;
- отдельный порог critical recall;
- максимальный уровень ложных замечаний на чистых патчах;
- желаемое снижение некэшированных входных токенов;
- желаемое снижение вызовов и прочитанных строк;
- правила автоматического перехода в расширенный режим.
Если стратегия дешевле, но не проходит порог качества, её нельзя использовать как единственного reviewer-а. Если качество сохраняется только на локальных патчах, применяйте маршрутизацию вместо глобальной замены.
Короткий чек-лист повторения
- Закрепите модель, base SHA, head SHA и параметры генерации.
- Сохраните diff и его SHA-256.
- Подготовьте общий контракт замечаний.
- Создайте repo-first и diff-first инструкции.
- Пропустите все инструменты через журналируемый шлюз.
- Соберите исторические, синтетические и чистые случаи.
- Разметьте эталон до запуска агентов.
- Перемешайте порядок парных запусков.
- Сохраните сырые usage, трассы и ответы.
- Проведите слепое сопоставление замечаний.
- Сравните качество до стоимости.
- Изучите результаты по типам изменений, а не только общий итог.
- Настройте escape hatch для случаев, которым нужен широкий контекст.
- Повторите серию после изменения модели, промпта или набора инструментов.