LLM вернула слишком вложенный JSON: ограничиваем обход аргументов инструмента
Инструмент принимает JSON-фильтр для поиска заявок. В обычном запросе два уровня: условия и значения. Иногда модель возвращает длинную цепочку групп and и or. JSON разбирается, тип верхнего поля правильный, а рекурсивный преобразователь фильтра уходит глубже ожидаемого и завершается с RecursionError. Формально корректный ответ оказался неподходящим входом для следующего этапа.
Для такого интерфейса нужен явный бюджет структуры. Ограничение длины строки полезно, но не описывает допустимую сложность фильтра: много коротких вложенных объектов помещается в небольшой ответ. Зададим максимальную глубину контейнеров и максимальное число посещённых узлов, а проверку выполним до преобразования в запрос к хранилищу.
Определите, что считать уровнем
Для примера корневой словарь имеет глубину один. Словарь или список внутри него увеличивает глубину на единицу. Строки, числа и остальные скалярные значения глубину контейнеров не увеличивают. Это правило нужно записать рядом с контрактом, иначе два обработчика будут по-разному оценивать один фильтр на границе лимита.
Количество узлов ограничивается отдельно. Здесь узлом считается каждое значение, включая контейнеры; имена ключей в число узлов не входят. Широкий список из тысячи простых условий имеет небольшую глубину, но требует большой работы. Второй бюджет помогает остановить такую структуру до бизнес-преобразования. Сами правила фильтра — разрешённые операторы, поля и значения — проверяются после структурного бюджета.
Ниже три безопасных синтетических примера. Нормальный фильтр содержит группу из одного условия. Глубокий имеет всего четыре небольших контейнера. Широкий — короткий список чисел. Этого достаточно, чтобы проверить механизм отказа, без генерации огромных структур. Все объекты сначала разбираются стандартным JSON-декодером; затем выполняется отдельная проверка.
import json
def check_structure(value, max_depth, max_nodes):
stack = [(value, 1)]
visited, deepest = 0, 0
while stack:
node, depth = stack.pop()
visited += 1
if visited > max_nodes:
raise ValueError("node budget exceeded")
if isinstance(node, (dict, list)):
if depth > max_depth:
raise ValueError("container depth exceeded")
deepest = max(deepest, depth)
children = node.values() if isinstance(node, dict) else node
stack.extend((child, depth + 1) for child in children)
return deepest, visited
normal = json.loads('{"and":[{"status":"open"}]}')
print(check_structure(normal, max_depth=3, max_nodes=10))
for raw, depth, nodes in [
('{"a":{"b":{"c":{}}}}', 3, 10),
('[1,2,3,4]', 3, 4),
]:
try:
check_structure(json.loads(raw), depth, nodes)
except ValueError as error:
print(error)
else:
raise AssertionError("budget not enforced")
assert check_structure({}, 1, 1) == (1, 1)
assert check_structure([1, 2, 3], 1, 4) == (1, 4)
Первый объект проходит: максимальная глубина контейнеров равна трём, посещено четыре узла. Второй отклоняется на четвёртом контейнере с причиной container depth exceeded. Третий превышает разрешённые четыре узла и получает node budget exceeded. Эти два отказа различаются, поэтому команда видит, нужно ли упростить вложенность или сократить количество условий.
Почему проверка тоже не должна быть рекурсивной
Рекурсивная функция, которая сначала пытается измерить всю глубину, может упасть по той же причине, что и защищаемый преобразователь. Здесь используется явный стек. Глубина проверяется при извлечении контейнера, а дальнейшие значения добавляются только в рамках уже ограниченного по размеру входа. При первом превышении работа прекращается.
Эта проверка выполняется после json.loads и защищает последующий обход, а не сам парсер. Ограничения JSON-декодера зависят также от размера входа и доступных ресурсов. Поэтому до разбора задайте предел сырых байтов ответа, а для недоверенного потока с высокими требованиями используйте парсер с собственным лимитом вложенности или изолированный процесс. RecursionError парсера должен стать контролируемым отказом входа, не поводом увеличивать глобальный предел рекурсии.
Как объяснить отказ модели и пользователю
Запрос к модели: «Подготовь фильтр заявок по отделу и статусу. Используй одну группу and и простые условия eq; максимальная глубина контейнеров — три». Если модель превысила контракт, верните безопасное сообщение «Сократи вложенность фильтра до трёх уровней» и ограничьте число повторов. Не передавайте неудачный объект в инструмент в надежде, что хранилище само его упростит.
Пользователю достаточно сообщить, что фильтр получился слишком сложным, и предложить разделить вопрос. Например, сначала выбрать отдел, затем уточнить статус. В журнале сохраните вид превышения, лимит, идентификатор запроса и версию контракта. Полный фильтр может содержать персональные данные, поэтому его хранение требует тех же правил, что и остальные аргументы инструмента.
В платном курсе «LLM Security PRO: Prompt Injection, утечки, tool-abuse» вы будете валидировать структурированный вывод, ограничивать полномочия и стоимость работы агента, собирать security-гейты для 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.
