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

В августе у Google вышел текст о том, почему Go хорошо подходит для разработки с агентами. С тезисами спорить трудно: единый форматтер, быстрая компиляция, строгий компилятор, большая стандартная библиотека, обещание совместимости. Беда в том, что из этих тезисов не следует ни одной строчки конфига. Хорошие свойства языка сами по себе не мешают агенту сдать диф, который собирается, проходит тесты и при этом делает совсем другое.

Дальше о том, как довести идею до инженерного состояния. Почему форма сообщения об ошибке влияет на исход правки сильнее, чем формулировка промпта. Какие поломки Go пропускает молча и чем их ловить. Как за полчаса написать свой анализатор, превращающий договорённость команды в ошибку сборки. И чем закончились попытки закрыть тот же вопрос документом AGENTS.md. Всё обкатано на монорепозитории примерно на сто восемьдесят тысяч строк, где за последний квартал около половины дифов написал агент.

Три слоя проверки и кто за них платит

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

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

Что ломается

Чем ловится

Цена проверки

Опечатка в имени, несовпадение типов

go build

секунды

Осиротевший импорт, забытая переменная

go build

секунды

Сигнатура разошлась с интерфейсом

go build, если стоит ассерт реализации

секунды

Интерфейс с nil‑указателем внутри

staticcheck, nilness

секунды

Сравнение обёрнутой ошибки через ==

errorlint

секунды

Потерянный cancel у context.WithCancel

go vet

секунды

Гонка в сгенерированной горутине

go test -race

минуты

time.Now в домене, запрос к базе из хендлера

свой анализатор на go/analysis

секунды

Незнакомая зависимость в go.mod

govulncheck и отдельный пулл‑реквест

минуты

Тест подогнан под текущее поведение

проверка дифа testdata, фаззинг

минуты

Решена не та задача

человек

часы

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

Форма сообщения об ошибке важна не меньше самой ошибки

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

internal/billing/charge.go:42:24: undefined: repo.FindByEmail
internal/billing/charge.go:58:9: cannot use id (variable of type int)
	as UserID value in argument to svc.Charge

Файл, строка, колонка, что получили и что ожидали. Такое сообщение не нужно интерпретировать, по нему правят с первой попытки. Плюс компилятор по умолчанию печатает не больше десяти ошибок на пакет и обрывается на too many errors, так что даже полностью сломанная сборка даёт ограниченный по объёму вывод. Флаг -gcflags=-e снимает лимит, и в агентских прогонах его лучше не включать.

По той же причине агенту стоит отдавать машинно читаемый вывод там, где он есть. Команда go test -json даёт поток событий, из которого несложно достать только упавшие тесты вместе с их выводом. Цвет в выводе линтеров гасится переменной NO_COLOR. На длинной сессии это экономит заметную долю бюджета: раскраска тратится на каждый прогон и не несёт для модели никакой информации.

Что компилятор ловит бесплатно

Неиспользованный импорт и неиспользованная локальная переменная в Go являются ошибками компиляции. Человек по этому поводу брюзжит пятнадцать лет, но для работы с агентом свойство оказалось на редкость удачным: мусор после генерации выглядит именно так. Осиротевший импорт после переписанной функции, объявленная и забытая переменная, оставшийся от предыдущей версии параметр. Там, где это предупреждение, мусор накапливается и его перестают читать. Здесь сборка просто не проходит.

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

Неявная реализация интерфейсов

Go проверяет соответствие типа интерфейсу в точке присваивания. Агент дописывает метод к структуре, случайно меняет сигнатуру (int вместо int64, потерянный context.Context первым аргументом, одиночный error вместо пары), и файл, который он редактировал, компилируется без замечаний. Ошибка вылезает в другом пакете, в месте сборки зависимостей, и выглядит как does not implement с перечислением недостающих методов. Агент видит позицию далеко от места правки и начинает чинить не там, попутно ломая то, что работало.

type UserRepository interface {
	ByID(ctx context.Context, id UserID) (*User, error)
	Save(ctx context.Context, u *User) error
}

// ошибка появится в этом файле и на первой же сборке
var _ UserRepository = (*pgUserRepo)(nil)

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

Тихие поломки, которые компилятор пропускает

Классика, на которой модели ломаются регулярно:

