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

Всплеск отказов модели («I can't help with that»)

Модель, которая вдруг начала отказывать («I can’t help with that», «As an AI I can’t…»), — тихая, но дорогая поломка. Пользователь получает бесполезный ответ, а вы не видите этого в логах ошибок — HTTP 200, всё «зелёное».

Причины у всплеска отказов почти всегда две:

  • релиз новой версии модели — провайдер обновил safety-политику, и ваш системный промпт («ты можешь обсуждать медицину») перестал работать;
  • волна абьюза — кто-то долбит ваш продукт запросами, которые триггерят safety-фильтры, и доля отказов взлетает.

И то, и другое ловится одним сигналом: rate отказов подскочил.

Не нужен 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 — она же подстрахует, если сам монитор упадёт (отсутствие пинга — тоже алёрт).

  • текущая доля отказов и на скольких ответах она посчитана;
  • сравнение с базовой линией (вчера / прошлая неделя);
  • 2–3 примера запросов, на которые модель отказала (в gist-ссылке);
  • версия модели и хеш системного промпта — чтобы понять, что менялось.