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).
What to put in the push
Section titled “What to put in the push”- 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.
Related recipes
Section titled “Related recipes”- Quality drop (eval regression) — the same rate/threshold pattern for quality.
- Hallucination spike — a neighboring degradation signal.
- Safety filter trigger — individual dangerous queries, not the overall rate.