javascript

Многопользовательский рой агентов для Ollama

  • понедельник, 3 февраля 2025 г. в 00:00:11
https://habr.com/ru/articles/878658/

В данной статье осуществлен разбор многопользовательского телеграм чат бота на LLM, код которого опубликован в этом репозитории

Куда движется рынок

Когда-то давным давно графический пользовательский интерфейс сменил консольный ввод. Казалось бы, проблему неудобства взаимодействия для неподготовленного пользователя мог бы решить псевдографический интерфейс, но есть фактор, который не все замечают

Псевдо графический интерфейс
Псевдо графический интерфейс

Важно! Разрабатывать графический пользовательский интерфейс дешевле, чем псевдографический. В историческом процессе, сразу после выхода Next CUBE был представлен язык ObjC с графическим редактором форм, где страницы можно компоновать мышкой. В современном же мире, Frontend предоставляет графическую отладку форм через Dev Tools, что примерно то же самое: код номинальный без технических деталей, при проблемах есть GUI, удешевляющее поиск бага.

Графический интерфейс
Графический интерфейс

Но ещё дешевле - не делать пользовательский интерфейс вообще. Тебе не нужен статический IP, PCI DSS, домен раскрученый в яндексе и гугле, highload, если ты решил не изобретать велосипед и не создавать очередной веб продукт, на привлечение посетителей в которого заплатить денег придется в три раза больше, чем на разработку.

Голосовой интерфейс
Голосовой интерфейс

Телефон это от слова фон, фонетика - звук. Вместо того, чтобы учить огромное количество сочетаний кнопок для Figma, Blender, Photoshop, Unreal Engine, проще просто озвучить команду. Как повернуть чертёж в архикаде?

LLM как новый вид пользовательского интерфейса

Agent Swarm это как фрагменты в android или роутер в react: они позволяют конкретизировать скоуп задач (кнопки на экране) исходя из предидущего пользовательского ввода. Например, когда поступил звонок на SIP телефон, сначала нужно понять, хочет ли человек купить или вернуть товар в принципе, а потом предложить ему список товаров в наличие для покупки

Диаграмма схемы данных для кода ниже
Диаграмма схемы данных для кода ниже

Технические требования

Налоговая в любом случае спросит дебет/кредит в табличном виде, по этому CRM системы никуда не денутся. Задача LLM - спарсить естественный текст или чата или распознавалки голоса и трансформировать его в сигнатуру функции с наименованием и аргументами, чтобы можно было осуществить вызов и записать данные в базу

Для решения этой проблемы, важно знать чреду нюансов

  1. На момент 2025 года OpenSource языковые модели для офлайн запуска галлюцинируют в 40%-50% случаев, когда оригинальный ChatGPT в 5%-10%.

  2. Если не разделять промпт на агенты, модель будет больше галюцинировать, так как предмет разговора становится расплывчатым

  3. Каждый месяц появляются новые модели, которые галюцинируют меньше. Появляются альтернативные SaaS, код которых закрыт, но они дешевле ChatGPT, например, DeepSeek

Как следствие

  1. Код чат бота должен быть обособленным от провайдера LLM с возможностью переключения на GPT4All, Ollama, OpenAI, DeepSeek и тд без редактирования бизнес логики

  2. Должен быть testbed, который позволяет оценить, какое количество промптов поломалось при смене языковой модели или провайдера, так как версионирование из-за SJW цензуры не предусмотрено: поведение промптов меняют в тихую не афишируя подробности

  3. Код для оркестрации сессий чатов с контекстом активного агента должен быть отделён от провайдера LLM или фреймворка, так как в любой момент выйдет что-то новое

Разбор кода

На каждую открытую сессию чата нужно осуществить оркестрацию Swarm c деревом агентов, имеющих общую историю чата между собой и отдельную для разных пользователей. В данном коде, это реализовано под капотом agent-swarm-kit

import { addSwarm } from "agent-swarm-kit";

export const ROOT_SWARM = addSwarm({
    swarmName: 'root_swarm',
    agentList: [
        TRIAGE_AGENT,
        SALES_AGENT,
    ],
    defaultAgent: TRIAGE_AGENT,
});

...

app.get("/api/v1/session/:clientId", upgradeWebSocket((ctx) => {
  const clientId = ctx.req.param("clientId");

  const { complete, dispose } = session(clientId, ROOT_SWARM)

  return {
    onMessage: async (event, ws) => {
      const message = event.data.toString();
      ws.send(await complete(message));
    },
    onClose: async () => {
      await dispose();
    },
  }
}));

