Zum Inhalt
wannacode

Der Agent-Loop: Plan, Aktion, Beobachtung

Kurz gesagt: Ein KI-Agent ist eine Schleife: Das Modell nennt einen Plan und bittet um eine Aktion, das Programm führt sie aus und gibt die Beobachtung zurück, bis es eine Antwort gibt, das Schrittlimit erreicht ist oder ein Mensch gebraucht wird.

Problem

Der Assistent antwortet, erledigt aber nichts

Der Kunde schreibt: „Die Kopfhörer aus Bestellung 1187 sind kaputt. Ich möchte sie zurückgeben.“

Das ist keine Frage mit einer Antwort. Jemand muss prüfen, ob die Bestellung zugestellt wurde, eine Rückgabeanfrage öffnen und dem Kunden sagen, wie es weitergeht. Ist die Bestellung noch nicht zugestellt, sieht der Weg anders aus. Will der Kunde eine Entschädigung, wieder anders.

Die Schleife aus der Lektion über Tool Use hat eine Frage beantwortet: Das Modell bittet um Daten, das Programm gibt sie, das Modell antwortet. Hier müssen mehrere Aktionen nacheinander passieren, und welche als Nächste kommt, weiß man erst nach der vorigen.

Du kannst es mit einem if für jeden Fall schreiben. Das funktioniert für die Fälle, die du vorhergesehen hast. Kunden schreiben aber auch die anderen.

Die Idee

Der Agent: Plan, Aktion, Beobachtung

Ein Agent ist ein Programm, in dem das Modell den nächsten Schritt zu einem Ziel wählt. Jeder Schritt besteht aus drei Dingen:

  1. Plan. Das Modell sagt in Worten, was es tun will: „Zuerst prüfe ich, ob die Bestellung zugestellt wurde.“
  2. Aktion. Das Modell bittet um ein Tool (ein tool_use-Block), und das Programm führt es aus.
  3. Beobachtung. Das Ergebnis geht zurück in den Verlauf (ein tool_result-Block). Im nächsten Schritt sieht das Modell es und wählt neu.

Das ist der Agent-Loop. Das Programm hält drei Ausgänge daraus bereit:

  • Antwort. Das Modell ist fertig und liefert end_turn.
  • Schrittlimit. Nach 5 Schritten ohne Antwort hält das Programm an und übergibt an einen Kollegen.
  • Ein Mensch wird gebraucht. Das Modell selbst bittet um das Tool hand_to_human. Das Programm führt es nicht aus, sondern hält an.

Die Tools des Agenten sind sicher: Bestellstatus, das Lieferdatum des Kuriers und eine Rückgabeanfrage. Die Anfrage wartet nur auf einen Kollegen. Geld läuft nie über den Agenten.

Sieh es dir an

Die Tools und die Daten

Die vier Tools. Die Beschreibung ist für das Modell: Es wählt danach.

function tool(name, description, field) {
const schema = {
type: "object",
properties: { [field]: { type: "string" } },
required: [field],
};
return { name, description, input_schema: schema };
}
const TOOLS = [
tool("get_order_status",
"Status und Datum einer Bestellung nach ihrer Nummer.", "order_id"),
tool("estimate_delivery",
"Das Lieferdatum, das der Kurier erwartet.", "order_id"),
tool("open_return_request",
"Öffnet eine Rückgabeanfrage. Ein Kollege gibt sie frei.", "order_id"),
tool("hand_to_human",
"Übergibt das Gespräch an einen Kollegen, mit dem Grund.", "reason"),
];

Das Programm führt aus, worum gebeten wurde, gegen seine eigenen Daten. Die Rückgabeanfrage kommt in eine Liste, die ein Kollege ansieht:

function runTool(use) {
const orderId = use.input.order_id ?? "";
const order = ORDERS[orderId];
if (order === undefined) return result(use, `keine Bestellung ${orderId}`, true);
const delivered = order.status === DELIVERED;
let text;
switch (use.name) {
case "get_order_status":
text = `${orderId} — ${order.status}, Lieferung ${order.date}`;
break;
case "estimate_delivery":
text = delivered
? `${orderId} — schon zugestellt`
: `${orderId} — erwartete Lieferung ${order.date}, kein neuer Scan`;
break;
case "open_return_request": {
if (!delivered) return result(use, `${orderId} ist noch nicht zugestellt`, true);
const request = `R-${REQUESTS.length + 1} für ${orderId}`;
REQUESTS.push(request);
text = `${request} — wartet auf die Freigabe durch einen Kollegen`;
break;
}
default:
return result(use, `kein Tool ${use.name}`, true);
}
return result(use, text);
}
function result(use, text, isError = false) {
const block = { type: "tool_result", tool_use_id: use.id, content: text };
if (isError) block.is_error = true;
return block;
}

