Ves al contingut
23/30Capítol 23 de 30

Construeix un agent harness: el bucle i les cinc maneres de sortir-ne

Un bucle de quinze línies que funciona a la primera, trencat set vegades expressament, començant amb una fuga 77 cops més cara.

En aquesta pàgina

Comença per la part honesta, perquè ningú més ho dirà: "harness" és argot, no un estàndard. No hi ha cap especificació, cap comitè, cap definició de referència. Els quatre articles que cita aquest capítol — ReAct,1 CoALA,2 SWE-bench i vLLM — no fan servir ni una sola vegada aquesta paraula als seus resums. La implementació més descarregada d’això, el paquet ai de Vercel, amb 89,4 milions de descàrregues al mes, tampoc no la fa servir: la cadena harness apareix zero vegades als 397 KB de declaracions de tipus distribuïdes amb la versió 7.0.93.3 L’únic lloc on la paraula que té pes significa una cosa completament diferent. SWE-bench diu "harness" cinc vegades al seu README, sempre com a evaluation harness — l’esquelet contenidoritzat que aplica un pedaç i executa les proves — i el seu mòdul de Python és literalment swebench.harness.run_evaluation.4

Així doncs, dues coses diferents comparteixen nom. Un evaluation harness immobilitza l’agent i el puntua. Un agent harness és el programa que executa l’agent: crida el model, executa el que el model demana, decideix quan s’ha d’aturar i manté l’estat entremig. Aquest capítol en construeix el segon, en menys de dues-centes línies de TypeScript, sense cap framework.

El bucle en si té quinze línies i funciona al primer intent. Tot el que ve després és una manera de sortir-ne.

Mostra els detalls

Què necessita aquest capítol dels anteriors.

  • Capítol 14 per al client: terminis, triatge d’estats, cancel·lació, claus d’idempotència i la tècnica del proveïdor simulat que es torna a fer servir aquí.
  • Capítol 16 per a l’aritmètica: els token d’entrada creixen amb el quadrat de la conversa, i les tarifes utilitzades més avall són les que s’hi van llegir el 6 de setembre de 2026.
  • Capítol 18 per al catàleg d’eines: un esquema que el model veu, un endpoint que no veu mai i la regla que els errors són context, no excepcions.
  • Capítol 22 pel bucle que aquest hereta i per les dues definicions publicades d’"agent" que no estan d’acord entre elles.

Aquí no hi ha tensors. Aquest és el segon node de dependències del curs: els capítols 24, 25, 29 i 30 s’executen sobre el fitxer de sota, i del 26 al 28 es construeixen sobre allò que pot abastar.

El capítol 14 no es podia escriure contra un proveïdor real, perquè no pots demanar-li un 429 en un moment escollit. Aquest capítol té el mateix problema amb una altra forma: no pots demanar a un model real que s’embali, o que sol·liciti la mateixa eina dues vegades seguides, a demanda i de manera reproduïble.

Així que el primer programa és un proveïdor guionitzat: un endpoint amb la forma d’una API de complecions de xat la resposta del qual és una funció de l’índex del torn i del que les eines han retornat fins ara. Compta token amb un codificador de parells de bytes real, de manera que els diners de més avall són aritmètica i no decoració.

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

Dues línies sostenen el disseny. L’índex de torn es deriva de la conversa, no es guarda en una variable, així que el proveïdor no té estat i una execució es pot matar i reprendre contra ell. I recover llegeix els resultats de les eines abans de decidir: un model guionitzat que llegeix la seva pròpia transcripció és el mínim necessari per mesurar si el harness li ha donat alguna cosa que valgui la pena llegir.

El catàleg és el del capítol 18, quatre eines sobre tres fitxers: list_files, read_file, delete_file — marcada com a needsApproval — i scan_archive, que és lenta expressament.

Aquí tens tota la idea, abans de qualsevol de les parts que la fan suportable.

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

Apunta’l al proveïdor guionitzat i fa exactament el que sembla que fa:

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

