Spring til indhold
23/30Kapitel 23 af 30

Byg et agent harness: loopet og dets fem veje ud

En loop på femten linjer, der virker første gang — og derefter brydes syv gange med vilje, fra en runaway der kostede 77× mere.

På denne side

Start med den ærlige del, for ingen andre siger det: »harness« er jargon, ikke en standard. Der findes ingen specifikation, ingen komité, ingen reference-definition. De fire artikler, dette kapitel citerer — ReAct,1 CoALA,2 SWE-bench og vLLM — bruger ikke ordet én eneste gang i deres abstracts. Den mest downloadede implementering af tingen, Vercels ai-pakke med 89,4 millioner downloads om måneden, bruger det heller ikke: strengen harness optræder nul gange i de 397 KB type-deklarationer, der følger med version 7.0.93.3 Det ene sted, hvor ordet er bærende, betyder det noget helt andet. SWE-bench siger »harness« fem gange i sin README, altid som evaluation harness — det containeriserede stillads, der anvender en patch og kører testene — og dets Python-modul hedder bogstaveligt swebench.harness.run_evaluation.4

Så to forskellige ting deler et navn. Et evaluation harness holder agent stille og scorer den. Et agent harness er programmet, der kører agent: det kalder modellen, udfører det modellen beder om, beslutter hvornår der skal stoppes, og holder tilstanden imellem. Dette kapitel bygger den anden slags på under to hundrede linjer TypeScript, helt uden framework.

Selve loopet er femten linjer, og det virker i første forsøg. Alt derefter er en måde at forlade det på.

Vis detaljer

Det dette kapitel kræver fra de tidligere.

  • Kapitel 14 til klienten: deadlines, statustriage, annullering, idempotency keys og mock provider-teknikken, der bruges igen her.
  • Kapitel 16 til aritmetikken: input tokens vokser med kvadratet på samtalen, og satserne nedenfor er dem, der blev aflæst dér den 6. september 2026.
  • Kapitel 18 til værktøjskataloget: et schema modellen ser, et endpoint den aldrig ser, og reglen om at fejl er kontekst snarere end exceptions.
  • Kapitel 22 til det loop, dette kapitel arver, og til de to publicerede definitioner af »agent«, som er uenige med hinanden.

Ingen tensors her. Dette er kursets andet afhængighedsknudepunkt: Kapitel 24, 25, 29 og 30 kører på filen nedenfor, og 26 til 28 bygger på det, den kan nå.

Kapitel 14 kunne ikke skrives mod en rigtig provider, fordi du ikke kan bede en om en 429 på et valgt tidspunkt. Dette kapitel har samme problem i en anden form: du kan ikke bede en rigtig model om at løbe løbsk, eller om at anmode om det identiske værktøj to gange i træk, på kommando og reproducerbart.

Så det første program er en scripted provider: et endpoint med formen af en chat completions API, hvis svar er en funktion af turindekset og af det, værktøjerne indtil videre har returneret. Den tæller tokens med en rigtig byte-pair encoder, så pengene nedenfor er aritmetik og ikke pynt.

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);

To linjer bærer designet. Turindekset er afledt af samtalen, ikke gemt i en variabel, så provideren er stateless, og et run kan dræbes og genoptages mod den. Og recover læser værktøjsresultaterne, før den beslutter sig: en scripted model, der læser sit eget transcript, er det minimum, der skal til for at måle, om harness gav den noget, der var værd at læse.

Kataloget er Kapitel 18's: fire værktøjer over tre filer: list_files, read_file, delete_file — markeret needsApproval — og scan_archive, som med vilje er langsomt.

Her er hele idéen, før nogen af de dele, der gør den overlevelig.

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 });
  }
}

Peg det mod den scripted provider, og det gør præcis det, det ser ud til at gøre:

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

Tre ture, to værktøjsudførsler, en kvart amerikansk cent. Bemærk den sidste linje: 204, 269, 342. Hver tur sender alt før den igen, hvilket er Kapitel 16's kvadratiske regning, der ankommer et sted, hvor ingen har skrevet noget. Resten af dette kapitel er det, der sker, når den linje ikke stopper med at vokse.

Peg det samme loop mod runaway-scriptet — en model, der beder om et værktøj i hver eneste tur og aldrig udsender prosa — og den markerede return aktiveres aldrig. Der er ingen anden udgang. Programmet kører, indtil processen dør, eller kreditkortet gør.

Løsningen er én linje, det er den første kontrol litteraturen anbefaler,5 og alle skriver den før eller siden. Det næsten ingen gør, er at måle, hvad den er værd:

turgrænsemodelkaldinput tokensomkostning
883.431$0.009070
202016.259$0.038038
505088.649$0.191098
100100337.299$0.702198

Læs de sidste to rækker sammen. At fordoble grænsen fra 50 til 100 fordoblede ikke omkostningen; det gangede den med 3,7. Input tokens gik fra 88.649 til 337.299, en faktor 3,8, fordi tur nn bærer alle tidligere ture med sig, og totalen er Θ(n2)\Theta(n^2). En turgrænse er ikke en lineær knap. Det er en knap på kvadratroden af dit worst case, og derfor er det en beslutning, der bør prissættes, før du hæver den fra 20 til 100 »bare for en sikkerheds skyld«.

Brud to: en grænse på ture er ikke en grænse på penge

Link til afsnittet: Brud to: en grænse på ture er ikke en grænse på penge

Problemet med en turgrænse er, at en tur ikke har en fast pris. Tyve ture over et kort transcript kostede $0.038 ovenfor. Tyve ture med et katalog på 200 værktøjer, et hentet dokumentsæt og fyrre historikbeskeder koster hundredvis af gange mere, og grænsen ved det ikke. Det operatøren vil begrænse, er regningen.

Så loopet tæller penge ved at bruge Kapitel 16's computeCost mod de satser, der blev aflæst dér — $2.00 pr. million input tokens og $12.00 pr. million output for den model, der prissættes gennem hele kurset:

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);

Samme runaway-script, ingen turgrænse overhovedet, tre budgetter:

budgetnåede turefaktisk brugt
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

To ting er værd at navngive. For det første køber budgettet et forskelligt antal ture hver gang, hvilket er pointen: det begrænser det, operatøren bekymrer sig om, og lader antallet af ture falde dér, hvor transcriptet lægger det. For det andet overskrider hver række budgettet. Budgettet var $0.010, og $0.010780 blev brugt, fordi tjekket kører før en tur, og prisen på en tur ikke kendes, før den er slut. Du kan ikke begrænse forbrug præcist; du kan begrænse det til inden for én turs omkostning. Sig det i interfacet i stedet for at lade som om, og læg tjekket før kaldet, så overskridelsen er én tur og ikke to.

Fem måder at forlade loopet på, ikke én

Link til afsnittet: Fem måder at forlade loopet på, ikke én

Nu har loopet tre udgange, og formen på resten af kapitlet er synlig. Et produktions-run ender på præcis én af fem måder, og de er ikke variationer af hinanden:

hvordan det enderhvem besluttedehvad kalderen bør gøre
modellen stoppede med at spørgemodellenlæs svaret
turgrænsedig, på forhåndhæv grænsen, eller accepter et delvist resultat
budget opbrugtdig, på forhåndgodkend flere penge, eller accepter et delvist resultat
en fejl du ikke kan retryprovideren eller et værktøjret deploymentet; Kapitel 14's triage beslutter
et menneske greb inden personvent på en afgørelse, og genoptag derefter

At kollapse disse til én boolean er den mest almindelige designfejl i denne fil, og den er dyr på en specifik måde: tre af de fem kan genoptages, og to kan ikke. En agent, der ramte sin turgrænse, har et gyldigt transcript, et reelt delresultat og et næste skridt; en agent, der fik en 401, har ingen af delene. Derfor registrerer harness årsagen som data:

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 sluttede med en påstand uden et tal: giv et værktøjs fejl tilbage til modellen som et værktøjsresultat i stedet for at kaste den, og modellen retter normalt sig selv. Her er tallet.

Én fejl, tre policies. Den scripted model gætter på en fil, der ikke findes; værktøjet kaster no such file: timeout.log. Call list_files to see what exists.

hvad harness gør med fejlentureværktøjskørsleromkostninghvad brugeren fik
kaster den ud af loopet11$0.000756en stack trace
returnerer Error: the tool failed.21$0.001462»Jeg kunne ikke læse filen, så jeg ved det ikke.«
returnerer det, der faktisk skete43$0.003550»errors.log nævner en timeout.«

Den tredje række koster 4,7 gange så meget som den første og er den eneste, der besvarer spørgsmålet. Og den anden række er den interessante, fordi det er det, de fleste codebases faktisk gør: fejlen blev fanget, loopet overlevede, modellen fik at vide at noget fejlede og ikke hvad, og den gav høfligt op. Forskellen mellem række to og tre er ikke error handling. Det er en sætning skrevet til en læser.

