Przejdź do treści
23/30Rozdział 23 z 30

Zbuduj agent harness: pętla i pięć dróg wyjścia

Piętnastolinijkowa pętla, która działa od razu, a potem siedem celowych awarii — od runaway kosztującego 77 razy więcej.

Na tej stronie

Zacznijmy od uczciwej części, bo nikt inny tego nie powie: „harness” to żargon, nie standard. Nie ma specyfikacji, komitetu ani definicji referencyjnej. Cztery prace cytowane w tym rozdziale — ReAct,1 CoALA,2 SWE-bench i vLLM — nie używają tego słowa ani razu w swoich abstraktach. Najczęściej pobierana implementacja tej rzeczy, pakiet Vercel ai z 89,4 mln pobrań miesięcznie, też go nie używa: ciąg harness pojawia się zero razy w 397 KB deklaracji typów dostarczanych z wersją 7.0.93.3 Jedyne miejsce, w którym to słowo naprawdę coś niesie, oznacza zupełnie coś innego. SWE-bench mówi „harness” pięć razy w README, zawsze jako evaluation harness — skonteneryzowany szkielet, który aplikuje patch i uruchamia testy — a jego moduł Python dosłownie nazywa się swebench.harness.run_evaluation.4

Tak więc dwie różne rzeczy mają tę samą nazwę. Evaluation harness unieruchamia agent i go ocenia. Agent harness to program, który uruchamia agent: wywołuje model, wykonuje to, o co model prosi, decyduje, kiedy przerwać, i przechowuje stan pomiędzy krokami. Ten rozdział buduje tę drugą rzecz, w mniej niż dwustu linijkach TypeScript, bez żadnego frameworka.

Sama pętla ma piętnaście linijek i działa za pierwszym razem. Wszystko potem jest sposobem, by ją opuścić.

Pokaż szczegóły

Czego ten rozdział potrzebuje z wcześniejszych.

  • Rozdział 14 dla klienta: deadliny, triage statusów, anulowanie, klucze idempotency i technika mock providera używana tu ponownie.
  • Rozdział 16 dla arytmetyki: input tokens rosną z kwadratem rozmowy, a stawki używane niżej to te odczytane tam 6 września 2026 r.
  • Rozdział 18 dla katalogu narzędzi: schema, którą widzi model, endpoint, którego nigdy nie widzi, oraz zasada, że błędy są kontekstem, a nie wyjątkami.
  • Rozdział 22 dla pętli, którą ten rozdział dziedziczy, i dla dwóch opublikowanych definicji „agent”, które sobie przeczą.

Nie ma tu tensorów. To drugi hub zależności w kursie: rozdziały 24, 25, 29 i 30 działają na pliku poniżej, a 26–28 budują na tym, do czego potrafi sięgnąć.

Provider, który możesz oskryptować

Link do sekcji: Provider, który możesz oskryptować

Rozdziału 14 nie dało się napisać przeciwko prawdziwemu providerowi, bo nie możesz poprosić go o 429 w wybranym momencie. Ten rozdział ma ten sam problem w innym kształcie: nie możesz poprosić prawdziwego modelu, żeby uciekł w nieskończoność albo żeby zażądał identycznego narzędzia dwa razy z rzędu, na żądanie i powtarzalnie.

Pierwszy program to więc scripted provider: endpoint o kształcie chat completions API, którego odpowiedź jest funkcją indeksu tury i tego, co dotąd zwróciły narzędzia. Liczy token prawdziwym byte-pair encoderem, więc pieniądze poniżej są arytmetyką, a nie dekoracją.

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

Dwie linie niosą projekt. Indeks tury jest wyprowadzany z rozmowy, a nie trzymany w zmiennej, więc provider jest bezstanowy i run można zabić oraz wznowić względem niego. A recover czyta wyniki narzędzi przed podjęciem decyzji: oskryptowany model, który czyta własny transcript, to minimum potrzebne, by zmierzyć, czy harness dał mu cokolwiek wartego czytania.