Tres torns, dues execucions d’eines, un quart de cèntim de dòlar. Fixa’t en l’última línia: 204, 269, 342. Cada torn reenvia tot el que tenia abans, que és la factura quadràtica del capítol 16 arribant a un lloc on ningú no ha escrit res. La resta d’aquest capítol és el que passa quan aquesta línia no deixa de créixer.

Apunta el mateix bucle al script runaway — un model que demana una eina a cada torn i no emet mai prosa — i el return marcat no s’activa mai. No hi ha cap altra sortida. El programa s’executa fins que mor el procés o la targeta de crèdit.

La solució és una línia, és el primer control que recomana la literatura,5 i tothom l’acaba escrivint. El que gairebé ningú no fa és mesurar què val:

límit de tornscrides al modeltoken d’entradacost
883.431$0,009070
202016.259$0,038038
505088.649$0,191098
100100337.299$0,702198

Llegeix les dues últimes files juntes. Doblar el límit de 50 a 100 no va doblar el cost; el va multiplicar per 3,7. Els token d’entrada van passar de 88.649 a 337.299, un factor de 3,8, perquè el torn nn porta tots els torns anteriors i el total és Θ(n2)\Theta(n^2). Un límit de torns no és un dial lineal. És un dial sobre l’arrel quadrada del teu pitjor cas, i per això pujar-lo de 20 a 100 "per anar sobre segur" és una decisió que val la pena posar preu abans de prendre-la.

Trencament dos: un límit de torns no és un límit de diners

Enllaç a la secció: Trencament dos: un límit de torns no és un límit de diners

El problema d’un límit de torns és que un torn no té un preu fix. Vint torns sobre una transcripció curta costen $0,038 a dalt. Vint torns amb un catàleg de 200 eines, un conjunt de documents recuperats i quaranta missatges d’historial costen centenars de vegades més, i el límit no ho sap. El que l’operador vol limitar és la factura.

Així que el bucle compta diners, fent servir el computeCost del capítol 16 contra les tarifes que s’hi van llegir — $2,00 per milió de token d’entrada i $12,00 per milió de sortida, per al model que es tarifa al llarg de tot aquest curs:

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

El mateix script desbocat, sense cap límit de torns, tres pressupostos:

pressuposttorns assolitsdespesa real
$0,019$0,010780
$0,0524$0,051790
$0,2052$0,205398

Val la pena posar nom a dues coses. Primer, el pressupost compra un nombre diferent de torns cada vegada, i aquesta és la idea: limita allò que importa a l’operador i deixa que el recompte de torns caigui on el posi la transcripció. Segon, totes les files se’n passen. El pressupost era de $0,010 i es van gastar $0,010780, perquè la comprovació s’executa abans d’un torn i el preu d’un torn no se sap fins que s’ha acabat. No pots limitar la despesa exactament; pots limitar-la dins del cost d’un torn. Digues-ho a la interfície en lloc de fingir, i posa la comprovació abans de la crida perquè l’excés sigui d’un torn i no de dos.

A aquestes altures el bucle té tres sortides, i la forma de la resta del capítol ja es veu. Una execució de producció acaba exactament d’una de cinc maneres, i no són variacions l’una de l’altra:

com acabaqui ho ha deciditquè ha de fer qui crida
el model ha deixat de demanarel modelllegir la resposta
límit de tornstu, per avançatpujar el límit o acceptar un resultat parcial
pressupost esgotattu, per avançataprovar més diners o acceptar un resultat parcial
un error que no pots reintentarel proveïdor o una einaarreglar el deploy; el triatge del capítol 14 decideix
ha intervingut una personauna personaesperar un veredicte i reprendre

Col·lapsar tot això en un sol booleà és l’error de disseny més habitual en aquest fitxer, i és car d’una manera concreta: tres de les cinc són reprenibles i dues no. Un agent que ha arribat al seu límit de torns té una transcripció vàlida, un resultat parcial real i un pas següent; un agent que ha rebut un 401 no té res d’això. Per tant, el harness registra el motiu com a dades:

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

