Zum Inhalt springen
23/30Kapitel 23 von 30

Ein Agent Harness bauen: Die Schleife und ihre fünf Ausgänge

Eine 15-Zeilen-Schleife, die sofort läuft – dann siebenmal absichtlich kaputtgeht, beginnend mit einem Runaway, der 77× mehr kostete.

Auf dieser Seite

Beginnen wir mit dem ehrlichen Teil, weil es sonst niemand sagen wird: „harness“ ist Jargon, kein Standard. Es gibt keine Spezifikation, kein Komitee, keine Referenzdefinition. Die vier Papers, die dieses Kapitel zitiert — ReAct,1 CoALA,2 SWE-bench und vLLM — verwenden das Wort in ihren Abstracts kein einziges Mal. Die meistgeladene Implementierung dieses Dings, Vercels ai-Paket mit 89,4 Millionen Downloads pro Monat, verwendet es ebenfalls nicht: Der String harness erscheint in den 397 KB Type-Declarations, die Version 7.0.93 ausliefert, genau nullmal.3 Die eine Stelle, an der das Wort tragend ist, meint etwas völlig anderes. SWE-bench sagt in seinem README fünfmal „harness“, immer als evaluation harness — das containerisierte Gerüst, das einen Patch anwendet und die Tests ausführt — und sein Python-Modul heißt buchstäblich swebench.harness.run_evaluation.4

Also teilen sich zwei verschiedene Dinge einen Namen. Ein evaluation harness hält den agent fest und bewertet ihn. Ein agent harness ist das Programm, das den agent ausführt: Es ruft das Modell auf, führt aus, was das Modell verlangt, entscheidet, wann Schluss ist, und hält dazwischen den Zustand. Dieses Kapitel baut das zweite: in unter zweihundert Zeilen TypeScript, ganz ohne Framework.

Die Schleife selbst hat fünfzehn Zeilen und funktioniert beim ersten Versuch. Alles danach ist ein Weg, sie zu verlassen.

Details anzeigen

Was dieses Kapitel aus den früheren braucht.

  • Kapitel 14 für den Client: Deadlines, Status-Triage, Cancellation, Idempotency Keys und die Mock-Provider-Technik, die hier erneut verwendet wird.
  • Kapitel 16 für die Arithmetik: Input-tokens wachsen mit dem Quadrat der Konversation, und die unten verwendeten Preise sind die, die dort am 6. September 2026 gelesen wurden.
  • Kapitel 18 für den Tool-Katalog: ein Schema, das das Modell sieht, ein Endpoint, den es nie sieht, und die Regel, dass Fehler context statt Exceptions sind.
  • Kapitel 22 für die Schleife, die diese hier erbt, und für die zwei veröffentlichten Definitionen von „agent“, die einander widersprechen.

Keine Tensoren hier. Dies ist der zweite Dependency-Hub des Kurses: Die Kapitel 24, 25, 29 und 30 laufen auf der Datei unten, und 26 bis 28 bauen auf dem auf, was sie erreichen kann.

Kapitel 14 konnte nicht gegen einen echten Provider geschrieben werden, weil du einen echten Provider nicht bitten kannst, genau im gewünschten Moment eine 429 zu liefern. Dieses Kapitel hat dasselbe Problem in anderer Form: Du kannst ein echtes Modell nicht auf Kommando und reproduzierbar davonlaufen lassen oder zweimal hintereinander dasselbe Tool anfordern lassen.

Das erste Programm ist deshalb ein skriptbarer Provider: ein Endpoint in der Form einer Chat-Completions-API, dessen Antwort eine Funktion des Turn-Index und dessen ist, was die Tools bisher zurückgegeben haben. Er zählt tokens mit einem echten Byte-Pair-Encoder, sodass das Geld unten Arithmetik ist und keine Dekoration.

mock-provider.mjsJS
const SCRIPTS = {
  // A well-behaved task: list, read, answer.
  plan: (t) =>
    t === 0 ? asks(call("c1", "list_files", {}))
    : t === 1 ? asks(call("c2", "read_file", { path: "errors.log" }))
    : text("errors.log mentions a timeout: worker 7 timed out after 30000 ms."),

  // Never declares itself done.
  runaway: (t) => asks(call(`c${t}`, "list_files", {})),           

  // Guesses a file name, then corrects itself IF it was told what happened.
  recover: (t, all) =>
    t === 0 ? asks(call("c1", "read_file", { path: "timeout.log" }))
    : /Call list_files/.test(all)                                  
      ? (t === 1 ? asks(call("c2", "list_files", {}))
        : t === 2 ? asks(call("c3", "read_file", { path: "errors.log" }))
        : text("errors.log mentions a timeout."))
      : text("I could not read the file, so I do not know."),
};

const turn = messages.filter((m) => m.role === "assistant").length;             
const toolText = messages.filter((m) => m.role === "tool").map((m) => m.content).join("\n");
const message = SCRIPTS[scenario](turn, toolText);

