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

Модель отвечает не на том языке

Пользователь пишет по-русски — а модель отвечает по-английски. Или на арабском вопросе выдаёт ответ на фарси. Для одного ответа это раздражает, для продакшена — тихая деградация: пользователь не жалуется, он просто уходит. Такое почти всегда всплывает скачком после деплоя: кто-то отредактировал системный промпт и выкинул строку «reply in the user’s language», или сменили модель — и у новой другое поведение по умолчанию.

Ловим не отдельный ляп, а рост доли несовпадений — и шлём push один раз на всплеск.

Определяем язык ответа и сравниваем с ожидаемым. Ожидаемый берём из локали запроса или из языка пользовательского ввода. Полновесный вариант — 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, got

expected_lang — это язык запроса: либо явная локаль (request.locale), либо язык, определённый по вводу пользователя тем же detect_lang.

Копим долю несовпадений в окне и шлём push, когда она пробивает порог. Флаг-файл гарантирует один алёрт на всплеск — иначе на каждом ответе полетит новый.

WIN = [] # 1 = mismatch, 0 = ok
FLAG = "/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 кладём отредактированный сэмпл ввод → ответ — так сразу видно характер поломки, без утечки персональных данных.

Абсолютный порог не заметит медленный дрейф. Раз в день сравниваем долю несовпадений сегодня против вчера — так ловится «внезапно 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.

  • доля несовпадений за окно + пара ожидалось → получено;
  • отредактированный сэмпл ввода (без email/телефонов);
  • сравнение с baseline (сегодня vs вчера);
  • намёк на причину: правили системный промпт?, сменили модель?.

Счётчик держите в общем хранилище (Redis), чтобы окно переживало рестарты и охватывало все инстансы.