Katalog jest katalogiem z rozdziału 18: cztery narzędzia w trzech plikach: list_files, read_file, delete_file — oznaczone needsApproval — oraz scan_archive, celowo wolne.

Oto cała idea, zanim pojawi się cokolwiek, co czyni ją zdatną do przeżycia.

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

Skieruj ją na scripted provider i robi dokładnie to, na co wygląda:

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

Trzy tury, dwa wykonania narzędzi, ćwierć amerykańskiego centa. Zwróć uwagę na ostatnią linię: 204, 269, 342. Każda tura wysyła ponownie wszystko, co było przed nią — to kwadratowy rachunek z rozdziału 16 przychodzący w miejscu, w którym nikt niczego nie wpisał. Reszta tego rozdziału opowiada o tym, co się dzieje, gdy ta linia nie przestaje rosnąć.

Awaria pierwsza: zadanie, które nigdy się nie kończy

Link do sekcji: Awaria pierwsza: zadanie, które nigdy się nie kończy

Skieruj tę samą pętlę na skrypt runaway — model, który prosi o narzędzie w każdej turze i nigdy nie emituje prozy — a oznaczone return nigdy się nie odpala. Nie ma innego wyjścia. Program działa, dopóki nie umrze proces albo karta kredytowa.

Poprawka to jedna linia, pierwsza kontrola rekomendowana przez literaturę,5 i każdy w końcu ją pisze. Prawie nikt nie mierzy jednak, ile jest warta:

limit turwywołania modeluinput tokenskoszt
883,431$0.009070
202016,259$0.038038
505088,649$0.191098
100100337,299$0.702198

Przeczytaj razem dwa ostatnie wiersze. Podwojenie limitu z 50 do 100 nie podwoiło kosztu; pomnożyło go przez 3,7. Input tokens wzrosły z 88,649 do 337,299, czyli 3,8 raza, bo tura nn niesie ze sobą każdą wcześniejszą turę, a suma to Θ(n2)\Theta(n^2). Limit tur nie jest liniowym pokrętłem. To pokrętło na pierwiastku kwadratowym najgorszego przypadku, dlatego podniesienie go z 20 do 100 „dla bezpieczeństwa” jest decyzją, którą warto wycenić, zanim ją podejmiesz.

Awaria druga: limit tur nie jest limitem pieniędzy

Link do sekcji: Awaria druga: limit tur nie jest limitem pieniędzy

Problem z limitem tur polega na tym, że tura nie ma stałej ceny. Dwadzieścia tur na krótkim transcripcie kosztowało powyżej $0.038. Dwadzieścia tur z katalogiem 200 narzędzi, zbiorem pobranych dokumentów i czterdziestoma wiadomościami historii kosztuje setki razy więcej, a limit o tym nie wie. Operator chce ograniczyć rachunek.

Pętla liczy więc pieniądze, używając computeCost z rozdziału 16 względem odczytanych tam stawek — $2.00 za milion input tokens i $12.00 za milion output, dla modelu wycenianego w całym tym kursie:

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

Ten sam runaway script, bez żadnego limitu tur, trzy budżety:

budżetosiągnięte turyfaktycznie wydane
$0.019$0.010780
$0.0524$0.051790
$0.2052$0.205398

Dwie rzeczy warto nazwać. Po pierwsze, budżet kupuje za każdym razem inną liczbę tur, i o to chodzi: ogranicza to, na czym zależy operatorowi, a liczbę tur zostawia tam, gdzie stawia ją transcript. Po drugie, każdy wiersz przekracza budżet. Budżet wynosił $0.010, a wydano $0.010780, bo sprawdzenie działa przed turą, a cena tury jest znana dopiero po jej zakończeniu. Nie da się dokładnie ograniczyć wydatku; da się ograniczyć go do kosztu jednej tury. Powiedz to w interfejsie zamiast udawać i umieść sprawdzenie przed wywołaniem, żeby przekroczenie wynosiło jedną turę, a nie dwie.

