Цикълът на агента: план, действие, наблюдение
Накратко: AI агентът е цикъл: моделът казва план и иска действие, програмата го изпълнява и връща наблюдението, докато има отговор, лимит на стъпките или нужда от човек.
Проблем
Асистентът отговаря, но не върши работата
Клиентът пише: „Слушалките от поръчка 1187 са повредени. Искам да ги върна.“
Това не е въпрос с един отговор. Някой трябва да провери дали поръчката е доставена, да отвори заявка за връщане и да каже на клиента какво следва. Ако поръчката още не е доставена, пътят е друг. Ако клиентът иска обезщетение, пак е друг.
Цикълът от урока за инструментите отговаряше на въпрос: моделът иска данни, програмата ги дава, моделът отговаря. Тук трябва няколко действия едно след друго, а кое е следващото се разбира чак след предишното.
Можеш да го напишеш с if за всеки случай. Работи за случаите, които си предвидил. Клиентите пишат и такива, които не си.
Идеята
Агентът: план, действие, наблюдение
Агент е програма, в която моделът избира следващата стъпка към една цел. Всяка стъпка е три неща:
- План. Моделът казва с думи какво ще направи: „първо проверявам дали поръчката е доставена“.
- Действие. Моделът иска един инструмент (блок
tool_use), а програмата го изпълнява. - Наблюдение. Резултатът се връща в историята (блок
tool_result). На следващата стъпка моделът го вижда и избира пак.
Това е цикълът на агента. Програмата държи три изхода от него:
- Отговор. Моделът е готов и връща
end_turn. - Лимит на стъпките. След 5 стъпки без отговор програмата спира и предава на служител.
- Трябва човек. Моделът сам иска инструмента
hand_to_human. Програмата не го изпълнява, а спира.
Инструментите на агента са безопасни: статус на поръчка, срок при куриера и заявка за връщане. Заявката само чака служител. Парите не минават през агента.
Виж го
Инструментите и данните
Четирите инструмента. Описанието е за модела: по него той избира.
static class Shop{ public const string SystemPrompt = "Ти си агентът на поддръжката на онлайн магазин. Ползвай инструментите; парите решава служител."; public const int MaxSteps = 5; public const string Delivered = "доставена";
public static readonly Tool[] Tools = [ new("get_order_status", "Статус и дата на поръчка по номер.", "order_id"), new("estimate_delivery", "Датата на доставка, която куриерът очаква.", "order_id"), new("open_return_request", "Отваря заявка за връщане. Служител я одобрява.", "order_id"), new("hand_to_human", "Предава разговора на служител, с причината.", "reason"), ];
public static readonly Dictionary<string, Order> Orders = new() { ["1042"] = new("изпратена", "03.11"), ["1187"] = new("доставена", "26.10"), };
// Заявката само чака: парите ги връща служител. public static readonly List<string> Requests = [];}Програмата изпълнява поисканото срещу своите данни. Заявката за връщане се записва в списък, който гледа служител:
static ToolResult RunTool(ToolUse use){ var orderId = use.Input.GetValueOrDefault("order_id", ""); if (!Orders.TryGetValue(orderId, out var order)) { return new ToolResult(use.Id, $"няма поръчка {orderId}", true); } var delivered = order.Status == Delivered; string text; switch (use.Name) { case "get_order_status": text = $"{orderId} — {order.Status}, доставка {order.Date}"; break; case "estimate_delivery": text = delivered ? $"{orderId} — вече е доставена" : $"{orderId} — очаквана доставка {order.Date}, няма ново сканиране"; break; case "open_return_request": if (!delivered) { return new ToolResult(use.Id, $"{orderId} още не е доставена", true); } var request = $"R-{Requests.Count + 1} за {orderId}"; Requests.Add(request); text = $"{request} — чака одобрение от служител"; break; default: return new ToolResult(use.Id, $"няма инструмент {use.Name}", true); } return new ToolResult(use.Id, text, false);}hand_to_human го няма тук. Той не е действие, а изход от цикъла.
Виж го
Цикълът
static void RunAgent(FakeModel model, string task){ Console.WriteLine($"задача: {task}"); List<Message> history = [new("user", [new TextBlock(task)])]; for (var step = 1; step <= MaxSteps; step++) { var reply = model.Create(SystemPrompt, history, Tools); var head = $" стъпка {step}. "; var text = TextOf(reply); if (reply.StopReason != "tool_use") { Console.WriteLine($"{head}край: {text}"); return; } var use = ToolUseOf(reply); Console.WriteLine($"{head}план: {text}"); var action = Describe(use); Console.WriteLine($" действие: {action}"); if (use.Name == "hand_to_human") { Console.WriteLine(" спирам: трябва човек — " + use.Input["reason"]); return; } history.Add(new("assistant", reply.Content)); var result = RunTool(use); Console.WriteLine(" наблюдение: " + result.Content); history.Add(new("user", [result])); } Console.WriteLine($" спирам: {MaxSteps} " + "стъпки без отговор → към служител");}Стъпка по стъпка, с думи
- Задачата е първото съобщение. Агентът има 5 стъпки (MAX_STEPS) и четири инструмента.
- Стъпка 1. Моделът казва план и иска едно действие: get_order_status(1187).
- Не е hand_to_human, затова програмата го изпълнява. Наблюдението влиза в историята.
- Стъпка 2. Моделът вижда „доставена“ и планът е друг: open_return_request(1187).
- Инструментът само отваря заявка R-1. Тя чака служител: агентът не връща пари.
- Стъпка 3. Моделът отговаря с end_turn: цикълът спира на отговор, след 3 от 5 стъпки.
- Още два изхода чакат: лимитът на стъпките и hand_to_human. Без тях агентът може да върти безкрай.
Помощните функции намират плана (текстовия блок) и действието (блока tool_use):
static string TextOf(Reply reply) => reply.Content.OfType<TextBlock>().FirstOrDefault()?.Text ?? "";
static ToolUse ToolUseOf(Reply reply) => reply.Content.OfType<ToolUse>().First();
static string Describe(ToolUse use) => $"{use.Name}({string.Join(", ", use.Input.Values)})";static void RunAgent(FakeModel model, string task){ Console.WriteLine($"задача: {task}"); List<Message> history = [new("user", [new TextBlock(task)])]; for (var step = 1; step <= MaxSteps; step++) { var reply = model.Create(SystemPrompt, history, Tools); var head = $" стъпка {step}. "; var text = TextOf(reply); if (reply.StopReason != "tool_use") { Console.WriteLine($"{head}край: {text}"); return; } var use = ToolUseOf(reply); Console.WriteLine($"{head}план: {text}"); var action = Describe(use); Console.WriteLine($" действие: {action}"); if (use.Name == "hand_to_human") { Console.WriteLine(" спирам: трябва човек — " + use.Input["reason"]); return; } history.Add(new("assistant", reply.Content)); var result = RunTool(use); Console.WriteLine(" наблюдение: " + result.Content); history.Add(new("user", [result])); } Console.WriteLine($" спирам: {MaxSteps} " + "стъпки без отговор → към служител");}var model = new FakeModel();RunAgent(model, "Слушалките от поръчка 1187 са повредени. Искам да ги върна.");Console.WriteLine($"чака одобрение: {string.Join(", ", Requests)}");задача: Слушалките от поръчка 1187 са повредени. Искам да ги върна. стъпка 1. план: Първо проверявам дали поръчката е доставена. действие: get_order_status(1187) наблюдение: 1187 — доставена, доставка 26.10 стъпка 2. план: Доставена е. Отварям заявка за връщане. действие: open_return_request(1187) наблюдение: R-1 за 1187 — чака одобрение от служител стъпка 3. край: Отворих заявка R-1 за връщане на 1187. Служител ще я одобри и ще ви пише.чака одобрение: R-1 за 1187Никъде в кода не пише „след статуса отвори заявка“. Моделът го избира, защото наблюдението казва „1187 — доставена, доставка 26.10“. Програмата пази лимита, изпълнява и записва.
Моделът тук е фалшив клиент като в предишните уроци: по думите на задачата и по наблюденията досега избира следващата стъпка. Правилото му е в „По-точно“.
Пробвай
Попълни
Кое действие програмата не изпълнява, а спира цикъла?
if (use.Name == "")hand_to_human. Моделът го иска, когато задачата не е за него: обезщетение, жалба, нещо извън правилата. Програмата печата причината и спира. Истинският магазин тук праща разговора на служител с цялата история.
Идеята
Когато агентът не спира
Клиентът пише „Кога пристига 1042? Куриерът каза, че е днес.“. Куриерът няма ново сканиране, а моделът пита пак и пак:
var model = new FakeModel();RunAgent(model, "Кога пристига 1042? Куриерът каза, че е днес.");задача: Кога пристига 1042? Куриерът каза, че е днес. стъпка 1. план: Питам куриера за срока. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране стъпка 2. план: Няма промяна. Питам пак. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране стъпка 3. план: Няма промяна. Питам пак. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране стъпка 4. план: Няма промяна. Питам пак. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране стъпка 5. план: Няма промяна. Питам пак. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране спирам: 5 стъпки без отговор → към служителВсяка стъпка праща цялата история, затова всяка следваща е по-скъпа от предишната (сметката е в урока за цената). Без лимита програмата върти, докато свършат парите. С него клиентът стига до човек след 5 стъпки.
Лимитът е последната защита, не единствената. Тук моделът пита за едно и също пет пъти, а програмата можеше да го види още на втората стъпка. Това е упражнението на урока.
Пробвай
Предвиди изхода
Клиентът иска обезщетение. Какво печата runAgent?
var model = new FakeModel();RunAgent(model, "Искам 20 € обезщетение, защото 1042 закъснява.");Две стъпки. Моделът проверява поръчката и после иска hand_to_human: обезщетението не е нещо, което той решава. Програмата не изпълнява това действие и спира с причината. Не отваря заявка за връщане и не стига до лимита.
Пробвай
Намери грешката
Колега пише runAgent и всеки разговор свършва със „5 стъпки без отговор“, дори когато клиентът е получил отговор. На кой ред е грешката?
Избери реда с грешката.
break излиза само от цикъла. След него програмата стига до реда след цикъла и печата, че лимитът е свършил. Затова клиентът получава отговор и веднага след него „към служител“. Трябва return: отговорът е изход от цялата функция.
Идеята
Пусни при теб
Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в LLM_API_KEY, никога в кода. Доставчиците имат официални SDK-та за Java, C#, Python и JavaScript със същата форма (съобщения, роли, токени, инструменти); точните имена на класовете и методите са в документацията на доставчика.
Първата заявка носи задачата и четирите инструмента. Името на модела е заместител:
{ "model": "<името на модела>", "max_tokens": 1024, "system": "Ти си агентът на поддръжката на онлайн магазин. Ползвай инструментите; парите решава служител.", "tools": [ { "name": "get_order_status", "description": "Статус и дата на поръчка по номер.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "estimate_delivery", "description": "Датата на доставка, която куриерът очаква.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "open_return_request", "description": "Отваря заявка за връщане. Служител я одобрява.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "hand_to_human", "description": "Предава разговора на служител, с причината.", "input_schema": { "type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"] } } ], "messages": [ { "role": "user", "content": "Слушалките от поръчка 1187 са повредени. Искам да ги върна." } ]}Отговорът носи и плана, и действието: текстов блок и блок tool_use в едно съобщение. Числата в usage са груби, по правилото от урока за инструментите:
{ "content": [ { "type": "text", "text": "Първо проверявам дали поръчката е доставена." }, { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1187"} } ], "stop_reason": "tool_use", "usage": {"input_tokens": 72, "output_tokens": 11}}Всяка заявка се праща със стандартния HTTP клиент на езика, а цикълът около нея е runAgent. Адресът е заместител:
// requestJson е JSON-ът на заявката отгоре.// Точната заглавка за ключа е в документацията на доставчика.using var http = new HttpClient();var key = Environment.GetEnvironmentVariable("LLM_API_KEY");http.DefaultRequestHeaders.Add("Authorization", "Bearer " + key);var content = new StringContent(requestJson, Encoding.UTF8, "application/json");var response = await http.PostAsync("https://<доставчик>/messages", content);Console.WriteLine(await response.Content.ReadAsStringAsync());Идеята
Кога вреди
- Пътят е известен. Ако „статус на поръчка“ винаги е една и съща заявка, напиши я с код. Агентът е по-бавен, по-скъп и всеки път може да избере друго. Той си струва, когато стъпките зависят от това, което се вижда по пътя.
- Действия с последствия. Връщане на пари, отмяна, имейл до всички клиенти. Агентът ги подготвя, човек ги одобрява. Моделът може да сбърка поръчката, а текстът, който чете, може да го подведе. Как става това, е в урока за prompt injection, а проверките около модела са в урока за предпазителите.
- Без граница. Лимит на стъпките, на токените (или еврото) и на времето. Всеки от тях спира агента и предава на човек, вместо да върти.
- Без лог. Когато агентът сбърка, трябва да видиш плана, действието и наблюдението на всяка стъпка. Изходът на
runAgentе точно такъв лог. - Без тестове. Агентът се мери като всяко приложение с модел: тестов набор от задачи и проверки на крайния отговор, но и на пътя (кои действия, колко стъпки).
По-точно, за любопитните
Едно действие на стъпка. Истинският модел може да поиска няколко инструмента в един отговор, както в урока за инструментите. Тук всяка стъпка е едно действие, за да се вижда редът. Програмата с няколко действия изпълнява всичките и връща всички резултати в едно user съобщение.
Планът е текст. Моделът пише „първо проверявам“, но нищо не го задължава да направи точно това. Програмата не вярва на плана, а изпълнява само действието, и то само ако е от списъка с инструменти. Планът е полезен за лога и за човека, който чете след това.
Име от статиите. Редуването „мисъл, действие, наблюдение“ се среща като ReAct (reason + act). Има и библиотеки за агенти, които държат цикъла вместо теб. Отвътре са същите три неща и същите изходи.
Фалшивият модел отвътре. Събира наблюденията от историята и брои колко са. За връщане: първо статус; ако е „доставена“, заявка; после отговор с номера на заявката. За обезщетение: статус, после hand_to_human. За куриера: пита пак при всяка стъпка. Номерата на tool_use са call_1, call_2.
// Стои на мястото на истинския модел: записани отговори,// без мрежа и всеки път едни и същи.class FakeModel{ const string NotDelivered = "Поръчката още не е доставена. Ще можете да я върнете след доставката."; const string Thanks = "Моля! Пишете, ако имате въпрос."; int lastId = 0;
public Reply Create(string system, List<Message> messages, Tool[] tools) { var task = ((TextBlock)messages[0].Content[0]).Text; var seen = messages.SelectMany(m => m.Content) .OfType<ToolResult>().Select(r => r.Content).ToList(); var n = seen.Count; var id = task.Contains("1187") ? "1187" : "1042"; if (task.Contains("върна")) { if (n == 0) { return Act("Първо проверявам дали поръчката е доставена.", "get_order_status", "order_id", id); } if (n == 1 && seen[0].Contains("доставена")) { return Act("Доставена е. Отварям заявка за връщане.", "open_return_request", "order_id", id); } if (n == 1) { return Say(NotDelivered); } var request = seen[^1].Split(' ')[0]; return Say($"Отворих заявка {request} за връщане на {id}. Служител ще я одобри и ще ви пише."); } if (task.Contains("обезщетение")) { if (n == 0) { return Act("Проверявам поръчката.", "get_order_status", "order_id", id); } return Act("Обезщетение решава служител, не аз.", "hand_to_human", "reason", $"обезщетение за {id}"); } if (task.Contains("Куриерът")) { var plan = n == 0 ? "Питам куриера за срока." : "Няма промяна. Питам пак."; return Act(plan, "estimate_delivery", "order_id", id); } return Say(Thanks); }
// План (текст) и едно действие (tool_use) в един отговор. Reply Act(string plan, string tool, string key, string value) { lastId++; var use = new ToolUse($"call_{lastId}", tool, new() { [key] = value }); return new Reply([new TextBlock(plan), use], "tool_use"); }
static Reply Say(string text) => new([new TextBlock(text)], "end_turn");}Block и три наследника. ToolUseOf е OfType<ToolUse>().First(): отговор със stop_reason tool_use винаги има такъв блок.Упражнение
Същото действие два пъти
Реши задачата в редактора си.
Клиентът пише „Кога пристига 1042? Куриерът каза, че е днес.“, а агентът пита куриера пет пъти, преди да спре. Добави в runAgent проверка: ако моделът иска същото действие като на предишната стъпка (същото име и същия вход), програмата печата спирам: същото действие два пъти → към служител и спира.
задача: Кога пристига 1042? Куриерът каза, че е днес. стъпка 1. план: Питам куриера за срока. действие: estimate_delivery(1042) наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране стъпка 2. план: Няма промяна. Питам пак. действие: estimate_delivery(1042) спирам: същото действие два пъти → към служителОще задачи
- Бюджет на токени. Брой токените на всяка стъпка (думите × 1,3, както в урока за инструментите) и спри, когато сборът мине 300. На коя стъпка спира задачата с куриера?
- Недоставена поръчка. Пусни задача за връщане на поръчка 1042, която още не е доставена. Кой изход избира агентът и какво казва на клиента?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Структуриран изход: JSON по схема · Инструменти: моделът пита, програмата прави · Оценяване: тестов набор за асистента · Цена и бавност: бюджет, кеш, по-малък модел
- Продължава в: Prompt injection: писмото, което дава нареждания · Предпазители: проверки преди и след модела · Асистентът на поддръжката
- Задачи по темата: Агенти