- BrainTools - https://www.braintools.ru -
Привет! Меня зовут Глеб Смольяков, я инженер-программист в DevRel-отделе Битрикс24.
DevRel-команда работает с разными задачами, которые помогают разработчикам и пользователям быстрее разобраться в возможностях продукта. Одна из таких задач — улучшение документации и её перевод на другие языки.
В статье расскажу, как мы сделали сервис DocFlow, который автоматизирует перевод документации с русского на английский. Главная часть статьи — о том, как мы создавали инженерный слой для аккуратной точной работы.
Основной принцип: модель должна переводить только нужные фрагменты [4]
Детерминированный слой вокруг модели [5]
Плейсхолдеры [6]
Код [7]
Таблицы [8]
Деление на чанки [10]
Температура [11]
Повторные запросы [12]
Фиксеры [13]
Валидатор [14]
У нас была практичная проблема: переводить документацию с русского на английский.
До DocFlow автоматизация перевода была, но после неё оставалось много ручной работы. Технические писатели вычитывали результат, исправляли его и проверяли, что документ вообще нормально отображается на сайте. Именно эту ручную доводку хотелось убрать или хотя бы резко сократить.
Проект DocFlow стал попыткой превратить перевод документации в управляемый конвейер: система разбирает файл, переводит нужные части, собирает документ обратно и проверяет результат — сама.
В этой статье я расскажу в основном про инженерную часть вокруг модели — это было самой сложной задачей в работе. Потому что намного важнее умной модели оказалось построить слой правил и проверок, который не даёт ИИ сломать документ.
Документация включает вещи, которые будут в центре нашей статьи: методы API, примеры кода, таблицы, ссылки, служебная разметка. Всё это хранится в формате markdown/YFM, и для модели перевод такого текста намного сложнее, чем работа с обычной прозой. Это из-за спецсимволов для обозначения заголовков, ссылок, таблиц и других вещей.
YFM (Yandex Flavored Markdown) — это расширение markdown с дополнительными возможностями.
В документации важен не только смысл слов, но и структура: таблицы должны остаться таблицами, ссылки должны сохранять адрес, служебные блоки не должны сломаться. Структура получается благодаря спецсимволам. И здесь начинается проблема.
LLM видит перед собой текст и старается сделать его лучше: перевести, выровнять, переименовать, иногда упростить или переформулировать. Для обычной статьи это может быть полезно. Для API-документации это опасно.
Например, ссылка в markdown выглядит так:
[подробнее](../data-types.md#catalog_product)
Если модель изменит адрес, лишнюю скобку или якорь после #, ссылка может перестать работать.
То же самое с таблицами и кодом. Трогать синтаксис или случайно удалять кавычки при переводе нельзя. Одна потерянная кавычка превращает пример из документации в невалидный код.
Поэтому нужно переводить только то, что можно переводить, и не трогать всё остальное.
Если всю работу целиком оставить модели, рано или поздно она вмешивается в структуру документа.
Вот пример. Reasoning-модель gpt-oss-120b на структурно сложном файле оказалась хуже для нашей задачи, чем более простая bitrixgpt-5.5. Она раскрывала служебные заглушки независимо от инструкций в промпте, а на простом файле сломала структуру в двух местах: удалила ссылку на тип в таблице ответа и перепутала строки в таблице ошибок.
В чём проблема: модель сама по себе хорошо работает, но ведёт себя слишком активно для задачи, где нужна не инициатива, а аккуратность. «Почти такой же» markdown уже не работает так как надо.
Часто ошибки [17] появляются не из-за модели, а из-за кода.
Пример — в документации были таблицы параметров внутри вкладок и списков. Из-за вложенности строки таблицы начинались с отступа, например так:
{% 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]], а оригинал кладёт в память [18].
Упрощённый пример:
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]].
Это параметр случайности [19] ответа. При высокой температуре модель чаще выбирает разные варианты формулировок. При нуле она старается выбирать самый вероятный вариант.
Мы зафиксировали температуру модели в 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.
Обратите внимание [20], что эти цифры не означают, что пайплайн «во всём лучше человека». Метрики измеряют конкретные вещи: структуру, термины, близость к эталону и локализационные следы.
Тем же измерителем человеческий перевод дал structure 97.4% и terminology 90.8%. То есть по структуре и проектным терминам DocFlow оказался строже человека. Это понятно. Человек может устать, не заметить один термин и поправить структуру на глаз. Код не устаёт и каждый раз применяет одни и те же правила.
Вывод в том, что модель не стала идеальной и сохранила риски. Но теперь вокруг неё появился слой, который ловит типовые ошибки и не даёт им пройти дальше незамеченными.
В самом начале задача выглядела как «взять модель, дать ей файл и получить перевод».
После первых прогонов стало понятно, что для технической документации это слишком рискованно: обычный текст модель переводит хорошо, но плохо отвечает за сохранение хрупкой структуры. Поэтому роль модели в DocFlow сузили.
Сейчас модель отвечает за те куски текста, где действительно нужна языковая работа: описания, фразы, отдельные ячейки. Всё остальное забрал код: разобрать, спрятать, собрать, при необходимости — починить. В этом и был главный инженерный урок проекта.
Поэтому в контексте перевода технической документации вывод такой: во многом качество даёт не сама LLM, а граница между тем, что мы доверяем модели, и тем, что делаем детерминированно. Чем точнее эта граница, тем меньше сюрпризов в результате.
Автор: GlebSmolyakov
Источник [21]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/35118
URLs in this post:
[1] Зачем понадобился сервис перевода DocFlow: #zachem
[2] Почему нельзя просто отправить markdown в LLM: #pochemu
[3] Два источника проблем: модель и код: #dva-istochnika
[4] Основной принцип: модель должна переводить только нужные фрагменты: #princip
[5] Детерминированный слой вокруг модели: #sloy
[6] Плейсхолдеры: #placeholders
[7] Код: #kod
[8] Таблицы: #tablicy
[9] Кэш переводов и глоссарий: #kesh
[10] Деление на чанки: #chanki
[11] Температура: #temperatura
[12] Повторные запросы: #povtory
[13] Фиксеры: #fixery
[14] Валидатор: #validator
[15] Что получилось по цифрам: #cifry
[16] Что в итоге изменилось: #itog
[17] ошибки: http://www.braintools.ru/article/4192
[18] память: http://www.braintools.ru/article/4140
[19] случайности: http://www.braintools.ru/article/6560
[20] внимание: http://www.braintools.ru/article/7595
[21] Источник: https://habr.com/ru/companies/bitrix/articles/1078450/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1078450
Нажмите здесь для печати.