Pięć sposobów opuszczenia pętli, nie jeden

Link do sekcji: Pięć sposobów opuszczenia pętli, nie jeden

Pętla ma już trzy wyjścia i widać kształt reszty rozdziału. Produkcyjny run kończy się dokładnie na jeden z pięciu sposobów, które nie są wariantami tego samego:

jak się kończykto zdecydowałco powinien zrobić caller
model przestał pytaćmodelprzeczytać odpowiedź
limit turty, z górypodnieść limit albo zaakceptować częściowy wynik
budżet wyczerpanyty, z góryzatwierdzić więcej pieniędzy albo zaakceptować częściowy wynik
błąd, którego nie da się ponowićprovider albo narzędzienaprawić deployment; triage z rozdziału 14 decyduje
interweniował człowiekosobapoczekać na werdykt, potem wznowić

Sprowadzenie tego do jednego boolean to najczęstszy błąd projektowy w tym pliku, i jest kosztowny w bardzo konkretny sposób: trzy z pięciu są wznawialne, a dwa nie. Agent, który uderzył w limit tur, ma poprawny transcript, prawdziwy częściowy wynik i następny krok; agent, który dostał 401, nie ma żadnej z tych rzeczy. Dlatego harness zapisuje powód jako dane:

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

Awaria trzecia: narzędzie zawodzi

Link do sekcji: Awaria trzecia: narzędzie zawodzi

Rozdział 18 zakończył się twierdzeniem bez liczby: przekaż błąd narzędzia z powrotem do modelu jako wynik narzędzia zamiast go rzucać, a model zwykle sam się poprawia. Oto liczba.

Jedna awaria, trzy polityki. Oskryptowany model zgaduje plik, który nie istnieje; narzędzie rzuca no such file: timeout.log. Call list_files to see what exists.

co harness robi z błędemturyuruchomienia narzędzikosztco dostał użytkownik
wyrzuca go z pętli11$0.000756stack trace
zwraca Error: the tool failed.21$0.001462„Nie mogłem odczytać pliku, więc nie wiem.”
zwraca to, co naprawdę się stało43$0.003550„errors.log wspomina o timeoucie.”

Trzeci wiersz kosztuje 4,7 raza więcej niż pierwszy i jako jedyny odpowiada na pytanie. A drugi wiersz jest ciekawy, bo tak właśnie działa większość codebase’ów: błąd został złapany, pętla przetrwała, modelowi powiedziano, że coś się nie udało, a nie co, więc uprzejmie się poddał. Różnica między wierszami drugim i trzecim nie polega na obsłudze błędów. To zdanie napisane dla czytelnika.

Harness traktuje więc rzucone narzędzie jako dane, a brzmienie czyni polityką:

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

Rozdział 18 ostrzegał też przed drugą stroną, a ona również ma cenę. Skieruj pętlę na narzędzie, które zawodzi z powodu, którego żadna wiadomość nie może naprawić — odczyt, którego procesowi nie wolno wykonać — a model będzie próbował w nieskończoność:

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

Jedenaście identycznych wykonań wywołania, które nie może się udać, 5,2 raza kosztu runu, który podniósł się po naprawialnym błędzie, i nic na końcu. Błędy są kontekstem; trwały błąd jest kontekstem, który zatruwa resztę runu. To rozróżnienie to triage statusów z rozdziału 14 przesunięty o warstwę wyżej: błąd, na którym model może działać, wraca do transcriptu, a błąd, na którym nie może, powinien zatrzymać run z powodem. Limit tur jest dziś tym, co stoi między tobą a drugim przypadkiem — podłogą, nie poprawką.

Awaria czwarta: to samo wywołanie, dwa razy

Link do sekcji: Awaria czwarta: to samo wywołanie, dwa razy