При создании агента мы указываем хотя бы один system message, описывающий что он должен делать. Мы указываем коннектор к языковой модели, что позволит часть агентов обрабатывать бесплатно локально, сложные же делегировать в облачный червис openai. Если что-то не работает, добавляем промпты в массив system, например, фикс вызова функций для Ollama

const AGENT_PROMPT = `You are a sales agent that handles all actions related to placing the order to purchase an item.
Tell the users all details about products in the database by using necessary tool calls
Do not send any JSON to the user. Format it as plain text. Do not share any internal details like ids, format text human readable
If the previous user messages contains product request, tell him details immidiately
It is important not to call tools recursive. Execute the search once
`;

/**
 * @see https://github.com/ollama/ollama/blob/86a622cbdc69e9fd501764ff7565e977fc98f00a/server/model.go#L158
 */
const TOOL_PROTOCOL_PROMPT = `For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call>
`;


export const SALES_AGENT = addAgent({
  agentName: "sales_agent",
  completion: OLLAMA_COMPLETION,
  system: [TOOL_PROTOCOL_PROMPT],
  prompt: AGENT_PROMPT,
  tools: [SEARCH_PHARMA_PRODUCT, NAVIGATE_TO_TRIAGE],
});

В данном примере я использую ollama для обработки запросов пользователей. Для не сведующих в терминологии: процесс, когда языковая модель на вход получает историю переписки с пользователем, а на выход выдаёт новое сообщение, называется completion. В agent-swarm-kit используется абстрактный интерфейс, который одинокого подходит к любому облачному провайдеру или локальной модели. Используйте этот материал, чтобы подключить deepseek

import { addCompletion, IModelMessage } from "agent-swarm-kit";

const getOllama = singleshot(() => new Ollama({ host: CC_OLLAMA_HOST }));

export const OLLAMA_COMPLETION = addCompletion({
  completionName: "ollama_completion",
  getCompletion: async ({
    agentName,
    messages,
    mode,
    tools,
  }) => {
    const response = await getOllama().chat({
      model:  "nemotron-mini:4b", // "mistral-nemo:12b";
      keep_alive: "1h",
      messages: messages.map((message) => omit(message, "agentName", "mode")),
      tools,
    });
    return {
      ...response.message,
      mode,
      agentName,
      role: response.message.role as IModelMessage["role"],
    };
  },
});

Изменение активного агента и получение данных из БД осуществляется через вызов tools: языковая модель возвращает специальный XML, который обрабатывается фреймворком для локальных моделей или облачным провайдером для OpenAI, чтобы вызвать внешний код на python/js и тд. Результат исполнения кода записывается в историю переписки в виде {"role": "tool", "content": "В базе данных найден продукт Парацетамол: жаропонижающее для борьбы с гриппом"} . Со следующего сообщения от пользователя, языковая модель оперирует данными из инструмента

import { addTool, changeAgent, execute } from "agent-swarm-kit";

const PARAMETER_SCHEMA = z.object({}).strict();

export const NAVIGATE_TO_SALES = addTool({
  toolName: "navigate_to_sales_tool",
  validate: async (clientId, agentName, params) => {
    const { success } = await PARAMETER_SCHEMA.spa(params);
    return success;
  },
  call: async (clientId, agentName) => {
    await commitToolOutput(
      "Navigation success`,
      clientId,
      agentName
    );
    await changeAgent(SALES_AGENT, clientId);
    await execute("Say hello to the user", clientId, SALES_AGENT);
  },
  type: "function",
  function: {
    name: "navigate_to_sales_tool",
    description: "Navigate to sales agent",
    parameters: {
      type: "object",
      properties: {},
      required: [],
    },
  },
});

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

import {
  addTool,
  commitSystemMessage,
  commitToolOutput,
  execute,
  getLastUserMessage,
} from "agent-swarm-kit";

const PARAMETER_SCHEMA = z
  .object({
    description: z
      .string()
      .min(1, "Fulltext is required")
  })
  .strict();