hand_to_human steht hier nicht. Es ist keine Aktion, sondern ein Ausgang aus der Schleife.

Sieh es dir an

Die Schleife

function runAgent(model, task) {
console.log(`Aufgabe: ${task}`);
const first = { type: "text", text: task };
const history = [{ role: "user", content: [first] }];
for (let step = 1; step <= MAX_STEPS; step++) {
const reply = model.create(SYSTEM, history, TOOLS);
const head = ` Schritt ${step}. `;
const text = textOf(reply);
if (reply.stopReason !== "tool_use") {
console.log(`${head}Ende: ${text}`);
return;
}
const use = toolUseOf(reply);
console.log(`${head}Plan: ${text}`);
const action = describe(use);
console.log(` Aktion: ${action}`);
if (use.name === "hand_to_human") {
console.log(" Stopp: ein Mensch wird gebraucht —",
use.input.reason);
return;
}
history.push({ role: "assistant",
content: reply.content });
const result = runTool(use);
console.log(` Beobachtung: ${result.content}`);
history.push({ role: "user", content: [result] });
}
console.log(` Stopp: ${MAX_STEPS} Schritte ohne Antwort`,
"→ an einen Kollegen");
}
Schritt für Schritt, in Worten
  1. Die Aufgabe ist die erste Nachricht. Der Agent hat 5 Schritte (MAX_STEPS) und vier Tools.
  2. Schritt 1. Das Modell nennt einen Plan und bittet um eine Aktion: get_order_status(1187).
  3. Es ist nicht hand_to_human, also führt das Programm es aus. Die Beobachtung kommt in den Verlauf.
  4. Schritt 2. Das Modell sieht „zugestellt“, also ändert sich der Plan: open_return_request(1187).
  5. Das Tool öffnet nur die Anfrage R-1. Sie wartet auf einen Kollegen: Der Agent erstattet nie Geld.
  6. Schritt 3. Das Modell antwortet mit end_turn: Die Schleife endet mit einer Antwort, nach 3 von 5 Schritten.
  7. Zwei weitere Ausgänge stehen bereit: das Schrittlimit und hand_to_human. Ohne sie kann der Agent endlos kreisen.

Die Hilfsfunktionen finden den Plan (den Textblock) und die Aktion (den tool_use-Block):

function textOf(reply) {
return reply.content.find((b) => b.type === "text")?.text ?? "";
}
function toolUseOf(reply) {
return reply.content.find((b) => b.type === "tool_use");
}
function describe(use) {
return `${use.name}(${Object.values(use.input).join(", ")})`;
}
function runAgent(model, task) {
console.log(`Aufgabe: ${task}`);
const first = { type: "text", text: task };
const history = [{ role: "user", content: [first] }];
for (let step = 1; step <= MAX_STEPS; step++) {
const reply = model.create(SYSTEM, history, TOOLS);
const head = ` Schritt ${step}. `;
const text = textOf(reply);
if (reply.stopReason !== "tool_use") {
console.log(`${head}Ende: ${text}`);
return;
}
const use = toolUseOf(reply);
console.log(`${head}Plan: ${text}`);
const action = describe(use);
console.log(` Aktion: ${action}`);
if (use.name === "hand_to_human") {
console.log(" Stopp: ein Mensch wird gebraucht —",
use.input.reason);
return;
}
history.push({ role: "assistant",
content: reply.content });
const result = runTool(use);
console.log(` Beobachtung: ${result.content}`);
history.push({ role: "user", content: [result] });
}
console.log(` Stopp: ${MAX_STEPS} Schritte ohne Antwort`,
"→ an einen Kollegen");
}
const model = new FakeModel();
runAgent(model, "Die Kopfhörer aus Bestellung 1187 sind kaputt. Ich möchte sie zurückgeben.");
console.log("wartet auf Freigabe:", REQUESTS.join(", "));
Ausgabe
Aufgabe: Die Kopfhörer aus Bestellung 1187 sind kaputt. Ich möchte sie zurückgeben.
Schritt 1. Plan: Zuerst prüfe ich, ob die Bestellung zugestellt wurde.
Aktion: get_order_status(1187)
Beobachtung: 1187 — zugestellt, Lieferung 26. Oktober
Schritt 2. Plan: Sie ist zugestellt. Ich öffne eine Rückgabeanfrage.
Aktion: open_return_request(1187)
Beobachtung: R-1 für 1187 — wartet auf die Freigabe durch einen Kollegen
Schritt 3. Ende: Ich habe die Anfrage R-1 zur Rückgabe von Bestellung 1187 geöffnet. Ein Kollege gibt sie frei und schreibt dir.
wartet auf Freigabe: R-1 für 1187

