Перейти к содержимому
arckep.ru — все нейросети в одном месте без VPN Перейти
>AISTUDY_
Поддержать
AUTHORСвежий выпуск №024 → Куда внедрять агентов: фронт или тыл
Авторская колонка · перевод сайта языковой модельюСЕРИЯ 009
Авторская колонка · выпуск №009

Сайт
на двух
языках

Когда перевод делает не библиотека, а языковая модель. Три механизма под разный контент, настоящий промпт с глоссарием и грабли, на которых модель ломается в проде — на живом коде naidiyogu.ru.

naidiyogu.ru — площадка, где ищут преподавателей йоги в Москве и онлайн. Русскоязычная по умолчанию. В какой-то момент понадобилась английская версия: внутри России хватает людей, которые по-русски не читают — туристы, экспаты, иностранные преподаватели. Переводить я решил языковой моделью, той же Claude, что отвечает в чатах. И первое, обо что упёрся, ещё не написав ни строчки: «перевести сайт» — это не одна операция.
Раздел 01

Контент разный — поэтому машин три

Перевести я решил не онлайн-переводчиком и не словарём, а языковой моделью: даёшь ей текст и инструкцию «переведи вот так», она возвращает перевод. Дальше под «моделью» я везде имею в виду это.

Контент на площадке разный, и живёт он по-разному. Надписи интерфейса меняются раз в релиз. Каталог направлений и анкеты преподавателей — изредка. Посты и отзывы пользователи пишут когда хотят и сколько хотят. Под каждый тип перевод приходится делать в свой момент и своим способом, иначе получается либо дорого, либо читатель упирается в непереведённую страницу и ждёт. Из-за этого механизмов в итоге три, а не один.

Схема 1Три машины перевода под три типа контента
flowchart TD
  UI["Надписи интерфейса, около 4000 строк"] --> M1["Скрипт перед релизом"]
  CAT["Каталог и анкеты преподавателей"] --> M2["Фоновый автомат по триггеру в базе"]
  UGC["Посты, отзывы, комментарии"] --> M3["Кнопка Перевести"]
  M1 --> F["Файл переводов, едет в сборку сайта"]
  M2 --> DB["Английские поля в базе, готовы заранее"]
  M3 --> C["Кеш переводов, перевод по требованию"]
Слева — тип контента, в центре — механизм, справа — куда ложится готовый перевод.
01Интерфейс — заранее, скриптомПеред релизом запускаю скрипт. Он берёт русские строки, переводит недостающие английские через Claude Sonnet 4.6, пишет результат в файл, файл едет в сборку. Разовая ручная операция, человек глазами проверяет результат.
02Каталог и анкеты — фоновый автоматНа таблицах направлений, районов и преподавателей стоит триггер: запись сохранилась без английского поля — триггер кладёт задачу в очередь и будит процесс-работник. Работник переводит и дописывает английские колонки. Человек не нажимает ничего.
03Посты и отзывы — по кнопкеПод чужим текстом есть кнопка «Перевести». Нажал — текст уходит на перевод, результат показывается и складывается в кеш. Следующий читатель получит готовое без обращения к модели.
Главное решение в мультиязычностиНе «каким сервисом переводить», а «в какой момент»: статику — заранее с проверкой человеком, полу-статичный каталог — фоном впрок, живой поток — лениво по требованию. Один механизм на всё не натянулся ни разу.
Раздел 02

Промпт и глоссарий: чем модель бьёт переводчик

Перевод задаётся модели не кодом, а инструкцией на обычном языке — системным промптом. Мой собран в коде из кусков, и каждая строка стоит там после конкретной поломки. Вот он целиком.

Про копируемый блок нижеЗамена длинных тире на запятые касается только этого промпта — у меня так заведено для рабочих файлов. В тексте статьи тире на месте.
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 рядом не случайно — он участвует в сбросе кеша, об этом ниже.
Раздел 03

Где модель ломается на проде

Если бы модель всегда честно переводила то, что ей дали, дальше писать было бы нечего. Но в эксплуатации она ведёт себя по-разному, и под каждое поведение пришлось приделать страховку. Сначала — как весь путь кнопки «Перевести» выглядит целиком, с развилками, потом четыре истории по отдельности.

Схема 2Путь одного клика «Перевести», с развилками
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));
}

Восемь шаблонов, русские и английские. Сработал любой — отказ опознан как брак: результат не кешируется, и наружу отдаётся исходный текст, а событие тегируется в мониторинге, чтобы я видел частоту.