func find(id UserID) *NotFoundError {
	return nil
}

func handle(id UserID) {
	var err error = find(id)
	if err != nil {
		// сюда попадём всегда: интерфейс хранит тип и nil-указатель
	}
}

Компилятор здесь молчит, формально всё корректно. Ловят это staticcheck с проверкой SA4023 и анализатор nilness из x/tools, который gopls запускает в редакторе. В проекте, где код пишет агент, оба должны стоять в обязательном прогоне, потому что редактор агент не открывает.

Рядом живёт история с обёрнутыми ошибками. Модель по привычке пишет err == sql.ErrNoRows, потому что такого кода в обучающих данных горы, и сравнение тихо перестаёт работать, как только кто‑то добавил fmt.Errorf с глаголом %w. Линтер errorlint закрывает случай целиком, требуя errors.Is и errors.As. Туда же go vet с проверкой lostcancel, которая ловит потерянный cancel от context.WithCancel, и детектор гонок, без которого сгенерированный конкурентный код проверять бессмысленно.

Линтер как исполняемая спецификация команды

Дальше начинается то, ради чего всё затевалось. Готовые линтеры знают про язык и ничего не знают про ваш проект. Между тем большая часть замечаний, которые ревьюер пишет агенту, это локальные инварианты: время берётся только через Clock, в хендлерах нет прямых запросов к базе, context.Context идёт первым аргументом, доменные идентификаторы не смешиваются с примитивами. Каждое такое правило можно записать словами и получить вероятностное соблюдение, а можно записать анализатором и получить гарантию.

Анализатор на go/analysis пишется за полчаса. Вот запрет time.Now вне пакета clock:

package nowcheck

import (
	"go/ast"
	"go/types"

	"golang.org/x/tools/go/analysis"
	"golang.org/x/tools/go/analysis/passes/inspect"
	"golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
	Name:     "nowcheck",
	Doc:      "запрещает time.Now вне пакета clock",
	Requires: []*analysis.Analyzer{inspect.Analyzer},
	Run:      run,
}

func run(pass *analysis.Pass) (any, error) {
	if pass.Pkg.Name() == "clock" {
		return nil, nil
	}
	insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	insp.Preorder([]ast.Node{(*ast.CallExpr)(nil)}, func(n ast.Node) {
		sel, ok := n.(*ast.CallExpr).Fun.(*ast.SelectorExpr)
		if !ok || sel.Sel.Name != "Now" {
			return
		}
		id, ok := sel.X.(*ast.Ident)
		if !ok {
			return
		}
		pkg, ok := pass.TypesInfo.Uses[id].(*types.PkgName)
		if !ok || pkg.Imported().Path() != "time" {
			return
		}
		pass.Reportf(sel.Pos(),
			"time.Now вне пакета clock: добавьте поле clock.Clock в структуру "+
				"и вызовите c.Now(), иначе тест станет плавающим")
	})
	return nil, nil
}

Подключается он одной обёрткой и запускается как обычная команда:

package main

import (
	"golang.org/x/tools/go/analysis/singlechecker"

	"example.com/app/tools/nowcheck"
)

func main() { singlechecker.Main(nowcheck.Analyzer) }

Отдельного внимания заслуживает текст диагностики. Его читает не только человек: для агента это единственная инструкция о том, что делать дальше. Сообщение вида «использование time.Now запрещено» отправит модель искать обход, вплоть до собственной обёртки над time или перехода на time.Since. Сообщение, в котором написано, куда прокинуть зависимость и что вызвать, приводит к правильной правке с первого раза. Диагностики стоит писать как промпты, приём дешёвый, а разница в поведении агента заметна невооружённым глазом.

Если правку можно вычислить, добавьте SuggestedFix. Тогда исправление применяется механически флагом -fix, и модель в этом цикле вообще не участвует: анализатор сам переписывает код, а вы читаете результат одним диффом.

Кодомод вместо просьбы переделать всё

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

В Go для этого есть детерминированные инструменты. Переименование делает gopls, опираясь на информацию о типах, поэтому он не трогает одноимённые сущности из других пакетов. Простые механические замены выражений закрывает сам gofmt:

gofmt -r 'x.Sub(time.Now()) -> time.Until(x)' -w ./internal