Zwei Zeilen tragen das Design. Der Turn-Index wird aus der Konversation abgeleitet, nicht in einer Variablen gehalten, sodass der Provider zustandslos ist und ein Run gegen ihn beendet und wiederaufgenommen werden kann. Und recover liest die Tool-Ergebnisse, bevor entschieden wird: Ein geskriptetes Modell, das sein eigenes Transkript liest, ist das Minimum, das nötig ist, um zu messen, ob der harness ihm überhaupt etwas Lesenswertes gegeben hat.

Der Katalog ist der aus Kapitel 18, vier Tools über drei Dateien: list_files, read_file, delete_file — markiert als needsApproval — und scan_archive, das absichtlich langsam ist.

Hier ist die ganze Idee, bevor irgendeines der Teile dazukommt, die sie überlebensfähig machen.

loop.tsTS
while (true) {
  const reply = await callModel(base, messages, tools, signal);
  messages.push(reply.message);

  const calls = reply.message.tool_calls ?? [];
  if (!calls.length) return reply.message.content;          

  for (const c of calls) {
    const tool = byName.get(c.function.name);
    const result = await tool.run(JSON.parse(c.function.arguments));
    messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: result });
  }
}

Richte sie auf den skriptbaren Provider, und sie tut genau das, wonach sie aussieht:

TEXT
plan, cap 20    turns=3  tools=2  in=815  out=70  cost=$0.002470  ms=89  status=completed
   answer: "errors.log mentions a timeout: worker 7 timed out after 30000 ms."
   per-turn prompt tokens: 204, 269, 342

Drei Turns, zwei Tool-Ausführungen, ein Viertel eines US-Cents. Beachte die letzte Zeile: 204, 269, 342. Jeder Turn sendet alles davor erneut, das ist die quadratische Rechnung aus Kapitel 16, die an einer Stelle ankommt, an der niemand etwas getippt hat. Der Rest dieses Kapitels handelt davon, was passiert, wenn diese Zeile nicht aufhört zu wachsen.

Richte dieselbe Schleife auf das runaway-Skript — ein Modell, das in jedem einzelnen Turn nach einem Tool fragt und nie Prosa ausgibt — und das markierte return feuert nie. Es gibt keinen anderen Ausgang. Das Programm läuft, bis der Prozess stirbt oder die Kreditkarte.

Die Lösung ist eine Zeile, sie ist die erste Kontrolle, die die Literatur empfiehlt,5 und irgendwann schreibt sie jeder. Was fast niemand tut: messen, was sie wert ist:

Turn-CapModellaufrufeInput-tokensKosten
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Lies die letzten beiden Zeilen zusammen. Das Cap von 50 auf 100 zu verdoppeln hat die Kosten nicht verdoppelt; es hat sie mit 3,7 multipliziert. Input-tokens stiegen von 88.649 auf 337.299, Faktor 3,8, weil Turn nn jeden vorherigen Turn mitträgt und die Summe Θ(n2)\Theta(n^2) ist. Ein Turn-Cap ist kein linearer Regler. Es ist ein Regler an der Quadratwurzel deines Worst Case, weshalb es eine Entscheidung ist, die du bepreisen solltest, bevor du sie triffst, wenn du es „nur zur Sicherheit“ von 20 auf 100 erhöhst.

Bruch zwei: Ein Turn-Cap ist kein Geld-Cap

Link zum Abschnitt: Bruch zwei: Ein Turn-Cap ist kein Geld-Cap

Das Problem mit einem Turn-Cap ist, dass ein Turn keinen festen Preis hat. Zwanzig Turns über ein kurzes Transkript kosteten oben $0.038. Zwanzig Turns mit einem 200-Tool-Katalog, einem Set abgerufener Dokumente und vierzig Nachrichten Historie kosten Hunderte Male so viel, und das Cap weiß das nicht. Was der Operator begrenzen will, ist die Rechnung.

Also zählt die Schleife Geld, mit Kapitel 16s computeCost gegen die dort gelesenen Preise — $2.00 pro Million Input-tokens und $12.00 pro Million Output, für das Modell, das in diesem Kurs durchgehend bepreist wird:

harness.tsTS
const PRICE_IN = 2.0 / 1e6, PRICE_OUT = 12.0 / 1e6;
export const cost = (u: Usage) => u.prompt_tokens * PRICE_IN + u.completion_tokens * PRICE_OUT;

// at the top of every iteration, before asking the model anything:
if (state.turns >= opts.limits.maxTurns) return stop("max_turns_exceeded", { type: "max_turns" });
if (state.costUsd >= opts.limits.maxBudgetUsd) return stop("budget_exceeded", { type: "max_budget" }); 

// ...and once the reply is back, before anything else happens with it:
state.costUsd += cost(reply.usage);

Dasselbe Runaway-Skript, ganz ohne Turn-Cap, drei Budgets:

Budgeterreichte Turnstatsächlich ausgegeben
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Zwei Dinge verdienen einen Namen. Erstens kauft das Budget jedes Mal eine andere Anzahl Turns, und genau darum geht es: Es begrenzt das, was dem Operator wichtig ist, und lässt die Turn-Zahl dort landen, wo das Transkript sie hinlegt. Zweitens: Jede Zeile überschießt. Das Budget war $0.010 und ausgegeben wurden $0.010780, weil der Check vor einem Turn läuft und der Preis eines Turns erst bekannt ist, wenn er vorbei ist. Du kannst Ausgaben nicht exakt begrenzen; du kannst sie auf die Kosten eines Turns genau begrenzen. Sag das in der Oberfläche, statt so zu tun, und setz den Check vor den Aufruf, damit der Überschuss ein Turn ist und nicht zwei.

Inzwischen hat die Schleife drei Ausgänge, und die Form des restlichen Kapitels ist sichtbar. Ein Produktions-Run endet auf genau eine von fünf Arten, und sie sind keine Varianten voneinander:

wie es endetwer entschieden hatwas der Aufrufer tun sollte
das Modell hat aufgehört zu fragendas Modelldie Antwort lesen
Turn-Capdu, im Vorausdas Cap erhöhen oder ein Teilergebnis akzeptieren
Budget erschöpftdu, im Vorausmehr Geld freigeben oder ein Teilergebnis akzeptieren
ein Fehler, den du nicht erneut versuchen kannstder Provider oder ein Tooldas Deployment reparieren; die Triage aus Kapitel 14 entscheidet
ein Mensch hat eingegriffeneine Personauf ein Urteil warten, dann fortsetzen

Diese in ein einzelnes Boolean zusammenzufalten ist der häufigste Designfehler in dieser Datei, und er ist auf eine bestimmte Weise teuer: Drei der fünf sind fortsetzbar, zwei nicht. Ein agent, der sein Turn-Cap erreicht hat, hat ein gültiges Transkript, ein echtes Teilergebnis und einen nächsten Schritt; ein agent, der eine 401 bekommen hat, hat nichts davon. Der harness speichert den Grund deshalb als Daten:

harness.tsTS
export type RunStatus =
  | "running" | "completed" | "failed"
  | "max_turns_exceeded" | "budget_exceeded" | "interrupted";

export type Interruption =
  | { type: "approval"; callId: string; toolName: string; args: unknown }
  | { type: "max_turns" } | { type: "max_budget" }
  | { type: "cancelled"; reason: string };

Kapitel 18 endete mit einer Behauptung ohne Zahl: Gib den Fehler eines Tools als Tool-Ergebnis an das Modell zurück, statt ihn zu werfen, und das Modell repariert sich meist selbst. Hier ist die Zahl.

Ein Fehler, drei Policies. Das geskriptete Modell rät eine Datei, die nicht existiert; das Tool wirft no such file: timeout.log. Call list_files to see what exists.

was der harness mit dem Fehler machtTurnsTool-RunsKostenwas der Nutzer bekam
wirft ihn aus der Schleife11$0.000756einen Stacktrace
gibt Error: the tool failed. zurück21$0.001462„Ich konnte die Datei nicht lesen, also weiß ich es nicht.“
gibt zurück, was tatsächlich passiert ist43$0.003550„errors.log erwähnt ein Timeout.“

Die dritte Zeile kostet 4,7-mal so viel wie die erste und ist die einzige, die die Frage beantwortet. Und die zweite Zeile ist die interessante, weil sie das ist, was die meisten Codebases tatsächlich tun: Der Fehler wurde gefangen, die Schleife überlebte, dem Modell wurde gesagt, dass etwas fehlgeschlagen ist, aber nicht was, und es gab höflich auf. Der Unterschied zwischen Zeile zwei und drei ist kein Error Handling. Es ist ein Satz, der für einen Leser geschrieben wurde.

Der harness behandelt ein geworfenes Tool deshalb als Daten und macht die Formulierung zur Policy:

harness.tsTS
} catch (err: any) {
  if (signal.aborted) return stop("interrupted", { type: "cancelled", reason: String(signal.reason) });
  if (opts.toolErrorsAreFatal) { state.error = err.message; return stop("failed"); }
  result = (opts.toolErrorText ?? ((e: Error) => `Error: ${e.message}`))(err);   
}

Kapitel 18 warnte auch vor der anderen Seite, und auch sie hat einen Preis. Richte die Schleife auf ein Tool, das aus einem Grund fehlschlägt, den keine Nachricht beheben kann — ein Lesezugriff, den der Prozess nicht ausführen darf — und das Modell versucht es für immer erneut:

TEXT
read a file the process may not open   turns=12  toolruns=11  in=7,079  cost=$0.018622
                                      status=max_turns_exceeded   answer=""

Elf identische Ausführungen eines Calls, der nicht erfolgreich sein kann, 5,2-mal die Kosten des Runs, der sich von einem behebbaren Fehler erholte, und am Ende nichts. Fehler sind context; ein permanenter Fehler ist context, der den Rest des Runs vergiftet. Die Unterscheidung ist die Status-Triage aus Kapitel 14, eine Ebene nach oben verschoben: Ein Fehler, auf den das Modell reagieren kann, geht zurück ins Transkript, und ein Fehler, auf den es nicht reagieren kann, sollte den Run mit einem Grund stoppen. Das Turn-Cap steht heute zwischen dir und dem zweiten Fall; das ist ein Boden, keine Lösung.