El capítol 18 acabava amb una afirmació sense número: retorna l’error d’una eina al model com a resultat d’eina en lloc de llançar-lo, i el model normalment s’arregla sol. Aquí tens el número.

Un error, tres polítiques. El model guionitzat endevina un fitxer que no existeix; l’eina llança no such file: timeout.log. Call list_files to see what exists.

què fa el harness amb l’errortornsexecucions d’einacostquè obté l’usuari
el llança fora del bucle11$0,000756una traça de pila
retorna Error: the tool failed.21$0,001462"No he pogut llegir el fitxer, així que no ho sé."
retorna què ha passat realment43$0,003550"errors.log menciona un timeout."

La tercera fila costa 4,7 vegades la primera i és l’única que respon la pregunta. I la segona fila és la interessant, perquè és el que fan realment la majoria de bases de codi: l’error s’ha capturat, el bucle ha sobreviscut, al model se li ha dit que alguna cosa ha fallat i no què, i s’ha rendit educadament. La diferència entre les files dos i tres no és gestió d’errors. És una frase escrita per a un lector.

Per tant, el harness tracta una eina que llança com a dades i converteix la redacció en una política:

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

El capítol 18 també avisava de l’altra banda, i aquesta també té un preu. Apunta el bucle a una eina que falla per un motiu que cap missatge pot arreglar — una lectura que el procés no té permís per fer — i el model la reintenta per sempre:

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

Onze execucions idèntiques d’una crida que no pot tenir èxit, 5,2 vegades el cost de l’execució que es va recuperar d’un error arreglable, i res al final. Els errors són context; un error permanent és context que enverina la resta de l’execució. La distinció és el triatge d’estats del capítol 14 pujat un nivell: un error sobre el qual el model pot actuar torna a la transcripció, i un error sobre el qual no pot actuar hauria d’aturar l’execució amb un motiu. Avui, el límit de torns és el que s’interposa entre tu i el segon cas; això és un mínim, no una solució.

Trencament quatre: la mateixa crida, dues vegades

Enllaç a la secció: Trencament quatre: la mateixa crida, dues vegades

Ara ve l’error que la majoria de gent assumeix que no pot passar. Els models es repeteixen. Demana a qualsevol bucle que s’executi prou temps i veuràs la mateixa eina amb els mateixos arguments en dos torns consecutius.

Mesurat contra la línia base de la mateixa tasca sense la repetició:

tornsexecucions d’einacost
la tasca, sense repetició21$0,001396
la mateixa tasca, una crida repetida32$0,002446
repetida, amb una memòria cau de resultats en eines de només lectura31$0,002446

La crida duplicada va costar $0,001050 extra, un augment del 75 %, i aquí hi ha la part que sorprèn la gent: posar el resultat a la memòria cau no en va recuperar res. La deduplicació va estalviar l’execució de l’eina, no el torn, perquè quan el teu codi detecta la repetició el model ja ha cobrat per demanar-la. L’estalvi és real quan l’eina és lenta, té límits de quota o es factura per crida — i és zero a la partida que va créixer.

Hi ha una versió pitjor. Aplica la mateixa memòria cau a una eina que escriu, i la segona crida silenciosament no passa:

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

Quina d’aquestes és correcta? Cap, de manera demostrable. El protocol diu que són dues crides: porten dos valors tool_call_id diferents. Els arguments diuen que podrien ser una. Un harness que decideix comparant cadenes d’arguments algun dia s’empassarà la segona de dues càrregues idèntiques i intencionades — i el capítol 14 ja va posar nom a l’únic mecanisme que resol això honestament, que és una clau d’idempotència generada per operació lògica per la capa que sap què és l’operació. Fins que l’eina no en porti una, el valor per defecte defensable és la porta de només lectura de dalt: posa a la memòria cau les lectures, executa les escriptures i deixa que la idempotència pròpia de l’escriptura s’ocupi de la resta.

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

El script destructive llista els fitxers i després demana esborrar-ne un que la tasca no havia mencionat mai. Res del bucle fins ara no ho aturaria.