Derfor behandler harness et kastet værktøj som data og gør formuleringen til en 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 advarede også om den anden side, og den har også en pris. Peg loopet mod et værktøj, der fejler af en grund, ingen besked kan rette — en læsning processen ikke har lov til at udføre — og modellen prøver igen for evigt:

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

Elleve identiske udførsler af et kald, der ikke kan lykkes, 5,2 gange omkostningen ved det run, der kom sig over en fejl, der kunne rettes, og intet til sidst. Fejl er kontekst; en permanent fejl er kontekst, der forgifter resten af runnet. Skellet er Kapitel 14's statustriage flyttet et lag op: en fejl modellen kan handle på, går tilbage i transcriptet, og en fejl den ikke kan handle på, bør stoppe runnet med en årsag. Turgrænsen er det, der står mellem dig og det andet tilfælde i dag, hvilket er et gulv og ikke en løsning.

Nu den fejl, de fleste antager ikke kan ske. Modeller gentager sig selv. Bed et hvilket som helst loop om at køre længe nok, og du vil se det identiske værktøj med de identiske argumenter i to på hinanden følgende ture.

Målt mod baseline for samme opgave uden gentagelsen:

tureværktøjskørsleromkostning
opgaven, ingen gentagelse21$0.001396
samme opgave, ét kald gentaget32$0.002446
gentaget, med en resultat-cache på read-only-værktøjer31$0.002446

Det duplikerede kald kostede $0.001050 ekstra, en stigning på 75 %, og her er den del, der overrasker folk: caching af resultatet indhentede intet af det. Deduplication sparede værktøjsudførelsen og ikke turen, fordi modellen allerede er blevet betalt for at spørge, når din kode opdager gentagelsen. Besparelsen er reel, når værktøjet er langsomt, rate-limited eller afregnes pr. kald — og den er nul på den linjepost, der voksede.

Der findes en værre version. Anvend samme cache på et værktøj, der skriver, og det andet kald sker stiltiende ikke:

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"]

Hvilken af dem er korrekt? Ingen af dem, vidbart. Protokollen siger, at dette er to kald: de bærer to forskellige tool_call_id-værdier. Argumenterne siger, at de måske er ét. Et harness, der beslutter ved at sammenligne argumentstrenge, vil en dag sluge det andet af to identiske, tilsigtede opkrævninger — og Kapitel 14 har allerede navngivet den eneste mekanisme, der løser dette ærligt, nemlig en idempotency key genereret pr. logisk operation af det lag, der ved, hvad operationen er. Indtil værktøjet bærer en, er den forsvarlige standard read-only-gaten ovenfor: cache læsninger, udfør skrivninger, og lad skrivningens egen idempotency håndtere resten.

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;
}

destructive-scriptet oplister filerne og beder derefter om at slette en, som opgaven aldrig nævnte. Intet i loopet indtil nu ville stoppe det.

Et værktøj markeret needsApproval fejler ikke og fortsætter ikke. Det stopper runnet og giver kontrollen tilbage med alt det, en person har brug for for at beslutte:

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."

Det er hele mekanismen, og grunden til at det er en return snarere end et callback, er næste afsnit: mellem stoppet og afgørelsen findes processen måske ikke længere.

Men først målingen, ingen forventer. En afvisning er ikke fraværet af et resultat — transcriptet har en slot keyed by tool_call_id, og der skal noget ind i den. Kør den samme afvisning to gange, og ændr kun hvad det noget siger:

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."

Intet blev slettet i nogen af kørslerne, og i den anden får brugeren at vide, at det blev. Tilladelsessystemet fungerede perfekt; rapporten er en løgn. Det er samme mekanisme som tabellen med værktøjsfejl, men den dukker op et sted, hvor det betyder langt mere — et menneske sagde nej, handlingen blev korrekt blokeret, og agentens summary modsiger virkeligheden, fordi afslaget aldrig blev skrevet ned dér, hvor modellen læser. Reglen, der falder ud af dette, er kort: uanset hvad din kode beslutter om et værktøjskald, så skriv beslutningen ind i transcriptet med ord. Kapitel 30 vender tilbage til dette fra sikkerhedssiden, hvor det er forskellen mellem et audit trail og fiktion.

En approval tager minutter eller timer. Et deploy tager sekunder. Hvis runnet lever i en lokal variabel inde i en HTTP request, er hver genstart et tabt run, og hver approval er et race.