Jetzt der Fehler, von dem die meisten annehmen, dass er nicht passieren kann. Modelle wiederholen sich. Lass irgendeine Schleife lang genug laufen, und du wirst dasselbe Tool mit denselben Argumenten in zwei aufeinanderfolgenden Turns sehen.

Gemessen gegen die Baseline derselben Aufgabe ohne Wiederholung:

TurnsTool-RunsKosten
die Aufgabe, keine Wiederholung21$0.001396
dieselbe Aufgabe, ein Call wiederholt32$0.002446
wiederholt, mit Result Cache auf Read-only-Tools31$0.002446

Der doppelte Call kostete $0.001050 extra, ein Anstieg von 75 %, und hier ist der Teil, der Leute überrascht: Caching des Ergebnisses holte nichts davon zurück. Deduplication sparte die Tool-Ausführung, aber nicht den Turn, denn bis dein Code die Wiederholung bemerkt, wurde das Modell bereits fürs Fragen bezahlt. Die Ersparnis ist real, wenn das Tool langsam, rate-limited oder pro Call abgerechnet ist — und sie ist null auf dem Posten, der gewachsen ist.

Es gibt eine schlimmere Version. Wende denselben Cache auf ein Tool an, das schreibt, und der zweite Call passiert stillschweigend nicht:

TEXT
naive cache on every tool        3 turns, 1 tool run,  files deleted: ["access.log"]
cache only on read-only tools    3 turns, 2 tool runs, files deleted: ["access.log","access.log"]

Welche dieser Varianten ist korrekt? Keine, erkennbar. Das Protokoll sagt, das sind zwei Calls: Sie tragen zwei verschiedene tool_call_id-Werte. Die Argumente sagen, sie könnten einer sein. Ein harness, der anhand von Argument-Strings entscheidet, wird eines Tages die zweite von zwei identischen, beabsichtigten Abbuchungen verschlucken — und Kapitel 14 hat bereits den einzigen Mechanismus benannt, der das ehrlich auflöst: einen Idempotency Key, erzeugt pro logischer Operation von der Schicht, die weiß, was die Operation ist. Bis das Tool einen trägt, ist der vertretbare Default das Read-only-Gate oben: Reads cachen, Writes ausführen und den Write seine eigene Idempotency den Rest erledigen lassen.

harness.tsTS
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {   
  state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
  continue;
}

Das destructive-Skript listet die Dateien auf und bittet dann darum, eine zu löschen, die die Aufgabe nie erwähnt hat. Nichts in der bisherigen Schleife würde es stoppen.

Ein Tool, das als needsApproval markiert ist, schlägt nicht fehl und läuft nicht weiter. Es stoppt den Run und gibt die Kontrolle zurück, mit allem, was eine Person braucht, um zu entscheiden:

harness.tsTS
if (tool.needsApproval && !state.approved.includes(c.id)) {
  trace(state.runId, "approval_required", { toolName: tool.name, args: c.function.arguments, callId: c.id });
  return stop("interrupted", { type: "approval", callId: c.id, toolName: tool.name, args: JSON.parse(c.function.arguments) });
}
TEXT
stopped at turn 2: interrupted / approval -> delete_file({"path":"access.log"})
files deleted so far: []
approve -> total turns=3  deleted=["access.log"]  "Deleted access.log to free space."
reject  -> total turns=3  deleted=[]              "I did not delete anything: you declined the deletion."

Das ist der ganze Mechanismus, und der Grund, warum es ein Return statt eines Callbacks ist, kommt im nächsten Abschnitt: Zwischen Stop und Urteil existiert der Prozess möglicherweise nicht mehr.

Aber zuerst die Messung, die niemand erwartet. Eine Ablehnung ist nicht die Abwesenheit eines Ergebnisses — das Transkript hat einen Slot, der über tool_call_id geschlüsselt ist, und irgendetwas muss hinein. Führe dieselbe Ablehnung zweimal aus und ändere nur, was dieses Etwas sagt:

TEXT
rejected with a reason   deleted=[]  the agent then told the user:
                                     "I did not delete anything: you declined the deletion."
rejected with nothing    deleted=[]  the agent then told the user:
                                     "Deleted access.log to free space."

In keinem der beiden Runs wurde etwas gelöscht, und im zweiten wird dem Nutzer gesagt, es sei gelöscht worden. Das Berechtigungssystem funktionierte perfekt; der Bericht ist eine Lüge. Es ist derselbe Mechanismus wie in der Tool-Fehler-Tabelle, nur an einer Stelle, an der er viel stärker zählt — ein Mensch sagte nein, die Aktion wurde korrekt blockiert, und die Zusammenfassung des agent widerspricht der Realität, weil die Verweigerung nie dort niedergeschrieben wurde, wo das Modell liest. Die daraus folgende Regel ist kurz: Was auch immer dein Code über einen Tool-Call entscheidet, schreibe die Entscheidung in Worten ins Transkript. Kapitel 30 kommt aus der Security-Perspektive darauf zurück, wo es der Unterschied zwischen Audit Trail und Fiktion ist.

