Skip to content

Spike in model refusals ("I can't help with that")

A model that suddenly starts refusing (“I can’t help with that”, “As an AI I can’t…”) is a quiet but expensive breakage. The user gets a useless answer, and you don’t see it in your error logs — HTTP 200, everything “green.”

A refusal spike almost always has one of two causes:

  • a new model version release — the provider updated its safety policy, and your system prompt (“you may discuss medicine”) stopped working;
  • an abuse wave — someone hammers your product with queries that trip the safety filters, and the refusal share soars.

Both are caught by a single signal: the refusal rate jumped.

Approach 1: a lightweight refusal classifier

Section titled “Approach 1: a lightweight refusal classifier”

No ML needed — most refusals start with recognizable phrases. Catch them with a regex (both EN and RU phrasings), and optionally send ambiguous cases to a cheap LLM judge:

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] # a refusal is almost always at the start
if REFUSAL_RE.search(head):
return True
return False # ambiguous — hand off to the judge, see below
def is_refusal_llm(text):
# a cheap fallback only for ambiguous answers
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")

The regex catches 90% cheaply and instantly; wire in the LLM judge only if you need precision on borderline answers ("I'll help, but with a caveat…").

Approach 2: a rolling window + a “one alert per spike” flag

Section titled “Approach 2: a rolling window + a “one alert per spike” flag”

Count the refusal share over the last N responses via a deque. When the threshold is crossed, send a push once — the flag resets when the rate returns to normal. Otherwise a push would fire on every request:

import collections, os, requests
class RefusalMonitor:
def __init__(self, window=200, threshold=0.15):
self.window = collections.deque(maxlen=window) # 1=refusal, 0=ok
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 # too little data — don't panic
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 # back to normal — can alert again
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()
# in your pipeline, after each response:
# mon.record(response.text)

The hysteresis (threshold * 0.6 for the reset) keeps the alert from chattering when the rate hovers around the threshold.

Approach 3: baseline comparison (today vs yesterday)

Section titled “Approach 3: baseline comparison (today vs yesterday)”

An absolute 15% threshold can’t tell “we’re always ~2%” apart from “a product where 40% of queries are legitimately declined.” It’s more robust to compare today’s share with yesterday’s — a sharp jump matters more than the absolute number:

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) # first run — it's its own baseline
# a 3x+ jump over yesterday — a regression signal
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"))

Run it once a day via cron or a scheduled cloud function on YC — which also backstops you if the monitor itself dies (a missing ping is an alert too).

  • the current refusal share and over how many responses it’s computed;
  • a comparison to the baseline (yesterday / last week);
  • 2–3 example queries the model refused (in a gist link);
  • the model version and system-prompt hash — to see what changed.