31.10.2026 796 материалов

Как логировать использование AI-функций, не записывая данные клиентов

Когда в продукте появляется AI-функция, логи часто превращаются в теневую копию клиентских данных. Разбираем подход, при котором в логи попадает только метаданные, а содержимое запросов сохраняется точечно и с очисткой.

Как логировать использование AI-функций, не записывая данные клиентов

Лог должен отвечать на вопросы «кто, где, когда и сколько стоило», а не дублировать переписку клиентов: метаданные по умолчанию, содержимое — только как исключение и после редактирования.

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

Логи при этом необходимы: без них не отлаживать странные ответы модели, не отвечать на аудиторские вопросы и не контролировать расходы на токены. Задача — найти промежуточный вариант, когда журнал полезен, но не становится вторым хранилищем клиентских данных.

Сначала определите, зачем вообще нужны логи

До написания кода стоит перечислить вопросы, на которые журнал должен отвечать. Для небольшой команды список почти всегда одинаковый:

  1. Отладка: какая версия промпта и какая модель дали конкретный ответ, был ли сбой вызова?
  2. Аудит: кто — пользователь или сервис — пользовался AI-функцией, когда и на какой записи?
  3. Стоимость: сколько токенов израсходовано в разбивке по функциям и клиентам?
  4. Безопасность: был ли вызов заблокирован, отфильтрован или помечен системой?

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

Что должно быть в записи по умолчанию

Типичная запись о каждом вызове AI выглядит примерно так:

import hmac
import json
import logging
import os
import time
import uuid

log = logging.getLogger("ai_usage")

FP_KEY = os.environ["AI_LOG_FP_KEY"].encode()   # случайный секрет, не в коде

def fingerprint(text: str) -> str:
    """Ключевой необратимый отпечаток для группировки одинаковых входов."""
    return hmac.new(FP_KEY, text.encode("utf-8"), "sha256").hexdigest()[:16]

def log_ai_call(*, feature, prompt_name, prompt_version, model, actor_id,
                record_id, prompt_text, response_text, usage, status, started):
    usage = usage or {}      # у неудачных вызовов данных об использовании часто нет
    call_id = str(uuid.uuid4())
    log.info(json.dumps({
        "event": "ai_call",
        "call_id": call_id,
        "feature": feature,
        "prompt": prompt_name,
        "prompt_version": prompt_version,
        "model": model,
        "actor_id": actor_id,
        "record_id": record_id,
        "input_chars": len(prompt_text),
        "input_fp": fingerprint(prompt_text),
        "output_chars": len(response_text or ""),
        "input_tokens": usage.get("input_tokens"),
        "output_tokens": usage.get("output_tokens"),
        "status": status,
        "latency_ms": int((time.monotonic() - started) * 1000),
    }))
    return call_id

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

Отдельно стоит пояснить про fingerprint. Обычный хэш короткого или предсказуемого текста — адреса электронной почты, ответа «да/нет» — восстанавливается простым перебором. Поэтому отпечаток делают ключевым: без секрета AI_LOG_FP_KEY подобрать вход не получится. Но назначение у отпечатка другое — группировать длинные разнообразные запросы («были ли эти два вызова одной и той же задачей?»), а не скрывать идентификаторы. Почты и прочие идентификаторы в него вообще не должны попадать.

Пара замечаний к самому коду. FP_KEY обязан лежать в переменной окружения, а не в репозитории: иначе отпечаток теряет смысл. И actor_id — это внутренний идентификатор пользователя или сервиса, а не email: так журнал не разрастается до реестра персональных данных.

Когда содержимое всё-таки нужно — сначала редактирование

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

import re

PATTERNS = [
    (re.compile(r"\b(?:sk|pk|api|key|token|ghp|xox[bp])[-_][A-Za-z0-9_-]{12,}\b", re.I), "[SECRET]"),
    (re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "[EMAIL]"),
    (re.compile(r"\b(?:\d[ -]?){13,19}\b"), "[CARD]"),
    (re.compile(r"(?<![\w.])\+?\d(?:[\s()-]{0,2}\d){9,14}(?![\w.])"), "[PHONE]"),
]

def redact(text: str, limit: int = 500) -> str:
    for pattern, label in PATTERNS:
        text = pattern.sub(label, text)
    return text[:limit]

Порядок паттернов важен: сначала самые специфичные. Секреты — до почт, карт и телефонов, иначе телефонный шаблон может «съесть» цифры из середины ключа и оставить в логе его фрагменты.

import os

DEBUG_CONTENT = os.getenv("AI_LOG_CONTENT") == "1"

call_id = log_ai_call(...)

if DEBUG_CONTENT:
    log.info(json.dumps({
        "event": "ai_call_content",
        "call_id": call_id,
        "prompt_sample": redact(prompt_text),
        "response_sample": redact(response_text or ""),
    }))

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

Разные риски — разные потоки и разные сроки

Метаданные и содержимое несут разную угрозу, поэтому обращаться с ними нужно по-разному:

Лог метаданных Лог фрагментов содержимого
По умолчанию Всегда включён Выключен; включается под инцидент
Кто читает Инженеры и те, кто ведёт расходы Несколько конкретных инженеров
Срок хранения Столько, сколько требует аудит (согласуется с ответственным за комплаенс) Дни, а не месяцы
Где Обычная система логирования Отдельный поток или индекс со своим правилом доступа и автоочисткой

Большинство лог-платформ умеют маршрутизировать события по полю (здесь — event) и задавать отдельный срок хранения для каждого потока. Это стоит использовать: фрагменты истекают автоматически, и никто не должен помнить, чтобы удалить их вручную.

Другие места, где остаются копии промптов

Собственные логи — лишь одна точка, где оседает переписка с моделью. Список длиннее:

  • Провайдер модели. Проверьте настройки хранения и обучения на данных вашего аккаунта, отключите всё лишнее и зафиксируйте, что именно выбрано и почему.
  • Трекеры ошибок. Отчёты об исключениях часто захватывают локальные переменные, включая промпт. Настройте очистку тел запросов и переменных с текстом. Ошибки провайдера к тому же нередко цитируют часть запроса — поэтому в лог пишите тип исключения и код статуса, а не полное сообщение об ошибке.
  • Аналитика и трассировка. Если запросы трассируются от начала до конца, убедитесь, что спаны не прикладывают полный промпт в качестве атрибута.
  • Чаты и тикеты. Когда кто-то вставляет «плохой ответ» в рабочий чат для обсуждения, это тоже копия. Передавайте call_id и открывайте детали там, где доступ контролируется.

Что обычно спрашивают аудиторы

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

  • какие функции используют ИИ и какие модели;
  • кто ими пользовался и на каких записях;
  • есть ли история изменений промптов;
  • как долго хранятся AI-данные и кто имеет к ним доступ.

Умение отвечать на эти вопросы с дашборда, а не поиском по сырым текстам, — хороший признак того, что баланс найден.

Короткий чек-лист

  1. Пишите запись о метаданных на каждый AI-вызов: функция, версия промпта, модель, актор, запись, токены, статус, задержка.
  2. Не подставляйте почты и другие идентификаторы в actor_id и отпечаток.
  3. Держите логирование содержимого выключенным; при включении — редактирование и обрезка.
  4. Отправляйте фрагменты в отдельный поток с коротким сроком хранения и узким доступом.
  5. Перепроверьте настройки хранения у провайдера, очистку в трекере ошибок и атрибуты трассировки.
  6. Передавайте в чаты идентификаторы вызовов, а не промпты.

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