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

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

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

Один идентификатор вместо одного текста

Всё держится на сквозном идентификаторе. В документации LiteLLM для каждого запроса предусмотрен call_id (LiteLLM: Logging). Это и есть та «ручка», за которую можно вытащить историю одного вызова, не зная, что в нём было написано. Если вы используете другой шлюз или собственную прослойку, принцип тот же: у каждого обращения должен быть один устойчивый ключ, который попадает во все записи о нём.

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

  • Когда? Время приёма запроса и время завершения. Из них считается длительность, а по сравнению с временем провайдера видна задержка внутри вашего контура.
  • Куда? Маршрут: какой логический алиас модели запросило приложение и какая реальная модель и провайдер ответили. Расхождение между ними часто и есть объяснение «почему вдруг стало иначе».
  • Чем закончилось? Статус и класс ошибки: успех, отказ по лимиту, таймаут, отказ провайдера, отмена клиентом, отказ по бюджету.
  • Сколько стоило? Число токенов и рассчитанный расход. Без этого невозможно объяснить скачок счёта.
  • От чьего имени? Не имя человека, а служебная сущность: проект, ключ, команда или псевдоним пользователя, который сам по себе ничего не раскрывает.

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

Отключить сообщения — не значит ослепнуть по деньгам

Частое опасение звучит так: если выключить запись содержимого, пропадёт и учёт расходов, и мы не поймём, кто сколько потратил. По документации LiteLLM это не так: отключение журналирования сообщений сохраняет учёт метаданных, например расхода (LiteLLM: Logging). То есть расход и содержимое — разные слои данных, и первый можно держать без второго. Доступность исключений из этого правила регулируется настройками, поэтому конкретное поведение в вашей версии и конфигурации стоит проверить на тестовом вызове, а не принимать на веру.

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

Карточка запроса: что записывать и что оставлять пустым

Удобно один раз договориться о форме записи и закрепить её в проекте как схему. Ниже условная карточка с пустыми учебными значениями. Это не формат конкретного продукта, а шаблон для вашего собственного журнала; названия полей можно менять.

call_id:            <идентификатор вызова>
parent_request_id:  <идентификатор запроса приложения, если есть>
received_at:        <время приёма, UTC>
finished_at:        <время завершения, UTC>
route_alias:        <алиас модели, запрошенный приложением>
resolved_model:     <фактически использованная модель>
provider:           <имя провайдера>
attempt:            <номер попытки>
status:             <success | error | timeout | cancelled | budget_blocked>
error_class:        <класс ошибки или пусто>
http_status:        <код ответа провайдера или пусто>
t_queue_ms:         <ожидание в шлюзе>
t_first_token_ms:   <до первого фрагмента, если потоковый ответ>
t_total_ms:         <полная длительность>
tokens_in:          <число>
tokens_out:         <число>
cost:               <расход в учётных единицах>
project:            <проект или команда>
key_alias:          <псевдоним ключа, не сам ключ>
subject_ref:        <необратимый внутренний псевдоним или пусто>
messages_logged:    false

Несколько замечаний к карточке. Поле messages_logged нужно не для красоты: оно делает режим записи видимым в самой записи. Если однажды кто-то включит подробное журналирование на отдельном участке, такие записи будут отличаться, и их можно будет найти и ограничить по сроку.

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

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

Три журнала, которые легко принять за один

Когда говорят «логи ИИ-запроса», обычно имеют в виду что-то одно, но на деле записей как минимум три, и у каждой своя зона ответственности и своя утечка.

Журнал приложения

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

Журнал шлюза

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

Записи у внешнего провайдера

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

Разделение полезно ещё и для диагностики. Когда запрос медленный, первым делом надо понять, какому из трёх контуров принадлежит задержка. Сравнение времени приёма в приложении и времени приёма в шлюзе показывает потери до шлюза. Разница между временем шлюза и временем, которое он сам измерил до ответа провайдера, показывает ваш вклад. Остаток — вклад провайдера и сети до него. Если сеть между вашими узлами вызывает подозрения, порядок проверки подробно разобран в статье «Между двумя своими серверами связи нет, а интернет у обоих есть».