Nirgends steht im Code „nach dem Status öffne eine Anfrage“. Das Modell wählt es, weil die Beobachtung sagt „1187 — zugestellt, Lieferung 26. Oktober“. Das Programm hält das Limit ein, führt aus und protokolliert.

Das Modell hier ist ein falscher Client, wie in den früheren Lektionen: Anhand der Wörter der Aufgabe und der bisherigen Beobachtungen wählt es den nächsten Schritt. Seine Regel steht unter „Mehr“.

Probier es aus

Ergänze

Bei welcher Aktion führt das Programm nichts aus, sondern hält die Schleife an?

if (use.name === "") {

Die Idee

Wenn der Agent nicht aufhört

Der Kunde schreibt „Wann kommt 1042 an? Der Kurier hat gesagt, heute.“. Der Kurier hat keinen neuen Scan, und das Modell fragt immer wieder:

const model = new FakeModel();
runAgent(model, "Wann kommt 1042 an? Der Kurier hat gesagt, heute.");
Ausgabe
Aufgabe: Wann kommt 1042 an? Der Kurier hat gesagt, heute.
Schritt 1. Plan: Ich frage den Kurier nach dem Termin.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Schritt 2. Plan: Keine Änderung. Ich frage noch einmal.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Schritt 3. Plan: Keine Änderung. Ich frage noch einmal.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Schritt 4. Plan: Keine Änderung. Ich frage noch einmal.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Schritt 5. Plan: Keine Änderung. Ich frage noch einmal.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Stopp: 5 Schritte ohne Antwort → an einen Kollegen

Jeder Schritt schickt den ganzen Verlauf, also kostet jeder Schritt mehr als der vorige (die Zahlen stehen in der Lektion über Kosten). Ohne das Limit dreht sich das Programm, bis das Geld alle ist. Mit ihm erreicht der Kunde nach 5 Schritten einen Menschen.

Das Limit ist die letzte Verteidigung, nicht die einzige. Hier fragt das Modell fünfmal dasselbe, und das Programm hätte es schon im zweiten Schritt sehen können. Das ist die Übung dieser Lektion.

Probier es aus

Sag die Ausgabe voraus

Der Kunde will eine Entschädigung. Was gibt runAgent aus?

const model = new FakeModel();
runAgent(model, "Ich möchte 20 € Entschädigung, weil 1042 zu spät kommt.");
Antwort

Probier es aus

Finde den Fehler

Ein Kollege schreibt runAgent, und jedes Gespräch endet mit „5 Schritte ohne Antwort“, auch wenn der Kunde eine Antwort bekommen hat. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Bei dir ausführen

Dieses Programm ist nicht Teil der Prüfung der Lektion: Du brauchst ein Konto bei einem Modell-Anbieter, einen Schlüssel und Geld für jeden Aufruf. Der Schlüssel steht in LLM_API_KEY, nie im Code. Anbieter liefern offizielle SDKs für Java, C#, Python und JavaScript mit derselben Form (Nachrichten, Rollen, Tokens, Tools); die genauen Namen von Klassen und Methoden stehen in der Doku des Anbieters.

Die erste Anfrage trägt die Aufgabe und die vier Tools. Der Name des Modells ist ein Platzhalter:

{
"model": "<Name des Modells>",
"max_tokens": 1024,
"system": "Du bist der Support-Agent eines Online-Shops. Nutze die Tools; über Geld entscheidet ein Mensch.",
"tools": [
{
"name": "get_order_status",
"description": "Status und Datum einer Bestellung nach ihrer Nummer.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "estimate_delivery",
"description": "Das Lieferdatum, das der Kurier erwartet.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "open_return_request",
"description": "Öffnet eine Rückgabeanfrage. Ein Kollege gibt sie frei.",
"input_schema": {
"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]
}
},
{
"name": "hand_to_human",
"description": "Übergibt das Gespräch an einen Kollegen, mit dem Grund.",
"input_schema": {
"type": "object",
"properties": {"reason": {"type": "string"}},
"required": ["reason"]
}
}
],
"messages": [
{
"role": "user",
"content": "Die Kopfhörer aus Bestellung 1187 sind kaputt. Ich möchte sie zurückgeben."
}
]
}

Die Antwort trägt sowohl den Plan als auch die Aktion: einen Textblock und einen tool_use-Block in einer Nachricht. Die Zahlen in usage sind grob, nach der Regel aus der Lektion über Tool Use:

{
"content": [
{
"type": "text",
"text": "Zuerst prüfe ich, ob die Bestellung zugestellt wurde."
},
{
"type": "tool_use",
"id": "call_1",
"name": "get_order_status",
"input": {"order_id": "1187"}
}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 81, "output_tokens": 13}
}

Jede Anfrage wird mit dem Standard-HTTP-Client der Sprache gesendet, und die Schleife darum ist runAgent. Die Adresse ist ein Platzhalter:

// requestJson ist das JSON der Anfrage oben.
// Der genaue Header für den Schlüssel steht in der Doku des Anbieters.
const response = await fetch("https://<anbieter>/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: requestJson,
});
console.log(await response.text());

