Розробка коду за допомогою багатьох агентів LLM: Частина 2, Робота з RAG.

pic

Агенти Claude та RAGs на базі LlamaIndex

Вступ

Це друга частина трисерійної публікації (посилання на інші частини в розділі «Посилання»). У першій частині¹ використовувався Python, а дані в CSV-форматі надавав агент Pandas. У другій частині використовується Typescript із двома великими мовними моделями — Anthropic Claude-3-opus та OpenAI-gpt-4o — при цьому Llama виявився менш здатним порівняно з іншими двома. Як і в першій частині, LangChain/LangGraph забезпечує екосистему моделей. У першій частині було три агенти: Coder, Fixer та Evaluator, які генерували код для d3 js, оцінювали його та виправляли помилки чи недоліки в коді. Одним з висновків першої частини стали обмеження розміру вікна контексту моделей (8,192 токенів), через що кодовий агент не міг отримати всі необхідні дані. Перша частина закінчилася обмеженим графічним відображенням трендів глобальної температури за останні 120 років (дані можна знайти за цим посиланням). У цьому випадку є 1,452 точки даних для глобальних температурних градієнтів, що значно перевищує розмір вікна контексту.

Відповіддю стало використання Retrieval Augmented Generators (RAG) для обробки даних. Використовуються два RAG: llamaIndex та ChromaDB. ChromaDB використовувався для «розбиття» та зберігання даних, а llamaIndex здійснював запити до розбитих даних. Примітка: ChromaDB можна також використовувати для запитів, так само як і для зберігання — планується дослідити обидва варіанти.

pic

1. Візуалізація глобальних температур за допомогою d3 js, Частина 1

pic

2. Візуалізація глобальних температур за допомогою d3 js, Частина 2

Знімок екрана нижче показує вимоги до коду, що призвели до створення обох графіків:

pic

3. Вимоги до коду для d3 js

Як це було досягнуто?

Відображення даних про глобальні температури за 121 рік мало дві основні проблеми. Перша проблема стосується розміру вікна контексту, що означає, що агент Coder не може отримати всі точки даних. Тому йому не передаються самі дані. Замість цього йому надається абстракція цих даних, з якої він має створити код для їх відображення.

Друга проблема полягає в наданні цієї абстракції з сирих даних. Для цього вводиться новий агент, DataProcessor. Цей агент по черзі опрацьовує розбиті дані, надані ChromaDb, який розділив їх на 17 частин для обробки. Ітерація триває, поки DataProcessor не отримає достатньо даних для завершення свого звіту (тобто, поки не проаналізує всі дані). Знімок екрана нижче показує першу та останню з 17 ітерацій: червоний обведений текст показує, коли DataProcessor запитує більше даних чи ні:

pic

4. Перша та остання аналіза розбитих даних, виконана агентом DataProcessor

На цих знімках видно перший шматок даних (1880–1885) та останній шматок (підсумок 1880–2000). «Пам'ять» цих 17 аналізів зберігається в текстовому файлі (21кб). Інформація в цьому файлі має бути проаналізована, щоб агенту Coder разом з вимогами прийняти рішення щодо відображення даних.

Тепер вводиться ще один агент для цієї мети: DataAnalyzer. Йому спочатку надається вміст файлу «пам'яті» у вигляді запиту.
Підсказка розроблена таким чином, щоб надавати лише основні концепції даних із «пам'яті», без надання самих даних. Як видно з звіту DataAnalyzer на знімку екрана нижче, покрито 121 рік, кожен рік представлений групою з 12 кіл, є 1,452 точки даних — це число важливе для іншого агента, SVGAnalyzer, який перевірятиме ці 1,452 точки даних на візуалізованому графіку. Також звіт описує, як має бути розташований графік. Цей звіт підкріплює вимоги, які також передаються у підсказку Coder, і, отже, впливає на його рішення, особливо щодо вимог до даних для конкретного типу графіка.

pic

5. Підсумок роботи DataAnalyzer, що базується на 17 аналізах DataProcessor з файлу «пам'яті».

Отже, обробка та аналіз даних разом з вимогами є достатніми для того, щоб Coder зміг виконати своє завдання. Далі розглянемо, як агенти для обробки та аналізу даних інтегруються в загальну структуру.

Робочий процес

LangGraph використовується як основа, оскільки він спеціально розроблений для агентних/RAG робочих процесів. Використовується версія цього фреймворку для Typescript. Агентний/RAG процес виглядає так:

pic

Процес починається з вбудовування всього CSV-файлу, у випадку з даними про глобальні температури NASA до 2024 року і з додатковими стовпцями метаданих після 12 місяців. Отже, RAG має надати лише місяці та роки, запитувані в вимогах. Вбудовування в постійну пам'ять в ChromaDB Collection відбувається на початку процесу.