Что видно без текста, а что без него не разобрать

Честный вопрос к схеме метаданных: достаточно ли её? Для значительной части неисправностей — да. Для другой части — нет, и лучше признать это заранее, чем бороться с соблазном «включить всё на минутку».

Какие симптомы объясняет карточка метаданных
СимптомВидно по карточкеНужно ли содержимое
Ответ идёт медленноРазложение времени: очередь, первый фрагмент, полная длительность, номер попыткиОбычно нет
Всплеск ошибокКласс ошибки, код провайдера, маршрут, время начала всплескаНет
Счёт выросТокены на входе и выходе, проект, ключ, число повторовНет
Запросы упираются в лимитыЧастота по ключу и проекту, статусы отказаНет
Выход слишком длинныйАномально большое число выходных токенов у одного маршрутаДля причины иногда да
Входной контекст раздулсяРост числа входных токенов с конкретного приложенияЧаще достаточно кода приложения, а не текста пользователей
Ответ плохой по смыслуТолько косвенно: модель, версия маршрутаДа, нужен отдельный контролируемый разбор

Из таблицы видно, что содержимое нужно в основном там, где речь идёт о качестве, а не о работоспособности. И даже для раздутого контекста сначала стоит посмотреть, что делает приложение: подмешивает ли оно историю целиком, сколько документов прикладывает. Это можно выяснить по коду и по числу токенов, не читая чужие разговоры.

Когда без текста действительно не обойтись

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

  1. Включается на конкретный проект или ключ, а не на весь шлюз.
  2. Использует тестовые или специально подготовленные данные, а не живые обращения сотрудников, где это возможно.
  3. Имеет срок: дата включения и дата, после которой записи удаляются.
  4. Пишется в отдельное хранилище со своим кругом доступа, а не в общий журнал.
  5. Помечается в карточке: messages_logged принимает другое значение, чтобы такие записи находились поиском.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Убрали имя — и что осталось

Теперь о ловушке, в которую попадают добросовестные команды. Допустим, содержимое решили всё же сохранять для разбора качества, но перед записью вырезать имена, телефоны и адреса электронной почты. Получается безопасно? Не обязательно.

Возьмём условную фразу: «Подготовь ответ клиенту: заказ на поставку серверных стоек для филиала в маленьком городе, оплата задержана на третий месяц, ответственный бухгалтер ушла в отпуск». Имён здесь нет. Но совокупность деталей — отрасль, тип заказа, город, срок просрочки, обстоятельство с отпуском — может однозначно указывать на конкретную сделку и конкретных людей для всякого, кто знает контекст. А в вашей компании контекст знают многие.

Есть несколько причин, по которым удаление прямых идентификаторов не делает текст безличным:

  • Косвенные признаки. Должность, редкое событие, дата, название небольшой организации, уникальное сочетание обстоятельств.
  • Нестандартные формы. Номер телефона с опечаткой, имя в уменьшительной форме, адрес, записанный словами, не поймает типичное правило-шаблон. Автоматическая очистка работает хуже всего именно на тех данных, которые люди пишут не по форме.
  • Вложения и вставки. Скопированное письмо с подписью, фрагмент таблицы, текст из сканов. Структуры в них много, и шаблоны с ними не справляются.
  • Ответ модели. Модель может повторить или реконструировать детали из входа, так что чистить придётся обе стороны разговора.
  • Склейка с другими данными. Время запроса плюс псевдоним сотрудника плюс текст дают то, чего не дают порознь.