Die Idee

Wenn es wehtut

  • Der Weg ist bekannt. Wenn „Bestellstatus“ immer dieselbe Anfrage ist, schreib sie im Code. Der Agent ist langsamer, kostet mehr und wählt vielleicht jedes Mal etwas anderes. Er lohnt sich, wenn die Schritte davon abhängen, was unterwegs auftaucht.
  • Aktionen mit Folgen. Erstattungen, Stornierungen, eine E-Mail an alle Kunden. Der Agent bereitet sie vor, ein Mensch gibt sie frei. Das Modell kann die falsche Bestellung wählen, und der Text, den es liest, kann es in die Irre führen. Wie das geschieht, steht in der Lektion über Prompt Injection, und die Prüfungen um das Modell herum stehen in der Lektion über Guardrails.
  • Kein Limit. Ein Limit für Schritte, für Tokens (oder Euro) und für die Zeit. Jedes davon hält den Agenten an und übergibt an einen Menschen, statt zu kreisen.
  • Kein Protokoll. Wenn der Agent sich irrt, musst du Plan, Aktion und Beobachtung jedes Schritts sehen. Die Ausgabe von runAgent ist genau so ein Protokoll.
  • Keine Tests. Ein Agent wird gemessen wie jede App mit einem Modell: ein Testset aus Aufgaben mit Prüfungen der Endantwort, aber auch des Wegs (welche Aktionen, wie viele Schritte).
Genauer, für Neugierige

Eine Aktion pro Schritt. Ein echtes Modell kann in einer Antwort um mehrere Tools bitten, wie in der Lektion über Tool Use. Hier ist jeder Schritt eine Aktion, damit die Reihenfolge sichtbar bleibt. Ein Programm mit mehreren Aktionen führt sie alle aus und gibt alle Ergebnisse in einer user-Nachricht zurück.

Der Plan ist Text. Das Modell schreibt „Zuerst prüfe ich“, aber nichts zwingt es, genau das zu tun. Das Programm vertraut dem Plan nicht; es führt nur die Aktion aus, und nur, wenn sie auf der Liste der Tools steht. Der Plan ist nützlich für das Protokoll und für den Menschen, der es danach liest.

Ein Name aus den Papers. Der Rhythmus „Gedanke, Aktion, Beobachtung“ ist als ReAct bekannt (reason + act). Es gibt auch Agent-Bibliotheken, die die Schleife für dich ausführen. Innen sind es dieselben drei Dinge und dieselben Ausgänge.

Das falsche Modell innen. Es sammelt die Beobachtungen aus dem Verlauf und zählt sie. Bei einer Rückgabe: zuerst der Status; ist er „zugestellt“, eine Anfrage; dann eine Antwort mit der Nummer der Anfrage. Bei einer Entschädigung: der Status, dann hand_to_human. Beim Kurier: Es fragt in jedem Schritt erneut. Die tool_use-IDs sind call_1, call_2.

