Салют, Хабр! На связи Арсенин Никита из команды R&D в SberDevices. Сегодня я хочу рассказать про одно из наших направлений исследований — разработку агентских систем на основе больших языковых моделей.
В этой статье мы постараемся сделать обзорный тур по ключевым технологическим аспектам проектирования и реализации LLM‑агентов, рассмотрим способы работы связок LLM и функций, некоторые компоненты мультиагентных систем, методы контролируемой генерации и повышения робастности. Кроме того, представим и подробно опишем архитектуру и способ построения одного из прототипов LLM‑агентов, нацеленных на выполнение задач в Google SpreadSheets.
Наш LLM‑агент был реализован при помощи SDK GigaChain и GigaGraph, адаптированными под работу с GigaChat. Вы можете посмотреть на итоговую версию Google SpreadSheets агента в репозитории или начать разработку своего агента с вводного туториала.
Что такое LLM‑агент? Пока что нет устоявшегося и всеми принятого определения для агентов, один из вариантов можно сформулировать так:
LLM‑агент — вид программного обеспечения, которое использует LLM для определения перечня выполняемых операций, их порядка и входных параметров
Другими словами, при разработке классического ПО, последовательность вызовов функций и их аргументы полностью определяются человеком‑разработчиком, а в LLM‑агенте за это отвечает некоторая языковая модель. Важно подчеркнуть, что сама LLM ничего не выполняет (за редкими исключениями), она лишь говорит, какие операции, с какими параметрами и в каком порядке нужно выполнить.
Рассмотрим простой пример: спросим у LLM про актуальную температуру в столице Аргентины.
User: Какая сейчас погода в столице Аргентины?
LLM: Нужно вызвать функцию get_capital() c параметром country="Аргентина"
Executor: Буэнос-Айрес
LLM: Теперь необходимо обратиться к функции get_weather() c параметром city="Буэнос-Айрес"
Executor: +12, Дождь
LLM: В столице Аргентины сейчас дождливо, +12 градусов
Поскольку веса любой LLM содержат информацию только из исторических обучающих данных, модель не знает о том, какая погода в столице Аргентины в текущий момент. Более того, она может не знать столицу Аргентины или, что более вероятно, выдать галлюцинацию и ошибиться. Для заполнения пробелов в памяти LLM и уменьшения количества фактологических ошибок можно разрешить модели использовать внешние функции.
Список доступных функций и их описание передается LLM заранее в системном промпте или в специальной синтаксической форме (например, через выделенные токены). При получении запроса LLM определяет, какие функции нужно вызвать (и нужно ли), в каком порядке их нужно вызвать и как задать входные аргументы. Сами вызовы при этом делает внешний модуль (executor). В примере выше LLM выдает выбранные для выполнения функции в свободной текстовой форме, которая удобна для восприятия человеком, но сложна для парсинга и автоматизации. В этом аспекте логичнее заставить LLM возвращать перечень функций для вызова в специальной текстовой форме, например, в JSON.
Наш модифицированный пример станет таким:
User: Какая сейчас погода в столице Аргентины?
LLM:
{
"name": "get_capital",
"parameters": {
"country": "Аргентина"
}
}
Executor: Буэнос-Айрес
LLM:
{
"name": "get_weather",
"parameters": {
"city": "Буэнос-Айрес"
}
}
Executor: +12, Дождь
LLM: В столице Аргентины сейчас дождливо, +12 градусов
Как несложно видеть, диалог стал сложнее восприниматься на глаз, но с ответами от LLM гораздо проще работать в коде. Такой подход вызовов операций LLM был предложен OpenAI и называется tool/function calling (иногда сокращается до FC). Он требует достаточно долгого дообучения модели через SFT на специальном датасете, поэтому поддерживается не всеми языковыми моделями. Тем не менее, на данный момент function calling является, пожалуй, наиболее распространенным способом работы с функциями в LLM, однако, есть и другие подходы.
Изначально OpenAI назвала подход function calling, следуя, видимо, общепринятому определению функции с точки зрения процедурного программирования, и допуская, что функция может не только возвращать значение, но и неявно изменять состояние объекта (побочный эффект). С этим сильно не согласны приверженцы функционального программирования, где функция не может создавать побочных эффектов. Для разделения этих понятий OpenAI переименовали function calling в tool calling, где tool — более общая абстракция, чем function (любая function — это tool, но не наоборот). Однако, на данный момент никаких tools кроме functions в API OpenAI нет, поэтому эти понятия можно использовать как взаимозаменяемые, хоть термин «function calling» самой компанией считается устаревшим.
С точки зрения описанной выше логики любого LLM‑агента можно считать частной версией RL‑агента, где любое состояние среды — это последовательности токенов, передаваемых в LLM. Схематично работу такого агента можно так:
1) Агент наблюдает начальное состояние , которое является набором начальных диалоговых сообщений для LLM. Для примера выше состояние — это системный промпт + запрос вида «Какая сейчас погода в столице Аргентины?».
2) Агент выбирает (семплирует) одно действие из политики при условии состояния :
Для LLM‑агента — — множество всех доступных tools (+ действие «не выбирать ни один tool»), состояние s — текущий диалог, а политика моделируется LLM.
3) Агент «выполняет» действие и переходит в новое состояние (новый диалог) :
Функция переходов , по сути, моделируется самой LLM и зависит как от весов модели, так и от обратной связи после выполнения действия .
4) Агент возвращается на пункт 2 и работает до тех пор, пока состояние не станет терминальным (например, если было выбрано действие «не выбирать ни один tool»).
Наиболее важным наблюдением в таком сравнении LLM и RL агента является то, что политика над действиями присутствует неявно в весах LLM и у нас как правило нет возможности напрямую ее изменять. Даже если у нас есть опция изменить веса модели через дообучение, то выразить эту политику как функцию весов LLM и оптимизировать градиентными методами довольно сложно (хоть и можно). По этой причине, декомпозиция входной задачи и составление грамотных промптов — главный инструмент настройки политики LLM‑агента.
Выше мы рассмотрели очень простой пример LLM‑агента, где и задача, и функции были довольно тривиальными. В реальном мире задачи могут быть существенно сложнее, что заставит LLM галлюцинировать и вызывать не те операции, которые нужно вызывать. Эвристика для решения этой проблемы довольно простая: можно разбить большую задачу на несколько подзадач и решать их последовательно. По этой причине современные LLM‑агенты, как правило, состоят из нескольких компонент. Далее мы рассмотрим 4 наиболее важные из них (на самом деле их больше).
Задача этой компоненты — получить необходимый контекст о задаче (или, более формально, о world model) для дальнейшего использования LLM. На этом шаге, по сути, собирается вся доступная информация, до которой может дотянуться агент. Конкретные примеры работы этого модуля сложно привести, поскольку они полностью зависят от предметной области (domain), но можно перечислить наиболее распространенные паттерны:
— RAG с поиском по коллекциям текстовых документов;
— Web search по ключевым словам;
— Поиск информации в knowledge maps и базах данных;
— Human feedback (запрос дополнительной информации у пользователя в диалоговом формате);
— API обращения к целевому сервису.
Пример Context retriever»a мы уже видели в первом примере, когда LLM вызывала функции для извлечения информации о столицах стран и о погоде в городе:
— Для определения погоды, очевидно, легче всего использовать API открытого сервиса прогнозы погоды.
— Для поиска столицы страны можно, например, использовать готовый текстовый документ с парами «страна — столица» и устроить линейный поиск по нему (ну или бинарный, если заранее отсортировать страны :)).
Тем не менее, последний вариант подразумевает, что мы заранее создали такой файл и положили в доступное агенту место. Если мы хотим извлекать по названию страны только столицу, такой подход кажется нормальным, но если мы хотим уметь извлекать еще 100–200 характеристик (население, год образования, текст гимна, …), то создание и поддержка такого файла (файлов) становится неоправданным. Можно рассмотреть другой подход: по названию страны мы можем выгрузить исходный текст страницы с википедии/рувики и попросить LLM выделить нужную характеристику.
Пример про Аргентину:
User:
Аргенти́на (исп. Argentina [aɾxenˈtina]), официальное название — Аргенти́нская Респу́блика (исп. República Argentina [reˈpuβlika aɾxenˈtina]) — второе после Бразилии по территории и третье после Бразилии и Колумбии по населению государство Южной Америки, состоящее из 24 административных единиц: 23 провинций и федерального столичного округа Буэнос-Айрес. Крупнейшая по площади в мире из испаноязычных стран. По состоянию на 2023 год, по оценкам всемирной книги фактов ЦРУ, по численности населения Аргентина 33-е государство в мире (46 621 847 человек)[8]. Состав населения Аргентины по вероисповеданию по состоянию на 2022 год: католики — 48,9 %, другие христиане — 10 %, нерелигиозны — 39,8 %, верующие в другую религию или конфессию — 1,3 %[9].
User: Найди в тексте выше название столицы страны
LLM: Буэнос-Айрес
Поэтому важно заметить, что для получения контекста можно и нужно применять саму LLM для подготовки и модификации извлеченных данных.
Задача планировщика формулируется довольно просто — ему необходимо составить план выполнения задачи, который впоследствии получит LLM для вызова функций. Тем не менее, простой запрос может дать совершенно не тот результат, который нам мог бы быть полезным.
Давайте рассмотрим пример (GPT-4o):
User: Напиши пошаговый план приготовления молочного улуна
LLM:
**Шаг 1: Подготовка необходимых инструментов и ингредиентов**
- **Ингредиенты**: 5-7 граммов молочного улуна (1-2 чайные ложки).
- **Инструменты**: чайник для заваривания (глиняный, фарфоровый или стеклянный), чашка, сито.
- **Вода**: фильтрованная вода, подогретая до 80-90°C.
**Шаг 2: Прогрев посуды**
- Вскипятите воду и налейте её в чайник и чашку для заваривания.
- Дайте постоять несколько секунд, чтобы посуда прогрелась, затем вылейте воду.
**Шаг 3: Промывка чая (по желанию)**
- Положите молочный улун в чайник.
- Залейте небольшим количеством горячей воды и сразу же слейте её.
- Это помогает очистить и разбудить чайные листья.
**Шаг 4: Заваривание**
- Залейте чайник горячей водой (80-90°C), полностью покрывая чайные листья.
- Настаивайте чай в течение 1-2 минут для первого пролива.
**Шаг 5: Разливание чая**
- Процедите чай через сито и разлейте его по чашкам.
**Шаг 6: Повторное заваривание**
- Молочный улун можно заваривать до 5-7 раз.
- Для каждого следующего пролива увеличивайте время настаивания на 30 секунд.
**Шаг 7: Наслаждение чаем**
- Пейте чай сразу после заваривания, наслаждаясь его мягким и сливочным вкусом.
С точки зрения человека план выглядит полным, понятным и довольно логичным. Однако для LLM‑агента, который будет по нему работать, план представляет из себя одну большую галлюцинацию: никто не может гарантировать, что каждый шаг, написанный выше, может быть выполнен. Для этого нужно, чтобы на каждый шаг плана нашлась нужная операция (функция), которую LLM может вызвать, а её может и не быть.
Для избежания этой проблемы нужно определить action space — множество всех действий, которые может совершать агент. Полученный action space передаётся планировщику, который может оперировать только доступными событиями. В этом случае планировщик называется «grounded».
Рассмотрим наш модифицированный пример:
User:
Напиши пошаговый план приготовления молочного улуна
Ты можешь выполнять только эти действия
- Взять определенное количество нужного чая
- Убрать заваренный чай
- Налить воду в чайник
- Положить чай в чашку
- Подождать определенное количество минут
- Налить воду в чашку
- Вскипятить воду до заданной температуры
LLM:
1. Вскипятить воду до 85-90°C.
2. Взять 1 чайную ложку молочного улуна на 150-200 мл воды.
3. Положить чай в чашку.
4. Налить горячую воду в чашку.
5. Подождать 3-5 минут, чтобы чай настоялся.
6. Убрать заваренный чай.
Теперь LLM составляет план только из тех действий, которые ей доступны (если не будет галлюцинировать, конечно). Однако далее встает гораздо более сложный вопрос: как научить LLM генерировать как можно более точный и оптимальный план? Рассмотрим основные подходы.
Chain‑of‑thoughts (COT)
Идея: использовать chain‑of‑thoughts до генерации плана.
Thoughts:
Нужно использовать доступные действия для правильного приготовления молочного улуна.
Начну с кипячения воды, а затем перейду к завариванию чая, следуя шагам,
чтобы получить идеальный вкус.
Plan:
1. Вскипятить воду до заданной температуры 85-90°C.
2. Налить воду в чайник.
3. Взять 1 чайную ложку молочного улуна.
4. Положить чай в чашку.
5. Налить воду в чашку.
6. Подождать 3-5 минут.
7. Убрать заваренный чай.
Self‑consistency (SC)
Идея: сгенерировать несколько планов, а затем поручить LLM выбрать лучший.
Plan 1:
Вскипятить воду до заданной температуры 85-90°C.
Налить воду в чайник.
Взять 1 чайную ложку молочного улуна.
Положить чай в чашку.
Налить воду в чашку.
Подождать 3-5 минут.
Убрать заваренный чай.
Plan 2:
Вскипятить воду до заданной температуры 85-90°C.
Взять 1 чайную ложку молочного улуна.
Положить чай в чашку.
Налить воду в чайник.
Налить воду в чашку.
Подождать 3-5 минут.
Убрать заваренный чай.
Best plan: 1
Tree-of-thoughts (TOT)
Идея: на каждом шаге генерируем несколько вариантов следующего шага, оцениваем или приоритизируем их и обходим дерево выбранным алгоритмом (ниже тривиальный пример с greedy search, аналогично можно применять BFS, DFS, etc.)
Текущий план:
1. Вскипятить воду до заданной температуры 85-90°C.
2. Налить воду в чайник.
3. Взять 1 чайную ложку молочного улуна.
Вариант 1: Положить чай в чашку.
Вариант 2: Налить воду в чашку.
Вариант 3: Подождать определенное количество минут.
Thoughts: Логичнее сначала положить чай в чашку перед тем, как налить в неё воду, чтобы чай мог сразу начать завариваться.
Лучший вариант: 1
Monte Carlo Tree Search (MCTS)
Отдельно стоит упомянуть подход через MCTS и UCB оценку, которому уделятся большое внимание в литературе в последнее время. Данный алгоритм позволяет стохастически обходить дерево вариантов действий и обновлять значение Q‑функции для каждой пары (состояние, действие). Подход примечателен еще тем, что позволяет автоматически собирать датасет для последующего дообучения LLM агента через DPO (или обычные RL алгоритмы для LLM). Тем не менее, для MCTS необходимо наличие либо хорошей прокси‑оценки Q‑функции (через логиты или внешнюю модель), либо хорошей reward функции.
Reflector (критик) — компонента, которая генерирует комментарии к уже готовому ответу модели и на их основе улучшает ответ. Критик может применяться для произвольных частей LLM‑агента, мы же сконцентрируемся на критиках для планировщиков. Их задача заключается в том, чтобы улучшить уже готовый план.
Критики для планировщика можно разделить на следующие типы:
— Общий критик;
— Специализированный критик (может менять только отдельные части/аспекты плана);
— Корректирующий критик (исправляет явно найденные ошибки);
— Action критик (изменяет пункты плана так, чтобы они были из action space).
Задача данного модуля — вызывать те функции, которые LLM‑агент запланировал вызвать. На вход он принимает всю доступную информацию (контекст), которую собрал Context retriever, и план, который был сгенерирован компонентой Planner и, возможно, Reflector. Далее он в цикле вызывает доступные ему функции до тех пор, пока не посчитает, что весь план выполнен.
Ключевым для executor'a является набор доступных ему функций (или tools). Tools, как правило, описываются в заданной OpenAI форме:
{
"type": "function",
"function": {
"name": "get_delivery_date",
"description": "Get the delivery date for a customer's order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The customer's order ID.",
},
},
"required": ["order_id"],
"additionalProperties": False,
},
}
}
Важно понимать, что action space у планировщика и множество доступных агенту функций может не совпадать. Более того, executor может не выполнить план даже без галлюцинаций в рассуждениях (например, если встретит недоступное или запрещенное действие). С этой точки зрения план не выполняется детерминировано по пунктам, а лишь помогает LLM‑агенту быть более предсказуемым, робастным и точным.
Выше мы рассмотрели 4 основных компоненты LLM‑агента:
— Context retriever
— Planner
— Reflector
— Executor
Для более удобной работы с ними в промптах, как правило, устанавливается ограничение на форму ответа модели, например:
User:
Напиши информацию о любой стране мира
Твой ответ должен в форме:
{
"country": название страны,
"capital": название столицы,
"population": население
}
LLM:
{
"country": "Japan",
"capital": "Tokyo",
"population": 126.3 million
}
И хотя современные модели очень хорошо справляются с ограничениями на формат ответа, они иногда могут ошибаться (например, забыть поставить запятую и не учесть тип данных). Для решения этой проблемы используется контролируемая генерация, которая физически запрещает LLM генерировать неподходящие токены. Подробнее можно прочитать в документации OpenAI.
Выше мы рассмотрели общие принципы построения LLM-агентов, теперь более подробно остановимся на Google Spreadsheet. Этот инструмент по функционалу напоминает Excel, но работает в облаке как SaaS. Для работы с ним через API Google предоставляет бесплатный и довольно широкий набор инструментов.
Для API запросов к конкретному файлу работы необходимо 2 компоненты:
ID документа (уникальный, находится в URL адресе);
Файл с credentials пользователя, от имени которого будет вестись работа с файлом.
Google дает готовую библиотеку на python для работы с простыми запросами (вроде чтения или записи) и для аутентификации.
Пример кода для чтения ячеек из листа:
def read_values(self, range_name):
service = build("sheets", "v4", credentials=self.creds)
result = (
service.spreadsheets()
.values()
.get(spreadsheetId=self.spreadsheet_id, range=range_name)
.execute()
)
rows = result.get("values", [])
return rows
В нашем агенте были реализованы следующие операции с spreadsheet листом:
— Запись значения или формулы в ячейку;
— Чтения данных из одной или нескольких ячеек;
— Запись таблиц из формата markdown;
— Создание сводных таблиц;
— Создание графиков;
— Автозаполнение ячеек.
Эта функциональность достаточна для большинства типичных сценариев работы с таблицами и полностью покрывается возможностями API (кроме, разве что, информации об ошибках).
Как уже было разобрано выше, для построения LLM‑агента нам понадобится реализовать несколько компонент по отдельности. Важно заметить, что во всех модулях используется контролируемая генерация и CoT для промежуточных рассуждений для повышения качества и робастности LLM‑агента.
Наиболее важная информация о мире для нашего LLM‑агента — уже записанные данные в лист Google Spreadsheet. Вообще говоря, детерминированного алгоритма чтения и анализа информации с excel‑подобного листа нет, поскольку пространственная структура может быть очень сложной, но есть приближенные методы, работающие с относительно простыми сценариями. Мы остановились на двух основных структурах: реляционная таблица и вертикальный словарь.
Для получения контекста используем следующую идею: можно передать в LLM прочитанные данные, чтобы получить их семантику. Здесь возникает проблема: данных может быть очень много (сотни тысяч строк) и их нельзя положить в контекст модели. Однако, это и не нужно — достаточно взять небольшую выборку данных и отправить её в LLM.
Итоговый запрос выглядит примерно так:
You are a professional data annotator. You are given a markdown table with columns names and sample data. Also you are given a user query.
First, you should find out if this table is in a usual relation database form or in a form of dictionary
If this table is in a dictionary form, then you should return a json dictionary of this table, for example:
{
"cats" : 5,
"dogs" : 4,
"all" : 9,
}
If table is in a relational database form, then describe all data columns in the following json form:
{
"name" : name of column,
"description" : brief description of this column, try to provide meaning of this column for the user
"type" : one of the following: number, date, text, empty
}.
At the end give brief (1-2 sentences) annotation about entire table in the following json form:
"table_description" : description of this table
Make sure that this description useful for the user.
Example:
{
"columns": [
{
"name": "column1",
"description": "Number of cats",
"type": "number"
},
{
"name": "column2",
"description": "Number of dogs",
"type": "number"
}
],
"table_description": "Table with information about cats and dogs"
}
Полученная от LLM структура содержит в себе семантику (смысл) данных и информацию об их расположении на листе.
Отдельно в Context retriever реализован human feedback модуль, а именно возможность LLM задать вопрос пользователю для уточнения смысла или формата данных.
Для планировщика мы выбрали следующие доступные действия (action space):
— Write a markdown table to specified cell;
— Write a formula or a value to one specified cell;
— Create a pivot table from existing data;
— Create a simple chart of one of the following types: line, column, area;
— Repeat a formula to a range of cells with automatic increment of cell address;
— Autofill blank range with a constant value;
— Autofill blank range with a data series with constant difference.
API Google Spreadsheet позволяет делать и некоторые другие действия (например, условное форматирование), однако этих функций достаточно для совершения основных Excel‑like операций над данными.
В рамках экспериментов мы использовали разные подходы к планировщику и их комбинации. Наилучшие результаты показали следующие варианты:
— Chain‑of‑thoughts + Self‑consistency;
— Tree‑of‑thoughts (greedy);
— Tree‑of‑thoughts (DFS).
Также мы планируем попробовать Monte Carlo Tree Search (+UCB) в следующих итерациях.
В агенте используется 3 критика для планировщика:
— General Reflector: одна итерация общего запроса улучшить план;
— Cell Reflector: проверяет, что в плане верно расставлены ячейки для записи данных;
— Formula Reflector: проверяет, что все формулы в плане доступны, а их аргументы корректны.
Executor принимает на вход описание семантики данных от Context retriever'a и улучшенный план от Reflector'a.
В качестве функций ему доступны все описанные выше действия из action space планировщика, реализованные через Google Spreadsheet API.
Пример функции (tool):
class write_value_description(BaseModel):
cell_id: str = Field(description="ID of the cell in A1 notation.")
value: str = Field(description="Value or formula that will be written into the cell.")
class write_table_tool(BaseTool, extra='allow'):
name = "write_table"
description = \
"""
Call this function with a cell ID and a markdown table to write this table into the specified cell.
Markdown table must have headers. Pass it into argument as a string.
Formulas should begin with "=".
"""
args_schema: Type[BaseModel] = write_table_description
def __init__(self, gapi, **data):
super().__init__(**data)
self.gapi = gapi
def _run(self, range_name, markdown_table):
matrix = markdown_to_matrix(markdown_table)
self.gapi.write_values(range_name, matrix)
return f"The data from the table {markdown_table} has been written into the cell {range_name}"
Функция позволяет записать markdown таблицу в ячейку листа Google Spreadsheet через API (внешний объект «gapi»).
Схематично итоговая архитектура изображена ниже:
Ноутбук с кодом Google Spreadsheet Агента доступен в репозитории GigaChain по ссылке.
Современные LLM отлично справляются с задачей описания структуры и семантики данных (реляционных таблиц, сводных таблиц и тд).
GPT-4o и Gemini-1.5 «из коробки» хорошо понимают методы работы с excel‑подобными инструментами (формулы, графики, pivot tables, автозаполнение).
Для сложных запросов zero‑shot планировщик случаен и выдает разные варианты плана даже при нулевой температуре, что усложняет воспроизводимость результатов.
Chain‑of‑thought и self‑consistency улучшают воспроизводимость и детерминированность планировщика, но не увеличивает качество его работы.
Tree‑of‑thought не дает заметных преимуществ при построении плана, но кратно увеличивает количество API вызовов.
В среднем специализированные критики повышают качество работы планировщика.
Использование структурного вывода (JSON + контролируемая генерация) существенно снижает количество галлюцинаций у LLM‑агента.
Качество LLM-агента складывается из многих составляющих. Из них можно выделить те, на которые мы сделаем упор в ближайшее время:
— Реализация алгоритма Monte Carlo Tree Search для построения плана и для работы самого executor’a.
— Проведение экспериментов с применением разных по качеству моделей для разных компонент агента (например, сильная модель для планировщика и более слабая для критика).
— Добавление возможности агенту считывать данные во время работы executor’a для уменьшения количества ошибочно записанных формул на листе.
Спасибо, что дочитали :)
Статью подготовил: Арсенин Никита ( @Lirol) .
Помогали в редакции: Валерий Терновский (@walfry17) Сергей Марков (@oulenspiegel) и Дмитрий Головин (@GolovinDS).