Teraz awaria, o której większość ludzi zakłada, że nie może się zdarzyć. Modele się powtarzają. Poproś dowolną pętlę, by działała wystarczająco długo, a zobaczysz identyczne narzędzie z identycznymi argumentami w dwóch kolejnych turach.

Zmierzono względem baseline’u tego samego zadania bez powtórki:

turyuruchomienia narzędzikoszt
zadanie, bez powtórki21$0.001396
to samo zadanie, jedno wywołanie powtórzone32$0.002446
powtórzone, z cache wyniku dla narzędzi read-only31$0.002446

Zduplikowane wywołanie kosztowało dodatkowe $0.001050, wzrost o 75%, i tu jest część, która zaskakuje ludzi: cachowanie wyniku nie odzyskało niczego. Deduplication oszczędziła wykonanie narzędzia, a nie turę, bo zanim twój kod zauważy powtórkę, model już dostał zapłatę za zadanie pytania. Oszczędność jest realna, gdy narzędzie jest wolne, rate-limited albo rozliczane za wywołanie — i wynosi zero w pozycji kosztowej, która urosła.

Istnieje gorsza wersja. Zastosuj ten sam cache do narzędzia, które zapisuje, a drugie wywołanie po cichu się nie wydarzy:

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

Które z nich jest poprawne? Żadne, w sposób możliwy do stwierdzenia. Protokół mówi, że to dwa wywołania: niosą dwie różne wartości tool_call_id. Argumenty mówią, że mogą być jednym. Harness, który decyduje przez porównanie stringów argumentów, pewnego dnia połknie drugą z dwóch identycznych, zamierzonych opłat — a rozdział 14 nazwał już jedyny mechanizm, który uczciwie to rozwiązuje: klucz idempotency wygenerowany per logiczna operacja przez warstwę, która wie, czym ta operacja jest. Dopóki narzędzie go nie niesie, defensywną domyślną opcją jest bramka read-only powyżej: cache’uj odczyty, wykonuj zapisy, a resztę zostaw idempotency samego zapisu.

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

Skrypt destructive listuje pliki, a potem prosi o usunięcie takiego, którego zadanie nigdy nie wspomniało. Nic w dotychczasowej pętli by go nie zatrzymało.

Narzędzie oznaczone needsApproval nie zawodzi i nie idzie dalej. Ono zatrzymuje run i oddaje kontrolę, z wszystkim, czego człowiek potrzebuje, by zdecydować:

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

To cały mechanizm, a powód, dla którego jest to return, a nie callback, jest w następnej sekcji: między zatrzymaniem a werdyktem proces może już nie istnieć.

Najpierw jednak pomiar, którego nikt się nie spodziewa. Odrzucenie nie jest brakiem wyniku — transcript ma slot kluczowany przez tool_call_id i coś musi do niego trafić. Uruchom to samo odrzucenie dwa razy, zmieniając tylko to, co to coś mówi:

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

W żadnym runie nic nie zostało usunięte, a w drugim użytkownik słyszy, że zostało. System uprawnień zadziałał perfekcyjnie; raport jest kłamstwem. To ten sam mechanizm co w tabeli błędów narzędzia, tylko przychodzący w miejscu znacznie ważniejszym — człowiek powiedział „nie”, akcja została poprawnie zablokowana, a podsumowanie agent przeczy rzeczywistości, bo odmowa nigdy nie została zapisana tam, gdzie model czyta. Reguła, która z tego wynika, jest krótka: cokolwiek twój kod zdecyduje o tool call, zapisz tę decyzję w transcripcie słowami. Rozdział 30 wraca do tego od strony bezpieczeństwa, gdzie jest to różnica między audit trailem a fikcją.

Approval trwa minuty albo godziny. Deploy trwa sekundy. Jeśli run żyje w zmiennej lokalnej wewnątrz żądania HTTP, każdy restart to utracony run, a każde approval to wyścig.