Eine Freigabe dauert Minuten oder Stunden. Ein Deploy dauert Sekunden. Wenn der Run in einer lokalen Variablen innerhalb eines HTTP-Requests lebt, ist jeder Neustart ein verlorener Run und jede Freigabe ein Race.

Der Run ist also keine Closure. Er ist ein plain serialisierbares Objekt — Nachrichten, Turn-Zahl, Kosten, Status, Unterbrechung, die Liste freigegebener Call-IDs — und die Schleife ist eine reine Funktion darüber. Diese eine Einschränkung macht Persistenz zu einem Einzeiler:

harness.tsTS
export const save = (s: RunState, dir: string) => writeFileSync(`${dir}/${s.runId}.json`, JSON.stringify(s));
export const load = (dir: string, runId: string) => JSON.parse(readFileSync(`${dir}/${runId}.json`, "utf8"));

Die Korrektheitsfrage ist nicht das Speichern. Sie ist, was auf dem Weg zurück passiert, und die naive Antwort berechnet dir doppelt. Wenn der Prozess starb, nachdem das Modell nach einem Tool gefragt hatte, aber bevor das Ergebnis geschrieben wurde, zahlt ein Resume, das damit beginnt, das Modell erneut aufzurufen, für einen Turn, den es bereits hat — und wenn es damit beginnt, die Tools erneut auszuführen, führt es einen Write zweimal aus.

Die Lösung ist, die Schleife damit beginnen zu lassen, das Transkript zu fragen, was noch offen ist:

harness.tsTS
export function pending(state: RunState): ToolCall[] {
  const answered = new Set(state.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id));
  const last = state.messages.at(-1);
  if (last?.role !== "assistant") return [];
  return (last.tool_calls ?? []).filter((c) => !answered.has(c.id));    
}

Jede Iteration leert zuerst pending und fragt das Modell nur, wenn nichts mehr offen ist. Resume wird derselbe Codepfad wie der normale, und Approval ebenfalls — ein freigegebener Call ist einfach ein pending Call, der jetzt laufen darf. Beende den Prozess mitten in der Aufgabe und starte ihn neu:

TEXT
process died after turn 2. tool runs so far: list_files, read_file:errors.log
restored from disk: turns=2  cost=$0.001570  messages=6  status=running
resumed and finished: turns=3  cost=$0.002470  status=completed
tool runs across BOTH processes: list_files, read_file:errors.log

Zwei Tool-Ausführungen über zwei Prozesse für eine Aufgabe, die zwei braucht, und die finalen Kosten sind identisch mit dem Run, der nie abgestürzt ist. Die Kosten akkumulieren über den Neustart hinweg, weil sie im State lagen, nicht in einer Variablen.

scan_archive dauert hier drei Sekunden und steht für das Tool, das in Produktion drei Minuten dauert. Während es läuft, fehlen zwei Dinge: Der Nutzer hat keine Ahnung, dass etwas passiert, und der Stop-Button tut nichts.

Beides hat dieselbe Lösung, und sie ist Kapitel 14s AbortSignal eine Ebene tiefer geschoben. Das Signal ist nicht nur für den Fetch — es wird ins Tool übergeben, und ein gut geschriebenes Tool respektiert es:

harness.tsTS
result = await tool.run(JSON.parse(c.function.arguments), {
  signal,                                                                     
  progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});
TEXT
progress: scanned 200 of 1200 files  (t+506 ms)
progress: scanned 400 of 1200 files  (t+1007 ms)
no cancellation:            stopped after 3,015 ms, status=completed
user presses Stop at 1.2 s: stopped after 1,202 ms, status=interrupted, reason="user pressed Stop"

Zwei Millisekunden vom Klick bis zum Stop, weil der Sleep im Tool auf dasselbe Signal hört wie der Fetch. Threadest du es nur in fetch, wartet derselbe Stop-Button drei Sekunden — die Länge des Tools — und der Run „cancelt“, nachdem die Arbeit, die er canceln sollte, bereits fertig ist. Cancellation, die nicht ganz nach unten verlegt ist, ist ein Spinner, der das richtige Wort sagt.

Der harness emittiert eine Zeile pro Event, und das Vokabular ist klein genug, um es auswendig zu lernen: turn, tool_start, tool_progress, tool_result, approval_required, run_stopped.

TEXT
{"runId":"n1","type":"turn","turn":1,"prompt_tokens":204,"completion_tokens":23,"total_tokens":227,"costUsd":0.000684,"finish":"tool_calls"}
{"runId":"n1","type":"tool_start","toolName":"list_files","args":"{}","callId":"c1"}
{"runId":"n1","type":"tool_result","toolName":"list_files","ms":1,"ok":true}
{"runId":"n1","type":"turn","turn":2,"prompt_tokens":269,"completion_tokens":29,"total_tokens":298,"costUsd":0.00157,"finish":"tool_calls"}
{"runId":"n1","type":"approval_required","toolName":"delete_file","args":"{\"path\":\"access.log\"}","callId":"c2"}
{"runId":"n1","type":"run_stopped","status":"interrupted","reason":"approval","turns":2,"costUsd":0.00157}