Отсюда не следует, что маскирование бесполезно. Оно снижает риск, и там, где без содержимого не обойтись, его стоит применять. Но называть результат «обезличенным» и считать вопрос закрытым нельзя. Одна настройка в LiteLLM тоже ничего не обезличивает на уровне всей инфраструктуры: она управляет тем, что пишет сам шлюз, и ничего не говорит о приложении, о системе сбора логов, о резервных копиях и о стороне провайдера. Юридические выводы о соответствии требованиям из технической настройки делать нельзя; это вопрос к специалисту по вашему праву и вашим договорам.

Проверка маркерами: ловим утечку, а не верим в её отсутствие

Хороший способ не полагаться на убеждение «у нас текст не пишется» — проверить. Метод простой и не требует чужих данных. Придумайте уникальные строки, которых нет в реальной работе, например ТЕСТ-МАРКЕР-7Q4X и выдуманное имя с выдуманным номером. Отправьте через весь путь несколько запросов: через приложение, а не напрямую в шлюз, чтобы пройти все слои. Затем поищите эти строки там, где они не должны быть:

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

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

Срок, доступ и удаление: не одно правило на все записи

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

Разные записи — разные условия (значения сроков условные, подбираются под проект)
Тип записиЧто в нейСрок храненияКто видитУдаление
Карточка метаданных вызоваВремя, маршрут, статус, токены, расходДольше всего: нужна для динамики и бюджетаЭксплуатация, владельцы проектовПо истечении срока автоматически; агрегаты можно оставить
Агрегированные метрикиСуммы и перцентили без привязки к вызовуДолгоШирокий кругОбычно не требуется
Таблица соответствия псевдонимовСвязь псевдонима с человекомСтолько, сколько нужна задачаУзкий, отдельное разрешениеПо запросу и по сроку
Подробная диагностика содержимогоТексты по согласованному исключениюКороткий, с датой окончанияНазванные людиЖёсткое по дате, включая копии

Логика такая: чем ближе запись к человеку и к тексту, тем короче срок и уже круг. Карточка без содержимого не раскрывает разговор, поэтому её можно держать долго и показывать эксплуатации. Подробный журнал содержимого, напротив, живёт неделями, а не месяцами, и открывается по заявке.

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

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

Где всё это живёт: ресурсы под журнал

Если шлюз и хранилище журналов вы разворачиваете у себя, то журнал метаданных сам по себе нетяжёл: это короткие записи, а не тексты разговоров. Но им нужны место на диске, процесс сбора и отдельная служба хранения с ограничением доступа. Для такой связки подходит собственный VPS с root-доступом: вы сами решаете, что пишет шлюз, куда уходят записи и как настроено удаление. В публичном предложении VPS MAATRIX указаны NVMe-диски, процессоры AMD EPYC и root-доступ, локации UK, US, RU и FR, а тарифы начинаются с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и доходят до Djed (16 vCPU, 32 ГБ RAM, 320 ГБ). Для небольшого шлюза и журнала метаданных логично начинать с младших конфигураций и смотреть на реальную нагрузку: число вызовов в секунду, размер записи, срок хранения. Ресурсы тарифа не означают обещанной ёмкости, поэтому размер журнала на диске и скорость его роста лучше измерить на своём потоке, а не рассчитывать по таблице. Расположение сервера и работа сторонних API из выбранной локации тоже проверяются отдельно: соответствие требованиям и доступность внешних сервисов зависят не от самого VPS.

Медленный запрос без чтения переписки

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

Приложение при каждом обращении показывает в интерфейсе короткий номер запроса, а в журнале приложения он связан с call_id. Она присылает номер. Инженер открывает карточку по идентификатору и видит условную картину: запрос принят в приложении, в шлюзе ожидание в очереди составило долю секунды, значит, шлюз не виноват. Статус успешный, но номер попытки — вторая: первая закончилась таймаутом у провайдера. Время до первого фрагмента у второй попытки обычное, а полная длительность большая, потому что к ней прибавилась ожидающая первая попытка. Число выходных токенов небольшое, значит, ответ не был необычно длинным. Маршрут — тот, что и должен быть, подмены модели нет.

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

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