Run nie jest więc closure. To zwykły serializowalny obiekt — wiadomości, licznik tur, koszt, status, interruption, lista zatwierdzonych call ids — a pętla jest czystą funkcją nad nim. To jedno ograniczenie sprawia, że persistence staje się jednowierszową sprawą:

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

Pytanie o poprawność nie dotyczy zapisywania. Dotyczy tego, co dzieje się przy powrocie, a naiwna odpowiedź nalicza ci podwójnie. Jeśli proces umarł po tym, jak model poprosił o narzędzie, ale przed zapisaniem wyniku, wznowienie, które zaczyna od ponownego wywołania modelu, płaci za turę, którą już ma — a jeśli zaczyna od ponownego uruchomienia narzędzi, wykonuje zapis dwa razy.

Poprawka polega na tym, żeby pętla zaczynała od zapytania transcriptu, co jest zaległe:

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

Każda iteracja najpierw opróżnia pending i pyta model dopiero wtedy, gdy nie ma nic zaległego. Resume staje się tą samą ścieżką kodu co normalny przebieg, podobnie jak approval — zatwierdzone wywołanie to po prostu pending call, który teraz wolno uruchomić. Zabij proces w połowie zadania i uruchom go ponownie:

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

Dwa wykonania narzędzi w dwóch procesach dla zadania, które potrzebuje dwóch, a końcowy koszt jest identyczny jak w runie, który nigdy się nie wywrócił. Koszt kumuluje się przez restart, bo był w stanie, a nie w zmiennej.

scan_archive trwa tu trzy sekundy i zastępuje narzędzie, które na produkcji trwa trzy minuty. W czasie jego działania brakuje dwóch rzeczy: użytkownik nie ma pojęcia, że cokolwiek się dzieje, a przycisk Stop nic nie robi.

Obie rzeczy naprawia to samo rozwiązanie, czyli AbortSignal z rozdziału 14 przesunięte poziom głębiej. Sygnał nie jest tylko dla fetcha — jest przekazywany do narzędzia, a dobrze napisane narzędzie go respektuje:

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"

Dwie milisekundy od kliknięcia do zatrzymania, bo sleep wewnątrz narzędzia nasłuchuje tego samego sygnału co fetch. Przekaż go tylko do fetch, a identyczny przycisk Stop będzie czekał trzy sekundy — długość narzędzia — i run „anuluje się” po tym, jak praca, którą anulował, już się zakończyła. Cancellation, które nie jest podpięte do samego dołu, to spinner mówiący właściwe słowo.

Harness emituje jedną linię na event, a słownik jest na tyle mały, że da się go zapamiętać: 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}

Trzy właściwości czynią z tego trace, a nie logging. Każda linia niesie run id, więc run rozciągnięty na trzy procesy i dwa dni jest jednym zapytaniem. Każda linia turn niesie własne liczniki token i koszt narastająco, więc na pytanie „dlaczego ten run kosztował czterdzieści dolarów” da się odpowiedzieć po fakcie, zamiast odtwarzać to tylko teoretycznie. A run_stopped niesie powód, czyli pole, które zmienia ticket do supportu w jednowierszową odpowiedź: agent zatrzymany na budżecie i agent, który się wywalił, wyglądają z zewnątrz identycznie i wymagają przeciwnych reakcji.

Rozdział 13 mierzył time to first token na sprzęcie, który posiadasz. Rozdział 14 mierzył go przez socket. Agent go mnoży, a mnożnik jest liczbą, której nikt nie wybrał:

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

To samo trzyturowe zadanie, zmienia się tylko latencja providera:

latencja providera na turęczas ścienny, 3 tury
0 ms15 ms
200 ms615 ms
800 ms2,413 ms

Sam harness dokłada piętnaście milisekund do trzyturowego runu. Cała reszta to NN pomnożone przez liczbę, której nie kontrolujesz — ustawioną wewnątrz serving schedulera, który batchuje twoje żądanie z żądaniami obcych osób6 — a NN wybiera model. Dlatego streaming z rozdziału 14 ma tu większe znaczenie niż w chacie i pomaga mniej: możesz streamować ostatnią turę, a cztery tury przed nią są ciszą, chyba że harness emituje postęp. To także cały argument za eventem tool_progress powyżej — w agent uczciwą jednostką feedbacku nie jest token, tylko krok.