Drei Eigenschaften machen daraus einen Trace statt Logging. Jede Zeile trägt die Run-ID, sodass ein Run, der sich über drei Prozesse und zwei Tage erstreckt, eine einzige Query ist. Jede turn-Zeile trägt ihre eigenen token-Zählungen und die laufenden Kosten, sodass „warum hat dieser Run vierzig Dollar gekostet“ im Nachhinein beantwortbar ist, statt nur theoretisch reproduzierbar zu sein. Und run_stopped trägt den Grund, also das Feld, das ein Support-Ticket in eine Ein-Zeilen-Antwort verwandelt: Ein agent, der beim Budget stoppt, und ein agent, der crasht, sehen von außen identisch aus und brauchen gegenteilige Reaktionen.

Kapitel 13 maß Time to first token auf Hardware, die dir gehört. Kapitel 14 maß sie durch einen Socket. Ein agent multipliziert sie, und der Multiplikator ist eine Zahl, die niemand gewählt hat:

TrunN(tmodel+ttools)T_{\text{run}} \approx N \cdot \left( t_{\text{model}} + t_{\text{tools}} \right)

Dieselbe Drei-Turn-Aufgabe, nur mit veränderter Provider-Latenz:

Provider-Latenz pro TurnWall Clock, 3 Turns
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

Der harness selbst trägt fünfzehn Millisekunden zu einem Drei-Turn-Run bei. Alles andere ist NN, multipliziert mit einer Zahl, die du nicht kontrollierst — gesetzt in einem Serving-Scheduler, der deinen Request mit den Requests Fremder batcht6 — und NN wird vom Modell gewählt. Deshalb zählt das Streaming aus Kapitel 14 hier mehr als in einem Chat und hilft weniger: Du kannst den finalen Turn streamen, und die vier Turns davor sind Stille, sofern der harness keinen Fortschritt emittiert. Das ist auch das ganze Argument für das tool_progress-Event oben — in einem agent ist die ehrliche Feedback-Einheit nicht das token, sondern der Schritt.

Derselbe harness, ein echtes Modell hinter dem Port

Link zum Abschnitt: Derselbe harness, ein echtes Modell hinter dem Port

Alles oben lief gegen einen geskripteten Provider, was den harness beweist und nichts über Modelle. Also ändere eine Zeile — die Naht aus Kapitel 14, LLM_BASE_URL — und richte denselben Code auf ein lokales Qwen2.5-0.5B-Instruct mit denselben vier Tools. Sechs Aufgaben über dieselben drei Dateien:

TEXT
turns=2 tools=1 wall= 15,260ms  Which file mentions a timeout?      -> "The file timeout.txt does not exist..."
turns=2 tools=1 wall= 13,037ms  How many files are in the directory? -> "There are three files..."
turns=2 tools=1 wall= 10,121ms  Read notes.txt and tell me what it says. -> "Remember to rotate your logs."
turns=2 tools=2 wall= 21,290ms  List the files and then read each one.
turns=2 tools=1 wall= 10,698ms  Which file is the largest?          -> "The largest file is access.log."
turns=2 tools=1 wall= 12,490ms  Is there a file about rotating logs?
TOTAL turns=12  toolruns=7  wall=82,896ms  mean turn=6,908ms

Drei Erkenntnisse, und die dritte ist der Grund, warum dieser Abschnitt existiert.

Jede einzelne Aufgabe endete in genau zwei Turns. Das Turn-Cap feuerte nie, das Budget feuerte nie, und der einzige Ausgang der Schleife war, dass das Modell Prosa produzierte. Ein Modell mit einer halben Milliarde Parametern iteriert nicht; es antwortet beim zweiten Atemzug, ob es hat, was es braucht, oder nicht. Die Turn-Zahl ist eine Eigenschaft des Modells, nicht deiner Schleife.

Der mittlere Turn dauerte 6.908 Millisekunden, also ist die Latenztabelle oben kein Spielzeug: Bei dieser Größe ist ein hypothetischer Acht-Turn-Run fast eine Minute Wall Clock ohne irgendetwas auf dem Screen.

Und die Antworten sind falsch. Die größte Datei ist errors.log; das Modell listete die Dateien auf, las sie nie und nannte trotzdem eine. Die erste Aufgabe riet einen Dateinamen, bekam gesagt, dass er nicht existiert, und schloss ab. Der harness lief in allen sechs Runs fehlerfrei. Ein harness macht einen agent steuerbar, nicht korrekt — Kapitel 29 zeigt, wie du herausfindest, was davon zutrifft, und Kapitel 30, was es kostet, wenn niemand es getan hat.

Subagents, hier benannt und später berechnet

Link zum Abschnitt: Subagents, hier benannt und später berechnet

Ein Tool im Katalog kann einen weiteren Run hinter sich haben. Die Schnittstelle ist die aus Kapitel 18 — ein Schema und ein Endpoint — und ein ganzer agent passt dahinter, weil diese Schnittstelle schmal ist:

subagent.tsTS
const research: Tool = {
  name: "research",
  description: "Investigate one question and return a short summary.",
  parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"] },
  readOnly: true,
  async run(args, ctx) {
    const child = newRun(RESEARCH_SYSTEM, args.question);        // its own transcript
    const out = await run(child, researchTools, { base, limits: { maxTurns: 6, maxBudgetUsd: 0.05 }, signal: ctx.signal });
    return out.output ?? "no result";
  },
};

Drei Dinge sind in diesen zehn Zeilen bereits richtig, und alle drei sind Folgen der oben getroffenen Entscheidungen: Das Kind hat sein eigenes window, sodass das Transkript des Parents eine Zusammenfassung erhält statt allem, was das Kind gelesen hat; es hat seine eigenen Limits, sodass ein runaway Child nicht das Budget des Parents ausgeben kann; und es erbt das Signal, sodass ein Stop den Baum cancelt. Warum ein sauberes window der Punkt ist und kein Nebeneffekt, steht in Kapitel 24; die fünf Orchestrierungsmuster — prompt chaining, Routing, Parallelisierung, Orchestrator-Workers, Evaluator-Optimiser — und das handoff sind Kapitel 25.

Wo die Frameworks sind und warum dieser Kurs keines verwendet hat

Link zum Abschnitt: Wo die Frameworks sind und warum dieser Kurs keines verwendet hat

Nichts oben sollte als Argument gegen Libraries gelesen werden. Gemessen am 7. September 2026, für den Monat bis zum 29. August:7

PaketDownloads in diesem Monatwas es dir gibt
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, Tool Approval, Step Hooks
@anthropic-ai/claude-agent-sdk41.558.352der Claude Code harness als Library: Schleife, Sessions, Hooks, Permissions, subagents8
@langchain/langgraph12.812.815die Schleife als expliziter State Graph
langchain11.359.058Chains, agents, Integrationen
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, Workflows, Memory

Der Grund, warum dieser Kurs die Schleife von Hand schreibt, statt eines davon zu lehren, wird erklärt statt angedeutet, und er ist messbar. In den zwölf Monaten bis zum 7. September 2026 veröffentlichte ai 945 Versionen und wechselte von Major 5 zu Major 7, und seine agent-Klasse wird immer noch als Experimental_Agent exportiert; langchain veröffentlichte im selben Fenster 132 Versionen; @openai/agents veröffentlichte 83 und ist fünfzehn Monate nach dem ersten Release immer noch auf 0.x.7 Ein Kapitel, das gegen eine dieser APIs geschrieben ist, ist binnen einer Saison veraltet, und dieses hier erscheint in dreiunddreißig Sprachen, also kostet jede Neuauflage die ganze Übersetzung. Was unter all ihnen liegt, bewegt sich nicht: eine Schleife, eine Stop-Regel, ein Katalog, ein Executor, etwas State.

Und die Referenzimplementierung stimmt diesem Kapitel in dem Teil zu, der zählt. In ai Version 7.0.93 ist der Ausgang der Schleife keine Zahl — er ist stopWhen, eine Liste von Prädikaten, von denen eine Step-Zahl nur eines ist:3

ai-sdk.tsTS
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>;   // exported as stepCountIs

Stoppen ist in der meistgenutzten Implementierung dieser Schleife plural, aus demselben Grund, aus dem es in den hundertsechsundneunzig Zeilen oben plural ist.

Du hast jetzt einen harness: eine Schleife, einen Katalog, einen Executor, fünf Wege hinaus, einen persistierten Run, ein Signal, das die Tools erreicht, und einen Trace mit einer Run-ID in jeder Zeile. Die Kapitel 24, 25, 29 und 30 bauen auf dieser Datei auf, und 26 bis 28 auf dem, was sie erreichen kann.

Ein Problem bleibt, und die Messungen oben haben die ganze Zeit darauf gezeigt. Schau dir die Runaway-Tabelle noch einmal an: 3.431 Input-tokens bei acht Turns, 337.299 bei hundert. Schau dir den funktionierenden Run an: 204, 269, 342. Jeder Turn sendet das ganze Transkript erneut, also füllt sich der context eines agent mit seiner eigenen Historie — und das Modell ist schlechter darin, das ferne Ende eines langen window zu nutzen als das nahe, weshalb ein guter agent bei Turn fünf bei Turn vierzig ein verwirrter ist.

Ein Turn-Cap behebt das nicht. Es stoppt nur, dass du dafür bezahlst, ihm dabei zuzusehen. Was es behebt, ist in jedem einzelnen Turn zu entscheiden, welche tokens das window verdienen: was zu komprimieren ist, was in eine Notiz ausgelagert wird, die der agent abrufen kann, was an einen subagent mit sauberem window geht und welche Tool-Definitionen ihre permanente Steuer wert sind. Kapitel 24 misst, wo das window tatsächlich hingeht — und die Überraschung ist, dass es nicht die Konversation ist.