Массовую модернизацию идиом закрывает go fix, переработанный в свежих версиях тулчейна: он прогоняет модернизаторы, которые превращают interface{} в any, счётчик for i := 0; i < n; i++ в for i := range n, sort.Slice в slices.SortFunc и так далее. Это тысячи строк дифа, полученные воспроизводимо и без единого токена.

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

Тесты, которые агенту трудно подделать

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

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

if git diff --name-only origin/main... | grep -q 'testdata/'; then
	echo "диф меняет testdata, это отдельная задача с ревью"
	exit 1
fi

Второе: инварианты проверяются надёжнее примеров. Тест, сверяющий выход с зафиксированной строкой, модель подгонит под текущее поведение за две секунды. Тест, проверяющий свойство, подогнать труднее, потому что придётся сломать саму формулировку свойства, а это сразу видно в дифе. Фаззинг доводит идею до конца:

func FuzzParseRange(f *testing.F) {
	f.Add("bytes=0-100")
	f.Add("bytes=-1")
	f.Fuzz(func(t *testing.T, s string) {
		r, err := ParseRange(s)
		if err != nil {
			return
		}
		if r.Start < 0 || r.Start > r.End {
			t.Fatalf("нарушен инвариант диапазона: %+v (вход %q)", r, s)
		}
	})
}

Запускается это как go test -run=Fuzz -fuzz=FuzzParseRange -fuzztime=30s, найденные входы падают в testdata/fuzz и с этого момента живут как обычные детерминированные тесты. Агент способен крутить такой цикл сам: написал парсер, погонял фаззер, получил падение с конкретным входом, починил, повторил. На выходе код, прошедший десятки тысяч входов, вместо трёх примеров, придуманных моделью.

Для конкурентного кода добавьте -race в обязательный прогон и посмотрите на пакет testing/synctest, стабилизировавшийся в Go 1.25. Он даёт виртуальное время и детерминированное выполнение группы горутин, то есть позволяет писать тесты на таймауты, ретраи и дедлайны без time.Sleep и без флаки. Конкурентность это ровно то место, где модели ошибаются чаще всего, и до synctest проверять их работу было почти нечем.

Зависимости: самое опасное место

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

База в Go тут неплохая. Контрольные суммы всех когда‑либо загруженных модулей лежат в базе контрольных сумм, копии в зеркале модулей, подмена версии и её исчезновение исключены. govulncheck сверяет зависимости с базой уязвимостей и отсекает те, до чьих уязвимых функций ваш код не дотягивается по графу вызовов. Низкий уровень шума здесь принципиален: агент, которому вывалили сорок предупреждений, начинает чинить всё подряд и ломает соседнее.

Поверх этого простое организационное правило, оформленное как проверка: зависимости меняются человеком и отдельным пулл‑реквестом.

git diff --exit-code -- go.mod go.sum || {
	echo "зависимости меняются отдельной задачей"
	exit 1
}

Скорость сборки как параметр качества генерации

Про быструю компиляцию Go говорят двадцать лет, но с агентами у неё появился новый смысл. Модель работает итерациями: собрала, прочитала ошибку, поправила, собрала снова. Если полный прогон занимает четыре секунды, агент внутри одной задачи успевает пятнадцать итераций и приносит работающий результат. Если прогон занимает полторы минуты, он сделает две попытки, упрётся в лимит и отдаст человеку полуфабрикат с вежливым перечнем того, что осталось поправить. Разница в качестве выхода огромная, причина чисто механическая.

Поэтому прогон стоит делить на быстрый и медленный. У нас быстрый занимает около шести секунд на тёплом кэше: go build, go vet, свои анализаторы и юнит‑тесты по изменённым пакетам. Медленный крутится в CI и занимает четыре с небольшим минуты: -race, интеграционные тесты, govulncheck и фаззинг с ненулевым fuzztime. Агент видит только быстрый, поэтому границу между ними стоит проводить по времени, а не по важности проверки. Кэш тестов при этом лучше не отключать глобально: -count=1 нужен точечно, иначе вы своими руками превращаете шесть секунд в сорок.

Одна команда вместо десяти

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

#!/usr/bin/env bash
set -euo pipefail
export NO_COLOR=1