Ten sam harness, prawdziwy model za portem

Link do sekcji: Ten sam harness, prawdziwy model za portem

Wszystko powyżej działało przeciwko scripted providerowi, co dowodzi harness i nie dowodzi niczego o modelach. Zmień więc jedną linię — seam z rozdziału 14, LLM_BASE_URL — i skieruj identyczny kod na lokalny Qwen2.5-0.5B-Instruct z tymi samymi czterema narzędziami. Sześć zadań na tych samych trzech plikach:

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

Trzy obserwacje, a trzecia jest powodem istnienia tej sekcji.

Każde pojedyncze zadanie skończyło się dokładnie w dwóch turach. Limit tur nigdy się nie odpalił, budżet nigdy się nie odpalił, a jedynym wyjściem pętli było wyprodukowanie prozy przez model. Model o pół miliarda parametrów nie iteruje; odpowiada na drugim oddechu niezależnie od tego, czy ma to, czego potrzebuje. Liczba tur jest właściwością modelu, nie twojej pętli.

Średnia tura trwała 6,908 milisekund, więc tabela latencji powyżej nie jest zabawką: przy takim rozmiarze hipotetyczny ośmioturowy run to prawie minuta czasu ściennego bez niczego na ekranie.

A odpowiedzi są błędne. Największy plik to errors.log; model wylistował pliki, nigdy ich nie odczytał i mimo to wskazał jeden. Pierwsze zadanie zgadło nazwę pliku, dowiedziało się, że nie istnieje, i zakończyło. Harness wykonał się bezbłędnie we wszystkich sześciu runach. Harness czyni agent sterowalnym, nie poprawnym — rozdział 29 pokazuje, jak odkryć, które z tych dwóch, a rozdział 30 ile kosztuje, gdy nikt tego nie zrobił.

Subagents, nazwani tutaj i rozliczeni później

Link do sekcji: Subagents, nazwani tutaj i rozliczeni później

Jedno narzędzie w katalogu może mieć za sobą kolejny run. Interfejs jest z rozdziału 18 — schema i endpoint — a cały agent mieści się za nim, bo interfejs jest wąski:

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

Trzy rzeczy są już poprawne w tych dziesięciu linijkach i wszystkie trzy wynikają z decyzji podjętych wyżej: child ma własne okno, więc transcript parenta dostaje summary zamiast wszystkiego, co child przeczytał; ma własne limity, więc runaway child nie może wydać budżetu parenta; i dziedziczy signal, więc jeden Stop anuluje drzewo. Dlaczego czyste okno jest sensem, a nie efektem ubocznym, wyjaśnia rozdział 24; pięć wzorców orchestration — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — oraz handoff są w rozdziale 25.

Gdzie są frameworki i dlaczego ten kurs żadnego nie użył

Link do sekcji: Gdzie są frameworki i dlaczego ten kurs żadnego nie użył

Nic z powyższego nie powinno być czytane jako argument przeciwko bibliotekom. Zmierzone 7 września 2026 r., dla miesiąca kończącego się 29 sierpnia:7

packagepobrania w tym miesiącuco ci daje
ai (Vercel AI SDK)89,385,860ToolLoopAgent, stopWhen, approval narzędzi, step hooks
@anthropic-ai/claude-agent-sdk41,558,352Claude Code harness jako biblioteka: pętla, sessions, hooks, permissions, subagents8
@langchain/langgraph12,812,815pętla jako jawny graf stanu
langchain11,359,058chains, agents, integrations
@openai/agents6,093,155agents, handoffs, guardrails
@mastra/core5,914,502agents, workflows, memory

