Проблема: доверять агенту — значит не видеть правды
Мой агент работает сессиями по несколько часов без моего контроля. Он читает файлы, редактирует код, запускает команды и возвращает отчёт: «Рефакторинг модуля аутентификации выполнен, все тесты пройдены». Но проблема в том, что этот отчёт написан тем же агентом, который мог ошибиться. Если он молча пропустил шаг, неверно прочитал файл или «починил» не ту функцию, итоговый отчёт всё равно будет уверенным и чистым. У меня не было независимой записи того, что произошло на самом деле — только нарратив самого агента о самом себе.
Однажды это привело к серьёзной ошибке. Агент отчитался об успешном рефакторинге, тесты были зелёными. Но оказалось, что на более раннем шаге редактирование файла молча не применилось, и «проходящие тесты» проверяли старый код, который не изменился. Ничего не упало, ничего не выглядело неправильно. Агент просто сделал меньше, чем заявил. Тогда я понял: тихий частичный сбой хуже громкого. Падение указывает, где искать. Молча пропущенный шаг не даёт никаких зацепок, а итоговый отчёт активно врёт, умалчивая о проблеме.
Решение: логировать на границе, а не внутри агента
Я добавил слой логирования между агентом и его инструментами, а не внутри рассуждений агента. Это ключевое различие: если лог пишет сам агент, он может быть так же ошибочен, как и его отчёт. Если лог генерируется обвязкой при каждом реальном вызове инструмента — это факт, а не нарратив.
Что я логирую
Для каждого вызова инструмента я фиксирую:
- Временная метка
- Имя инструмента и краткое описание аргументов (без секретов и полного содержимого файлов — только чтобы понять, что произошло)
- Статус результата: success, error или noop (последняя категория оказалась самой ценной)
- Длительность
Каждый запуск дописывается в файл .jsonl — один JSON-объект на строку. Этот формат оказался важен: он append-only, устойчив к сбоям (обрезанная последняя строка не портит предыдущие) и легко анализируется с помощью jq или Python, без базы данных.
Статус noop — настоящее спасение
Ошибка, с которой всё началось — молча пропущенное редактирование — проявилась сразу, как только я добавил статус noop. Инструмент редактирования, который выполняется, но ничего не меняет (потому что целевой текст не найден или файл уже в нужном состоянии) — это не то же самое, что успешное редактирование. Раньше оба случая показывались как «success» в моей голове. После разделения неудачный запуск выглядел бы так:
{"tool": "edit_file", "args": "auth.py: replace validate()", "status": "noop", "duration_ms": 4} {"tool": "run_tests", "args": "test_auth.py", "status": "success", "duration_ms": 812}Noop, сразу за которым следует success на том, что он должен был исправить — огромный красный флаг, когда вы это видите. Но это было невидимо, когда у меня был только итоговый отчёт агента.
Визуализация сессии
Как только у меня появились структурированные логи, отследить сессию как последовательность стало просто. Например, диаграмма последовательности из пяти строк из сырого лога сразу показала проблему, которую я упускал в течение трёх сеансов отладки, читая прозаический отчёт агента.
Сэмплирование, а не всё подряд
Я не логирую полное содержимое файлов или полный вывод команд — это раздуло бы файл трассировки и вернуло проблему «стены текста, которую никто не читает». Вместо этого я логирую краткую сводку (первые ~100 символов diff, имя команды и код возврата, а не stdout). Если мне нужны полные детали для конкретного шага, я перезапускаю этот шаг вручную. Задача трассировки — сказать мне, где искать, а не быть полным повторением.
Запросы к логу вместо чтения
Когда логи стали структурированными, я перестал читать их сверху вниз и начал отправлять запросы. Однострочник вроде этого отвечает на вопрос «было ли сегодня что-то тихое?» за секунду:
cat agent_trace.jsonl | jq -r 'select(.status == "noop") | "\(.tool) \(.args)"'После того как я начал запускать его после каждой сессии, выявилась закономерность, которую я раньше полностью упускал: примерно в 1 из 12 сессий был хотя бы один noop на записывающем инструменте. Большую часть времени это было безвредно — файл уже был в нужном состоянии. Но примерно четверть таких noop маскировали реальную проблему — тот же класс ошибок, с которого всё началось. До появления запроса у меня не было способа даже оценить эту пропорцию; я полагался на симптомы, которые замечал спустя дни.
Я также отслеживаю скользящее количество вызовов инструментов на сессию и помечаю всё, что выходит за пределы нормального диапазона. Сессия, которая обычно делает 20–40 вызовов, но внезапно делает 3 и останавливается, так же подозрительна, как и та, которая делает 200 — обычно это означает, что что-то сломалось выше по цепочке.
Честность в накладных расходах
Справедливый вопрос: не добавляет ли всё это логирование накладных расходов и сложности? На практике слой логирования — менее 40 строк кода, а сама запись занимает единицы миллисекунд — это один вызов open().write() на каждый вызов инструмента, без сетевых запросов и внешних сервисов. Стоимость ничтожна по сравнению с минутами (а иногда часами), которые я раньше тратил на восстановление сессии по памяти и скроллу после того, как что-то шло не так.
Выводы
- Логируйте на границе, а не внутри рассуждений. Лог, который пишет агент, наследует его слепые зоны. Лог, который генерирует обвязка при каждом реальном вызове инструмента, — это истина, независимая от того, что агент думает о произошедшем.
- «Ничего не сделано» заслуживает собственного статуса. Успех/неудача — недостаточно. Инструмент, который выполнился без ошибки, но ничего не изменил, — это отдельный важный случай. Именно там обычно прячутся настоящие баги, и объединение его с «успехом» — причина, по которой я упускал свою ошибку неделями.
- Append-only лучше, чем «умное» решение. Я сначала попробовал небольшую SQLite-базу, но она оказалась хрупкой — сбой во время записи мог повредить состояние, а для просмотра нужен был отдельный инструмент. Обычные JSON Lines файлы не требуют ничего, кроме cat и jq, устойчивы к сбоям, и их можно смотреть в реальном времени во время работы.
- Суммируйте, не выгружайте. Полный stdout/diff в каждой строке лога делает трассировку нечитаемой и дорогой для хранения. Логируйте достаточно, чтобы знать, где искать, и перезапускайте конкретный шаг для полных деталей, когда они действительно нужны.
- Сначала модель данных, потом визуализация. Мне хотелось с первого дня построить красивую панель. Но на самом деле важнее было сначала правильно определить схему лога — категории статусов, что считать одним «событием». Как только это стало надёжным, даже диаграмма последовательности из пяти строк из сырого лога оказалась полезнее той панели, которую я представлял.
Что дальше
Я работаю над тем, чтобы превратить детектор noop в активную проверку: вместо того чтобы я просматривал лог постфактум, обвязка будет помечать noop на любом инструменте редактирования/записи как предупреждение во время выполнения, чтобы тихий noop был обнаружен до того, как агент перейдёт к следующему шагу и выстроит ложный нарратив.
Также я хочу добавить флаги аномалий по длительности — если шаг, который обычно занимает 2 секунды, вдруг выполняется 40 секунд, это стоит проверить, даже если он технически «успешен». И я хочу вести небольшой скользящий базовый уровень (среднее количество вызовов инструментов, средняя длительность на инструмент) для каждого типа задач, чтобы «аномалия» измерялась относительно истории этой задачи, а не единого глобального порога, который сейчас даёт слишком много ложных срабатываний на действительно больших задачах.
Ничто из этого не должно быть сложным. Главный урок: observability не требует панели или вендора — она требует скучного, структурированного, append-only лога фактов, независимого от того, что вы пытаетесь наблюдать.
Заключение
Если вы запускаете любого автономного агента без присмотра, не доверяйте его собственному отчёту как единственной записи о произошедшем — логируйте вызовы инструментов независимо, дайте статус «ничего не сделал» и храните формат настолько простым, чтобы вы могли grep-нуть его в 2 часа ночи.
Комментарии (0)
Войдите, чтобы комментировать.
Пока нет комментариев. Будьте первым.