Пустые и обрезанные ответы модели
Модель вернула пустую строку или ответ обрезался на полуслове
(finish_reason=length) — по отдельности это норма, но всплеск почти всегда
означает проблему: кто-то выставил max_tokens=16, провайдер деградировал,
или новая версия модели «замолчала» на вашем промпте. Пользователь при этом
видит пустой экран, а в метриках ошибок — тишина, потому что HTTP-статус 200.
1. Считаем пустые и обрезанные в скользящем окне
Заголовок раздела «1. Считаем пустые и обрезанные в скользящем окне»Оборачиваем каждый вызов модели и ведём счётчик по последним N ответам. Состояние держим в Redis, чтобы пережить рестарты и охватить все инстансы.
import os, json, requests, redis
R = redis.from_url(os.environ["REDIS_URL"])KEY = "llm-completions" # список из "ok" / "empty" / "trunc"WIN = 200 # размер окнаMIN = 50 # не алёртим, пока мало данных
def observe(resp): """resp — объект ответа OpenAI-совместимого API.""" choice = resp.choices[0] content = (choice.message.content or "").strip() reason = choice.finish_reason
if reason == "length": tag = "trunc" elif not content: tag = "empty" else: tag = "ok"
R.lpush(KEY, tag) R.ltrim(KEY, 0, WIN - 1)
window = R.lrange(KEY, 0, WIN - 1) if len(window) < MIN: return
empty = sum(1 for x in window if x == b"empty") trunc = sum(1 for x in window if x == b"trunc") n = len(window)
if empty / n > 0.10: notify("🕳️ Пустые ответы модели", f"Пустой content: {empty}/{n} ({int(empty/n*100)}%).\n" "Проверьте промпт, stop-последовательности и статус провайдера.", priority=8) elif trunc / n > 0.20: notify("✂️ Ответы обрезаются", f"finish_reason=length: {trunc}/{n} ({int(trunc/n*100)}%).\n" "Скорее всего, слишком маленький max_tokens.", priority=7)
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)2. Дедупликация алёрта
Заголовок раздела «2. Дедупликация алёрта»Всплеск длится минутами — не хочется получать push на каждый вызов. Ставим флаг с TTL, чтобы алёртить не чаще раза в 10 минут:
def notify_once(title, message, priority, cooldown=600): flag = "alert:" + title if R.set(flag, "1", nx=True, ex=cooldown): notify(title, message, priority)3. Синтетическая канарейка
Заголовок раздела «3. Синтетическая канарейка»Не ждём реального трафика — раз в минуту cloud-функция с timer-trigger дёргает модель фиксированным промптом, который обязан дать непустой ответ:
def handler(event, context): r = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "Ответь одним словом: тест"}], max_tokens=20, ) c = (r.choices[0].message.content or "").strip() if not c or r.choices[0].finish_reason == "length": notify("🕳️ Канарейка: пустой ответ", f"finish_reason={r.choices[0].finish_reason}, len={len(c)}", priority=8) return {"statusCode": 200}Что положить в текст алёрта
Заголовок раздела «Что положить в текст алёрта»- доля пустых / обрезанных в окне и его размер;
- значение
max_tokens, с которым идут запросы; - модель и провайдер;
- последний ненулевой ответ для контраста.
Связанные рецепты
Заголовок раздела «Связанные рецепты»- Доступность LLM-провайдеров — частая первопричина.
- Повторы и зацикливание — обратная крайность генерации.