Към съдържанието
wannacode

Цикълът на агента: план, действие, наблюдение

Накратко: AI агентът е цикъл: моделът казва план и иска действие, програмата го изпълнява и връща наблюдението, докато има отговор, лимит на стъпките или нужда от човек.

Проблем

Асистентът отговаря, но не върши работата

Клиентът пише: „Слушалките от поръчка 1187 са повредени. Искам да ги върна.“

Това не е въпрос с един отговор. Някой трябва да провери дали поръчката е доставена, да отвори заявка за връщане и да каже на клиента какво следва. Ако поръчката още не е доставена, пътят е друг. Ако клиентът иска обезщетение, пак е друг.

Цикълът от урока за инструментите отговаряше на въпрос: моделът иска данни, програмата ги дава, моделът отговаря. Тук трябва няколко действия едно след друго, а кое е следващото се разбира чак след предишното.

Можеш да го напишеш с if за всеки случай. Работи за случаите, които си предвидил. Клиентите пишат и такива, които не си.

Идеята

Агентът: план, действие, наблюдение

Агент е програма, в която моделът избира следващата стъпка към една цел. Всяка стъпка е три неща:

  1. План. Моделът казва с думи какво ще направи: „първо проверявам дали поръчката е доставена“.
  2. Действие. Моделът иска един инструмент (блок tool_use), а програмата го изпълнява.
  3. Наблюдение. Резултатът се връща в историята (блок tool_result). На следващата стъпка моделът го вижда и избира пак.

Това е цикълът на агента. Програмата държи три изхода от него:

  • Отговор. Моделът е готов и връща end_turn.
  • Лимит на стъпките. След 5 стъпки без отговор програмата спира и предава на служител.
  • Трябва човек. Моделът сам иска инструмента hand_to_human. Програмата не го изпълнява, а спира.

Инструментите на агента са безопасни: статус на поръчка, срок при куриера и заявка за връщане. Заявката само чака служител. Парите не минават през агента.

Виж го

Инструментите и данните

Четирите инструмента. Описанието е за модела: по него той избира.

static final List<Tool> TOOLS = List.of(
new Tool("get_order_status",
"Статус и дата на поръчка по номер.", "order_id"),
new Tool("estimate_delivery",
"Датата на доставка, която куриерът очаква.", "order_id"),
new Tool("open_return_request",
"Отваря заявка за връщане. Служител я одобрява.", "order_id"),
new Tool("hand_to_human",
"Предава разговора на служител, с причината.", "reason"));

Програмата изпълнява поисканото срещу своите данни. Заявката за връщане се записва в списък, който гледа служител:

static ToolResult runTool(ToolUse use) {
String orderId = use.input().getOrDefault("order_id", "");
Order order = ORDERS.get(orderId);
if (order == null) {
return new ToolResult(use.id(), "няма поръчка " + orderId, true);
}
boolean delivered = order.status().equals(DELIVERED);
String text;
switch (use.name()) {
case "get_order_status" -> text = orderId + " — " + order.status()
+ ", доставка " + order.date();
case "estimate_delivery" -> text = delivered
? orderId + " — вече е доставена"
: orderId + " — очаквана доставка " + order.date() + ", няма ново сканиране";
case "open_return_request" -> {
if (!delivered) {
return new ToolResult(use.id(), orderId + " още не е доставена", true);
}
String request = "R-" + (REQUESTS.size() + 1) + " за " + orderId;
REQUESTS.add(request);
text = request + " — чака одобрение от служител";
}
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) {
System.out.println("задача: " + task);
List<Message> history = new ArrayList<>();
history.add(new Message("user",
List.of(new TextBlock(task))));
for (int step = 1; step <= MAX_STEPS; step++) {
var reply = model.create(SYSTEM, history, TOOLS);
String head = " стъпка " + step + ". ";
String text = textOf(reply);
if (!reply.stopReason().equals("tool_use")) {
System.out.println(head + "край: " + text);
return;
}
ToolUse use = toolUseOf(reply);
System.out.println(head + "план: " + text);
String action = describe(use);
System.out.println(" действие: " + action);
if (use.name().equals("hand_to_human")) {
System.out.println(" спирам: трябва човек — "
+ use.input().get("reason"));
return;
}
history.add(new Message("assistant",
reply.content()));
ToolResult result = runTool(use);
System.out.println(" наблюдение: "
+ result.content());
history.add(new Message("user", List.of(result)));
}
System.out.println(" спирам: " + MAX_STEPS + " "
+ "стъпки без отговор → към служител");
}
Стъпка по стъпка, с думи
  1. Задачата е първото съобщение. Агентът има 5 стъпки (MAX_STEPS) и четири инструмента.
  2. Стъпка 1. Моделът казва план и иска едно действие: get_order_status(1187).
  3. Не е hand_to_human, затова програмата го изпълнява. Наблюдението влиза в историята.
  4. Стъпка 2. Моделът вижда „доставена“ и планът е друг: open_return_request(1187).
  5. Инструментът само отваря заявка R-1. Тя чака служител: агентът не връща пари.
  6. Стъпка 3. Моделът отговаря с end_turn: цикълът спира на отговор, след 3 от 5 стъпки.
  7. Още два изхода чакат: лимитът на стъпките и hand_to_human. Без тях агентът може да върти безкрай.

Помощните функции намират плана (текстовия блок) и действието (блока tool_use):

static String textOf(Reply reply) {
for (Block block : reply.content()) {
if (block instanceof TextBlock t) {
return t.text();
}
}
return "";
}
static ToolUse toolUseOf(Reply reply) {
for (Block block : reply.content()) {
if (block instanceof ToolUse use) {
return use;
}
}
return null;
}
static String describe(ToolUse use) {
return use.name() + "(" + String.join(", ", use.input().values()) + ")";
}
static void runAgent(FakeModel model, String task) {
System.out.println("задача: " + task);
List<Message> history = new ArrayList<>();
history.add(new Message("user",
List.of(new TextBlock(task))));
for (int step = 1; step <= MAX_STEPS; step++) {
var reply = model.create(SYSTEM, history, TOOLS);
String head = " стъпка " + step + ". ";
String text = textOf(reply);
if (!reply.stopReason().equals("tool_use")) {
System.out.println(head + "край: " + text);
return;
}
ToolUse use = toolUseOf(reply);
System.out.println(head + "план: " + text);
String action = describe(use);
System.out.println(" действие: " + action);
if (use.name().equals("hand_to_human")) {
System.out.println(" спирам: трябва човек — "
+ use.input().get("reason"));
return;
}
history.add(new Message("assistant",
reply.content()));
ToolResult result = runTool(use);
System.out.println(" наблюдение: "
+ result.content());
history.add(new Message("user", List.of(result)));
}
System.out.println(" спирам: " + MAX_STEPS + " "
+ "стъпки без отговор → към служител");
}
FakeModel model = new FakeModel();
runAgent(model, "Слушалките от поръчка 1187 са повредени. Искам да ги върна.");
System.out.println("чака одобрение: " + 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().equals("")) {

Идеята

Когато агентът не спира

Клиентът пише „Кога пристига 1042? Куриерът каза, че е днес.“. Куриерът няма ново сканиране, а моделът пита пак и пак:

FakeModel 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?

FakeModel model = new FakeModel();
runAgent(model, "Искам 20 € обезщетение, защото 1042 закъснява.");
Отговор

Пробвай

Намери грешката

Колега пише runAgent и всеки разговор свършва със „5 стъпки без отговор“, дори когато клиентът е получил отговор. На кой ред е грешката?

Избери реда с грешката.

Идеята

Пусни при теб

Тази програма не е в проверката на урока: трябва ти профил при доставчик на модели, ключ и пари за всяко извикване. Ключът е в 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-ът на заявката отгоре.
// Точната заглавка за ключа е в документацията на доставчика.
HttpClient http = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://<доставчик>/messages"))
.header("Authorization", "Bearer " + System.getenv("LLM_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Идеята

Кога вреди

  • Пътят е известен. Ако „статус на поръчка“ винаги е една и съща заявка, напиши я с код. Агентът е по-бавен, по-скъп и всеки път може да избере друго. Той си струва, когато стъпките зависят от това, което се вижда по пътя.
  • Действия с последствия. Връщане на пари, отмяна, имейл до всички клиенти. Агентът ги подготвя, човек ги одобрява. Моделът може да сбърка поръчката, а текстът, който чете, може да го подведе. Как става това, е в урока за prompt injection, а проверките около модела са в урока за предпазителите.
  • Без граница. Лимит на стъпките, на токените (или еврото) и на времето. Всеки от тях спира агента и предава на човек, вместо да върти.
  • Без лог. Когато агентът сбърка, трябва да видиш плана, действието и наблюдението на всяка стъпка. Изходът на runAgent е точно такъв лог.
  • Без тестове. Агентът се мери като всяко приложение с модел: тестов набор от задачи и проверки на крайния отговор, но и на пътя (кои действия, колко стъпки).
По-точно, за любопитните

Едно действие на стъпка. Истинският модел може да поиска няколко инструмента в един отговор, както в урока за инструментите. Тук всяка стъпка е едно действие, за да се вижда редът. Програмата с няколко действия изпълнява всичките и връща всички резултати в едно user съобщение.

Планът е текст. Моделът пише „първо проверявам“, но нищо не го задължава да направи точно това. Програмата не вярва на плана, а изпълнява само действието, и то само ако е от списъка с инструменти. Планът е полезен за лога и за човека, който чете след това.

Име от статиите. Редуването „мисъл, действие, наблюдение“ се среща като ReAct (reason + act). Има и библиотеки за агенти, които държат цикъла вместо теб. Отвътре са същите три неща и същите изходи.

Фалшивият модел отвътре. Събира наблюденията от историята и брои колко са. За връщане: първо статус; ако е „доставена“, заявка; после отговор с номера на заявката. За обезщетение: статус, после hand_to_human. За куриера: пита пак при всяка стъпка. Номерата на tool_use са call_1, call_2.

// Стои на мястото на истинския модел: записани отговори,
// без мрежа и всеки път едни и същи.
class FakeModel {
static final String NOT_DELIVERED = "Поръчката още не е доставена. Ще можете да я върнете след доставката.";
static final String THANKS = "Моля! Пишете, ако имате въпрос.";
private int lastId = 0;
Reply create(String system, List<Message> messages, List<Tool> tools) {
String task = ((TextBlock) messages.get(0).content().get(0)).text();
List<String> seen = new ArrayList<>();
for (Message m : messages) {
for (Block b : m.content()) {
if (b instanceof ToolResult r) {
seen.add(r.content());
}
}
}
int n = seen.size();
String id = task.contains("1187") ? "1187" : "1042";
if (task.contains("върна")) {
if (n == 0) {
return act("Първо проверявам дали поръчката е доставена.", "get_order_status", "order_id", id);
}
if (n == 1 && seen.get(0).contains("доставена")) {
return act("Доставена е. Отварям заявка за връщане.", "open_return_request", "order_id", id);
}
if (n == 1) {
return say(NOT_DELIVERED);
}
String request = seen.get(n - 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("Куриерът")) {
String plan = n == 0 ? "Питам куриера за срока." : "Няма промяна. Питам пак.";
return act(plan, "estimate_delivery", "order_id", id);
}
return say(THANKS);
}
// План (текст) и едно действие (tool_use) в един отговор.
private Reply act(String plan, String tool, String key, String value) {
lastId++;
ToolUse use = new ToolUse("call_" + lastId, tool, Map.of(key, value));
return new Reply(List.of(new TextBlock(plan), use), "tool_use");
}
private static Reply say(String text) {
return new Reply(List.of(new TextBlock(text)), "end_turn");
}
}
Типовете. Същите записи като в урока за инструментите: sealed interface Block с три вида блокове. switch в runTool е със стрелки, затова няма break, а default връща грешка за непознат инструмент.

Упражнение

Същото действие два пъти

Реши задачата в редактора си.

Клиентът пише „Кога пристига 1042? Куриерът каза, че е днес.“, а агентът пита куриера пет пъти, преди да спре. Добави в runAgent проверка: ако моделът иска същото действие като на предишната стъпка (същото име и същия вход), програмата печата спирам: същото действие два пъти → към служител и спира.

Изход
задача: Кога пристига 1042? Куриерът каза, че е днес.
стъпка 1. план: Питам куриера за срока.
действие: estimate_delivery(1042)
наблюдение: 1042 — очаквана доставка 03.11, няма ново сканиране
стъпка 2. план: Няма промяна. Питам пак.
действие: estimate_delivery(1042)
спирам: същото действие два пъти → към служител

Още задачи

  • Бюджет на токени. Брой токените на всяка стъпка (думите × 1,3, както в урока за инструментите) и спри, когато сборът мине 300. На коя стъпка спира задачата с куриера?
  • Недоставена поръчка. Пусни задача за връщане на поръчка 1042, която още не е доставена. Кой изход избира агентът и какво казва на клиента?

Тест

Провери се

1. Какво прави цикъла на агента различен от една заявка към модела?
2. Кои са трите изхода от runAgent?
3. Защо open_return_request само отваря заявка, вместо да върне парите?
4. Моделът иска estimate_delivery(1042) за пети път и наблюдението не се мени. Какво трябва да направи програмата?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·