Модель перестала возвращать валидный 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, requestsfrom collections import dequefrom 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 raw3. Дневной baseline: сегодня против вчера
Заголовок раздела «3. Дневной baseline: сегодня против вчера»Скользящее окно ловит острый всплеск; дневное сравнение ловит медленный дрейф. Раз в сутки считаем долю фейлов за день и сравниваем со вчерашней:
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.
Что положить в push
Заголовок раздела «Что положить в push»- доля фейлов в окне и абсолютные числа
fail/total; - причина последнего фейла (
json.loadsvsschema); - редактированный сэмпл сырого ответа — по нему видно fences / вводную фразу;
- намёк на источник: свежий релиз модели или ваш деплой с правкой промпта.
Связанные рецепты
Заголовок раздела «Связанные рецепты»- Schema-drift в tool-calls — JSON валиден, но поля поехали.
- Просадка качества (eval regression) — тот же триггер: релиз модели.
- Токсичный вывод приложения — ещё один сигнал сломанного вывода.