Una eina marcada com a needsApproval no falla i no continua. Atura l’execució i retorna el control, amb tot el que una persona necessita per decidir:

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

Aquest és tot el mecanisme, i la raó per la qual és un return i no un callback és la secció següent: entre l’aturada i el veredicte, el procés pot haver deixat d’existir.

Però primer, la mesura que ningú no espera. Un rebuig no és l’absència d’un resultat: la transcripció té una ranura indexada per tool_call_id i alguna cosa hi ha d’anar. Executa el mateix rebuig dues vegades, canviant només què diu aquesta cosa:

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

No es va esborrar res en cap de les dues execucions, i en la segona s’informa a l’usuari que sí. El sistema de permisos va funcionar perfectament; l’informe és una mentida. És el mateix mecanisme que a la taula d’errors d’eina, arribant a un lloc on importa molt més: una persona va dir que no, l’acció es va bloquejar correctament i el resum de l’agent contradiu la realitat perquè la negativa no es va escriure mai allà on el model llegeix. La regla que se’n deriva és curta: sigui el que sigui que el teu codi decideixi sobre una crida d’eina, escriu la decisió a la transcripció amb paraules. El capítol 30 hi torna des del costat de la seguretat, on és la diferència entre una pista d’auditoria i ficció.

Una aprovació triga minuts o hores. Un deploy triga segons. Si l’execució viu en una variable local dins d’una petició HTTP, cada reinici és una execució perduda i cada aprovació és una cursa.

Així que l’execució no és una clausura. És un objecte pla serialitzable — missatges, recompte de torns, cost, estat, interrupció, la llista d’identificadors de crides aprovades — i el bucle és una funció pura sobre ell. Aquesta única restricció és el que fa que la persistència sigui una qüestió d’una línia:

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

La pregunta de correcció no és desar. És què passa quan hi tornes a entrar, i la resposta ingènua et cobra dues vegades. Si el procés ha mort després que el model demanés una eina però abans que se n’escrivís el resultat, una represa que comença cridant el model de nou paga un torn que ja té — i si comença reexecutant les eines, fa una escriptura dues vegades.

La solució és fer que el bucle comenci preguntant a la transcripció què queda pendent:

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

Cada iteració buida primer pending i només pregunta al model quan no hi ha res pendent. Reprendre esdevé el mateix camí de codi que l’execució normal, i l’aprovació també: una crida aprovada és simplement una crida pendent que ara pot executar-se. Mata el procés a mitja tasca i reinicia’l:

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

Dues execucions d’eina en dos processos per a una tasca que en necessita dues, i el cost final és idèntic al de l’execució que no va fallar mai. El cost s’acumula a través del reinici perquè era a l’estat, no en una variable.

scan_archive triga tres segons aquí i representa l’eina que triga tres minuts a producció. Mentre s’executa falten dues coses: l’usuari no té ni idea que estigui passant res, i el botó Atura no fa res.

Totes dues tenen la mateixa solució, i és el AbortSignal del capítol 14 empès un nivell més avall. El senyal no és només per al fetch: es passa dins de l’eina, i una eina ben escrita el respecta:

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"

Dos mil·lisegons des del clic fins a l’aturada, perquè l’espera dins de l’eina escolta el mateix senyal que el fetch. Fes-lo arribar només a fetch i el mateix botó Atura espera tres segons — la durada de l’eina — i l’execució es "cancel·la" després que la feina que estava cancel·lant ja hagi acabat. Una cancel·lació que no està connectada fins al fons és un indicador de càrrega que diu la paraula correcta.

El harness emet una línia per esdeveniment, i el vocabulari és prou petit per memoritzar-lo: 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}

Tres propietats fan que això sigui una traça i no logging. Cada línia porta el run id, així que una execució que abasta tres processos i dos dies és una sola consulta. Cada línia turn porta els seus propis recomptes de token i el cost acumulat, així que "per què aquesta execució va costar quaranta dòlars" es pot respondre a posteriori en lloc de ser reproduïble només en teoria. I run_stopped porta el motiu, que és el camp que converteix un tiquet de suport en una resposta d’una línia: un agent que s’ha aturat pel pressupost i un agent que ha petat semblen idèntics des de fora i necessiten respostes oposades.

