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ą.
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.
Pętla, która działa
Link do sekcji: Pętla, która działaOto cała idea, zanim pojawi się cokolwiek, co czyni ją zdatną do przeżycia.
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:
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, 342Trzy 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ńczySkieruj 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 tur | wywołania modelu | input tokens | koszt |
|---|---|---|---|
| 8 | 8 | 3,431 | $0.009070 |
| 20 | 20 | 16,259 | $0.038038 |
| 50 | 50 | 88,649 | $0.191098 |
| 100 | 100 | 337,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 niesie ze sobą każdą wcześniejszą turę, a suma to . 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ędzyProblem 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:
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żet | osiągnięte tury | faktycznie wydane |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $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 jedenPę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ńczy | kto zdecydował | co powinien zrobić caller |
|---|---|---|
| model przestał pytać | model | przeczytać odpowiedź |
| limit tur | ty, z góry | podnieść limit albo zaakceptować częściowy wynik |
| budżet wyczerpany | ty, z góry | zatwierdzić więcej pieniędzy albo zaakceptować częściowy wynik |
| błąd, którego nie da się ponowić | provider albo narzędzie | naprawić deployment; triage z rozdziału 14 decyduje |
| interweniował człowiek | osoba | poczekać 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:
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 zawodziRozdział 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łędem | tury | uruchomienia narzędzi | koszt | co dostał użytkownik |
|---|---|---|---|---|
| wyrzuca go z pętli | 1 | 1 | $0.000756 | stack trace |
zwraca Error: the tool failed. | 2 | 1 | $0.001462 | „Nie mogłem odczytać pliku, więc nie wiem.” |
| zwraca to, co naprawdę się stało | 4 | 3 | $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ą:
} 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ść:
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 razyTeraz 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:
| tury | uruchomienia narzędzi | koszt | |
|---|---|---|---|
| zadanie, bez powtórki | 2 | 1 | $0.001396 |
| to samo zadanie, jedno wywołanie powtórzone | 3 | 2 | $0.002446 |
| powtórzone, z cache wyniku dla narzędzi read-only | 3 | 1 | $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:
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.
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;
}Awaria piąta: coś usuwa
Link do sekcji: Awaria piąta: coś usuwaSkrypt 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ć:
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) });
}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:
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ą.
Awaria szósta: proces umiera
Link do sekcji: Awaria szósta: proces umieraApproval 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ą:
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:
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:
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.logDwa 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.
Awaria siódma: trzy minuty ciszy
Link do sekcji: Awaria siódma: trzy minuty ciszyscan_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:
result = await tool.run(JSON.parse(c.function.arguments), {
signal,
progress: (label) => { trace(state.runId, "tool_progress", { toolName: tool.name, label }); opts.onProgress?.(label); },
});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.
Trace i dlaczego nie jest logiem
Link do sekcji: Trace i dlaczego nie jest logiemHarness 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.
{"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.
Arytmetyka latencji
Link do sekcji: Arytmetyka latencjiRozdział 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ł:
To samo trzyturowe zadanie, zmienia się tylko latencja providera:
| latencja providera na turę | czas ścienny, 3 tury |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
Sam harness dokłada piętnaście milisekund do trzyturowego runu. Cała reszta to pomnożone przez liczbę, której nie kontrolujesz — ustawioną wewnątrz serving schedulera, który batchuje twoje żądanie z żądaniami obcych osób6 — a 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 portemWszystko 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:
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,908msTrzy 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óźniejJedno 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:
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
| package | pobrania w tym miesiącu | co ci daje |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent, stopWhen, approval narzędzi, step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | Claude Code harness jako biblioteka: pętla, sessions, hooks, permissions, subagents8 |
@langchain/langgraph | 12,812,815 | pętla jako jawny graf stanu |
langchain | 11,359,058 | chains, agents, integrations |
@openai/agents | 6,093,155 | agents, handoffs, guardrails |
@mastra/core | 5,914,502 | agents, 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
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsZatrzymywanie 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.
Dokąd to prowadzi dalej
Link do sekcji: Dokąd to prowadzi dalejMasz 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.
Źródła i metoda
Link do sekcji: Źródła i metodaKaż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.
Przypisy
Link do sekcji: Przypisy-
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. ↩
-
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. ↩
-
ai(Vercel AI SDK) wersja 7.0.93, opublikowana 4 września 2026 r.; deklaracje typów odczytane zcdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts7 września 2026 r. Plik 397 KB zawiera zero wystąpień ciąguharness. Klasa agent todeclare class ToolLoopAgent, eksportowana zarówno jakoToolLoopAgent, jak iExperimental_Agent;declare function isStepCount(stepCount: number)— eksportowane jakostepCountIs— jest zacytowane dosłownie powyżej;type StopConditionpokazano bez drugiego parametru typu (RUNTIME_CONTEXT extends Context = Context), co jest jedynym skrótem w fragmencie, podobnie jak kształtstopWhen?: Arrayable<StopCondition<...>>nagenerateTextistreamText. Ten sam plik deklarujetoolApproval,ToolApprovalStatus,prepareStepirepairToolCall, co oznacza, że implementacja referencyjna niezależnie doszła do approval gates, per-step preparation i error repair. ↩ ↩2 -
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 topython -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. ↩ -
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. ↩ -
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. ↩
-
Liczby pobrań z rejestru npm,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, jawne okno zamiast kroczącegolast-month, oraz historie wydań zregistry.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:ai945 (najnowsza 7.0.93 z 2026-09-04, z wersjami major 5, 6 i 7 pojawiającymi się w tym oknie),langchain132 (najnowsza 1.5.10 z 2026-08-20),@openai/agents83 (najnowsza 0.17.0 z 2026-08-19, pierwsza publikacja 2025-06-03). ↩ ↩2 -
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 podcode.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. ↩