LLM вернула JSON с двумя одинаковыми ключами: как обнаружить подмену значения

Ассистент подготовил JSON для создания заявки. В визуальном просмотре заметно priority="normal", а после разбора приложение отправляет priority="urgent". Оба значения были в исходной строке под одинаковым ключом. Обычный словарь не хранит два одинаковых имени, поэтому часть информации исчезла до проверки бизнес-правил. Поздняя валидация видит только оставшееся значение.

Такой ответ нельзя безопасно исправлять выбором «первого» или «последнего» ключа без явного правила контракта. Для машинного обмена удобнее отклонить неоднозначный объект и запросить исправленный результат. Особенно важно сделать это перед вызовом инструмента, где приоритет, сумма или адрес получателя меняют последствия действия.

Проверьте исходную строку раньше схемы

Модель данных получает уже разобранный объект. Если json.loads превратил две пары priority в одно значение, Pydantic или другая схема не сможет обнаружить потерю по готовому словарю. Нужна проверка на этапе декодирования, когда парсер ещё видит последовательность пар имени и значения для каждого объекта.

В Python для этого есть object_pairs_hook. Функция получает список пар и возвращает объект либо поднимает ошибку. Она вызывается также для вложенных JSON-объектов. Поэтому один обработчик обнаруживает дубликат внутри arguments, а не только на верхнем уровне. Стандартный декодер JSON по умолчанию допускает повторные имена и сохраняет последнее значение; для строгого контракта поведение нужно изменить.

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

import json
def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate key: " + key)
        result[key] = value
    return result
def strict_loads(raw):
    return json.loads(raw, object_pairs_hook=unique_object)
raw = '{"priority":"normal","priority":"urgent"}'
print(json.loads(raw)["priority"])
cases = [raw, '{"arguments":{"priority":"normal","priority":"urgent"}}']
for case in cases:
    try:
        strict_loads(case)
    except ValueError as error:
        print(error)
    else:
        raise AssertionError("duplicate accepted")
valid = strict_loads('[{"priority":"normal"},{"priority":"urgent"}]')
assert len(valid) == 2
print([item["priority"] for item in valid])
escaped = r'{"priority":1,"\u0070riority":2}'
try:
    strict_loads(escaped)
except ValueError:
    pass
else:
    raise AssertionError("escaped duplicate accepted")

Обычный разбор выводит urgent. Строгий декодер отклоняет обе неоднозначные строки с причиной duplicate key: priority. Корректный массив из двух объектов возвращает normal и urgent: имена повторяются между разными объектами, а не внутри одного. Эта разница важна для пакетных заявок, где одинаковая структура закономерно повторяется много раз.

Почему регулярное выражение не поможет

В JSON ключ может содержать экранированные символы. Записи priority и его эквивалент с Unicode-escape после разбора обозначают одно имя. Поиск одинаковых фрагментов исходного текста может пропустить такое совпадение. Парсер уже выполняет декодирование строк, поэтому обработчик пар сравнивает именно полученные имена. При этом одинаковые слова внутри обычных строк не принимаются за ключи.

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

Вход модели можно сформулировать так: «Подготовь JSON заявки из сообщения клиента. Верни по одному значению каждого поля; priority допускает normal или urgent; не добавляй служебные поля». Инструкция помогает получить удобный ответ, но серверная проверка остаётся обязательной. При отказе декодера передайте модели точную безопасную причину и ограничьте число повторов.

Как встроить отказ в обработку

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

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

Платный курс «LLM Security PRO: Prompt Injection, утечки, tool-abuse» включает валидацию структурированного вывода, ограничения полномочий инструментов и проверки безопасности для CI/CD. Следующая задача — связать строгий разбор с разрешёнными действиями инструмента и проверить всю цепочку на пограничных ответах. Откройте программу курса и научитесь принимать выход модели как контролируемый вход приложения.

Продолжить на практике

  • LLM Security PRO: Prompt Injection, утечки, tool-abuse ↗

    Практический курс по LLM Security. Запустите локальный RAG/агента на реальном LLM (OpenAI API) и укрепите прод: защита от prompt/indirect injection, утечек и tool-abuse, строгий JSON-вывод (Schema/Pydantic), redaction и лимиты стоимости/циклов. Плюс профессиональные блоки: OWASP LLM Top-10, threat modeling, red-team suite и security-gates в CI/CD.