El capítol 13 va mesurar el temps fins al primer token en maquinari propi. El capítol 14 el va mesurar a través d’un socket. Un agent el multiplica, i el multiplicador és un nombre que ningú no ha triat:

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

La mateixa tasca de tres torns, canviant només la latència del proveïdor:

latència del proveïdor per torntemps real, 3 torns
0 ms15 ms
200 ms615 ms
800 ms2.413 ms

El harness en si aporta quinze mil·lisegons a una execució de tres torns. Tota la resta és NN multiplicat per un nombre que no controles — definit dins d’un planificador de servei que agrupa la teva petició amb les peticions de desconeguts6 — i NN el tria el model. Per això l’streaming del capítol 14 importa més aquí que en un xat i ajuda menys: pots fer streaming del torn final, i els quatre torns anteriors són silenci tret que el harness emeti progrés. També és tot l’argument per a l’esdeveniment tool_progress de dalt: en un agent, la unitat honesta de feedback no és el token, és el pas.

El mateix harness, un model real darrere del port

Enllaç a la secció: El mateix harness, un model real darrere del port

Tot el que hi ha a dalt s’ha executat contra un proveïdor guionitzat, cosa que demostra el harness i no demostra res sobre els models. Així que canvia una línia — la costura del capítol 14, LLM_BASE_URL — i apunta el mateix codi a un Qwen2.5-0.5B-Instruct local amb les mateixes quatre eines. Sis tasques sobre els mateixos tres fitxers:

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

Tres conclusions, i la tercera és el motiu pel qual existeix aquesta secció.

Totes i cadascuna de les tasques van acabar exactament en dos torns. El límit de torns no es va activar mai, el pressupost no es va activar mai, i l’única sortida del bucle va ser que el model produís prosa. Un model de mig bilió de paràmetres no itera; respon al segon alè tant si té el que necessita com si no. El recompte de torns és una propietat del model, no del teu bucle.

El torn mitjà va trigar 6.908 mil·lisegons, així que la taula de latència de dalt no és cap joguina: a aquesta mida, una execució hipotètica de vuit torns és gairebé un minut de temps real sense res a la pantalla.

I les respostes són errònies. El fitxer més gran és errors.log; el model va llistar els fitxers, no els va llegir mai i en va anomenar un igualment. La primera tasca va endevinar un nom de fitxer, se li va dir que no existia i va concloure. El harness es va executar impecablement en les sis execucions. Un harness fa que un agent sigui governable, no correcte: el capítol 29 és com esbrines quina de les dues coses passa, i el capítol 30 és què costa quan ningú no ho ha fet.

Subagents, anomenats aquí i cobrats més endavant

Enllaç a la secció: Subagents, anomenats aquí i cobrats més endavant

Una eina del catàleg pot tenir una altra execució al darrere. La interfície és la del capítol 18 — un esquema i un endpoint — i un agent sencer hi cap al darrere perquè aquesta interfície és estreta:

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

Tres coses ja són correctes en aquestes deu línies, i totes tres són conseqüència de decisions preses més amunt: el fill té la seva pròpia finestra, així que la transcripció del pare rep un resum en lloc de tot el que el fill ha llegit; té els seus propis límits, així que un fill desbocat no pot gastar el pressupost del pare; i hereta el senyal, així que un sol Atura cancel·la l’arbre. Per què una finestra neta és el punt i no un efecte secundari és el capítol 24; els cinc patrons d’orquestració — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — i el handoff són el capítol 25.

On són els frameworks, i per què aquest curs no n’ha fet servir cap

Enllaç a la secció: On són els frameworks, i per què aquest curs no n’ha fet servir cap

Res del que hi ha a dalt s’hauria de llegir com un argument contra les biblioteques. Mesurat el 7 de setembre de 2026, per al mes acabat el 29 d’agost:7

