Привет! Меня зовут Глеб Смольяков, я инженер-программист в DevRel-отделе Битрикс24.
DevRel-команда работает с разными задачами, которые помогают разработчикам и пользователям быстрее разобраться в возможностях продукта. Одна из таких задач — улучшение документации и её перевод на другие языки.
В статье расскажу, как мы сделали сервис DocFlow, который автоматизирует перевод документации с русского на английский. Главная часть статьи — о том, как мы создавали инженерный слой для аккуратной точной работы.
Содержание
Зачем понадобился сервис перевода DocFlow
У нас была практичная проблема: переводить документацию с русского на английский.
До DocFlow автоматизация перевода была, но после неё оставалось много ручной работы. Технические писатели вычитывали результат, исправляли его и проверяли, что документ вообще нормально отображается на сайте. Именно эту ручную доводку хотелось убрать или хотя бы резко сократить.
Проект DocFlow стал попыткой превратить перевод документации в управляемый конвейер: система разбирает файл, переводит нужные части, собирает документ обратно и проверяет результат — сама.
В этой статье я расскажу в основном про инженерную часть вокруг модели — это было самой сложной задачей в работе. Потому что намного важнее умной модели оказалось построить слой правил и проверок, который не даёт ИИ сломать документ.
Почему нельзя просто отправить markdown в LLM
Документация включает вещи, которые будут в центре нашей статьи: методы API, примеры кода, таблицы, ссылки, служебная разметка. Всё это хранится в формате markdown/YFM, и для модели перевод такого текста намного сложнее, чем работа с обычной прозой. Это из-за спецсимволов для обозначения заголовков, ссылок, таблиц и других вещей.
YFM (Yandex Flavored Markdown) — это расширение markdown с дополнительными возможностями.
В документации важен не только смысл слов, но и структура: таблицы должны остаться таблицами, ссылки должны сохранять адрес, служебные блоки не должны сломаться. Структура получается благодаря спецсимволам. И здесь начинается проблема.
LLM видит перед собой текст и старается сделать его лучше: перевести, выровнять, переименовать, иногда упростить или переформулировать. Для обычной статьи это может быть полезно. Для API-документации это опасно.
Например, ссылка в markdown выглядит так:
[подробнее](../data-types.md#catalog_product)
Если модель изменит адрес, лишнюю скобку или якорь после #, ссылка может перестать работать.
То же самое с таблицами и кодом. Трогать синтаксис или случайно удалять кавычки при переводе нельзя. Одна потерянная кавычка превращает пример из документации в невалидный код.
Поэтому нужно переводить только то, что можно переводить, и не трогать всё остальное.
Два источника проблем: модель и код
Если всю работу целиком оставить модели, рано или поздно она вмешивается в структуру документа.
Вот пример. Reasoning-модель gpt-oss-120b на структурно сложном файле оказалась хуже для нашей задачи, чем более простая bitrixgpt-5.5. Она раскрывала служебные заглушки независимо от инструкций в промпте, а на простом файле сломала структуру в двух местах: удалила ссылку на тип в таблице ответа и перепутала строки в таблице ошибок.
В чём проблема: модель сама по себе хорошо работает, но ведёт себя слишком активно для задачи, где нужна не инициатива, а аккуратность. «Почти такой же» markdown уже не работает так как надо.
Часто ошибки появляются не из-за модели, а из-за кода.
Пример — в документации были таблицы параметров внутри вкладок и списков. Из-за вложенности строки таблицы начинались с отступа, например так:
{% list tabs %} - Параметры #| || Параметр | Описание || || ID | Идентификатор сделки || |# {% endlist %}
Но код DocFlow, который разбирал таблицы, сначала ожидал, что строка таблицы начинается сразу с ||, без пробелов в начале:
#| || Параметр | Описание || || ID | Идентификатор сделки || |#
Как выглядел первоначальный код:
# Было: парсер считал строкой таблицы только ту строку, # где разделитель || стоит прямо в начале. def _split_columns(self, row_text: str) -> tuple[TableColumnSpan, ...]: if not row_text.startswith("||"): return () # Текст ячеек начинается сразу после первых двух символов: || content_start = 2
Из-за этого строки с отступом не распознавались как таблица. Система не видела ячейки и не отправляла их на перевод.
Второй этап смотрел на таблицу грубее: видел весь табличный блок и прятал его от основного перевода, чтобы модель не сломала разметку. В результате таблица доходила до пользователя целой, но непереведённой.
После фикса код сначала считает отступ, а потом ищет || уже после него. Поэтому ячейки таких таблиц снова попадают на перевод:
# Стало: сначала считаем отступ в начале строки. # Так парсер видит таблицы внутри вкладок и списков, # где перед || стоят пробелы или табы. def _split_columns(self, row_text: str) -> tuple[TableColumnSpan, ...]: indent = len(row_text) - len(row_text.lstrip(" \t")) # Проверяем, что || стоит не обязательно в начале строки, # а сразу после отступа. if not row_text.startswith("||", indent): return () # Текст ячеек начинается после отступа и двух символов: || content_start = indent + 2
Основной принцип: модель должна переводить только нужные фрагменты
Промптом задачу полностью не решить.
Чем сложнее документ, тем выше шанс, что где-то модель поправит лишнее. А в нашем случае ошибка в один символ уже может быть видимой поломкой — в зависимости от того, где модель допустит эту ошибку.
Что сделали мы: перестали считать модель самостоятельным переводчиком всего файла и сделали её одним этапом внутри конвейера. Схема получалась такой:
Сначала обычный код разбирает документ.
Всё чувствительное для перевода прячется или обрабатывается отдельно.
Модель получает те фрагменты, которые она может перевести и не сломать: обычный текст, описания, отдельные значения.
После перевода сервис собирает документ обратно.
В конце результат проверяется и чинится автоматическими правилами.
Этот слой обычного кода вокруг модели можно назвать детерминированным слоем, который работает по заранее заданным правилам: на одном и том же входе даёт один и тот же результат.
Детерминированный слой вокруг модели
Что сюда вошло:
Защита структуры через плейсхолдеры.
Отдельный перевод кода и таблиц.
Кэш переводов и глоссарий.
Разбиение больших файлов на куски — чанки.
Повторные запросы при потере служебных элементов.
Фиксеры, валидаторы и метрики качества.
Плейсхолдеры
Первым шагом мы стали прятать от модели всё, что она не должна менять.
Для этого появились плейсхолдеры — временные заглушки вместо фрагмента документа. Например, если в тексте есть ссылка, код или служебная разметка, система заменяет этот кусок на токен вроде [[PROTECTED_0]], а оригинал кладёт в память.
Упрощённый пример:
def add_placeholder(self, original: str) -> str: placeholder = f"[[PROTECTED_{self.counter}]]" self.blocks[placeholder] = original self.counter += 1 return placeholder
Модель видит не исходную ссылку или таблицу, а короткую заглушку. После перевода DocFlow возвращает оригинальный фрагмент на место. Вот пример процесса.
Что было до перевода:
См. [описание типа](../data-types.md#catalog_product)
Что получает модель:
См. [[PROTECTED_0]]
Что модель возвращает:
See [[PROTECTED_0]]
Что получается в итоге, когда DocFlow собирает результат:
See [описание типа](../data-types.md#catalog_product)
На этом этапе модель уже не может случайно испортить адрес ссылки, закрывающую скобку или якорь после #.
После перевода DocFlow собирает документ обратно и прогоняет результат через набор автоматических исправлений. Восстановление плейсхолдеров — первый шаг после ответа модели. Если до перевода ссылка была заменена на [[PROTECTED_0]], после перевода DocFlow возвращает на это место исходную ссылку. Это работает через словарь, который система собрала на этапе защиты.
Код
В документации часто есть примеры на JavaScript, PHP или в формате JSON. В коде тоже нельзя переводить всё подряд, потому что там важны кавычки, скобки, двоеточия и другие символы. Но можно переводить русские значения внутри строк. В таком примере:
const title = "Связаться с клиентом";
Здесь нужно перевести только Связаться с клиентом, но нельзя трогать const, title, кавычки и точку с запятой. Поэтому DocFlow ищет русские фрагменты внутри кода и отправляет их в модель отдельно маленькими порциями.
Таблицы
Таблицы тоже пошли отдельно. Система разбирает таблицу на ячейки, переводит текст внутри них, но оставляет без изменений каркас таблицы — это служебные символы, которые делают таблицу таблицей: например #|, ||, |# в YFM.
Вот упрощённый пример.
#| || Поле | Описание || || ID | Идентификатор сделки || |#
DocFlow берёт на перевод только это:
Поле Описание Идентификатор сделки
А потом собирает таблицу обратно:
#| || Field | Description || || ID | Deal identifier || |#
Так модель не может переставить строки, потерять разделитель или поменять закрытие таблицы.
Кэш переводов и глоссарий
Ещё один слой — память уже готовых переводов: если строка раньше была переведена правильно, DocFlow подставляет её сам и не зовёт модель. Это ускоряет перевод, снижает стоимость и делает повторяющиеся фразы одинаковыми.
Рядом с кэшем работает глоссарий — список терминов и правильных переводов. Например, в проекте принято переводить «портал», «коммерческое предложение», «смарт-процесс». DocFlow находит в документе нужные термины и добавляет их в подсказку модели, чтобы она не выбирала перевод заново. Это может выглядеть примерно так:
Переведи текст с учётом терминов: Glossary: - портал → account - коммерческое предложение → estimate - смарт-процесс → SPA Текст: Портал вернул коммерческое предложение из смарт-процесса.
Деление на чанки
Большие файлы пришлось резать на чанки — куски текста, которые отправляют в модель отдельным запросом. Здесь тоже нельзя просто отрезать каждые 3000 символов: можно попасть внутрь ссылки, таблицы или плейсхолдера. Поэтому DocFlow сначала пытается делить текст по разделам, потом по абзацам, потом по строкам, и отдельно проверяет, что граница не проходит внутри чего-то вроде [[PROTECTED_12]].
Температура
Это параметр случайности ответа. При высокой температуре модель чаще выбирает разные варианты формулировок. При нуле она старается выбирать самый вероятный вариант.
Мы зафиксировали температуру модели в 0. Для технической документации это полезно, потому что перевод должен быть воспроизводимым. В запросе это выглядит как один параметр:
class Translator: def __init__(self, ..., temperature: float = 0.0) -> None: self._temperature = temperature def _build_payload(self, content: str, prompt: str) -> dict[str, object]: return { "model": self._model, "temperature": self._temperature, "messages": [ {"role": "system", "content": prompt}, {"role": "user", "content": content}, ], }
Эффект для процесса получился значительный. До фиксации температуры один и тот же файл между прогонами мог отличаться на 1-4 пункта chrF (метрика машинного перевода, которая показывает близость текста к эталону). После установки temperature=0 перевод стал воспроизводимым, и сравнивать версии стало проще.
Повторные запросы
Модель может потерять плейсхолдер, продублировать его или убрать пустую строку между абзацем и заголовком. Поэтому DocFlow проверяет, совпадает ли количество плейсхолдеров до и после перевода.
Например, если в исходном куске было:
[[PROTECTED_0]] текст [[PROTECTED_1]]
А модель вернула только это:
[[PROTECTED_0]] text
Значит, [[PROTECTED_1]] потерялся. В этом случае DocFlow делает повторный запрос и прямо перечисляет модели, какие плейсхолдеры она потеряла.
Фиксеры
Фиксер — это маленький модуль, который исправляет один тип частой ошибки. Что делают разные фиксеры в DocFlow:
Восстанавливает закрывающие
||в таблицах.Возвращает правильный вид строк с типами данных.
Чинит отступы таблиц внутри вкладок.
Допереводит строки там, где после основного перевода осталась кириллица.
Ниже — один из фиксеров, CellTypeFixer. Он восстанавливает строку типа в YFM-ячейке по оригиналу. Сначала фиксер сопоставлял строку по имени поля и ошибался. Например, поле formatName встречалось и в таблице параметров, и в таблице ответа, а карта хранила одну запись на имя поля, и фиксер брал не тот оригинал. Потом мы научили его учитывать номер вхождения:
# Для каждого поля храним список строк типа в порядке появления в оригинале. originals = self._original_type_lines.get(field_line) # N-е вхождение поля в переводе берёт N-ю строку типа из оригинала. idx = occurrence_count.get(field_line, 0) occurrence_count[field_line] = idx + 1 correct = originals[min(idx, len(originals) - 1)]
Валидатор
Блок проверок, который сравнивает исходный документ и перевод. Валидатор смотрит, сохранилась ли структура документа, отслеживает остатки кириллицы и проверяет соблюдение проектных терминов.
Один из примеров — правило ArtifactsRule. Оно ищет следы того, что сборка документа прошла не до конца: заглушку, которая осталась в готовом тексте, или ссылку, которую модель дорисовала неправильно.
class ArtifactsRule(ValidationRule): # Служебная заглушка осталась в готовом тексте: [[PROTECTED_5]], [[TABLE_ITEM_2]] _LEAKED_SENTINEL_RE = re.compile( r'\[\[/?(?:PROTECTED_\d+|TABLE_ITEM_\d+)\]\]', re.IGNORECASE ) # Обрывок ссылки: ]](url) вместо [текст](url) _LEAKED_PLACEHOLDER_RE = re.compile(r'\]\]\([^)]+\)') # Двойные скобки: [текст]((url)), модель дорисовала лишние _DOUBLE_PAREN_LINK_RE = re.compile(r'\]\(\([^)\n]+\)\)')
Это работает как обычная автоматическая проверка: если в переводе остался такой фрагмент, система подсвечивает проблему в логах. Валидатор не блокирует сохранение файла. Если проверка ошиблась, человек всё равно сможет открыть перевод и посмотреть, что произошло.
Что получилось по цифрам
На заранее выбранном наборе файлов для контрольной проверки получились такие результаты:
Метрика |
Значение |
Что означает |
|---|---|---|
structure |
100.0% |
сохранился скелет документа: заголовки, код, таблицы, якоря, служебные блоки |
terminology |
96.3% |
соблюдены термины из словарей и глоссария |
chrF / BLEU |
89.1 / 79.2 |
перевод близок к человеческому эталону |
localization |
13/13 чисто |
не осталось российских артефактов вроде .ru, +7, RUB |
BLEU — ещё одна стандартная метрика машинного перевода, которая сравнивает результат модели с человеческим эталоном. Чем выше значения chrF и BLEU, тем ближе перевод к эталону.
Всего речь шла примерно о 2400 RU-файлах документации; детерминированную часть проверяли на 2417 файлах, а реальные LLM-прогоны — на 108.
Обратите внимание, что эти цифры не означают, что пайплайн «во всём лучше человека». Метрики измеряют конкретные вещи: структуру, термины, близость к эталону и локализационные следы.
Тем же измерителем человеческий перевод дал structure 97.4% и terminology 90.8%. То есть по структуре и проектным терминам DocFlow оказался строже человека. Это понятно. Человек может устать, не заметить один термин и поправить структуру на глаз. Код не устаёт и каждый раз применяет одни и те же правила.
Вывод в том, что модель не стала идеальной и сохранила риски. Но теперь вокруг неё появился слой, который ловит типовые ошибки и не даёт им пройти дальше незамеченными.
Что в итоге изменилось
В самом начале задача выглядела как «взять модель, дать ей файл и получить перевод».
После первых прогонов стало понятно, что для технической документации это слишком рискованно: обычный текст модель переводит хорошо, но плохо отвечает за сохранение хрупкой структуры. Поэтому роль модели в DocFlow сузили.
Сейчас модель отвечает за те куски текста, где действительно нужна языковая работа: описания, фразы, отдельные ячейки. Всё остальное забрал код: разобрать, спрятать, собрать, при необходимости — починить. В этом и был главный инженерный урок проекта.
Поэтому в контексте перевода технической документации вывод такой: во многом качество даёт не сама LLM, а граница между тем, что мы доверяем модели, и тем, что делаем детерминированно. Чем точнее эта граница, тем меньше сюрпризов в результате.