Честно про незакрытый край — я оставил его сознательноНаружу уходит именно исходный текст: англоязычный читатель, нажав «Перевести» на русском отзыве, получает обратно тот же русский текст, без всякой пометки, что перевести не удалось. Для него это выглядит так, будто перевод «сработал» и почему-то вернулся на русском. Я это вижу и пока оставил намеренно: приоритет был — не пустить мусор «I'm sorry, I cannot…» в интерфейс, и это сделано. Внятное сообщение «перевод сейчас недоступен» вместо молчаливой подмены — следующий шаг, он в работе, просто не первым в очереди: отказы редки, показать вместо перевода исходный текст не катастрофа. «Поймать брак» и «внятно объяснить это пользователю» — две разные задачи: первая закрыта, вторая сознательно отложена.

Следствие. Два рубежа вместо одного: промпт давит частоту отказов на входе, детектор ловит то, что просочилось на выходе. По отдельности ни один не надёжен.

Перевод теряет подстановки и ломает страницу

Что происходит. В интерфейсе есть строки вида «осталось {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 — весь старый кеш разом считается несовместимым и пересчитывается по мере обращений, руками чистить ничего не надо. Текст не менялся — мгновенный ответ из кеша, модель не вызывается.

Раздел 04

Текст на перевод беру из базы, а не от клиента

Это была не просто грабля, а дыра в безопасности, и я её поймал не сразу.

Как было сначала. Кнопка «Перевести» слала на сервер сам текст: «вот этот абзац переведи». Логично же — что показано на странице, то и переводим.

Почему это плохо. Раз текст приходит от клиента, клиент может прислать любой текст. Не тот, что на странице, а что угодно — и я за свой счёт через свою модель перевожу произвольные простыни постороннему человеку. Бесплатный переводчик на моём ключе. Плюс так обходится любой список «что можно переводить».

Как переделал. Клиент теперь шлёт не текст, а что переводить: тип сущности, её 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 в час на пользователя — чтобы и легальную кнопку не молотили в цикле.

Раздел 05

Как блокировка на перевод уронила весь сайт

Самая дорогая моя ошибка в этой истории, и чисто архитектурная.

Что я сделал. Чтобы два человека, нажавшие «Перевести» на одном и том же тексте одновременно, не дёргали модель дважды, я поставил блокировку: первый запрос берёт замок, переводит, второй ждёт и берёт готовое из кеша. Разумно звучит.

Что сломалосьЗамок я держал на блокировке уровня базы, а она занимает соединение из пула. Пул — 20 соединений. Вызов модели идёт до 30 секунд. Получилось: 20 человек одновременно нажали перевод — 20 соединений заняты и висят по 30 секунд каждое в ожидании модели. Пул исчерпан. И теперь весь сайт не может сходить в базу ни за чем — ни главная, ни поиск, ни запись. Не «перевод тормозит», а площадка целиком ложится от двадцати кликов. Публичный способ её уронить.

Как починил. Замок убрал совсем. Допустил гонку: да, два одновременных перевода одного текста сходят к модели оба — цена этому один лишний, по сути дублирующий вызов. А запись в кеш сделал устойчивой к гонке через 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
УрокНикогда не держать соединение из пула на время сетевого вызова к внешнему сервису. Лучше изредка перевести один текст дважды, чем дать двадцати кликам положить весь сайт. Дешёвая гонка честнее дорогой блокировки.
Раздел 06

Фоновый автомат: очередь, что переживает рестарт

Это второй из трёх механизмов — тот, что переводит каталог и анкеты сам.

Задача. Сохранили направление или анкету без английской версии — перевод должен появиться без моего участия, и ни одно событие не должно потеряться, даже если сервис в этот момент перезапускался.

Как сделано. Триггер в базе при сохранении кладёт строку в очередь и шлёт сигнал. Отдельный процесс-работник слушает сигнал и разбирает очередь. Задачу он берёт так, чтобы два параллельных разбора не схватили одну и ту же:

SELECT id, entity_type, entity_id, attempts
  FROM i18n_translation_queue
 WHERE status = 'pending'
 ORDER BY created_at
 FOR UPDATE SKIP LOCKED
 LIMIT 1
Схема 3Фоновый перевод: от сохранения до записи английского поля
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"]
Сигнал — только будильник. Само задание лежит в очереди и не теряется, даже если работник в момент сигнала лежал.
Где граблиСигнал из базы не сохраняется: если работник в момент сигнала лежал (перезапуск, деплой) — событие потеряно навсегда. Поэтому при старте, до того как сесть слушать, работник первым делом разгребает всё, что накопилось в очереди со статусом «ожидает». Так перезапуск не теряет переводы. Плюс на каждую задачу до 3 попыток, и тревога в Telegram — только когда попытки исчерпаны: иначе любой сетевой моргнёт завалит меня сообщениями.

Итог. Автомат, который не теряет задачи при рестарте и зовёт человека только когда сам исчерпал попытки. Плюс мелкая защита от мусора: на переводе названий районов, если модель вернула больше 80 символов вместо короткого топонима, я её ответ отбрасываю — длинная простыня вместо «Кузьминки» это явно не перевод названия.

Раздел 07

Сначала алфавит, потом модель

Маленькая деталь, но характерная для всего подхода. Чтобы перевести пользовательский текст, надо сперва понять, на каком он языке — иначе непонятно, в какую сторону переводить, и стоит ли вообще показывать кнопку.

Гонять ради этого модель на каждый текст — расточительно. Сначала работает проверка по алфавиту, без всякой модели:

const hasCyrillic = /[Ѐ-ӿ]/.test(letters);
const hasLatin    = /[A-Za-z]/.test(letters);
if (hasCyrillic && !hasLatin) return 'ru';   // чистая кириллица
// смесь, латиница или непонятное → к модели с вопросом "какой язык?"

Чистая кириллица — русский, решено мгновенно. И только смешанный текст («Аштанга class с Anna») уходит к модели с коротким запросом. Тот же дешёвый приём решает, показывать ли кнопку: текст уже на языке читателя — кнопки нет; намешано и уверенности нет — кнопку показываем, пусть человек решит сам. Дорогой инструмент включается там, где дешёвый спасовал.

Раздел 08

Почему Claude, а не Google Translate или своя модель

Два довода за облачную модель на сегодня. Качество — Claude переводит хорошо там, где важны нюансы: терминология, санскрит, живая стилистика анкет и отзывов; заметно выше обычного машинного перевода, который не чувствует контекста. На текущем объёме это необременительно — переводов пока немного, держать облачную модель недорого по силам; городить схему сложнее ради экономии, которой на этом объёме нет, — лишняя работа без выгоды.

Альтернативы я взвешивал, не отметал с порога:

Google Translate и DeepLНе дают контроля над терминологией — того самого глоссария. Для области с санскритом и доменными словами это решающий минус.
Своя модель на сервереСчитал всерьёз. На сегодняшнем объёме не нужна: облако и качественнее, и проще в обслуживании. Но записал, при каких условиях вернусь — когда объём вырастет так, что своё железо начнёт окупаться, или когда понадобится живой перевод чата в реальном времени, где задержка облака уже велика.
Доступность модели из России — как принципОтдельная засада для любого, кто строит такое внутри РФ: облачная модель напрямую из России недоступна. Решается тем, что запросы к ней идут через внешний узел за пределами страны; отвалился узел — кнопка честно говорит «попробуйте через минуту», а не висит. Инфраструктурную конкретику опускаю, но факт держите в голове: облачную модель из России надо отдельно «дотянуть», и это решаемо.

Что я намеренно не перевожу

Половина работы с переводом — решить, чего переводить не надо. Не трогаю личную переписку преподавателя с учеником — приватный разговор, ему не место во внешней модели. Не перевожу приватные заметки преподавателей про учеников. Не перевожу юридические документы — оферту, политику: там только русский и честная плашка «договор на русском». Не перевожу адреса страниц.

Соблазн «переведём всё, что есть» ведёт сразу в две ямы: лишний перевод того, что на другом языке никому не нужно, и — серьёзнее — утечка приватного во внешний сервис. Поэтому пользовательский контент переводится только по явной кнопке и с согласия человека, а не фоном за его спиной. Список запрещённого к переводу — такой же рабочий инструмент, как сам переводчик.

Чем это живёт дальше

Сейчас три машины закрывают почти все случаи: интерфейс переведён заранее, каталог и анкеты подхватывает фоновый автомат, живой контент — кнопка с кешем. В поддержке два пункта, оба завязаны на рост. Первый — своя модель на сервере, если объём вырастет или появится чат с переводом в реальном времени. Второй — глоссарий: нахожу термин, переведённый не так, как принято на площадке, добавляю в словарь, поднимаю GLOSSARY_VERSION, и кеш на следующем обращении пересчитывается сам.

Если коротко, что я вынес: перевод на модели — это не «вызвал и получил». Это решение, в какой момент переводить каждый тип контента, плюс набор страховок от того, как модель ведёт себя на живых людях — отказывается, роняет подстановки, видит чужой телефон. И ещё это дисциплина не уронить себе прод архитектурой вокруг перевода. Сам вызов модели — самая короткая часть всей работы.

Серия 009 · 2026-06-03 · перевод сайта языковой моделью, разобрано по живому коду
Авторская колонка · выпуск №009 · «Сайт на двух языках, когда переводит модель»

// Обсуждение

Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.