Почему замечания ИИ-ревью нужно проверять¶
Многие команды подключили к merge request ИИ-ревьюера. Он пишет замечания уверенно, как опытный коллега, но уверенный тон ещё не значит, что замечание верно.
Допустим, ИИ-ревьюер находит проблему в merge request коллеги: новый повтор резервирования на складе может создать дубликат, если ответ потеряется. Прежде чем эта находка станет комментарием, кто-то должен проверить, что она верна для этого склада.
Тот же проход вернул ещё три находки. Проверим все четыре, опубликуем ту, что подтвердится, и перепроверим её после исправления автора. Воспроизведение и исправление можно запустить; сами находки придуманы для разбора, это не оценка какой-либо модели.
Начинаем с изменения и контракта¶
Сервис заказов вызывает warehouse.reserve(order_id). В нашем примере каждый принятый вызов создаёт новый резерв, склад не убирает дубликаты по номеру заказа, а TimeoutError не говорит вызывающему коду, успела ли произойти запись. MR добавляет вторую попытку:
async def reserve_with_retry(order_id, warehouse):
for attempt in range(2):
try:
return await warehouse.reserve(order_id)
except TimeoutError:
if attempt == 1:
raise
Этого контракта нет в репозитории, и модель о нём не узнает, если его не передать. В mr-review он идёт в дополнительные инструкции на этапе Brief вместе с целью изменения; как готовить такой контекст, разобрано в статье о документации для агентов.
Воспроизводим подозрение¶
На этапе Dispatch модель возвращает по циклу четыре находки:
- Потерянный ответ может создать два резерва.
- После второго таймаута цикл заканчивается и функция возвращает
None. - В вызов нужно добавить ключ идемпотентности.
- Переменную
attemptстоит переименовать.
Серьёзная из них первая, и её можно проверить. В практикуме WarehouseStub по порядку задаёт исход каждого вызова: "timeout_after_write" создаёт резерв и бросает TimeoutError, как будто ответ потерялся; "ok" создаёт ещё один резерв и возвращает его. Проверка считает резервы и игнорирует ReservationOutcomeUnknown: это исключение из исправления, которое появится позже.
from reservation import ReservationOutcomeUnknown
from warehouse_stub import WarehouseStub
async def check_lost_reply(reserve):
warehouse = WarehouseStub("timeout_after_write", "ok")
try:
await reserve("order-42", warehouse)
except ReservationOutcomeUnknown:
pass
print(f"calls={len(warehouse.calls)}, reservations={len(warehouse.reservations)}")
assert len(warehouse.reservations) == 1, (
"One operation created duplicate reservations"
)
С reserve_with_retry она печатает calls=2, reservations=2 и падает: вызывающий код видит обычный результат, а первый резерв никуда не делся. Заглушка доказывает это только при заданном контракте; для настоящего склада нужна документация его API или интеграционный тест.
Разбираем находки: ошибка, ложное утверждение, вопрос, шум¶
| Находка | Что удалось установить | Что делаем |
|---|---|---|
| Потерянный ответ создаёт два резерва | Воспроизведение показывает две записи при заданном контракте | Публикуем вместе со сценарием и тестом |
После второго таймаута цикл возвращает None |
При attempt == 1 блок except пробрасывает ошибку дальше; тест с двумя потерянными ответами получает TimeoutError |
Отклоняем |
| Добавить ключ идемпотентности | Поможет, только если склад его соблюдает, а по контракту не соблюдает | Сначала спрашиваем про контракт |
Переименовать attempt |
Проблемы в поведении нет | Оставляем линтерам и правилам проекта |
Если опубликовать этот проход автоматически, в MR появятся четыре ветки обсуждения: одна настоящая ошибка, одно ложное утверждение, которое автору придётся опровергать, одно исправление, зависящее от непроверенного API, и одна придирка к стилю. Отклонить ложное утверждение в черновике ревьюер успеет за минуту, а опубликованное оно стоит автору разбирательства, а каждая ложная тревога делает следующую настоящую находку менее заметной. Воспроизведение агент мог бы написать и сам. Но он не прочитает контракт, которого нет в репозитории, не решит, что увидит покупатель при неизвестном исходе, и не ответит за комментарий, когда автор возразит.
Где в этом процессе человек¶
mr-review делит ревью на Brief → Dispatch → Polish → Post: контекст и инструкции, находки модели, правка ревьюером и комментарии к строкам MR. Код он не запускает, так что воспроизводить находку приходится отдельно, пока она ждёт на этапе Polish.
Каждую находку после Dispatch проверяют до Post. Подтверждённую ошибку публикуют с доказательством, опровергнутую отклоняют, а находка, которая зависит от контракта, превращается в вопрос.
Автор может пройти тот же цикл до того, как позовёт коллег. В любом случае, если ИИ ничего не нашёл, это не доказывает, что MR корректен.
Публикуем короткий комментарий, который можно отстоять¶
Если склад создаст резерв, а ответ потеряется, этот повтор создаст второй резерв на тот же заказ. Приложенный тест имитирует таймаут после записи и заканчивается двумя резервами. Может, уберём автоматический повтор, пока API склада не умеет убирать дубликаты?
Прикладывайте сам тест, чтобы автор мог запустить его в своей ветке. Если автор ответит, что настоящий склад убирает дубликаты по номеру заказа, проверьте это и уточните или снимите замечание. Бот, который отвечает сам, продолжал бы защищать возражение, которое уже неверно.
Исправление автора¶
Автор соглашается и пушит минимальное исправление: без автоматического повтора и с явным неизвестным исходом:
class ReservationOutcomeUnknown(RuntimeError):
def __init__(self, order_id):
self.order_id = order_id
super().__init__(f"Reservation outcome is unknown for order {order_id}")
async def reserve_once(order_id, warehouse):
try:
return await warehouse.reserve(order_id)
except TimeoutError as error:
raise ReservationOutcomeUnknown(order_id) from error
Пока это отменяет цель MR: таймаут по-прежнему проваливает запрос, но вызывающий код узнаёт, что исход неизвестен. ReservationOutcomeUnknown не является TimeoutError, поэтому общий код «повторить при таймауте» его не повторит, а места, где ловили TimeoutError, придётся обновить. «Неизвестно» не значит «не удалось»: приложение должно выяснить исход проверкой статуса или сверкой. В перспективе лучшим решением может оказаться защита от дубликатов, но заголовка Idempotency-Key на нашей стороне мало: склад должен его соблюдать, как разобрано в статье об идемпотентности. Кто выясняет судьбу резерва и что видит покупатель, решает команда, а не воспроизведение.
Перепроверяем находку на новой ревизии¶
После пуша комментарий описывает цикл, которого больше нет. Та же проверка теперь печатает calls=1, reservations=1 и проходит, а тесты подтверждают, что таймаут до записи даёт тот же ReservationOutcomeUnknown. Закройте ветку обсуждения, а не переносите старый текст дальше, и любой черновик, написанный до пуша, сверяйте с текущим diff.
Запуск примера¶
С установленным uv выполните из корня репозитория сайта:
cd docs/blog/lab/2026-09-13-ai-code-review-should-not-be-fully-autonomous
uv run --no-project --python 3.13 python reproduce.py before
uv run --no-project --python 3.13 python reproduce.py after
uv run --no-project --python 3.13 python test_review.py
before намеренно падает на assert, after проходит, а семь тестов покрывают обе реализации. Практикуму нужна только стандартная библиотека: ни модели, ни склада, ни публикации комментариев. Допущения перечислены в README практикума.
Доказательство вместе с замечанием¶
Из четырёх правдоподобных находок проверку прошла одна; одна оказалась ложной, ещё одна превратилась в вопрос о контракте. Команде для этого хватит трёх правил: передавать в Brief задачу и контракты, проверять каждую находку до Post и оставлять обсуждение тому, кто опубликовал комментарий. Модель выдала находки за секунды. Чтобы понять, какие из них верны, понадобились контракт, тест и человек, готовый отвечать за комментарий.