go build ./...
go vet ./...
go run ./tools/nowcheck ./...
go test -count=1 ./...
git diff --exit-code -- go.mod go.sum

Требования к такому скрипту скучные и важные. Ненулевой код возврата при любой проблеме. Короткий вывод, стабильный по порядку строк. Никаких спиннеров и прогресс‑баров, при захвате stdout они превращаются в мусор. Относительные пути, чтобы модель могла сразу открыть файл. И один и тот же скрипт локально и в CI, иначе агент будет чинить то, что в CI не проверяется, и наоборот.

AGENTS.md после этого становится коротким. Туда идёт только то, что машина проверить не может: границы модулей, доменный словарь, соглашения об именовании сущностей предметной области и ссылка на скрипт. Всё остальное переезжает в проверки.

Три подхода, которые у меня не сработали

Первый: включить в golangci-lint всё, что есть. Конфиг с полусотней линтеров на живом репозитории выдаёт несколько сотен замечаний, и агент начинает чинить их подряд, потому что ему велели добиться зелёного. В дифе появляются переименованные переменные, вынесенные константы и переписанные условия, ни одно из которых не относится к задаче. Работающий вариант оказался обратным: узкий набор из errcheck, errorlint, staticcheck, govet и ineffassign плюс свои анализаторы, и нулевая терпимость к замечаниям внутри этого набора. Линтер полезен агенту ровно до тех пор, пока каждое его срабатывание требует правки.

Второй: отдавать агенту полный лог CI. Сто килобайт с шагами, таймингами и escape‑последовательностями раскраски вытесняют из контекста саму задачу, и модель начинает пересказывать лог вместо того, чтобы править код. Работающий вариант: скрипт печатает только упавшее, поток go test -json фильтруется до имён упавших тестов и их вывода, всё остальное уходит в файл, который агент откроет, если ему это понадобится.

Третий: описать правила словами и рассчитывать на их соблюдение. AGENTS.md работает первые тысячи токенов сессии. Дальше он вытесняется из контекста, и модель возвращается к статистике обучающих данных: снова time.Now в домене, снова запрос к базе прямо из хендлера, снова %v вместо %w при оборачивании. Правило держится ровно настолько, насколько оно проверяемо, и это, пожалуй, единственный вывод из всей истории, в котором я уверен полностью.

Где Go мешает

Теперь честная часть. Система типов Go бедна на выражение инвариантов, и в сценарии с агентом это бьёт сильнее, чем в работе человека. Нет сумм типов, нет настоящих перечислений, нет неизменяемости, нет типа «указатель, который точно не nil». Целый класс договорённостей компилятору не делегируется, и модель нарушает их молча.

Часть отыгрывается приёмами. Собственные типы вместо примитивов не дают перепутать аргументы:

type UserID int64
type OrderID int64

func Charge(u UserID, o OrderID) error

// Charge(orderID, userID) больше не скомпилируется

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

type Event interface{ isEvent() }

type Created struct{ ID UserID }

type Deleted struct {
	ID     UserID
	Reason string
}

func (Created) isEvent() {}
func (Deleted) isEvent() {}

Полноту разбора в type switch компилятор всё равно не проверит: забытую ветку придётся ловить линтером exhaustive либо веткой default с паникой, которая обязательно всплывёт в тестах. Многословная обработка ошибок тоже играет против: пропущенный if err != nil прекрасно компилируется, поэтому errcheck обязателен. И отдельно про рефлексию. ORM и универсальные мапперы переносят проверки в рантайм и обнуляют главное преимущество всей конструкции. Если контракт можно превратить в сгенерированный типизированный код (sqlc для SQL, protoc для gRPC, oapi-codegen для HTTP), это стоит сделать: генерация даёт агенту типизированную поверхность, по которой компилятор бьёт сразу.

Что из этого следует

Если свести всё к одному измеримому параметру, это доля ревью, выразимая командой с кодом возврата. Чем она выше, тем больший объём агентского вывода можно пропускать через себя без потери качества. В Go эту долю реально поднять высоко, и работа тут разовая: ассерты на интерфейсы, пара своих анализаторов с внятными текстами диагностик, инварианты в тестах вместо зафиксированных строк, генерация типов на границах, один скрипт verify и короткий AGENTS.md.

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

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

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