Så runnet er ikke en closure. Det er et almindeligt serialiserbart objekt — beskeder, turantal, omkostning, status, interruption, listen over godkendte call ids — og loopet er en ren funktion over det. Den ene begrænsning er det, der gør persistence til en énlinjes bekymring:

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"));

Korrekthedsspørgsmålet er ikke at gemme. Det er, hvad der sker på vej ind igen, og det naive svar opkræver dig dobbelt. Hvis processen døde, efter modellen bad om et værktøj, men før resultatet blev skrevet, betaler en genoptagelse, der starter med at kalde modellen igen, for en tur, den allerede har — og hvis den starter med at køre værktøjerne igen, udfører den en skrivning to gange.

Løsningen er at få loopet til at begynde med at spørge transcriptet, hvad der er outstanding:

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));    
}

Hver iteration dræner pending først og spørger kun modellen, når der ikke er noget outstanding. Resume bliver samme code path som den normale, og det samme gør approval — et godkendt kald er blot et pending kald, der nu har lov til at køre. Dræb processen midt i opgaven, og genstart den:

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

To værktøjsudførsler over to processer for en opgave, der har brug for to, og den endelige omkostning er identisk med det run, der aldrig crashede. Omkostningen akkumulerer på tværs af genstarten, fordi den lå i state, ikke i en variabel.

scan_archive tager tre sekunder her og står i stedet for det værktøj, der tager tre minutter i produktion. To ting mangler, mens det kører: brugeren har ingen idé om, at der sker noget, og Stop-knappen gør ingenting.

Begge er samme løsning, og det er Kapitel 14's AbortSignal skubbet et niveau dybere. Signalet er ikke kun til fetch — det sendes ind i værktøjet, og et velskrevet værktøj respekterer det:

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"

To millisekunder fra klik til stop, fordi sleep inde i værktøjet lytter til det samme signal som fetch. Thread det kun ind i fetch, og den identiske Stop-knap venter tre sekunder — værktøjets længde — og runnet »annulleres«, efter det arbejde, den var ved at annullere, allerede er færdigt. Annullering, der ikke er ført hele vejen ned, er en spinner, der siger det rigtige ord.

Harness udsender én linje pr. event, og vokabularet er lille nok til at lære udenad: 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}

Tre egenskaber gør dette til en trace snarere end logging. Hver linje bærer run id, så et run, der spænder over tre processer og to dage, er én query. Hver turn-linje bærer sine egne token counts og den løbende omkostning, så »hvorfor kostede dette run fyrre dollars« kan besvares bagefter i stedet for kun at være reproducerbart i teorien. Og run_stopped bærer årsagen, som er feltet, der forvandler en supportticket til et énlinjes svar: en agent, der stoppede ved budgettet, og en agent, der crashede, ser identiske ud udefra og kræver modsatte svar.

Kapitel 13 målte time to first token på hardware, du ejer. Kapitel 14 målte det gennem en socket. En agent multiplicerer det, og multiplikatoren er et tal, ingen har valgt:

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

Den samme tretursopgave, hvor kun providerens latency ændres:

provider latency pr. turwall clock, 3 ture
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

Selve harness bidrager med femten millisekunder til et treturs-run. Alt andet er NN ganget med et tal, du ikke kontrollerer — sat inde i en serving scheduler, der batcher din request med fremmedes requests6 — og NN vælges af modellen. Det er derfor streaming fra Kapitel 14 betyder mere her end i en chat og hjælper mindre: du kan streame den sidste tur, og de fire ture før den er stilhed, medmindre harness udsender fremdrift. Det er også hele argumentet for tool_progress-eventen ovenfor — i en agent er den ærlige feedback-enhed ikke token, det er skridtet.

Alt ovenfor kørte mod en scripted provider, hvilket beviser harness og intet beviser om modeller. Så ændr én linje — seamet fra Kapitel 14, LLM_BASE_URL — og peg den identiske kode mod en lokal Qwen2.5-0.5B-Instruct med de samme fire værktøjer. Seks opgaver over de samme tre filer:

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

Tre fund, og det tredje er grunden til, at dette afsnit findes.

Hver eneste opgave sluttede på præcis to ture. Turgrænsen blev aldrig aktiveret, budgettet blev aldrig aktiveret, og loopets eneste udgang var, at modellen producerede prosa. En model med en halv milliard parametre itererer ikke; den svarer på sit andet åndedrag, uanset om den har det, den behøver. Antallet af ture er en egenskab ved modellen, ikke ved dit loop.