// Steht anstelle des echten Modells: aufgezeichnete Antworten,
// ohne Netz und jedes Mal dieselben.
class FakeModel {
static NOT_DELIVERED = "Die Bestellung ist noch nicht zugestellt. Du kannst sie nach der Lieferung zurückgeben.";
static THANKS = "Gern geschehen! Schreib, wenn du eine Frage hast.";
lastId = 0;
create(system, messages, tools) {
const task = messages[0].content[0].text;
const seen = messages
.flatMap((m) => m.content)
.filter((b) => b.type === "tool_result")
.map((b) => b.content);
const n = seen.length;
const id = task.includes("1187") ? "1187" : "1042";
if (task.includes("zurückgeben")) {
if (n === 0) return this.act("Zuerst prüfe ich, ob die Bestellung zugestellt wurde.", "get_order_status", "order_id", id);
if (n === 1 && seen[0].includes("zugestellt")) {
return this.act("Sie ist zugestellt. Ich öffne eine Rückgabeanfrage.", "open_return_request", "order_id", id);
}
if (n === 1) return FakeModel.say(FakeModel.NOT_DELIVERED);
const request = seen[n - 1].split(" ")[0];
return FakeModel.say(`Ich habe die Anfrage ${request} zur Rückgabe von Bestellung ${id} geöffnet. Ein Kollege gibt sie frei und schreibt dir.`);
}
if (task.includes("Entschädigung")) {
if (n === 0) return this.act("Ich prüfe die Bestellung.", "get_order_status", "order_id", id);
return this.act("Über eine Entschädigung entscheidet ein Mensch, nicht ich.", "hand_to_human", "reason", `Entschädigung für ${id}`);
}
if (task.includes("Kurier")) {
const plan = n === 0 ? "Ich frage den Kurier nach dem Termin." : "Keine Änderung. Ich frage noch einmal.";
return this.act(plan, "estimate_delivery", "order_id", id);
}
return FakeModel.say(FakeModel.THANKS);
}
// Ein Plan (Text) und eine Aktion (tool_use) in einer Antwort.
act(plan, tool, key, value) {
this.lastId++;
const use = { type: "tool_use", id: `call_${this.lastId}`, name: tool, input: { [key]: value } };
return { content: [{ type: "text", text: plan }, use], stopReason: "tool_use" };
}
static say(text) {
return { content: [{ type: "text", text }], stopReason: "end_turn" };
}
}

Übung

Dieselbe Aktion zweimal

Löse es in deinem Editor.

Der Kunde schreibt „Wann kommt 1042 an? Der Kurier hat gesagt, heute.“, und der Agent fragt den Kurier fünfmal, bevor er anhält. Füge runAgent eine Prüfung hinzu: Bittet das Modell um dieselbe Aktion wie im vorigen Schritt (derselbe Name und dieselbe Eingabe), gibt das Programm Stopp: dieselbe Aktion zweimal → an einen Kollegen aus und hält an.

Ausgabe
Aufgabe: Wann kommt 1042 an? Der Kurier hat gesagt, heute.
Schritt 1. Plan: Ich frage den Kurier nach dem Termin.
Aktion: estimate_delivery(1042)
Beobachtung: 1042 — erwartete Lieferung 3. November, kein neuer Scan
Schritt 2. Plan: Keine Änderung. Ich frage noch einmal.
Aktion: estimate_delivery(1042)
Stopp: dieselbe Aktion zweimal → an einen Kollegen

Weitere Aufgaben

  • Ein Token-Budget. Zähle die Tokens jedes Schritts (Wörter × 1,3, wie in der Lektion über Tool Use) und halte an, sobald die Summe 300 übersteigt. In welchem Schritt hält die Kurier-Aufgabe an?
  • Eine noch nicht zugestellte Bestellung. Führe eine Rückgabe-Aufgabe für Bestellung 1042 aus, die noch nicht zugestellt ist. Welchen Ausgang nimmt der Agent, und was sagt er dem Kunden?

Test

Teste dich

1. Was unterscheidet den Agent-Loop von einer einzelnen Anfrage an das Modell?
2. Welche drei Ausgänge hat runAgent?
3. Warum öffnet open_return_request nur eine Anfrage, statt das Geld zu erstatten?
4. Das Modell bittet zum fünften Mal um estimate_delivery(1042), und die Beobachtung ändert sich nicht. Was sollte das Programm tun?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·