Перейти к содержимому

Модель перестала возвращать валидный JSON

Вы просите модель вернуть JSON, парсите json.loads(...) — и однажды получаете JSONDecodeError. Один раз — бывает. Но когда доля парс-фейлов подскочила с 0.5% до 30%, это не случайность: провайдер выкатил новую версию модели или ваша же правка system-промпта сломала формат. Пользователи при этом видят пустой экран или 500, а вы узнаёте об этом из тикетов.

Это не schema-drift: там JSON валиден и парсится, но поля поехали. Здесь беда грубее — ответ вообще не парсится (модель обернула JSON в ```json fences, добавила фразу «Вот ваш ответ:», обрезалась по max_tokens) или не проходит валидацию схемы.

1. Скользящее окно: доля фейлов + один алёрт на всплеск

Заголовок раздела «1. Скользящее окно: доля фейлов + один алёрт на всплеск»

Оборачиваем шаг парсинга: json.loads плюс опциональная валидация (pydantic / jsonschema). Результат — success/fail — пишем в кольцевое окно последних N. Когда доля фейлов пробила порог, шлём push. Флаг-файл гарантирует один алёрт на всплеск, а не по push-у на каждый вызов.

import os, json, time, requests
from collections import deque
from jsonschema import validate, ValidationError
WINDOW = 200 # сколько последних вызовов держим
THRESHOLD = 0.15 # >15% фейлов в окне → алёрт
FLAG = "/tmp/json-parse-fail.flag"
_recent = deque(maxlen=WINDOW) # 1 = fail, 0 = ok
SCHEMA = { # опционально: описание ожидаемого JSON
"type": "object",
"required": ["intent", "confidence"],
"properties": {
"intent": {"type": "string"},
"confidence": {"type": "number"},
},
}
def parse_structured(raw: str):
# Возвращает (obj, None) при успехе или (None, reason) при фейле.
try:
obj = json.loads(raw)
except json.JSONDecodeError as e:
return None, f"json.loads: {e}"
try:
validate(obj, SCHEMA) # уберите, если валидация не нужна
except ValidationError as e:
return None, f"schema: {e.message}"
return obj, None
def observe(raw: str):
obj, reason = parse_structured(raw)
_recent.append(0 if reason is None else 1)
if len(_recent) >= WINDOW:
rate = sum(_recent) / len(_recent)
if rate >= THRESHOLD and not os.path.exists(FLAG):
open(FLAG, "w").write(str(time.time()))
notify("🧩 JSON перестал парситься",
f"Доля парс-фейлов: {rate:.0%} за последние {WINDOW} вызовов.\n"
f"Последняя причина: {reason}\n"
"Проверьте: релиз модели? правка system-промпта?",
priority=8)
elif rate < THRESHOLD * 0.5 and os.path.exists(FLAG):
os.remove(FLAG) # всплеск утих — снимаем флаг, ждём следующий
return obj, reason
def notify(title, message, priority):
requests.post(f"{os.environ['NOTIFLY_URL']}/message",
params={"token": os.environ["NOTIFLY_TOKEN"]},
json={"title": title, "message": message, "priority": priority},
timeout=5)

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

2. Показать, ЧТО именно вернула модель (редактированный сэмпл)

Заголовок раздела «2. Показать, ЧТО именно вернула модель (редактированный сэмпл)»

Знать, что «JSON сломался», мало — надо видеть, чем его сломали. Чаще всего это ```json fences или вводная фраза перед объектом. Кладём в push усечённый и обеззараженный сэмпл сырого ответа:

import re
def redact_sample(raw: str, limit: int = 400) -> str:
# маскируем очевидный PII и режем длину, чтобы не тащить лишнее в push
s = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "<email>", raw)
s = re.sub(r"\b\d{12,}\b", "<num>", s)
return s[:limit] + ("" if len(s) > limit else "")
def observe_with_sample(raw: str):
obj, reason = observe(raw)
if reason and os.path.exists(FLAG):
notify("🧩 Сырой ответ вместо JSON",
f"Причина: {reason}\n\nЧто вернула модель:\n{redact_sample(raw)}",
priority=7)
return obj, reason

Часто по сэмплу диагноз ставится за секунду: видно Вот ваш JSON:\n```json — значит, промпт перестал давить на «только объект, без обёрток». Простое лечение — снять fences перед парсингом:

def strip_fences(raw: str) -> str:
m = re.search(r"```(?:json)?\s*(\{.*\}|\[.*\])\s*```", raw, re.S)
return m.group(1) if m else raw

Скользящее окно ловит острый всплеск; дневное сравнение ловит медленный дрейф. Раз в сутки считаем долю фейлов за день и сравниваем со вчерашней:

STATE = "/tmp/json-parse-daily.json"
def daily_report(ok_today: int, fail_today: int):
total = ok_today + fail_today or 1
rate = fail_today / total
prev = (json.load(open(STATE)) if os.path.exists(STATE) else {}).get("rate", rate)
if rate - prev >= 0.05: # +5 п.п. к вчера
notify("🧩 Парс-фейлы растут день ко дню",
f"Сегодня: {rate:.1%} ({fail_today}/{total})\n"
f"Вчера: {prev:.1%}\n"
"Проверьте историю деплоев и релизы моделей.",
priority=7)
json.dump({"rate": rate, "ts": time.time()}, open(STATE, "w"))

Запускайте отчёт по cron / systemd-timer или из scheduled cloud-функции на YC — там же удобно держать флаг-файл во внешнем сторедже, если функция stateless.

  • доля фейлов в окне и абсолютные числа fail/total;
  • причина последнего фейла (json.loads vs schema);
  • редактированный сэмпл сырого ответа — по нему видно fences / вводную фразу;
  • намёк на источник: свежий релиз модели или ваш деплой с правкой промпта.