Den gennemsnitlige tur tog 6.908 millisekunder, så latenstabellen ovenfor er ikke et legetøj: ved denne størrelse er et hypotetisk otte-turs-run næsten et minuts wall clock uden noget på skærmen.

Og svarene er forkerte. Den største fil er errors.log; modellen oplistede filerne, læste dem aldrig og navngav alligevel en. Den første opgave gættede et filnavn, fik at vide at det ikke fandtes, og konkluderede. Harness kørte fejlfrit i alle seks runs. Et harness gør en agent styrbar, ikke korrekt — Kapitel 29 er, hvordan du finder ud af hvilken, og Kapitel 30 er, hvad det koster, når ingen gjorde det.

Subagents, navngivet her og opkrævet senere

Link til afsnittet: Subagents, navngivet her og opkrævet senere

Ét værktøj i kataloget kan have et andet run bag sig. Interfacet er Kapitel 18's — et schema og et endpoint — og en hel agent passer bag det, fordi interfacet er smalt:

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";
  },
};

Tre ting er allerede rigtige i de ti linjer, og alle tre er konsekvenser af beslutningerne ovenfor: barnet har sin egen window, så forælderens transcript modtager et summary i stedet for alt det, barnet læste; det har sine egne limits, så et runaway-barn ikke kan bruge forælderens budget; og det arver signalet, så ét Stop annullerer træet. Hvorfor en ren window er pointen snarere end en sideeffekt, er Kapitel 24; de fem orchestration patterns — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — og handoff er Kapitel 25.

Hvor frameworks er, og hvorfor dette kursus ikke brugte et

Link til afsnittet: Hvor frameworks er, og hvorfor dette kursus ikke brugte et

Intet ovenfor bør læses som et argument mod libraries. Målt den 7. september 2026 for måneden, der sluttede 29. august:7

packagedownloads den månedhvad den giver dig
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, tool approval, step hooks
@anthropic-ai/claude-agent-sdk41.558.352Claude Code harness som library: loop, sessions, hooks, permissions, subagents8
@langchain/langgraph12.812.815loopet som en eksplicit state graph
langchain11.359.058chains, agents, integrations
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memory

Grunden til, at dette kursus skriver loopet i hånden i stedet for at undervise i et af dem, erklæres snarere end antydes, og den kan måles. I de tolv måneder frem til 7. september 2026 udgav ai 945 versioner og flyttede fra major 5 til major 7, og dens agent-klasse eksporteres stadig som Experimental_Agent; langchain udgav 132 versioner i samme vindue; @openai/agents udgav 83 og er stadig på 0.x femten måneder efter sin første release.7 Et kapitel skrevet mod en hvilken som helst af de API'er er forældet inden for en sæson, og dette udgives på treogtredive sprog, så hver ny udgave koster hele oversættelsen. Det, der ligger under dem alle, flytter sig ikke: et loop, en stopping rule, et katalog, en executor, noget state.

Og referenceimplementeringen er enig med dette kapitel om den del, der betyder noget. I ai version 7.0.93 er loopets exit ikke et tal — det er stopWhen, en liste af predicates, hvor en step count blot er én: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

Stopping er plural i den mest brugte implementering af dette loop af samme grund, som den er plural i de hundrede og seksoghalvfems linjer ovenfor.

Du har nu et harness: et loop, et katalog, en executor, fem veje ud, et persisted run, et signal der når værktøjerne, og en trace med run id på hver linje. Kapitel 24, 25, 29 og 30 bygger på denne fil, og 26 til 28 på det, den kan nå.

Den har ét problem tilbage, og målingerne ovenfor har peget på det hele vejen. Se på runaway-tabellen én gang til: 3.431 input tokens ved otte ture, 337.299 ved hundrede. Se på det fungerende run: 204, 269, 342. Hver tur sender hele transcriptet igen, så en agents kontekst fyldes med dens egen historik — og modellen er dårligere til at bruge den fjerne ende af en lang window end den nære ende, hvilket er grunden til, at en god agent ved tur fem er en forvirret agent ved tur fyrre.

En turgrænse løser ikke det. Den stopper dig bare fra at betale for at se det ske. Det, der løser det, er at beslutte i hver eneste tur, hvilke tokens der fortjener window: hvad der skal komprimeres, hvad der skal flyttes ud til en note, agent kan hente, hvad der skal gives til en subagent med en ren window, og hvilke værktøjsdefinitioner der er deres permanente skat værd. Kapitel 24 måler, hvor window faktisk går hen — og overraskelsen er, at det ikke er samtalen.