paquetdescàrregues aquell mesquè et dona
ai (Vercel AI SDK)89.385.860ToolLoopAgent, stopWhen, aprovació d’eines, hooks de pas
@anthropic-ai/claude-agent-sdk41.558.352el Claude Code harness com a biblioteca: bucle, sessions, hooks, permisos, subagents8
@langchain/langgraph12.812.815el bucle com un graf d’estat explícit
langchain11.359.058cadenes, agents, integracions
@openai/agents6.093.155agents, handoffs, guardrails
@mastra/core5.914.502agents, workflows, memòria

La raó per la qual aquest curs escriu el bucle a mà en lloc d’ensenyar-ne un es declara en comptes d’insinuar-se, i és mesurable. En els dotze mesos fins al 7 de setembre de 2026, ai va publicar 945 versions i va passar de la major 5 a la major 7, i la seva classe agent encara s’exporta com a Experimental_Agent; langchain va publicar 132 versions en la mateixa finestra; @openai/agents en va publicar 83 i encara és a 0.x, quinze mesos després de la primera publicació.7 Un capítol escrit contra qualsevol d’aquestes API queda antiquat en una temporada, i aquest es publica en trenta-tres llengües, de manera que cada reedició costa tota la traducció. El que hi ha a sota de totes no es mou: un bucle, una regla d’aturada, un catàleg, un executor, una mica d’estat.

I la implementació de referència està d’acord amb aquest capítol sobre la part que importa. A la versió 7.0.93 de ai, la sortida del bucle no és un número: és stopWhen, una llista de predicats, dels quals un recompte de passos és només un: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

Aturar-se és plural a la implementació més utilitzada d’aquest bucle, per la mateixa raó que és plural a les cent noranta-sis línies de dalt.

Ara tens un harness: un bucle, un catàleg, un executor, cinc maneres de sortir, una execució persistida, un senyal que arriba a les eines i una traça amb un run id a cada línia. Els capítols 24, 25, 29 i 30 es construeixen sobre aquest fitxer, i del 26 al 28 sobre allò que pot abastar.

Li queda un problema, i les mesures de dalt l’han estat assenyalant tot el camí. Mira un cop més la taula del desbocament: 3.431 token d’entrada a vuit torns, 337.299 a cent. Mira l’execució que funciona: 204, 269, 342. Cada torn reenvia tota la transcripció, així que el context d’un agent s’omple amb la seva pròpia història — i el model és pitjor fent servir l’extrem llunyà d’una finestra llarga que l’extrem proper, per això un bon agent al torn cinc és un agent confós al torn quaranta.

Un límit de torns no ho arregla. Només evita que paguis per veure-ho passar. El que ho arregla és decidir, a cada torn, quins token mereixen la finestra: què compactar, què moure fora cap a una nota que l’agent pugui recuperar, què passar a un subagent amb una finestra neta, i quines definicions d’eina valen el seu impost permanent. El capítol 24 mesura on va realment la finestra — i la sorpresa és que no és a la conversa.