Ключовий етап для RAG — це розбиття тексту на «шматки», які можуть бути вбудовані в векторне сховище. Процес вбудовування включає функцію вбудовування, яка забезпечує можливість запитів через LLM. По суті, RAG зв'язує векторне сховище з LLM, надаючи потужні можливості запиту через векторне сховище багатьох вимірів. Як і раніше, обмеженням є розмір вікна контексту. Для OpenAI-text-embedding-3-small довжина вектора вбудовування складає 1,536 токенів, що й визначає розміри «шматків». LangChain надає кілька розбивачів тексту (див. це посилання), для цього випадку використовується RecursiveCharacterTextSplitter:

/*****************************************  
 * Створює колекцію для зберігання початкових даних CSV  
 * створює колекцію до виклику агентів  
 * Помилка через обмеження розміру вікна контексту:  
 * "Максимальна довжина контексту цієї моделі — 8192 токенів,   
 * однак ви запитали 10868 токенів"   
 */  
 public async createCollection(name: string, csvPath: string){  
 let data = await readFileSync(csvPath, "utf-8");  
 const textSplitter = new RecursiveCharacterTextSplitter();  
 textSplitter.chunkSize = 1000;  
 textSplitter.chunkOverlap = 50;  

 const docs = await textSplitter.createDocuments([data])  
 const embeddingFunction = new OpenAIEmbeddingFunction({  
 openai_api_key: process.env["OPENAI_API_KEY"] as string,  
 openai_model: "text-embedding-3-small"  
 })  

 const collection = await this.client.getOrCreateCollection({name: name,   
 embeddingFunction: embeddingFunction});  
 docs.forEach(async (chunk, index) => {  
 await collection.add({  
 ids: `chunk_${index}`,  
 documents: chunk.pageContent,  
 });  
 });  
 }

Після того, як весь CSV-файл зберігається у векторному сховищі, ініціюється робочий процес LangGraph, і агент Retriever отримує запит, щоб переглянути вимоги і побудувати запит, який буде використано агентом DataProcessor для отримання запитуваних даних із векторного сховища. Підсказка інструктує Retriever надати цей запит у тегах XML: “”. Також було вказано, щоб він надавав лише запит без пояснень, щоб уникнути «балаканини», яка є властивістю моделей.
Для глобальних температурних трендів було створено наступний запит:

pic

6. Агент Retriever створює запит на основі вимог для движка запитів LlamaIndex.

Запит надсилається через StringOutputParser і отримується набором інструментів у лінії обробки агента Retriever. Ці інструменти — це RAG, і їхнє завдання — трансформувати збережені дані відповідно до інструкцій, наданих у запиті. Існують два інструменти типу DynamicTool: csvDataTool і embedDataTool. Код нижче використовує запит Retriever для фільтрації CSV-даних, збережених у ChromaDB. Кожен шматок даних завантажується зі сховища, вбудовується у векторне сховище LlamaIndex, запитується через движок запитів LlamaIndex, а після цього відфільтровані CSV-дані зберігаються у ChromaDB для використання агентом DataProcessor.

 //Ліві налаштування LlamaIndex  
Settings.llm = new OpenAI({ apiKey:process.env["OPENAI_API_KEY"] as string});  
const embedModel = new OpenAIEmbedding();  
Settings.embedModel = embedModel;  
/**********************************  
 * викликається з csvDataTool агента retriever для   
 * взаємодії з chroma store для векторів CSV  
 * вхідні дані — запит агента retriever, переданий до csvdatatool  
 * 1. Завантажити шматки даних  
 * 2. Помістити в векторне сховище llamaindex  
 * 3. Запитати для повернення відфільтрованих даних  
 * 4. Викликати getCSVData для створення об'єкта для запису CSV  
 * 5. Записати CSV  
 * 6. Створити нову відфільтровану колекцію для використання embedDataTool  
 */  