export const SEARCH_PHARMA_PRODUCT = addTool({
  toolName: "search_pharma_product",
  validate: async (clientId, agentName, params) => {
    const { success } = await PARAMETER_SCHEMA.spa(params);
    return success;
  },
  call: async (clientId, agentName, params) => {
    let search = "";
    if (params.description) {
      search = String(params.description);
    } else {
      search = await getLastUserMessage(clientId);
    }
    if (!search) {
      await commitToolOutput(
        str.newline(`The products does not found in the database`),
        clientId,
        agentName
      );
      await execute(
        "Tell user to specify search criteria for the pharma product",
        clientId,
        agentName
      );
      return;
    }
    const products = await ioc.productDbPublicService.findByFulltext(
      search,
      clientId
    );
    if (products.length) {
      await commitToolOutput(
        str.newline(
          `The next pharma product found in database: ${products.map(
            serializeProduct
          )}`
        ),
        clientId,
        agentName
      );
      await commitSystemMessage(
        "Do not call the search_pharma_product next time!",
        clientId,
        agentName
      );
      await execute(
        "Tell user the products found in the database.",
        clientId,
        agentName
      );
      return;
    }
    await commitToolOutput(
      `The products does not found in the database`,
      clientId,
      agentName
    );
    await execute(
      "Tell user to specify search criteria for the pharma product",
      clientId,
      agentName
    );
  },
  type: "function",
  function: {
    name: "search_pharma_product",
    description:
      "Retrieve several pharma products from the database based on description",
    parameters: {
      type: "object",
      properties: {
        description: {
          type: "string",
          description:
            "REQUIRED! Minimum one word. The product description. Must include several sentences with description and keywords to find a product",
        },
      },
      required: ["description"],
    },
  },
});

Языковые модели умеют формировать словарь именованных параметров для вызова tools. Однако, OpenSource модели плохо справляются, если есть техническое требование закрытый контур, проще анализировать саму переписку.

Принцип работы роя агентов

  1. Несколько сессий chatgpt (агентов) выполняют вызовы инструментов. Каждый агент может использовать разные модели, например, mistral 7b для повседневного общения, nemotron для деловых разговоров.

  2. Рой агентов направляет сообщения к активной сессии chatgpt (агенту) для каждого канала WebSocket, используя параметр URL clientId. Для каждого нового чата с человеком создается новый канал со своим роем агентов

  3. Активная сессия chatgpt (агент) в рое может быть изменена путем выполнения инструмента.

  4. Все клиентские сессии используют общую историю сообщений чата для всех агентов. История чата каждого клиента хранит последние 25 сообщений с ротацией. Между сессиями chatgpt (агентами) передаются только сообщения типа assistant и user, а сообщения типа system и tool ограничены областью действия агента, поэтому каждый агент знает только те инструменты, которые относятся к нему. В результате каждая сессия chatgpt (агент) имеет свой уникальный системный промпт.

  5. Если вывод агента не прошел валидацию (несуществующий вызов инструмента, вызов инструмента с неверными аргументами, пустой вывод, XML-теги в выводе или JSON в выводе), алгоритм спасения попытается исправить модель. Сначала он скроет предыдущие сообщения от модели, если это не поможет, то вернет заглушку вида "Извините, я не понял. Не могли бы вы повторить?"

Полезные функции для администрирования роя агентов

  • addAgent - Регистрация нового агента

  • addCompletion - Регистрация новой языковой модели: облачной, локальной или мок

  • addSwarm - Регистрация группы агентов для обработки чатов с пользователями

  • addTool - Регистрация инструмента для интеграции языковых моделей во внешние системы

  • changeAgent - Изменить активный агент в рое

  • complete - Запросить ответ на сообщение, переданное в аргументы рою агентов

  • session - Создать сессию для чата, дать коллбек на завершение сессии и отправку новых сообщений

  • getRawHistory - Получает необработанную историю системы для отладки

  • getAgentHistory - Получает историю, которую видит агент с поправкой на механизм самовосстановления и получателей сообщений

  • commitToolOutput - Отправить в историю результат исполнения функции. Если была вызвана функция, агент замораживается до получения ответа

  • commitSystemMessage - Дополнить системный промпт новыми вводными

  • commitFlush - Очистить переписку для агента, если были получены некорректные ответы или модель ошибочно рекурсивно вызывает инструмент

  • execute - Попросить нейронку проявить инициативу и первой написать пользователю

  • emit - Отправить пользователю заранее заготовленное сообщение

  • getLastUserMessage - Получить последнее сообщение от пользователя (без учета execute)

  • commitUserMessage - Сохранить сообщение от пользователя в истории чата без ответа. Если пользователь спамит сообщения не дожидаясь обработки запроса

  • getAgentName - Получить имя активного агента

  • getUserHistory - Получает историю сообщений от пользователя

  • getAssistantHistory - Получает историю сообщений от языковой модели

  • getLastAssistantMessage - Получает последнее сообщение от языковой модели

  • getLastSystemMessage - Получает последнее дополнение системного промпта