Модель отвечает не на том языке
Пользователь пишет по-русски — а модель отвечает по-английски. Или на арабском вопросе выдаёт ответ на фарси. Для одного ответа это раздражает, для продакшена — тихая деградация: пользователь не жалуется, он просто уходит. Такое почти всегда всплывает скачком после деплоя: кто-то отредактировал системный промпт и выкинул строку «reply in the user’s language», или сменили модель — и у новой другое поведение по умолчанию.
Ловим не отдельный ляп, а рост доли несовпадений — и шлём push один раз на всплеск.
1. Лёгкий детектор языка
Заголовок раздела «1. Лёгкий детектор языка»Определяем язык ответа и сравниваем с ожидаемым. Ожидаемый берём из локали
запроса или из языка пользовательского ввода. Полновесный вариант —
langdetect / fasttext; но для пары «кириллица против латиницы» хватает
скрипт-эвристики без зависимостей.
import os, re, json, time, statistics, requests
def detect_lang(text): # дешёвая эвристика: доминирующий алфавит. cyr = len(re.findall(r"[а-яёА-ЯЁ]", text)) lat = len(re.findall(r"[a-zA-Z]", text)) if cyr == 0 and lat == 0: return "unknown" return "ru" if cyr >= lat else "en"
# для большего числа языков — полноценный детектор:# from langdetect import detect# def detect_lang(text): return detect(text) # 'ru', 'en', 'fa', ...
def is_mismatch(answer_text, expected_lang): got = detect_lang(answer_text) return got != "unknown" and got != expected_lang, gotexpected_lang — это язык запроса: либо явная локаль (request.locale),
либо язык, определённый по вводу пользователя тем же detect_lang.
2. Скользящее окно + флаг на всплеск
Заголовок раздела «2. Скользящее окно + флаг на всплеск»Копим долю несовпадений в окне и шлём push, когда она пробивает порог. Флаг-файл гарантирует один алёрт на всплеск — иначе на каждом ответе полетит новый.
WIN = [] # 1 = mismatch, 0 = okFLAG = "/tmp/lang_mismatch.flag"THRESHOLD = 0.15 # >15% ответов не на том языкеMIN_WINDOW = 40
def observe(answer_text, expected_lang, user_input=""): bad, got = is_mismatch(answer_text, expected_lang) WIN.append(1 if bad else 0) if len(WIN) > 200: WIN.pop(0) if len(WIN) < MIN_WINDOW: return rate = statistics.mean(WIN) if rate >= THRESHOLD and not os.path.exists(FLAG): open(FLAG, "w").write(str(time.time())) sample = redact(user_input) notify("🌐 Ответы не на том языке", f"{int(rate*100)}% ответов на неверном языке (окно {len(WIN)}).\n" f"Пример: ввод «{expected_lang}» → ответ «{got}»: {sample}\n" "Проверьте системный промпт и модель.", priority=8) elif rate < THRESHOLD * 0.5 and os.path.exists(FLAG): os.remove(FLAG) # всплеск прошёл — снимаем флаг
def redact(text, n=60): # обрезаем и убираем очевидную персональщину text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[email]", text) text = re.sub(r"\+?\d[\d\s()-]{7,}\d", "[phone]", text) return (text[:n] + "…") if len(text) > n else text
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)В push кладём отредактированный сэмпл ввод → ответ — так сразу видно
характер поломки, без утечки персональных данных.
3. Сравнение с вчера (baseline)
Заголовок раздела «3. Сравнение с вчера (baseline)»Абсолютный порог не заметит медленный дрейф. Раз в день сравниваем долю несовпадений сегодня против вчера — так ловится «внезапно 20% отвечаем по-английски там, где вчера было 2%».
STATE = "/tmp/lang_mismatch_daily.json"
def daily_baseline_check(today_rate): prev = (json.load(open(STATE)) if os.path.exists(STATE) else {}).get("rate", today_rate) if today_rate - prev >= 0.10: # +10 п.п. за сутки notify("📈 Скачок ответов не на том языке", f"Сегодня {int(today_rate*100)}% против {int(prev*100)}% вчера.\n" "Смотрите последний деплой: правка промпта или смена модели?", priority=9) json.dump({"rate": today_rate, "ts": time.time()}, open(STATE, "w"))Гоняйте через cron / systemd-timer или scheduled cloud-функцию на YC.
Что положить в push
Заголовок раздела «Что положить в push»- доля несовпадений за окно + пара
ожидалось → получено; - отредактированный сэмпл ввода (без email/телефонов);
- сравнение с baseline (сегодня vs вчера);
- намёк на причину:
правили системный промпт?,сменили модель?.
Счётчик держите в общем хранилище (Redis), чтобы окно переживало рестарты и охватывало все инстансы.
Связанные рецепты
Заголовок раздела «Связанные рецепты»- Просадка eval / качества — формальная оценка после смены промпта/модели.
- Всплеск галлюцинаций — рядом стоящий сигнал качества.
- Доля отказов модели — ещё один эффект правки системного промпта.