Практика · LLM Observability

Редакция чувствительных данных в LLM-трассировках без потери диагностической ценности

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

Продвинутый уровень До 9 минут Результат: проверенный конвейер редакции

Что мы построим

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

Итоговый конвейер будет выполнять пять операций:

  1. оставлять только разрешённые поля события;
  2. редактировать известные чувствительные поля независимо от их значения;
  3. псевдонимизировать повторяющиеся идентификаторы;
  4. искать опасные фрагменты внутри свободного текста;
  5. отбрасывать или сокращать данные, не прошедшие проверку.

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

Шаг 1. Зафиксируйте политику до написания регулярных выражений

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

Класс Примеры Действие
Разрешённые метаданные Тип события, длительность, имя модели, HTTP-статус Хранить с ограниченным сроком
Связующие идентификаторы User ID, session ID, tenant ID Псевдонимизировать
Чувствительное содержимое Email, телефон, внутренний номер договора Заменять типизированным маркером
Секреты Пароль, токен, приватный ключ Удалять; событие при необходимости помещать в карантин

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

Шаг 2. Создайте контролируемый набор

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

{
  "trace_id": "trace-demo-001",
  "event": "model.request",
  "model": "example-model",
  "latency_ms": 842,
  "user_id": "demo-user-42",
  "session_id": "demo-session-7",
  "messages": [
    {
      "role": "user",
      "content": "Напишите на test.person@example.invalid. Телефон +7 000 000-00-00. Код проекта ALJ-DEMO-17."
    },
    {
      "role": "system",
      "content": "api_key=DEMO_SECRET_DO_NOT_USE_123456"
    }
  ],
  "headers": {
    "authorization": "Bearer DEMO_TOKEN_DO_NOT_USE_123456",
    "content-type": "application/json"
  },
  "error": null
}

Домен .invalid зарезервирован для примеров и не должен разрешаться в рабочий адрес. Остальные строки также помечены как демонстрационные и не являются действующими реквизитами.

Шаг 3. Реализуйте многоступенчатую редакцию

Ниже — автономный пример на Python без сторонних пакетов. Сохраните его как redact_trace.py. Ключ псевдонимизации передаётся только через окружение и не записывается в код или журнал.

import hashlib
import hmac
import json
import os
import re
import sys

ALLOWED_TOP_LEVEL = {
    "trace_id", "event", "model", "latency_ms",
    "user_id", "session_id", "messages", "headers", "error"
}

DROP_FIELDS = {
    "authorization", "cookie", "set-cookie",
    "password", "passwd", "secret", "api_key", "access_token"
}

PSEUDONYM_FIELDS = {"user_id", "session_id", "tenant_id"}

PATTERNS = [
    (
        "EMAIL",
        re.compile(
            r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",
            re.IGNORECASE
        ),
    ),
    (
        "PHONE",
        re.compile(r"(?<!\w)\+?\d[\d ()-]{7,}\d(?!\w)"),
    ),
    (
        "BEARER",
        re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}", re.IGNORECASE),
    ),
    (
        "ASSIGNMENT_SECRET",
        re.compile(
            r"\b(api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]{8,}",
            re.IGNORECASE
        ),
    ),
]

MAX_TEXT_LENGTH = 4000
MAX_DEPTH = 12