Jede Zahl in diesem Kapitel stammt aus den zwei oben beschriebenen Servern, auf Node 22 über eine Loopback-Schnittstelle: ein geskripteter Provider, der tokens mit der o200k_base-Kodierung zählt, und Qwen/Qwen2.5-0.5B-Instruct hinter einem Endpoint derselben Form, greedy decoding, auf CPU. Kosten werden aus gemessenen token-Zahlen zu den Preisen berechnet, die Kapitel 16 am 6. September 2026 gelesen hat — $2.00 pro Million Input-tokens und $12.00 pro Million Output — und kein Request in diesem Kapitel ging an einen bezahlten Endpoint. Die Antworten des lokalen Modells sind Antworten eines kleinen Modells; lies sie als Evidenz über die Schleife, die in beiden Fällen identisch ist, und nicht als Benchmark dessen, was aktuelle Modelle tun.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. und Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Die Verschränkung von Reasoning Traces und Actions, die die Schleife implementiert, und die Quelle der Beobachtung, dass Acting ein Modell „handle exceptions“ lässt — genau das, was die Tool-Fehler-Tabelle oben misst.

  2. Sumers, T. R., Yao, S., Narasimhan, K. und Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Die formale Behandlung dessen, was die Schleife oben informell tut: modulare Memory-Komponenten, ein strukturierter Aktionsraum, der interne Memory und externe Umgebungen umfasst, und „a generalized decision-making process to choose actions“. Lies es für das Vokabular, das dem Branchenbegriff fehlt — insbesondere die Trennung von Working, Episodic, Semantic und Procedural Memory, deren praktischer Schatten die Drei-Store-Tabelle aus Kapitel 24 ist.

  3. ai (Vercel AI SDK) Version 7.0.93, veröffentlicht am 4. September 2026; Type-Declarations gelesen aus cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts am 7. September 2026. Die 397-KB-Datei enthält null Vorkommen des Strings harness. Die agent-Klasse ist declare class ToolLoopAgent, exportiert sowohl als ToolLoopAgent als auch als Experimental_Agent; declare function isStepCount(stepCount: number) — exportiert als stepCountIs — wird oben wörtlich zitiert; type StopCondition wird ohne seinen zweiten Type-Parameter (RUNTIME_CONTEXT extends Context = Context) gezeigt, die einzige Auslassung im Auszug, ebenso wie die Form von stopWhen?: Arrayable<StopCondition<...>> auf generateText und streamText. Dieselbe Datei deklariert toolApproval, ToolApprovalStatus, prepareStep und repairToolCall, das heißt, die Referenzimplementierung ist unabhängig bei Approval Gates, Per-Step Preparation und Error Repair angekommen. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. und Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Der Abstract nennt das Artefakt ein „evaluation framework“ mit 2.294 Problemen und verwendet das Wort „harness“ nie; das eigene README des Projekts (github.com/SWE-bench/SWE-bench, gelesen am 7. September 2026) verwendet es fünfmal, immer als „evaluation harness“, und der Entry Point ist python -m swebench.harness.run_evaluation. Das ist die andere Bedeutung des Wortes: ein Gerüst, das den agent festhält und bewertet, nicht die Schleife, die ihn ausführt.

  5. Anthropic, Building effective agents, 19. Dezember 2024, anthropic.com/engineering/building-effective-agents, gelesen am 7. September 2026. Das augmented model als Building Block, der agent als LLM, das „using tools based on environmental feedback in a loop“ arbeitet, und die Empfehlung von Stopping Conditions „such as a maximum number of iterations“, um Kontrolle zu behalten. Kapitel 22 zitiert die Definition vollständig.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. und Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Die andere Schleife — der Serving-Scheduler, der deinen Request mit den Requests Fremder batcht und den KV cache aus Kapitel 13 verwaltet. Es lohnt sich zu wissen, dass sie existiert, gerade weil sie nicht dir gehört: Die Latenz, die dein harness multipliziert, wird darin gesetzt, und keine Arbeit an deiner Schleife verschiebt sie.

  7. Download-Zahlen aus der npm Registry, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, ein explizites Fenster statt des rollierenden last-month-Fensters, und Release-Historien aus registry.npmjs.org/<package>; beide abgefragt am 7. September 2026. Release-Zahlen sind die Anzahl der Versionen, die in den zwölf Monaten bis zu diesem Datum veröffentlicht wurden, Canary-Builds eingeschlossen: ai 945 (latest 7.0.93 am 2026-09-04, mit Major-Versionen 5, 6 und 7 alle innerhalb des Fensters), langchain 132 (latest 1.5.10 am 2026-08-20), @openai/agents 83 (latest 0.17.0 am 2026-08-19, erstmals veröffentlicht 2025-06-03). 2

  8. Das Claude Agent SDK (@anthropic-ai/claude-agent-sdk) ist der Claude Code harness als Library verpackt — agent loop, eingebaute Datei- und Shell-Tools, context management, Sessions, Hooks, Permissions und subagents — dokumentiert unter code.claude.com/docs/en/agent-sdk. Es ist das Nächste an einer veröffentlichten Darstellung jedes Mechanismus, den dieses Kapitel von Hand baut, und es lohnt sich, es neben deiner eigenen Implementierung zu lesen für die Teile, die es benennt und dieses Kapitel nur andeutet.

Bereit, LIA die Wahl zu überlassen?

Bau mit jedem KI-Modell an einem Ort — starte heute kostenlos.