Workflow Automation be like
Workflow Automation be like

Сегодня пост для тех, кто не наигрался в пошаговые стратегии: о Yandex Cloud Serverless Integration Workflows. Нетрудно догадаться, что это представитель обширнейшего поля Workflow Automation Tools, eg OSS: Apache Airflow/Hop, n8n to name a few. Но YC Wokflows не Open Source, конечно же. Окей, ближайший аналог, скажем, AWS Step Functions.

Одна из его характерных особенностей — использование JQ как одного из краеугольных камней. Прямо скажем, не Yandex's vibe 🚲 ⛔. Не могу сказать, что было легко с JQ, нахлынули какие-то воспоминания об XSLT (не кликайте, не надо!). В целом, конечно, работает, но у любой абстракции существует критическая точка взаимодействия с реальным миром: по отдельности $global, Foreach и сложные шаги, например, работают замечательно, но их комбинация пока является крайним случаем, где всё не совсем очевидно.

Рассмотрим пример простого вызова языковой модели:

yawl: '0.1'
start: step-no-op706
steps:
 step-no-op706:
  noOp:
   output: '\({"sys_prompt": "соль", "usr_prompt":"земли"})'
   next: step-foundationModelsCall770
 step-foundationModelsCall770:
  foundationModelsCall:
   generate:
    temperature: 0
    maxTokens: 100
    messages:
     messages:
      - role: system
       text: \(.sys_prompt)
      - role: user
       text: \(.usr_prompt)
   modelUrl: gpt://yrownfldrid/yandexgpt-lite/latest
   output: '\({"step-foundationModelsCall770": .})'
  description: ''

Ничего необычного: задаём system&user prompts для inference. Работает.

Отступления (не писать же про них отдельно):

  1. Находка: Шаг NoOp! Поддержка посоветовала — большое им спасибо. Этот шаг позволяет отдельно от других шагов вызвать JQ-шаблонизатор. Это может оказаться полезнее, чем кажется. Здесь, например, это позволяет задать входные данные для последующих шагов процесса. В противном случае, при отладочных запусках пришлось бы постоянно копировать и вставлять входной JSON, а так можно запускать процесс в пустым вводом! А запускать его во время отладки может потребоваться много-много раз.

  2. Другой случай полезного NoOp, это PutObject. Объект положили, удобно узнать, куда именно — какой ключ созданного объекта. Но PutObject не возвращает ничего! После него можно поставить NoOp, в котором положить в вывод этот ключ. Впрочем, такая полезность NoOp компенсируется его полным отсутствием на диаграмме шкалы времени, там только его вывод в глобальный контекст. Не спрашивайте.

  3. Совсем далёкая подача от п.1. в соседний сервис облачных функций. Если workflow после исправления можно перезапустить, скопировав параметры предыдущего запуска, то окошко параметров тестирования функции всегда очищается, и дроп-дауна история не имеет. Печаль. Оказалось очень удобно отлогировать параметры вызова print(json.dumps(event)) и потом копировать их из логов или сохранить как sample_invocation.json отдельным файлом в редакторе. Файлов-то может быть много!

Итак, простейший inference работает. Теперь, допустим, у нас список user prompts, и нам надо их всех перебрать (batch inference, конечно, подойдёт, просто пример на список из одного элемента).

yawl: '0.1'
start: step-no-op706
steps:
 step-no-op706:
  noOp:
   output: '\({"sys_prompt": "соль", "usr_prompt":["земли"]})' # массив на вводе
   next: step-foreach780
 step-foreach780:
  foreach:
   input: \(.usr_prompt | map( {key:.})) # for нужен массив объектов а не строк, превращем строки в объекты
   output: '\({"step-foreach780": .})'
   do:
    start: step-foundationModelsCall296
    steps:
     step-foundationModelsCall296:
      foundationModelsCall:
       generate:
        temperature: 0
        maxTokens: 100
        messages:
         messages:
          - role: system
           text: \(.sys_prompt) # а вот внешний конекст тут не доступен - null
          - role: user
           text: \(.key) # переменная цикла - работает
       modelUrl: gpt://yrownfldrid/yandexgpt-lite/latest
       output: '\({"inference": .alternatives[0].message.text})'
      description: ''

Модель отвечает недоумённо:

{
  "step-foreach780": [
    {
      "inference": "Уточните, пожалуйста, что нужно сделать с этим словом?"
    }
  ]
}

Внимательные читатели уже догадались, что Одно-то слово user message модель получает, а вот system message — нет. Происходит это потому, что на ввод в шаги цикла подаётся только переменная цикла! Как и написано, и, наверное, переменная $global поможет нам? Заменим system message на text: \($global.sys_prompt)

$global, кстати, нет в JQ playground! Где его только нет. Хотя в JQ playground много чего нет.

Получаем ошибку, что, по-моему, лучше, чем пустое (null) значение в прошлый раз!

failed to evaluate JQ expression in messages[0] value, inner error: failed to compile JQ expression ("\($global.sys_prompt)"): variable not defined: $global

Это и есть проблема сложного шага со многими JQ-полями. $global есть в выражении на вкладке Ввод (yaml input:), а в других выражениях — нет.

Найдено два решения:

  • моё: явно передадим $global, подвинув переменную цикла. Теперь у нас i как в циклах нормальных языков! А $global явно передадим как g. Теперь .i,.g доступны во вложенных шаблонах

foundationModelsCall:
    generate:
      temperature: 0
      maxTokens: 100
      messages:
        messages:
          - role: system
            text: "повтори все сообщения пользователя"
          - role: user
            text: \(.g.sys_prompt)
          - role: user
            text: \(.i.key)
    modelUrl: gpt://yrownfldrid/yandexgpt-litelatest
    input: \({i:., g:$global})  # <-- тут вся соль!!!!
    output: '\({"inference": .alternatives[0].message.text})'

Теперь .i,.g доступны во вложенных шаблонах. Концепция примера немного поменялась по ходу статьи, но это только подтверждает...

  • второе неправильное. Спасибо специалистам поддержки за подсказку: явно подлить $global или нужное свойство в Foreach.input при создании списка объектов на итерацию.

  step-foreach780:
    foreach:
      input: \(.usr_prompt | map( {key:., sys_prompt_assigned:$global.sys_prompt}))
      output: '\({"step-foreach780": .})'
      do:
        start: step-foundationModelsCall296
        steps:
          step-foundationModelsCall296:
            foundationModelsCall:
              generate:
                temperature: 0
                maxTokens: 100
                messages:
                  messages:
                    - role: system
                      text: "повтори все сообщения пользователя"
                    - role: user
                      text: \(.sys_prompt_assigned) # явно переданное
                    - role: user
                      text: \(.key)
              modelUrl: gpt://yrownfldrid/yandexgpt-lite/latest
              output: '\({"inference": .alternatives[0].message.text})'

Модель подтверждает:

{
  "step-foreach780": [
    {
      "inference": "соль\nземли"
    }
  ]
}

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

Пока на этом всё. Желаю вам продуктивного рабочего потока!