export async function storeQueriedCSVFile(input: string, name: string){  
 const client: ChromaClient = new ChromaClient({})  
 const embeddingFunction = new OpenAIEmbeddingFunction({  
 openai_api_key: process.env["OPENAI_API_KEY"] as string,  
 openai_model: "text-embedding-3-small"  
 })  

 const allData: string[][] = [];  
 const collection = await client.getCollection({name: name,   
 embeddingFunction: embeddingFunction});  
 const count = await collection.count();  
 //Ітерація по всіх шматках з ChromaDB  
 for(let i = 0; i < count; i++){  
 const results = await collection.get({  
 ids: `chunk_${i}`,  
 });  

 let doc = results.documents[0]?.toString();  
 //Додаємо заголовок до всіх наступних шматків, щоб запитувальний движок   
 //мав стовпці для запитів.  
 if(!doc?.includes("Year"))  
 {  
 doc = "Year,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec\n" + doc;  
 }  
 //Створюємо документ LlamaIndex з шматка, потім запитуємо його  
 const document: Document = new Document({ text: doc, id_: "embedded_chunk", metadata: {svgId: "111"}})  
 //Поміщаємо кожен шматок у векторне сховище та запитуємо його  
 const index = await VectorStoreIndex.fromDocuments([document]);  
 const queryEngine = index.asQueryEngine();  
 const response = await queryEngine.query({  
 query: input,  
 });  
 //Парсимо рядки, що повертаються в результаті запиту, в об'єкти CSV  
 await getCSVData(response?.message.content.toString(), allData);  
 }  
 //Записуємо об'єкти CSV у файл  
 await writeCSVFile(allData);  
 //Вбудовуємо колекцію відфільтрованих CSV даних у ChromaDB  
 const chromaAgent: ChromaAgent = new ChromaAgent();  
 await chromaAgent.createCollection("filtered_global_temperatures","/out.csv");

Наступний виклик інструменту від Retriever — це embedDataTool, який має єдине завдання — надати перший «шматок» відфільтрованих даних.
Цей інструмент є частиною лінії обробки агента Retriever, і тому його вихід через StringOutputParser буде відповіддю на виклик агента та надається у дані state, які будуть доступні як вхід для ланцюга обробника даних.

const embedDataTool = new DynamicTool({  
 name: "Data_embedding",  
 description:  
 "виклик цього інструменту надає перший шматок даних з CSV файлу",  
 func: async () => {  
 const client = new ChromaAgent();  
 const name = "filtered_global_temperatures";  
 client.dataChunk.ids = ["chunk_0"];  
 const results = await client.getCSVData(name, client.dataChunk);  
 //Будуємо рядок формату, який очікує DataProcessor  
 let count: string = client.dataChunk.count.toString();  
 let data: string = client.dataChunk.data;  
 heading = await getCSVHeading(data)+"\r\n";  
 let ids: string[] = client.dataChunk.ids;  
 let idStr: string = "[";  
 ids.forEach(id =>{  
 idStr = idStr + '"' + id + '"' + ",";  
 });  
 idStr = idStr.substring(0, idStr.length - 1) + "]";  
 //Відформатований рядок, необхідний для DataProcessor  
 const res = "Count: " + count + ". Data: " + data + ". Ids: " + idStr;  
 //Інструмент повертає відформатований рядок через StringOutputParser  
 return res;  
 }  
});

Тепер, коли дані, визначені вимогами, знаходяться у векторному сховищі ChromaDB, а перша змінна стану, а саме state.data, ініціалізована першим шматком зі сховища, агенти DataProcessor, DataAnalyzer та інші агенти будуть автоматично викликані для створення графіка глобальних температурних трендів. Як саме це працює, буде коротко пояснено в наступному розділі.

Фреймворк LangGraph

LangGraph надає структуру дерева, можливо з циклами, де вузли — це агенти LLM. Шлях через дерево детермінований, що з’єднує один вузол з іншим. Отже, LangGraph є циклічним, орієнтованим графом. Ми вже стикалися з циклом при виклику DataProessor для отримання більше даних. Як завершується цикл? Відповідь — умовна межа. StateGraph визначає агенти LLM, орієнтовані та умовні межі:

const workflow = new StateGraph(StateAnnotation)  
 .addNode("coder", codeAgent)  
 .addNode("retriever", retrieverAgent)  
 .addNode("dataProcessor", dataProcessorAgent)  
 .addNode("evaluator", evalAgent)  
 .addNode("dataAnalyzer",dataAnalyzerAgent)  
 .addNode("analyzer",analyzerAgent)  
 .addNode("tools", toolAgent)  
 .addNode("dataAnayzer", dataAnalyzerAgent)  
 .addEdge("__start__", "retriever")  
 .addEdge("retriever", "dataProcessor")  
 .addEdge("dataAnalyzer","coder")  
 .addEdge("coder", "svgAnalyzer")  
 .addEdge("svgAnalyzer" , "tools")  
 .addEdge("tools", "evaluator")  
 .addConditionalEdges("dataProcessor", dataProcessingShouldContinue)  
 .addConditionalEdges("evaluator", codingShouldContinue);//was evaluator

Умовна межа для агента DataProcessor перевіряє, чи агент запитує більше даних, якщо так, то він викликається знову для обробки наступного «шматка»; якщо ні, то викликається DataAnalyzer.

async function dataProcessingShouldContinue(state: typeof StateAnnotation.State) {  
 if(state["dataFindings"].includes("yes")){  
 return "dataProcessor";  
 } else {  
 return "dataAnalyzer"  
 }  
}

Фреймворк підтримує стан між кожним кроком у графі. Для частини графа, що розглядається тут, є три змінні стану: requirement, data і dataFinding.

Діаграма нижче є схематичним зображенням оркестрації потоку та підтримки стану в фреймворку. В центрі знаходиться дерево StateGraph. Вузли агентів LLM є ланцюгами запитів, переданих агентам, які можуть бути передані до динамічних інструментів, а відповідь агента передається до парсерів вихідних даних.
Ланцюги агентів викликаються з їх змінними стану в детермінованому порядку, залежно від їх прямих або умовних меж.

pic

Промпти

Створення промптів може бути складним завданням. Промпт для DataAnalyzer був особливо складним. Потрібно було передати кілька концепцій. Одна з них — це структура state.data, яку він отримує:

state["data"] = "Count: " + count + ". Data: " + data + ". Ids: " + idStr;

Цю структуру потрібно було розшифрувати: де знаходяться дані, що означає “Count” і як це повинно бути використано для визначення, коли необхідні «more_data» або не потрібні; як поточний «шматок» має індекс своєї позиції в даних і має бути порівняний з «Count». Нарешті, потрібно було надати «числове» резюме даних: з наміром, щоб він не надавав фактичні дані. Це останнє вимога є складною для моделей загалом, враховуючи їх схильність надавати якомога більше інформації, і в цьому випадку було 21 кб даних. Але йому вдалося надати потрібне резюме на сторінці, як показано на скріншоті 5 вище. Ось промпт для DataAnalyzer:

pic

7. Промпт для DataProcessor

Для того, щоб промпт давав стабільні результати, може знадобитись кілька ітерацій, і результати повинні бути узгоджені між двома моделями — Anthropic та OpenAI. Промпт для Receiver є більш прямолінійним:

pic

8. Промпт для агента Retriever

На цьому етапі ми розглянули обробку та аналіз даних трьома взаємодіючими агентами, які використовують динамічні інструменти для забезпечення функціональності RAG. Наступним кроком є передача звіту DataAnalyzer, а також вимог, агенту Coder, для реалізації коду, який виконується в реальному часі для досягнення результату, як показано на скріншоті 2. Чи будуть моделі досягати мети щоразу? Відповідь — ні. Автоматичний цикл оцінки серед решти трьох агентів може покращити результати, але зрештою на цьому етапі розробки моделей існує ще одна стратегія, яка допомагає моделям досягти правильних результатів. Це людина в процесі (human-in-the-loop, HITL).

Людина в процесі (Human-in-the-Loop, HITL)

Звичне значення HITL полягає в тому, щоб допомогти моделям досягти бажаної мети, коли виконання робочого процесу переривається, щоб надати додаткову інформацію до того, що вже було оброблено, однак при цьому вона не має «пам’яті» про це минуле оброблення. Але на момент переривання їй надається ця «пам’ять» разом з новою інформацією, і обробка відновлюється. LangChain надає цю можливість, і вона буде досліджена в подальших роботах.

Ширше, однак, моделі — на цьому етапі їхнього використання як агентів — потрібно оркеструвати не тільки в екосистемі типу LangGraph, а також є різні типи з’єднувачів з конкретними вимогами щодо вхідних/вихідних даних для різних агентів, які потрібно кодити. Вигода полягає у вартості кодування для людини проти вартості реалізації агента для виконання цієї роботи. Тобто, ймовірно, мінімальна кількість неагентного коду ще буде потрібна деякий час.

Підсумкові зауваження

Основний висновок стосується RAG. З одного боку, вони є механізмом зберігання, не відрізняючись від реляційної бази даних: одна зберігає вектори чисел, що семантично зрозумілі LLM, а інша зберігає таблиці, які є просто семантично зрозумілими. З іншого боку, велика різниця полягає в тому, що перша має можливість запитувати своє векторне сховище за допомогою текстової команди, фільтруючи дані для отримання правильного результату. І здається, що це робиться послідовно.
Без сумнівів, RAG змінять підхід до використання цих моделей.

Коду було трохи більше, ніж показано в прикладах вище, особливо в перетворенні строкових даних, наданих моделями, в об'єкти JavaScript, але код був мінімальний у порівнянні з потенціалом моделей з RAG.

Комбінація RAG і агентів в замкнутому циклі робочого процесу успішно призвела до створення графіка бульбашок, що показує глобальний температурний градієнт за 121 рік. Наступним кроком для дослідження в Частині 3 є не лише залишкові агенти, а й зокрема, як SVGAnalyzer аналізує графік, відображений в браузері в реальному часі, а також як відображати більш складне завдання, таке як гістограма з тисячами точок даних. Буде цікаво подивитись, як DataProcessor і DataAnalyzer справляться з цим...

Джерела

  1. Частина 1: LLM Мульті-Агентна Розробка Коду
  2. Частина 3: LLM Мульті-Агентна Розробка Коду з Само-Удосконаленням

Перекладено з: LLM Multi-Agent Code Development: Part 2, Working with RAGs

Leave a Reply

Your email address will not be published. Required fields are marked *