Привет! Меня зовут Глеб Смольяков, я инженер-программист в 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, а граница между тем, что мы доверяем модели, и тем, что делаем детерминированно. Чем точнее эта граница, тем меньше сюрпризов в результате.

Комментарии (0)