1. Введение
В предыдущей работе мы рассматривали структуру, которая разделяет функцию автоматического перевода LLM на асинхронный поток событий. Мы организовали создание событий для ситуации, требующей перевода, не вызывая LLM непосредственно в запросе на хранение, после чего перевод выполняется в отдельном потоке. Эта работа является близким продолжением предыдущей.
Существующая структура ориентирована на перевод многоязычных полей, непосредственно принадлежащих сущностям. Однако в реальной модели домена многоязычные строки не всегда существуют только в прямых полях сущности. Часто многоязычные поля могут находиться внутри объектов значений (Value Object) сущности или объекты значений могут существовать в виде списка.
В этой статье я систематизирую технические проблемы, возникшие при расширении автоматических объектов для перевода LLM до вложенных объектов и структур списков. Ключевым моментом было не просто увеличение количества доступных для перевода местоположений, а то, как безопасно обрабатывать структуры списков без идентификаторов и как сохранить пользовательский API общих функций простым.
2. Ограничения существующей структуры
Существующий способ запроса автоматического перевода был относительно простым. Сервис передает целевую сущность и имя поля в помощник (Helper), который на этой основе создает событие запроса на перевод. Например, это будет выглядеть как requestTranslation(entity, "title").
Этот способ хорошо подходит для полей сущности, таких как title и description, так как когда уведомление об окончании приходит, можно найти целевую сущность по имени сущности, ID сущности и имени поля, и отобразить результаты перевода в данном поле.
Проблема возникает, когда целевой объект перевода находится на более глубоком уровне, например, detail.name или items[0].name. Можно сделать так, чтобы просто передавать строки пути пользователю, но этот способ может ухудшить удобство использования общих функций. Пользователь должен самостоятельно знать правила внутреннего пути полей, и в структурах списков также необходимо указывать индекс.
Особенно индекс в списке трудно считать стабильным идентификатором. Если нет гарантии, что items[0] в момент запроса является тем же объектом, когда поступает уведомление об окончании, результаты перевода могут быть применены к неправильной цели. Поэтому мы избегали простого разрешения записи с точками и указателей индексов в существующих fieldName.
[Рисунок 1. Сравнение перевода прямых полей и перевода вложенных объектов/списков]
3. Проблемы, возникшие при переводе вложенных объектов и структур списков
Первым вопросом, который мы обсудили при расширении перевода вложенных объектов, была проблема идентификации объектов значений. Обычно сущности имеют уникальные идентификаторы, поэтому их можно снова искать, когда поступает уведомление об окончании. В отличие от этого, у объектов значений, находящихся внутри сущностей, часто нет независимого идентификатора.
Одно значение относительно просто. Например, его можно выразить одним путем, таким как article.detail.name. Однако со списком объектов значений нужно быть более осторожным. Здесь вмешивается индекс, как в article.items[0].name, article.items[1].name.
Мы можем предоставить API, который будет переводить только определенный индекс списка. Однако в этом случае пользователю нужно будет передавать индекс напрямую, и если порядок списка изменится, результаты перевода могут отразиться на других элементах. Если у объекта значения нет отдельного ключа, индекс технически является лишь удобным местоположением, и трудно считать его стабильным идентификатором.
Поэтому в этой области мы выбрали направление, при котором API не предоставляет возможность перевода только определенного индекса. При поступлении списка мы рассматриваем весь список как объект перевода и внутри обходим каждый элемент, рассчитывая объекты перевода.
Ещё одной проблемой было обработка нескольких полей на разных языках. В действительном объекте значения может быть не только имя, но и несколько полей на разных языках, таких как имя, описание, резюме. Поэтому мы определили диапазон, чтобы можно было запрашивать не только одно поле, но и несколько полей как в отдельных вложенных объектах, так и в структурированном списке.
-
Единственное поле на разных языках внутри отдельного вложенного объекта
-
Несколько полей на разных языках внутри отдельного вложенного объекта
-
Единственное поле на разных языках внутри структурированного списка
-
Несколько полей на разных языках внутри структурированного списка
4. Упрощение пользовательского API
Что было важно в этой функции, так это простота пользовательского API. Общие функции используются во многих сервисах, поэтому, если разработчик, использующий их, должен тщательно знать внутреннюю структуру событий или правила пути отражения, то это не считается хорошим API.
Поэтому мы сделали отдельный метод запроса для перевода вложенных объектов. Пользователь просто передает объект сущности, имя вложенного поля и имя поля на разных языках внутри него. Например, это можно вызвать как requestNestedTranslation(entity, "items", "name").
Суть этого API в том, что пользователь не создает внутренний путь, такой как items[0].name. Пользователь просто передает имя поля сущности, items, и имя поля на разных языках внутри этого объекта, name.
Является ли это единичным объектом или списком, определяет Helper. Helper читает целевые поля на основе переданного объекта и, если это единичный объект, считает внутренние поля на разных языках одной переводимой целью. Если это список, то он проходит по всему списку, рассчитывая внутренние поля на разных языках каждой позиции как переводимые цели.
Таким образом, внутренняя обработка становится сложной, но код на стороне пользователя остается простым. Сложность общих функций обрабатывается внутри, и пользователю требуется только вводить минимально необходимые данные.
5. Расширение контракта событий и способа отражения результатов
Чтобы сохранять простой пользовательский API, также необходимо было расширить внутренний контракт событий. Существующие события запроса перевода разрабатывались на основе прямых полей сущности. Событие запроса выражает, какие сущности какого сервиса и какие поля будут переводиться.
Однако в вложенных объектах и структурированном списке один запрос может расширяться на несколько переводимых целей. Например, если перевести поля name и description списка items, то внутренне создаются несколько целей, таких как items[0].name, items[0].description, items[1].name, items[1].description.
Поэтому запросы на перевод вложенных объектов сложно выразить только одним именем поля. Внутри запроса должны помещаться несколько переводимых целей, и необходимо знать, в каком месте и какое поле на разных языках. Для этого мы отдельно создали событие для запросов на вложенный перевод и целевой объект.
Мы также подумали о способах обработки события завершения. Сервис может напрямую принимать и применять вложенные результаты перевода, но в существующей структуре автоматического перевода событие успешного завершения обрабатывается не самим сервисом, а общей библиотекой, которая автоматически применяет его. Если для перевода вложенных объектов потребуется отдельный обработчик событий, то использование будет непоследовательным.
Поэтому мы упорядочили перевод вложенных объектов так, чтобы он автоматически применялся аналогично существующей обработке успешных переводов. Сервис перевода публикует событие завершения, и общая библиотека принимает это событие и с помощью reflection применяет результат перевода в целевой позиции оригинента.
Напротив, обработка ошибок оставлена на усмотрение сервиса. Это связано с тем, что политика после неудачи может различаться для разных сервисов. Некоторые сервисы могут просто оставить запись в журнале, а другие могут отображать историю ошибок на экране администратора или предоставлять функции повторного запроса. Таким образом, событие ошибки предоставляется общим образом, но последующую обработку разумнее оставить в зависимости от политики домена каждого сервиса.
6. Заключение
Эта работа стала опытом расширения целевого перевода LLM от прямых полей сущностей до вложенных объектов и списковой структуры. Сначала это выглядело как простая задача — позволить переводить многоязычные поля внутренних объектов, но на самом деле нужно было учитывать API пользователя, стабильность индексов списков, соглашение о событиях, способы применения результатов, а также границы ответственности за успех и провал.
Особенно в списковой структуре было важным выяснить, следует ли раскрывать конкретные индексы через API. Индексы удобны для реализации, но не всегда можно считать их надежными идентификаторами в списках объектов значений. Поэтому было решено исключить функциональность перевода конкретного индекса и выбрать направление перевода для всего списка.
Кроме того, поддержание простоты пользовательского API было важной целью. Пользователи могли не создавать внутренние пути напрямую, а передавать только название вложенных полей и внутренние многоязычные имена полей. Является ли это единичным объектом или списком, и какие объекты перевода создаются внутренне, определяет Helper.
Этот опыт еще раз показал, что суть функционала LLM не ограничивается только вызовом модели. Для фактического применения в сервисе необходимо также спроектировать, какие данные будут считаться целевыми для перевода, куда и как результаты будут применяться, кто будет обрабатывать неудачи и насколько просто пользователи смогут использовать функции.
Общие функции должны обрабатывать множество случаев внутренних дел, но при этом они должны быть простыми для пользователя. Эта работа была процессом, в котором мы подтверждали эти принципы, более тесно интегрируя функционал автоматического перевода LLM в модель домена реального сервиса.
7. Пункты для будущего рассмотрения
С учетом этого расширения диапазон запросов на перевод и применения результатов расширился, но с операционной точки зрения еще есть моменты, над которыми стоит поразмыслить. В частности, где будет управляться состояние перевода. Простое первоначальное заполнение значений позволяет относительно легко судить о завершении, но если повторный перевод происходит, когда значение перевода уже присутствует, может быть сложно различить процесс и завершение только по значению поля.
Эти статусные данные больше похожи на историю обработки запросов на перевод или рабочий процесс, чем на многоязычные строковые значения. Поэтому может быть более ясно управлять отдельной историей перевода или моделью состояния, чем непосредственно вставлять статус внутрь строкового объекта. Функция повторного запроса ошибок также должна рассматриваться не просто как повторный вызов LLM, а как функциональность операций, учитывающая текущее состояние данных и политику сервиса.
[Список литературы]
Мартин Фаулер, Объект Значения
https://martinfowler.com/bliki/ValueObject.html
Microsoft Learn, паттерн асинхронного запроса-ответа
https://learn.microsoft.com/ko-kr/azure/architecture/patterns/asynchronous-request-reply
IBM, что такое событийная архитектура?
https://www.ibm.com/kr-ko/think/topics/event-driven-architecture
jyyou