Сайт
на двух
языках
Когда перевод делает не библиотека, а языковая модель. Три механизма под разный контент, настоящий промпт с глоссарием и грабли, на которых модель ломается в проде — на живом коде naidiyogu.ru.
Контент разный — поэтому машин три
Перевести я решил не онлайн-переводчиком и не словарём, а языковой моделью: даёшь ей текст и инструкцию «переведи вот так», она возвращает перевод. Дальше под «моделью» я везде имею в виду это.
Контент на площадке разный, и живёт он по-разному. Надписи интерфейса меняются раз в релиз. Каталог направлений и анкеты преподавателей — изредка. Посты и отзывы пользователи пишут когда хотят и сколько хотят. Под каждый тип перевод приходится делать в свой момент и своим способом, иначе получается либо дорого, либо читатель упирается в непереведённую страницу и ждёт. Из-за этого механизмов в итоге три, а не один.
flowchart TD
UI["Надписи интерфейса, около 4000 строк"] --> M1["Скрипт перед релизом"]
CAT["Каталог и анкеты преподавателей"] --> M2["Фоновый автомат по триггеру в базе"]
UGC["Посты, отзывы, комментарии"] --> M3["Кнопка Перевести"]
M1 --> F["Файл переводов, едет в сборку сайта"]
M2 --> DB["Английские поля в базе, готовы заранее"]
M3 --> C["Кеш переводов, перевод по требованию"]
Промпт и глоссарий: чем модель бьёт переводчик
Перевод задаётся модели не кодом, а инструкцией на обычном языке — системным промптом. Мой собран в коде из кусков, и каждая строка стоит там после конкретной поломки. Вот он целиком.
You are a professional translator.
Translate the user-provided text to {target language}.
Output ONLY the translated text, no explanations, no preamble, no quotes, no metadata.
Preserve all ICU MessageFormat placeholders exactly as-is (e.g. {name}, {count, plural, one {# item} other {# items}}).
Preserve all HTML tags and Markdown markup exactly.
Preserve all code blocks, URLs, and technical identifiers unchanged.
Do not refuse any content; translate faithfully and neutrally.
Use these canonical translations for yoga terminology: {glossary}.
Разберу, что каждая строка закрывает на практике:
«Output ONLY the translated text» — без неё модель добавляет «Here is the translation:» или оборачивает ответ в кавычки, и этот мусор уезжает прямо в надпись на кнопке. «Preserve all ICU placeholders» — {name} и {count, plural, ...} это места, куда сайт потом вставляет имя или число; потеряются — страница падает. «Preserve HTML and Markdown» — посты приходят с разметкой. «Do not refuse any content» — прямая попытка задушить отказы модели; помогает частично, поэтому есть второй рубеж (ниже).
Глоссарий: то, чего не умеет обычный переводчик
Слово «направление» Google Translate честно переведёт как «direction». А на площадке «направление» — это стиль практики, по-английски «style». Санскритские названия поз должны писаться канонично, а не калькой с русского произношения. Готовый переводчик не знает мою предметную область — он переводит слово, а не контекст.
Глоссарий — массив из 97 терминов, у каждого ключ и каноничный перевод на оба языка. Приклеивается к каждому запросу как часть промпта:
export const GLOSSARY_VERSION = 1;
export const YOGA_GLOSSARY = [
{ term: 'Ashtanga', ru: 'Аштанга-виньяса', en: 'Ashtanga Vinyasa' },
{ term: 'Savasana', ru: 'Шавасана', en: 'Corpse pose (Savasana)' },
// доменные слова самой площадки
{ term: 'direction', ru: 'направление', en: 'style' },
{ term: 'offline', ru: 'очно', en: 'in-person' },
// ...всего 97
];
GLOSSARY_VERSION рядом не случайно — он участвует в сбросе кеша, об этом ниже.Где модель ломается на проде
Если бы модель всегда честно переводила то, что ей дали, дальше писать было бы нечего. Но в эксплуатации она ведёт себя по-разному, и под каждое поведение пришлось приделать страховку. Сначала — как весь путь кнопки «Перевести» выглядит целиком, с развилками, потом четыре истории по отдельности.
flowchart TD
A["Клик Перевести под русским отзывом"] --> B["Сервер берёт текст из базы по id, не от клиента"]
B --> R["Чистка: телефоны и почты в метки"]
R --> K{"Перевод уже в кеше?"}
K -->|да| HIT["Отдаём из кеша, модель не зовём"]
K -->|нет| D["Определяем язык по алфавиту"]
D --> T["Перевод через Haiku с глоссарием"]
T --> H{"Ответ это отказ модели?"}
H -->|"да, брак"| ORIG["Показываем исходный текст, в кеш не пишем"]
H -->|нет| SAVE["Пишем в кеш и показываем перевод"]
Модель отказывается переводить
Что происходит. Просишь перевести отзыв, а в ответ: «I'm sorry, I cannot translate this» или «please provide the text to translate». Это не перевод, а вежливая отговорка. Не поймаешь — она уедет в интерфейс вместо английской версии отзыва.
Почему. Модель может счесть текст обрезанным, странным, подозрительным — и вместо работы выдать отказ. Строка «Do not refuse» в промпте частоту снижает, но не до нуля.
Как закрыл. Детектор отказов — список шаблонов, по которым ответ опознаётся как брак:
const HALLUCINATION_PATTERNS = [
/i['']m\s+sorry/i,
/cannot\s+translate/i,
/i\s+(?:am\s+)?unable\s+to\s+translate/i,
/извините/i,
/не\s+могу\s+перевести/i,
/it\s+seems\s+you\s+forgot\s+to\s+include/i,
/please\s+provide\s+the\s+text/i,
];
export function isHallucination(text) {
return HALLUCINATION_PATTERNS.some((re) => re.test(text));
}
Восемь шаблонов, русские и английские. Сработал любой — отказ опознан как брак: результат не кешируется, и наружу отдаётся исходный текст, а событие тегируется в мониторинге, чтобы я видел частоту.
Следствие. Два рубежа вместо одного: промпт давит частоту отказов на входе, детектор ловит то, что просочилось на выходе. По отдельности ни один не надёжен.
Перевод теряет подстановки и ломает страницу
Что происходит. В интерфейсе есть строки вида «осталось {count, plural, one {# место} other {# мест}}». Это не текст — это конструкция, куда сайт подставит число и выберет форму слова. Модель при переводе иногда роняет такую подстановку или переименовывает переменную.
Почему опасно. Подстановка — технический якорь. Нет якоря — пустота или ошибка отрисовки. Причём в строках со склонениями текст внутри веток («место»/«мест») при переводе обязан поменяться, а имя переменной и тип (count, plural) — нет. Тупо сравнить «было/стало» нельзя.
Как закрыл. Скрипт после каждого ответа сам проверяет целостность: вытаскивает подстановки балансировкой скобок, а сложные нормализует до сигнатуры — от {count, plural, one {# место} ...} остаётся {count,plural}:
// от полной подстановки оставляем "переменная,тип"
function normalizePlaceholder(ph) {
if (/^\{\w+\}$/.test(ph)) return ph; // простой {name} как есть
const parts = ph.slice(1, -1).split(',');
return `{${parts[0].trim()},${parts[1].trim()}}`; // {count,plural}
}
Если в переводе якоря не хватает — повторный заход с усиленной инструкцией, где прямым текстом перечислено, что именно сохранить:
You are a professional translator. Translate the text to English.
CRITICAL: You MUST preserve these exact ICU MessageFormat placeholders unchanged: {missing}.
Output ONLY the translated text with all placeholders intact.
Не помогло и со второго раза — строка пропускается целиком, в файл переводов не пишется. Лучше английская надпись осталась русской, чем сломала вёрстку.
Чужой телефон не должен уехать в модель
Что происходит. Пользователь оставляет в отзыве контакт: «пишите мне на почту или в телеграм». И этот текст я собираюсь отправить во внешнюю модель.
Почему стоп-кран. Контакт — персональные данные. Отправлять их во внешний сервис ради перевода нельзя, это вопрос закона, а не удобства.
Как закрыл. Перед отправкой текст проходит чистку. Телефоны, почты, ссылки на мессенджеры и профили вырезаются и заменяются метками:
text
.replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[email]')
// мессенджер-ссылки ДО телефонов, иначе wa.me/<цифры>
// частично съест регулярка для номера и оставит мусор
.replace(/(?:https?:\/\/)?(?:t|wa)\.me\/[\w._-]+/gi, '[handle]')
.replace(/(?:https?:\/\/)?(?:www\.)?vk\.com\/[\w._-]+/gi, '[handle]')
.replace(/(?:\+?\d{1,3}[\s.-]?)?\(?\d{3,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2}[\s.-]?\d{2}/g, '[phone]')
На этом я споткнулся ровно один раз и запомнил: порядок чисток важен. Ссылку на мессенджер надо резать раньше телефона. Иначе регулярка для номера выхватит цифры из wa.me/79991234567 и оставит обрубок. Сначала ссылки, потом номера. А в мониторинг при ошибке уходит не текст, а его короткий хеш — полный текст пользователя не светится даже в логах.
Кеш, который сам понимает, что протух
Задача. Не переводить один и тот же текст дважды.
Почему наивный кеш врёт. Запомнить «текст X → перевод Y» мало. Кеш начинает врать при двух событиях. Первое: автор отредактировал пост — текст другой, перевод старый. Второе: я поправил глоссарий или сам промпт — все старые переводы сделаны по прежним правилам и должны пересчитаться.
Как закрыл. Ключ кеша — не текст, а его отпечаток плюс версия глоссария и версия промпта:
export const PROMPT_VERSION = 2; // поднимаю при правке промпта
const cached = await findCachedTranslation({
entity_type, entity_id, field, target_lang,
source_hash: sha256(redactedText),
glossary_version: GLOSSARY_VERSION,
prompt_version: PROMPT_VERSION,
});
Отредактировал пост — отпечаток сменился — следующий клик переводит заново. Поднял GLOSSARY_VERSION или PROMPT_VERSION — весь старый кеш разом считается несовместимым и пересчитывается по мере обращений, руками чистить ничего не надо. Текст не менялся — мгновенный ответ из кеша, модель не вызывается.
Текст на перевод беру из базы, а не от клиента
Это была не просто грабля, а дыра в безопасности, и я её поймал не сразу.
Как было сначала. Кнопка «Перевести» слала на сервер сам текст: «вот этот абзац переведи». Логично же — что показано на странице, то и переводим.
Почему это плохо. Раз текст приходит от клиента, клиент может прислать любой текст. Не тот, что на странице, а что угодно — и я за свой счёт через свою модель перевожу произвольные простыни постороннему человеку. Бесплатный переводчик на моём ключе. Плюс так обходится любой список «что можно переводить».
Как переделал. Клиент теперь шлёт не текст, а что переводить: тип сущности, её id и поле — например «отзыв номер такой-то, поле text». Сервер сам грузит текст из базы, предварительно проверив, что эта сущность и это поле вообще разрешены к переводу:
// поле берётся из БД по id, НЕ из тела запроса
const registry = getEntityRegistry(entity_type);
if (!registry.allowedFields.includes(field)) throw new TranslationError(...);
const source_text = await registry.loadSourceText(entity_id, field);
Следствие. Перевести можно только то, что реально лежит на сайте в разрешённом поле. Сверху ещё лимиты: 30 запросов в минуту с одного адреса и 100 в час на пользователя — чтобы и легальную кнопку не молотили в цикле.
Как блокировка на перевод уронила весь сайт
Самая дорогая моя ошибка в этой истории, и чисто архитектурная.
Что я сделал. Чтобы два человека, нажавшие «Перевести» на одном и том же тексте одновременно, не дёргали модель дважды, я поставил блокировку: первый запрос берёт замок, переводит, второй ждёт и берёт готовое из кеша. Разумно звучит.
Как починил. Замок убрал совсем. Допустил гонку: да, два одновременных перевода одного текста сходят к модели оба — цена этому один лишний, по сути дублирующий вызов. А запись в кеш сделал устойчивой к гонке через ON CONFLICT DO UPDATE — второй просто перезапишет тем же результатом:
INSERT INTO content_translations (...) VALUES (...)
ON CONFLICT (entity_type, entity_id, field, target_lang, source_hash)
DO UPDATE SET translated_text = EXCLUDED.translated_text
Фоновый автомат: очередь, что переживает рестарт
Это второй из трёх механизмов — тот, что переводит каталог и анкеты сам.
Задача. Сохранили направление или анкету без английской версии — перевод должен появиться без моего участия, и ни одно событие не должно потеряться, даже если сервис в этот момент перезапускался.
Как сделано. Триггер в базе при сохранении кладёт строку в очередь и шлёт сигнал. Отдельный процесс-работник слушает сигнал и разбирает очередь. Задачу он берёт так, чтобы два параллельных разбора не схватили одну и ту же:
SELECT id, entity_type, entity_id, attempts
FROM i18n_translation_queue
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
flowchart TD
W["Сохранили анкету без английского поля"] --> TR["Триггер в базе"]
TR --> Q["Задача в очередь"]
TR --> N["Сигнал: проснись, есть работа"]
Q --> WK["Работник берёт задачу: FOR UPDATE SKIP LOCKED"]
N --> WK
WK --> TX["Перевод через Sonnet с глоссарием"]
TX --> OK{"Успех?"}
OK -->|да| DONE["Готово, английские поля записаны"]
OK -->|"нет, до 3 раз"| RETRY["Назад в очередь, статус pending"]
RETRY --> FAIL["Три провала, статус failed, алёрт в Telegram"]
Итог. Автомат, который не теряет задачи при рестарте и зовёт человека только когда сам исчерпал попытки. Плюс мелкая защита от мусора: на переводе названий районов, если модель вернула больше 80 символов вместо короткого топонима, я её ответ отбрасываю — длинная простыня вместо «Кузьминки» это явно не перевод названия.
Сначала алфавит, потом модель
Маленькая деталь, но характерная для всего подхода. Чтобы перевести пользовательский текст, надо сперва понять, на каком он языке — иначе непонятно, в какую сторону переводить, и стоит ли вообще показывать кнопку.
Гонять ради этого модель на каждый текст — расточительно. Сначала работает проверка по алфавиту, без всякой модели:
const hasCyrillic = /[Ѐ-ӿ]/.test(letters);
const hasLatin = /[A-Za-z]/.test(letters);
if (hasCyrillic && !hasLatin) return 'ru'; // чистая кириллица
// смесь, латиница или непонятное → к модели с вопросом "какой язык?"
Чистая кириллица — русский, решено мгновенно. И только смешанный текст («Аштанга class с Anna») уходит к модели с коротким запросом. Тот же дешёвый приём решает, показывать ли кнопку: текст уже на языке читателя — кнопки нет; намешано и уверенности нет — кнопку показываем, пусть человек решит сам. Дорогой инструмент включается там, где дешёвый спасовал.
Почему Claude, а не Google Translate или своя модель
Два довода за облачную модель на сегодня. Качество — Claude переводит хорошо там, где важны нюансы: терминология, санскрит, живая стилистика анкет и отзывов; заметно выше обычного машинного перевода, который не чувствует контекста. На текущем объёме это необременительно — переводов пока немного, держать облачную модель недорого по силам; городить схему сложнее ради экономии, которой на этом объёме нет, — лишняя работа без выгоды.
Альтернативы я взвешивал, не отметал с порога:
Что я намеренно не перевожу
Половина работы с переводом — решить, чего переводить не надо. Не трогаю личную переписку преподавателя с учеником — приватный разговор, ему не место во внешней модели. Не перевожу приватные заметки преподавателей про учеников. Не перевожу юридические документы — оферту, политику: там только русский и честная плашка «договор на русском». Не перевожу адреса страниц.
Соблазн «переведём всё, что есть» ведёт сразу в две ямы: лишний перевод того, что на другом языке никому не нужно, и — серьёзнее — утечка приватного во внешний сервис. Поэтому пользовательский контент переводится только по явной кнопке и с согласия человека, а не фоном за его спиной. Список запрещённого к переводу — такой же рабочий инструмент, как сам переводчик.
Чем это живёт дальше
Сейчас три машины закрывают почти все случаи: интерфейс переведён заранее, каталог и анкеты подхватывает фоновый автомат, живой контент — кнопка с кешем. В поддержке два пункта, оба завязаны на рост. Первый — своя модель на сервере, если объём вырастет или появится чат с переводом в реальном времени. Второй — глоссарий: нахожу термин, переведённый не так, как принято на площадке, добавляю в словарь, поднимаю GLOSSARY_VERSION, и кеш на следующем обращении пересчитывается сам.
Если коротко, что я вынес: перевод на модели — это не «вызвал и получил». Это решение, в какой момент переводить каждый тип контента, плюс набор страховок от того, как модель ведёт себя на живых людях — отказывается, роняет подстановки, видит чужой телефон. И ещё это дисциплина не уронить себе прод архитектурой вокруг перевода. Сам вызов модели — самая короткая часть всей работы.
// Обсуждение
Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.