Powód, dla którego ten kurs pisze pętlę ręcznie zamiast uczyć jednego z nich, jest zadeklarowany, nie zasugerowany, i jest mierzalny. W dwunastu miesiącach do 7 września 2026 r. ai opublikował 945 wersji i przeszedł z major 5 do major 7, a jego klasa agent nadal jest eksportowana jako Experimental_Agent; langchain opublikował 132 wersje w tym samym oknie; @openai/agents opublikował 83 i nadal jest na 0.x, piętnaście miesięcy po pierwszym wydaniu.7 Rozdział napisany przeciwko któremukolwiek z tych API starzeje się w jeden sezon, a ten jest publikowany w trzydziestu trzech językach, więc każda reedycja kosztuje całą translację. To, co leży pod nimi wszystkimi, się nie rusza: pętla, stopping rule, katalog, executor, trochę stanu.

A implementacja referencyjna zgadza się z tym rozdziałem co do części, która ma znaczenie. W ai w wersji 7.0.93 wyjście z pętli nie jest liczbą — to stopWhen, lista predykatów, z których liczba kroków jest tylko jednym: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

Zatrzymywanie jest mnogie w najczęściej używanej implementacji tej pętli z tego samego powodu, dla którego jest mnogie w stu dziewięćdziesięciu sześciu linijkach powyżej.

Masz teraz harness: pętlę, katalog, executor, pięć wyjść, utrwalony run, signal docierający do narzędzi i trace z run id w każdej linii. Rozdziały 24, 25, 29 i 30 budują na tym pliku, a 26–28 na tym, do czego potrafi sięgnąć.

Został mu jeden problem, a pomiary powyżej wskazywały na niego przez cały czas. Spójrz jeszcze raz na tabelę runaway: 3,431 input tokens przy ośmiu turach, 337,299 przy stu. Spójrz na działający run: 204, 269, 342. Każda tura wysyła ponownie cały transcript, więc kontekst agent wypełnia się jego własną historią — a model gorzej używa dalekiego końca długiego okna niż bliskiego, dlatego dobry agent w turze piątej jest zagubiony w turze czterdziestej.

Limit tur tego nie naprawia. Po prostu powstrzymuje cię przed płaceniem za oglądanie, jak to się dzieje. Naprawia to decyzja, w każdej pojedynczej turze, które token zasługują na okno: co skompaktować, co przenieść do notatki, którą agent może pobrać, co przekazać do subagent z czystym oknem i które definicje narzędzi są warte swojego stałego podatku. Rozdział 24 mierzy, dokąd okno naprawdę trafia — a niespodzianka polega na tym, że nie do rozmowy.