Hvert tal i dette kapitel kom fra de to servere beskrevet ovenfor, på Node 22 over en loopback interface: en scripted provider, der tæller tokens med o200k_base-encoding, og Qwen/Qwen2.5-0.5B-Instruct bag et endpoint af samme form, greedy decoding, på CPU. Omkostninger beregnes fra målte token counts til de satser, Kapitel 16 aflæste den 6. september 2026 — $2.00 pr. million input tokens og $12.00 pr. million output — og ingen request i dette kapitel gik til et betalt endpoint. Den lokale models svar er en lille models svar; læs dem som evidens om loopet, der er identisk uanset hvad, og ikke som en benchmark af, hvad nuværende modeller gør.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. og Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Sammenfletningen af reasoning traces og actions, som loopet implementerer, og kilden til observationen om, at acting lader en model »handle exceptions« — hvilket er præcis det, tabellen med værktøjsfejl ovenfor måler.

  2. Sumers, T. R., Yao, S., Narasimhan, K. og Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Den formelle behandling af det, loopet ovenfor gør uformelt: modulære memory-komponenter, et struktureret action space, der spænder over intern memory og eksterne environments, og »a generalized decision-making process to choose actions«. Læs den for det vokabular, industribegrebet mangler — især adskillelsen mellem working, episodic, semantic og procedural memory, hvis praktiske skygge er Kapitel 24's tre-store-tabel.

  3. ai (Vercel AI SDK) version 7.0.93, udgivet 4. september 2026; type-deklarationer læst fra cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts den 7. september 2026. Filen på 397 KB indeholder nul forekomster af strengen harness. Agent-klassen er declare class ToolLoopAgent, eksporteret både som ToolLoopAgent og som Experimental_Agent; declare function isStepCount(stepCount: number) — eksporteret som stepCountIs — citeres ordret ovenfor; type StopCondition vises uden sin anden type parameter (RUNTIME_CONTEXT extends Context = Context), hvilket er den eneste udeladelse i excerptet, ligesom formen på stopWhen?: Arrayable<StopCondition<...>>generateText og streamText. Samme fil deklarerer toolApproval, ToolApprovalStatus, prepareStep og repairToolCall, hvilket vil sige, at referenceimplementeringen uafhængigt er nået frem til approval gates, per-step preparation og error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. og Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Abstractet kalder artefaktet et »evaluation framework« med 2.294 problemer og bruger aldrig ordet »harness«; projektets egen README (github.com/SWE-bench/SWE-bench, læst 7. september 2026) bruger det fem gange, altid som »evaluation harness«, og entry point er python -m swebench.harness.run_evaluation. Det er ordets anden betydning: et stillads, der holder agent stille og scorer den, ikke loopet der kører den.

  5. Anthropic, Building effective agents, 19. december 2024, anthropic.com/engineering/building-effective-agents, læst 7. september 2026. Den augmented model som building block, agent som en LLM, der »using tools based on environmental feedback in a loop«, og anbefalingen af stopping conditions »such as a maximum number of iterations« for at bevare kontrol. Kapitel 22 citerer definitionen i fuld længde.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. og Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Det andet loop — serving scheduleren, der batcher din request med fremmedes requests og administrerer KV cache fra Kapitel 13. Det er værd at vide, at den findes, netop fordi den ikke er din: den latency, dit harness multiplicerer, sættes inde i den, og intet arbejde på dit loop flytter den.

  7. npm registry-downloadtal, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, et eksplicit vindue snarere end det rullende last-month-vindue, og release-historikker fra registry.npmjs.org/<package>; begge forespurgt 7. september 2026. Release counts er antallet af versioner udgivet i de tolv måneder frem til den dato, canary builds inkluderet: ai 945 (seneste 7.0.93 den 2026-09-04, med major versions 5, 6 og 7 alle inden for vinduet), langchain 132 (seneste 1.5.10 den 2026-08-20), @openai/agents 83 (seneste 0.17.0 den 2026-08-19, først udgivet 2025-06-03). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) er Claude Code harness pakket som et library — agent loop, indbyggede fil- og shell-værktøjer, context management, sessions, hooks, permissions og subagents — dokumenteret på code.claude.com/docs/en/agent-sdk. Det er det nærmeste på en publiceret gennemgang af hver mekanisme, dette kapitel bygger i hånden, og det er værd at læse ved siden af din egen implementering for de dele, det navngiver, som dette kapitel kun peger mod.

Klar til at lade LIA vælge for dig?

Byg med alle AI-modeller ét sted — kom gratis i gang i dag.