Всплеск отказов модели («I can't help with that»)
Модель, которая вдруг начала отказывать («I can’t help with that», «As an AI I can’t…»), — тихая, но дорогая поломка. Пользователь получает бесполезный ответ, а вы не видите этого в логах ошибок — HTTP 200, всё «зелёное».
Причины у всплеска отказов почти всегда две:
- релиз новой версии модели — провайдер обновил safety-политику, и ваш системный промпт («ты можешь обсуждать медицину») перестал работать;
- волна абьюза — кто-то долбит ваш продукт запросами, которые триггерят safety-фильтры, и доля отказов взлетает.
И то, и другое ловится одним сигналом: rate отказов подскочил.
Шаблон 1: лёгкий классификатор отказа
Заголовок раздела «Шаблон 1: лёгкий классификатор отказа»Не нужен ML — большинство отказов начинаются с узнаваемых фраз. Ловим их regex-ом (и EN, и RU формулировки), а спорные случаи при желании доотправляем дешёвому судье-LLM:
import re, os, requests
REFUSAL_PATTERNS = [ r"\bi (can'?t|cannot|won'?t) (help|assist|do that|provide)", r"\bi'?m (sorry|unable|not able)\b.*\b(can'?t|cannot)\b", r"\bas an ai\b.*\b(can'?t|cannot|unable)\b", r"\bне могу (помочь|с этим помочь|выполнить|предоставить)", r"\bк сожалению,? я не могу\b", r"\bя не могу (обсуждать|создавать|генерировать)\b", r"\bэто (противоречит|выходит за рамки)\b",]REFUSAL_RE = re.compile("|".join(REFUSAL_PATTERNS), re.IGNORECASE)
def is_refusal(text): head = text.strip()[:200] # отказ почти всегда в начале ответа if REFUSAL_RE.search(head): return True return False # спорное — можно отдать судье, см. ниже
def is_refusal_llm(text): # дешёвый fallback только для неоднозначных ответов import anthropic r = anthropic.Anthropic().messages.create( model="claude-haiku-4-5", max_tokens=4, messages=[{"role": "user", "content": f"Это отказ ассистента выполнять запрос? Ответь '1' или '0'.\n\n{text[:400]}"}]) return r.content[0].text.strip().startswith("1")Regex ловит 90% дёшево и мгновенно; судью-LLM подключайте, только если
важна точность на пограничных ответах («я помогу, но с оговоркой…»).
Шаблон 2: скользящее окно + флаг «одного алёрта на всплеск»
Заголовок раздела «Шаблон 2: скользящее окно + флаг «одного алёрта на всплеск»»Считаем долю отказов по последним N ответам через deque. При пробое
порога шлём push один раз — флаг сбрасывается, когда rate вернулся в
норму. Иначе на каждом запросе прилетал бы push:
import collections, os, requests
class RefusalMonitor: def __init__(self, window=200, threshold=0.15): self.window = collections.deque(maxlen=window) # 1=отказ, 0=ок self.threshold = threshold self._fired = False
def record(self, response_text): self.window.append(1 if is_refusal(response_text) else 0) if len(self.window) < self.window.maxlen // 2: return # мало данных — не паникуем rate = sum(self.window) / len(self.window)
if rate >= self.threshold and not self._fired: self._fired = True notify("🚫 Всплеск отказов модели", f"Доля отказов {rate:.0%} за последние {len(self.window)} ответов " f"(порог {self.threshold:.0%}).\n" "Проверь: релиз модели сломал промпт? волна абьюза?", priority=8) elif rate < self.threshold * 0.6: self._fired = False # вернулось в норму — можно алёртить снова
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)
mon = RefusalMonitor()# в вашем пайплайне после каждого ответа:# mon.record(response.text)Гистерезис (threshold * 0.6 на сброс) не даёт алёрту дребезжать, когда
rate болтается около порога.
Шаблон 3: сравнение с базовой линией (сегодня vs вчера)
Заголовок раздела «Шаблон 3: сравнение с базовой линией (сегодня vs вчера)»Абсолютный порог 15% не различает «у нас всегда ~2%» и «продукт, где
40% запросов законно отклоняются». Надёжнее сравнивать сегодняшнюю долю
со вчерашней — резкий скачок важнее абсолютного числа:
import json, os, time, requests
STATE = "/tmp/refusal_baseline.json"
def check_daily(today_total, today_refusals): st = json.load(open(STATE)) if os.path.exists(STATE) else {} today_rate = today_refusals / max(today_total, 1) base_rate = st.get("rate", today_rate) # первый запуск — сам себе база
# скачок в 3+ раза относительно вчерашнего — сигнал регресса if today_rate >= 0.05 and today_rate >= base_rate * 3: notify("📈 Отказы выросли в разы", f"Сегодня {today_rate:.0%}, вчера {base_rate:.0%}.\n" "Похоже на релиз модели или волну абьюза — сверь с датой апдейта провайдера.", priority=9)
json.dump({"rate": today_rate, "ts": time.time()}, open(STATE, "w"))Запускайте раз в день по крону или scheduled cloud-функцией на YC — она же подстрахует, если сам монитор упадёт (отсутствие пинга — тоже алёрт).
Что положить в push
Заголовок раздела «Что положить в push»- текущая доля отказов и на скольких ответах она посчитана;
- сравнение с базовой линией (вчера / прошлая неделя);
- 2–3 примера запросов, на которые модель отказала (в gist-ссылке);
- версия модели и хеш системного промпта — чтобы понять, что менялось.
Связанные рецепты
Заголовок раздела «Связанные рецепты»- Просадка качества (eval regression) — тот же rate/threshold паттерн для качества.
- Всплеск галлюцинаций — соседний сигнал деградации.
- Срабатывание safety-фильтра — отдельные опасные запросы, а не общий rate.