Tots els números d’aquest capítol van sortir dels dos servidors descrits a dalt, amb Node 22 sobre una interfície loopback: un proveïdor guionitzat que compta token amb la codificació o200k_base, i Qwen/Qwen2.5-0.5B-Instruct darrere d’un endpoint de la mateixa forma, greedy decoding, sobre CPU. Els costos es calculen a partir dels recomptes de token mesurats a les tarifes que el capítol 16 va llegir el 6 de setembre de 2026 — $2,00 per milió de token d’entrada i $12,00 per milió de sortida — i cap petició d’aquest capítol va anar a un endpoint de pagament. Les respostes del model local són respostes d’un model petit; llegeix-les com a evidència sobre el bucle, que és idèntic en tots dos casos, i no com un benchmark del que fan els models actuals.

  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). La intercalació de traces de raonament i accions que implementa el bucle, i la font de l’observació que actuar permet a un model "gestionar excepcions" — que és exactament el que mesura la taula d’errors d’eina de dalt.

  2. Sumers, T. R., Yao, S., Narasimhan, K. i Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). El tractament formal del que el bucle de dalt fa informalment: components de memòria modulars, un espai d’acció estructurat que abasta la memòria interna i els entorns externs, i "un procés generalitzat de presa de decisions per triar accions". Llegeix-lo pel vocabulari que li falta al terme de la indústria — en particular la separació entre memòria de treball, episòdica, semàntica i procedimental, l’ombra pràctica de la qual és la taula de tres magatzems del capítol 24.

  3. ai (Vercel AI SDK) versió 7.0.93, publicada el 4 de setembre de 2026; declaracions de tipus llegides de cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts el 7 de setembre de 2026. El fitxer de 397 KB conté zero aparicions de la cadena harness. La classe agent és declare class ToolLoopAgent, exportada tant com a ToolLoopAgent com com a Experimental_Agent; declare function isStepCount(stepCount: number) — exportat com a stepCountIs — se cita literalment a dalt; type StopCondition es mostra sense el segon paràmetre de tipus (RUNTIME_CONTEXT extends Context = Context), que és l’única elisió del fragment, igual que la forma de stopWhen?: Arrayable<StopCondition<...>> a generateText i streamText. El mateix fitxer declara toolApproval, ToolApprovalStatus, prepareStep i repairToolCall, és a dir, la implementació de referència ha arribat independentment a portes d’aprovació, preparació per pas i reparació d’errors. 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). El resum anomena l’artefacte "evaluation framework" de 2.294 problemes i no fa servir mai la paraula "harness"; el README del projecte mateix (github.com/SWE-bench/SWE-bench, llegit el 7 de setembre de 2026) la fa servir cinc vegades, sempre com a "evaluation harness", i el punt d’entrada és python -m swebench.harness.run_evaluation. Aquest és l’altre sentit de la paraula: un esquelet que immobilitza l’agent i el puntua, no el bucle que l’executa.

  5. Anthropic, Building effective agents, 19 de desembre de 2024, anthropic.com/engineering/building-effective-agents, llegit el 7 de setembre de 2026. El model augmentat com a bloc de construcció, l’agent com un LLM "que usa eines basant-se en feedback ambiental en un bucle", i la recomanació de condicions d’aturada "com ara un nombre màxim d’iteracions" per mantenir el control. El capítol 22 en cita la definició sencera.

  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). L’altre bucle: el planificador de servei que agrupa la teva petició amb les peticions de desconeguts i gestiona la KV cache del capítol 13. Val la pena saber que existeix precisament perquè no és teu: la latència que multiplica el teu harness es defineix dins seu, i cap quantitat de feina sobre el teu bucle la mou.

  7. Recompte de descàrregues del registre npm, api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, una finestra explícita en lloc de la finestra mòbil last-month, i historials de versions de registry.npmjs.org/<package>; tots dos consultats el 7 de setembre de 2026. Els recomptes de versions són el nombre de versions publicades en els dotze mesos fins a aquella data, inclosos els builds canary: ai 945 (última 7.0.93 el 2026-09-04, amb les versions majors 5, 6 i 7 apareixent totes dins de la finestra), langchain 132 (última 1.5.10 el 2026-08-20), @openai/agents 83 (última 0.17.0 el 2026-08-19, primera publicada el 2025-06-03). 2

  8. El Claude Agent SDK (@anthropic-ai/claude-agent-sdk) és el Claude Code harness empaquetat com a biblioteca — bucle d’agent, eines integrades de fitxers i shell, gestió de context, sessions, hooks, permisos i subagents — documentat a code.claude.com/docs/en/agent-sdk. És el més semblant a un relat publicat de cada mecanisme que aquest capítol construeix a mà, i val la pena llegir-lo al costat de la teva pròpia implementació per les parts que anomena i que aquest capítol només apunta.

A punt per deixar que triï LIA?

Crea amb tots els models d'IA en un sol lloc — comença gratis avui mateix.