Лог отвечает не на тот вопрос
Когда агент делает не то, что от него ждали, вопрос звучит не «где упало», а «почему он так решил». Лог отвечает на первый и молчит на втором. В этом различии помещается почти вся мотивация замены.
Лог — это событие: что-то случилось, и в момент случившегося записана строка. Шаг — это запись с началом и концом, с входом и выходом, с местом в дереве причин. Разница глубже формата вывода: у строки лога нет понятия «завершилось». «Вызываю инструмент» и «инструмент вернул» связаны только в голове читающего — система связи между ними не хранит.
С обычным сервисом это не мешало жить. Обработка запроса почти линейна: приняли, достали из базы, ответили. Хронологический порядок строк совпадает с причинным, и хвост лога при такой геометрии сойдёт за трассу — упало, видно где. Пока выполнение — линия, лога достаточно.
У агента выполнение — дерево. Модель решила позвать инструмент, инструмент упал, перезапуск с другими аргументами, параллельно пошла вторая ветка, потом возврат к началу цикла. Хвост лога здесь — дерево, сплющенное в ленту: строки идут в порядке времени, а думать приходится в порядке причин. Отсюда знакомое «смотрю в лог и не вижу»: часто сломан не инструмент, а способ записи.
Как устроена трассировка шагов
Понятий немного, на них держится всё остальное.
Прогон — одна попытка агента выполнить задачу. Шаг — один осмысленный акт внутри прогона: обращение к модели, вызов инструмента, выбор ветки. У шага есть родитель, и в этом главное отличие от кучи логов: дерево связей восстанавливает система в момент выполнения, а не вы вечером по грепу.
Что несёт шаг:
- идентичность — свой идентификатор, идентификатор прогона, родителя;
- вход — промпт, аргументы, ссылки на них;
- выход — ответ модели, результат инструмента;
- статус — успех, ошибка, отмена;
- начало и конец, из которых выводится длительность;
- конфигурацию, повлиявшую на результат: версию промпта, параметры модели, версию инструмента.
О том, что класть в шаг целиком, а что оставлять ссылкой, чтобы трассировка не превратилась в склад, у нас есть отдельный разбор — «Что писать в трассировку, а что нет». Здесь зафиксируем одно: конфигурация — часть записи, а не украшение. Шаг без неё отвечает на «что», но не на «почему».
От привычной трассировки распределённых вызовов это отличается источником границ. Там спаны ложатся на сетевые обращения — границы диктует транспорт. Здесь границы проводит семантика задачи, и это главное проектное решение; к нему вернёмся среди поломок.
И свойство, неочевидное снаружи, но определяющее внутри: шаг открывается в момент старта, а закрывается в момент конца. Не «соберём пачкой и сбросим». Почему это критично — там же.
Тупик: идентификатор прогона в каждой строке
Первое, что пробуют на пути к трассировке, — не трогать архитектуру, а добавить в каждую строку лога идентификатор прогона. Погрепали по нему — собрали всё, что относится к неудачному запуску. Кажется, дальше мелочь.
Не работает, и причина любопытная. Идентификатор собирает строки в кучу, но куча — не дерево. Чтобы получить из кучи шаги, придётся парсить пары «начал/закончил», связывать их по формату строк и надеяться, что новый тип шага не сломает парсер. Так пишут трассировщик внутри лог-пайплайна: ключи на строках, состояние на регулярках, две системы на сопровождении вместо одной. При этом знание о том, где шаг начался и где кончился, существует только у кода, который шаг выполняет, — парсер узнаёт его последним.
Вторая попытка того же класса — «будем писать всё»: поднять детальность логов до максимума. Объём растёт как число шагов, помноженное на средний размер шага, — считайте сами и подставляйте свой бюджет на хранение. А вопрос «почему решил» всё равно требует связки «этот вход → этот выход → это решение». Текст не становится структурой от того, что его стало больше.
Тупик притягивает дешевизной: одно поле, один флаг, код не меняется. Но не хватало не поля — не хватало понятия. Записи, у которой есть завершение и место в дереве, в системе не было, и косметика понятие не добавляет.
Честная оговорка: если вы дошли до того, что в каждой строке пишете идентификатор шага и родителя, вы уже пишете трассировку — только храните её в инструменте, рассчитанном на ленты, а не на деревья. Идея к этому моменту уже принята; развилка в том, где записям жить — в хранилище под греп или в просмотрщике, который сворачивает ветки и сравнивает прогоны.
Почему трассировка ломается именно так
Перенести понятие шага в код — полдела. Поломки у трассировки свои; ниже основные, по механизмам, не по симптомам.
Границы по модулям, а не по решениям. Ставить вызовы трассировщика удобно по границам функций и модулей. Информативно — там, где агент принимает решения. Когда границы проходят не по решениям, симптом узнаваемый: вы открываете трассу с вопросом «почему он дважды позвал инструмент», а ответ не лежит ни в одном шаге — он между шагами. Правило: шаг — это решение, а не функция. У той же поломки вторая сторона — размер: слишком крупные шаги дают трассу без детализации, слишком мелкие — дерево, которое никто не осилит. Размер подбирается по вопросам, которые вы задаёте прогонам, а не по удобству расстановки вызовов.
Повторы. Ретрай — один логический шаг и несколько физических попыток. Каждая попытка отдельным шагом — и трасса превращается в шум из близнецов. Схлопнуть попытки в одну — потерять разницу между ними, а разница часто и есть весь материал разбора: что изменилось между попытками — аргументы? ответ модели? Рабочее направление: попытки становятся детьми логического шага, а разница между ними — на видном месте.
Недетерминированность. Один и тот же вход даёт разные деревья. Главный приём отладки — «сравнить рабочий прогон со сломанным» — ломается наивно: если шаги идентифицированы порядковым номером, сравнение прогонов с разным числом узлов даёт мусор. Сравнивать нужно по смыслу — имя шага, состав аргументов, — а не по позиции. Формат, в котором шаги различаются только позицией, убивает главное применение трассировки.
Крах в середине шага. Незавершённый шаг не закрывается. Если трассировщик пишет шаг пачкой по завершению — а именно так пишет наивная реализация «собрали и сбросили» — самый интересный для разбора прогон, упавший, остаётся с пустой трассой. Поэтому шаг открывается в момент старта. Заодно нужен третий статус, «отменён»: параллельная ветка, снятая из-за ошибки соседней, — не ошибка и не успех, и при двух статусах она портит картину.
Выборка. Хранить всё нельзя: бюджет хранения конечен, а прогоны идут потоком. Отбирать по ошибкам — теряете базу для вопроса «это норма или нет», потому что норму меряют по здоровым прогонам. Отбирать случайно — редкие сбои проскакивают мимо. Универсального ответа нет; хуже любой политики — её отсутствие, когда хранилище решает за вас настройками по умолчанию.
Трассировка, которая врёт умолчанием. Шаг с входом и выходом, но без конфигурации выглядит законченным. Воспроизводите его — получаете другой результат, и запись не может объяснить разницу: версия промпта не записана, параметры не записаны. Это худший вид поломки, потому что он невидим: трасса выглядит полной и уводит разбор в сторону. Отсюда требование из раздела об устройстве — конфигурация входит в запись шага.
Позиция редакции
Мы считаем: для всего, что выполняет больше одного шага на задачу, — агентов, пайплайнов, оркестраций — основной записью о выполнении должна быть трассировка шагов. Логам остаётся инфраструктурный слой: сеть, диск, рантайм. Единица отладки — шаг; строка лога на эту роль не претендует.
Аргумент короткий. Вопросы сместились с «где» на «почему», а «почему» по определению требует связки входа, выхода и решения — то есть шага. К тому же запись работает дважды: прогоны, сохранённые как деревья шагов, переигрываются против нового промпта или новой конфигурации до релиза — из них же собирается оценка качества агента, тема нашего отдельного материала. Неструктурированные записи оценке дать нечего.
Условие, при котором мы неправы: если выполнение линейно и детерминированно — один и тот же вход всегда проходит один и тот же путь и даёт один и тот же результат, — трасса вырождается в линию, и хвост лога прилично её приближает. Тогда инструментировать шаги — получить сопровождение без отдачи; оставайтесь с логами. Наш совет оправдан ровно тогда, когда в выполнении есть ветвление, повторы или недетерминированность; если ничего этого нет, следовать ему вредно.
Пограничный случай, о котором скажем честно: если код, выполняющий шаги, вам не принадлежит и не инструментируется, потолок наблюдаемости — тот самый тупик с идентификаторами. Это ограничение, а не опровержение.
И последнее. Начинать мы советуем не с кода, а с вопросов: выпишите, что вы спрашиваете у сломанного прогона. Правильного размера шага не существует — он выводится из ваших вопросов, и только из них. Сначала вопросы, потом границы в коде.