def pseudonym(value, key):
    digest = hmac.new(
        key.encode("utf-8"),
        str(value).encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()[:16]
    return "pseudonym:" + digest

def redact_text(value):
    original_length = len(value)
    text = value[:MAX_TEXT_LENGTH]
    findings = []

    for label, pattern in PATTERNS:
        count = 0

        def replace(match):
            nonlocal count
            count += 1
            return f"[REDACTED:{label}]"

        text = pattern.sub(replace, text)
        if count:
            findings.append({"type": label, "count": count})

    return {
        "text": text,
        "original_length": original_length,
        "truncated": original_length > MAX_TEXT_LENGTH,
        "findings": findings,
    }

def walk(value, key, field=None, depth=0):
    if depth > MAX_DEPTH:
        return "[DROPPED:MAX_DEPTH]"

    if field and field.lower() in DROP_FIELDS:
        return "[REDACTED:FIELD]"

    if field and field.lower() in PSEUDONYM_FIELDS:
        return pseudonym(value, key)

    if isinstance(value, dict):
        result = {}
        for child_key, child_value in value.items():
            result[child_key] = walk(
                child_value, key, child_key, depth + 1
            )
        return result

    if isinstance(value, list):
        return [
            walk(item, key, field, depth + 1)
            for item in value[:100]
        ]

    if isinstance(value, str):
        redacted = redact_text(value)
        if redacted["findings"] or redacted["truncated"]:
            return redacted
        return value

    return value

def redact_event(event, key):
    filtered = {
        name: event[name]
        for name in ALLOWED_TOP_LEVEL
        if name in event
    }
    return walk(filtered, key)

def main():
    key = os.environ.get("TRACE_PSEUDONYM_KEY")
    if not key or len(key) < 32:
        raise SystemExit(
            "TRACE_PSEUDONYM_KEY должен содержать не менее 32 символов"
        )

    event = json.load(sys.stdin)
    result = redact_event(event, key)
    json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
    sys.stdout.write("\n")

if __name__ == "__main__":
    main()

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

Шаг 4. Запустите безопасно

Сохраните демонстрационное событие в fixture.json, а программу — в redact_trace.py. Для локальной проверки задайте временный демонстрационный ключ. Не используйте этот ключ в рабочей среде.

export TRACE_PSEUDONYM_KEY='demo-only-key-32-characters-minimum'
python3 redact_trace.py < fixture.json > redacted.json
python3 -m json.tool redacted.json

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

Шаг 5. Проверьте результат автоматически

В корректно обработанном файле должны сохраниться event, model, latency_ms, роли сообщений и код проекта. Email, телефон, демонстрационный секрет и bearer-токен не должны остаться в открытом виде. Идентификаторы пользователя и сессии должны превратиться в стабильные HMAC-псевдонимы.

Создайте verify_redaction.py:

import json
import sys

with open("redacted.json", encoding="utf-8") as source:
    result = json.load(source)

serialized = json.dumps(result, ensure_ascii=False)

for forbidden in [
    "test.person@example.invalid",
    "+7 000 000-00-00",
    "DEMO_SECRET_DO_NOT_USE_123456",
    "DEMO_TOKEN_DO_NOT_USE_123456",
    "demo-user-42",
    "demo-session-7",
]:
    if forbidden in serialized:
        raise SystemExit(f"Утечка контролируемого маркера: {forbidden}")

assert result["event"] == "model.request"
assert result["model"] == "example-model"
assert result["latency_ms"] == 842
assert "ALJ-DEMO-17" in serialized
assert result["user_id"].startswith("pseudonym:")
assert result["session_id"].startswith("pseudonym:")

print("Проверка пройдена: маркеры удалены, диагностические поля сохранены")

Запуск:

python3 verify_redaction.py

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

Как встроить конвейер в рабочую трассировку

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

  1. Сформируйте событие в памяти приложения.
  2. Примените структурный список разрешённых полей.
  3. Удалите секреты и псевдонимизируйте идентификаторы.
  4. Просканируйте свободный текст и ограничьте объём.
  5. Проверьте итоговую схему и только затем экспортируйте.

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

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

Типовые ошибки

Полагаться только на регулярные выражения

Они пропускают нестандартные форматы и могут ошибочно скрывать безопасный текст. Структурное правило для поля password надёжнее попытки распознать значение пароля.

Редактировать после записи

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

Использовать обычный хеш для идентификаторов

Предсказуемые значения можно перебрать. HMAC с отдельным секретным ключом снижает этот риск и сохраняет возможность сопоставлять события одного субъекта внутри выбранного периода.

Заменять всё одним маркером

[REDACTED] не сообщает, что именно было найдено. Типизированные маркеры и счётчики сохраняют диагностическую ценность без сохранения исходного значения.

Записывать ошибку редактора вместе с исходным событием

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

Не версионировать правила

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

Ограничения

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

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

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

Контрольный список перед включением

  • События проходят редакцию до очереди, коллектора и диска.
  • Верхнеуровневые поля разрешены явно, а не принимаются по умолчанию.
  • Известные секретные поля удаляются независимо от формата значения.
  • Связующие идентификаторы защищены HMAC, а ключ не попадает в логи.
  • Длина строк, размер списков и глубина вложенности ограничены.
  • Контролируемые маркеры проверяются автоматически при изменении правил.
  • Ошибка редактора не приводит к экспорту исходного события.
  • У политики есть версия, владелец и процедура пересмотра.
  • Для отредактированных логов заданы доступ и срок удаления.

Что дальше

Встройте проверку контролируемых маркеров в CI, а затем добавьте случаи из собственных схем — только синтетические или специально обезличенные. Для проектирования остальных частей наблюдаемости используйте практические руководства, а определения терминов сверяйте с глоссарием Agent Lab Journal.