Każda liczba w tym rozdziale wyszła z dwóch serwerów opisanych powyżej, na Node 22 przez interfejs loopback: scripted provider liczący token z kodowaniem o200k_base oraz Qwen/Qwen2.5-0.5B-Instruct za endpointem o tym samym kształcie, greedy decoding, na CPU. Koszty są obliczane z mierzonych liczników token według stawek odczytanych w rozdziale 16 dnia 6 września 2026 r. — $2.00 za milion input tokens i $12.00 za milion output — i żadne żądanie w tym rozdziale nie trafiło do płatnego endpointu. Odpowiedzi lokalnego modelu są odpowiedziami małego modelu; czytaj je jako dowód dotyczący pętli, która jest identyczna w obu przypadkach, a nie jako benchmark tego, co robią obecne modele.

  1. Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. i Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). Przeplatanie reasoning traces i akcji, które implementuje pętla, oraz źródło obserwacji, że działanie pozwala modelowi „handle exceptions” — czyli dokładnie to, co mierzy tabela błędów narzędzia powyżej.

  2. Sumers, T. R., Yao, S., Narasimhan, K. i Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). Formalne ujęcie tego, co powyższa pętla robi nieformalnie: modularne komponenty pamięci, uporządkowana przestrzeń akcji obejmująca pamięć wewnętrzną i środowiska zewnętrzne oraz „a generalized decision-making process to choose actions”. Czytaj dla słownika, którego brakuje terminowi branżowemu — zwłaszcza rozdzielenia pamięci working, episodic, semantic i procedural, którego praktycznym cieniem jest trzyczęściowa tabela store’ów w rozdziale 24.

  3. ai (Vercel AI SDK) wersja 7.0.93, opublikowana 4 września 2026 r.; deklaracje typów odczytane z cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts 7 września 2026 r. Plik 397 KB zawiera zero wystąpień ciągu harness. Klasa agent to declare class ToolLoopAgent, eksportowana zarówno jako ToolLoopAgent, jak i Experimental_Agent; declare function isStepCount(stepCount: number) — eksportowane jako stepCountIs — jest zacytowane dosłownie powyżej; type StopCondition pokazano bez drugiego parametru typu (RUNTIME_CONTEXT extends Context = Context), co jest jedynym skrótem w fragmencie, podobnie jak kształt stopWhen?: Arrayable<StopCondition<...>> na generateText i streamText. Ten sam plik deklaruje toolApproval, ToolApprovalStatus, prepareStep i repairToolCall, co oznacza, że implementacja referencyjna niezależnie doszła do approval gates, per-step preparation i error repair. 2

  4. Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. i Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). Abstrakt nazywa artefakt „evaluation framework” obejmującym 2,294 problemy i nigdy nie używa słowa „harness”; własne README projektu (github.com/SWE-bench/SWE-bench, odczytane 7 września 2026 r.) używa go pięć razy, zawsze jako „evaluation harness”, a punkt wejścia to python -m swebench.harness.run_evaluation. To drugie znaczenie tego słowa: szkielet, który unieruchamia agent i go ocenia, nie pętla, która go uruchamia.

  5. Anthropic, Building effective agents, 19 grudnia 2024 r., anthropic.com/engineering/building-effective-agents, odczytane 7 września 2026 r. Augmented model jako building block, agent jako LLM „using tools based on environmental feedback in a loop” oraz rekomendacja stopping conditions „such as a maximum number of iterations”, aby utrzymać kontrolę. Rozdział 22 cytuje tę definicję w całości.

  6. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. i Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). Druga pętla — serving scheduler, który batchuje twoje żądanie z żądaniami obcych osób i zarządza KV cache z rozdziału 13. Warto wiedzieć, że istnieje, właśnie dlatego, że nie należy do ciebie: latencja mnożona przez twój harness jest ustawiana wewnątrz niej, i żadna ilość pracy nad twoją pętlą tego nie przesunie.

  7. Liczby pobrań z rejestru npm, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, jawne okno zamiast kroczącego last-month, oraz historie wydań z registry.npmjs.org/<package>; oba zapytane 7 września 2026 r. Liczby wydań to liczba wersji opublikowanych w dwunastu miesiącach do tej daty, wraz z buildami canary: ai 945 (najnowsza 7.0.93 z 2026-09-04, z wersjami major 5, 6 i 7 pojawiającymi się w tym oknie), langchain 132 (najnowsza 1.5.10 z 2026-08-20), @openai/agents 83 (najnowsza 0.17.0 z 2026-08-19, pierwsza publikacja 2025-06-03). 2

  8. Claude Agent SDK (@anthropic-ai/claude-agent-sdk) to Claude Code harness spakowany jako biblioteka — agent loop, wbudowane narzędzia plików i shell, context management, sessions, hooks, permissions i subagents — udokumentowany pod code.claude.com/docs/en/agent-sdk. To najbliższa opublikowana relacja z każdego mechanizmu, który ten rozdział buduje ręcznie, i warto czytać ją obok własnej implementacji dla części, które nazywa, a ten rozdział tylko sygnalizuje.

Gotowy, żeby to LIA wybierała za Ciebie?

Twórz ze wszystkimi modelami AI w jednym miejscu — zacznij dziś za darmo.