Agent Harness 만들기: 루프와 그 루프를 빠져나오는 다섯 가지 길
첫 시도에 작동하는 15줄 루프를 만든 뒤, 의도적으로 7번 깨뜨리며 77배 비용 폭주까지 측정합니다.
이 페이지에서
솔직한 이야기부터 시작하자. 다른 누구도 말하지 않을 테니까: ‘harness’는 표준이 아니라 전문 용어다. 명세도, 위원회도, 참조 정의도 없다. 이 장에서 인용하는 네 편의 논문 — ReAct,1 CoALA,2 SWE-bench와 vLLM — 의 초록 어디에도 이 단어는 한 번도 나오지 않는다. 이 물건의 가장 많이 다운로드된 구현체인 Vercel의 ai package는 월 8,940만 다운로드를 기록하지만, 거기서도 이 단어를 쓰지 않는다. version 7.0.93이 배포한 397 KB type 선언 안에서 문자열 harness는 0번 등장한다.3 이 단어가 실제로 의미의 무게를 지는 유일한 곳에서는 전혀 다른 뜻이다. SWE-bench는 README에서 “harness”를 다섯 번 말하는데, 언제나 evaluation harness — patch를 적용하고 test를 실행하는 container화된 scaffold — 라는 뜻이며, Python module은 문자 그대로 swebench.harness.run_evaluation다.4
따라서 서로 다른 두 가지가 같은 이름을 공유한다. evaluation harness는 agent를 고정해 두고 점수를 매긴다. agent harness는 agent를 실행하는 program이다. model을 호출하고, model이 요청한 것을 실행하고, 언제 멈출지 결정하고, 그 사이의 state를 붙잡아 둔다. 이 장에서는 두 번째 것을 TypeScript 200줄 미만으로, framework 없이 만든다.
루프 자체는 15줄이고 첫 시도에 작동한다. 그다음의 모든 것은 그 루프를 빠져나오는 방법이다.
세부 정보 보기
이 장이 앞선 장들에서 가져오는 것.
- 14장 의 client: deadline, status triage, cancellation, idempotency key, 그리고 여기서 다시 쓰는 mock provider 기법.
- 16장 의 산술: input token은 대화의 제곱에 따라 늘어나며, 아래에서 쓰는 요율은 2026년 9월 6일 그 장에서 읽은 값이다.
- 18장 의 tool catalog: model이 보는 schema, model이 절대 보지 않는 endpoint, 그리고 error는 exception이 아니라 context라는 규칙.
- 22장 이 물려주는 loop, 그리고 서로 동의하지 않는 “agent”의 두 가지 공개 정의.
여기에는 tensor가 없다. 이곳은 이 과정의 두 번째 dependency hub다. 24장, 25장, 29장, 30장은 아래 file 위에서 돌아가고, 26장부터 28장까지는 그것이 닿을 수 있는 것 위에 세워진다.
script할 수 있는 provider
섹션 링크: script할 수 있는 provider14장은 실제 provider를 대상으로 쓸 수 없었다. 원하는 순간에 429를 내달라고 요청할 수는 없기 때문이다. 이 장도 모양만 다를 뿐 같은 문제가 있다. 실제 model에게 마음대로 runaway하라고, 혹은 같은 tool을 같은 arguments로 연속 두 번 요청하라고, 그것도 재현 가능하게 시킬 수는 없다.
그래서 첫 program은 scripted provider다. chat completions API와 같은 모양의 endpoint이며, 응답은 turn index와 지금까지 tool이 반환한 것의 함수다. 실제 byte-pair encoder로 token을 세기 때문에, 아래의 비용은 장식이 아니라 산술이다.
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);두 줄이 설계를 지탱한다. turn index는 변수에 보관하지 않고 대화에서 도출하므로, provider는 stateless이고 실행을 죽였다가 다시 이어도 된다. 그리고 recover는 결정하기 전에 tool result를 읽는다. 자기 transcript를 읽는 scripted model은 harness가 model에게 읽을 가치가 있는 것을 줬는지 측정하기 위한 최소 조건이다.
catalog는 18장의 것이다. 세 file에 걸친 네 tool: list_files, read_file, delete_file — needsApproval로 표시됨 — 그리고 일부러 느리게 만든 scan_archive.
작동하는 루프
섹션 링크: 작동하는 루프살아남게 만드는 어떤 부품도 붙이기 전, 전체 아이디어는 이것이다.
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 });
}
}scripted provider를 향하게 하면 겉보기 그대로 동작한다.
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세 turn, 두 번의 tool 실행, 미화 0.25 cent. 마지막 줄에 주목하라: 204, 269, 342. 매 turn은 그 이전의 모든 것을 다시 보낸다. 16장의 quadratic 청구서가 아무도 아무것도 입력하지 않은 곳에 도착한 것이다. 이 장의 나머지는 그 줄이 더 이상 멈추지 않을 때 벌어지는 일이다.
첫 번째로 깨뜨리기: 끝나지 않는 task
섹션 링크: 첫 번째로 깨뜨리기: 끝나지 않는 task같은 loop를 runaway script — 매 turn마다 tool을 요청하고 prose를 절대 내지 않는 model — 에 연결하면 표시된 return는 결코 실행되지 않는다. 다른 출구가 없다. program은 process가 죽거나 credit card가 죽을 때까지 돈다.
수정은 한 줄이다. 문헌이 권하는 첫 번째 control이며,5 결국 모두가 쓰게 된다. 하지만 그 값어치를 측정하는 사람은 거의 없다.
| turn cap | model calls | input tokens | cost |
|---|---|---|---|
| 8 | 8 | 3,431 | $0.009070 |
| 20 | 20 | 16,259 | $0.038038 |
| 50 | 50 | 88,649 | $0.191098 |
| 100 | 100 | 337,299 | $0.702198 |
마지막 두 행을 함께 읽어 보라. cap을 50에서 100으로 두 배 올렸다고 cost가 두 배가 된 것이 아니다. 3.7배가 됐다. input token은 88,649에서 337,299로 3.8배가 됐다. turn 가 이전 모든 turn을 함께 들고 가며 총합은 이기 때문이다. turn cap은 선형 다이얼이 아니다. worst case의 제곱근에 달린 다이얼이다. 그래서 “안전을 위해” 20에서 100으로 올리는 결정은 실행하기 전에 가격을 매겨 볼 가치가 있다.
두 번째로 깨뜨리기: turn cap은 비용 cap이 아니다
섹션 링크: 두 번째로 깨뜨리기: turn cap은 비용 cap이 아니다turn cap의 문제는 turn에 고정 가격이 없다는 점이다. 짧은 transcript에서 20 turn은 위에서 $0.038였다. 200개 tool catalog, 검색된 문서 묶음, history 메시지 40개가 붙은 20 turn은 그 수백 배가 들고, cap은 그 사실을 모른다. operator가 제한하고 싶은 것은 청구액이다.
그래서 loop는 돈을 센다. 이 과정 전체에서 가격 기준으로 쓰는 model에 대해 그 장에서 읽은 요율 — input token 100만 개당 $2.00, output 100만 개당 $12.00 — 로 16장의 computeCost를 사용한다.
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);같은 runaway script, turn cap은 전혀 없고, budget 세 가지:
| budget | turns reached | actually spent |
|---|---|---|
| $0.01 | 9 | $0.010780 |
| $0.05 | 24 | $0.051790 |
| $0.20 | 52 | $0.205398 |
두 가지는 이름 붙일 가치가 있다. 첫째, budget은 매번 다른 turn 수를 산다. 그게 핵심이다. operator가 신경 쓰는 것을 제한하고, turn count는 transcript가 놓는 곳에 떨어지게 둔다. 둘째, 모든 행이 초과한다. budget은 $0.010이었고 $0.010780이 지출됐다. check는 turn 전에 실행되지만 turn의 가격은 turn이 끝나기 전까지 알 수 없기 때문이다. spend를 정확히 제한할 수는 없다. 한 turn cost 이내로 제한할 수 있을 뿐이다. interface에서 그런 척하지 말고 그렇게 말하라. 그리고 check를 call 전에 둬라. 그래야 초과분이 두 turn이 아니라 한 turn이다.
루프를 빠져나오는 길은 하나가 아니라 다섯 가지
섹션 링크: 루프를 빠져나오는 길은 하나가 아니라 다섯 가지이제 loop에는 세 개의 exit가 있고, 남은 장의 모양도 보인다. production run은 정확히 다섯 가지 방식 중 하나로 끝나며, 이들은 서로의 변형이 아니다.
| how it ends | who decided | what the caller should do |
|---|---|---|
| model이 요청을 멈춤 | model | answer를 읽는다 |
| turn cap | 사전에 당신 | cap을 올리거나 partial result를 받아들인다 |
| budget exhausted | 사전에 당신 | 더 많은 돈을 승인하거나 partial result를 받아들인다 |
| retry할 수 없는 error | provider 또는 tool | deployment를 고친다. 14장의 triage가 결정한다 |
| human intervened | 사람 | verdict를 기다린 뒤 resume한다 |
이들을 하나의 boolean으로 뭉개는 것이 이 file에서 가장 흔한 design mistake이며, 특정한 방식으로 비싸다. 다섯 가지 중 세 가지는 resumable이고 두 가지는 아니다. turn cap에 걸린 agent에는 valid transcript, 실제 partial result, next step이 있다. 401을 받은 agent에는 그중 아무것도 없다. 그래서 harness는 reason을 data로 기록한다.
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 };세 번째로 깨뜨리기: tool이 실패한다
섹션 링크: 세 번째로 깨뜨리기: tool이 실패한다18장은 숫자 없이 하나의 주장으로 끝났다. tool의 error를 raise하지 말고 tool result로 model에게 돌려주면, model은 대개 스스로 고친다는 주장이다. 여기 그 숫자가 있다.
한 번의 실패, 세 가지 policy. scripted model은 존재하지 않는 file을 추측하고, tool은 no such file: timeout.log. Call list_files to see what exists.를 throw한다.
| what the harness does with the error | turns | tool runs | cost | what the user got |
|---|---|---|---|---|
| loop 밖으로 throw | 1 | 1 | $0.000756 | stack trace |
Error: the tool failed. 반환 | 2 | 1 | $0.001462 | “file을 읽을 수 없어서 모르겠습니다.” |
| 실제로 일어난 일을 반환 | 4 | 3 | $0.003550 | “errors.log에 timeout이 언급됩니다.” |
세 번째 행은 첫 번째의 4.7배 비용이 들며, 질문에 답하는 유일한 행이다. 그리고 두 번째 행이 흥미롭다. 실제 codebase 대부분이 하는 일이기 때문이다. error는 catch됐고, loop는 살아남았고, model은 무언가 실패했다는 사실은 들었지만 무엇이 실패했는지는 듣지 못했고, 예의 바르게 포기했다. 두 번째 행과 세 번째 행의 차이는 error handling이 아니다. 읽는 사람을 위해 쓴 한 문장이다.
따라서 harness는 throw된 tool을 data로 다루고, 문구를 policy로 만든다.
} 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);
}18장은 반대쪽도 경고했고, 그쪽에도 가격이 있다. 어떤 메시지로도 고칠 수 없는 이유로 실패하는 tool — process가 수행할 권한이 없는 read — 에 loop를 연결하면 model은 그것을 영원히 retry한다.
read a file the process may not open turns=12 toolruns=11 in=7,079 cost=$0.018622
status=max_turns_exceeded answer=""성공할 수 없는 call을 동일하게 11번 실행했고, fixable error에서 회복한 run보다 5.2배 비용이 들었으며, 끝에는 아무것도 없었다. Error는 context다. permanent error는 나머지 run을 오염시키는 context다. 이 구분은 14장의 status triage를 한 layer 위로 옮긴 것이다. model이 행동할 수 있는 error는 transcript로 돌아가고, 그렇지 못한 error는 reason과 함께 run을 멈춰야 한다. 오늘 당신과 두 번째 경우 사이에 서 있는 것은 turn cap뿐인데, 그것은 바닥이지 fix가 아니다.
네 번째로 깨뜨리기: 같은 call, 두 번
섹션 링크: 네 번째로 깨뜨리기: 같은 call, 두 번이제 많은 사람이 일어날 리 없다고 생각하는 실패다. model은 자신을 반복한다. 어떤 loop든 충분히 오래 돌리면 동일한 tool이 동일한 arguments로 두 consecutive turn에 나타나는 것을 보게 된다.
repeat 없는 같은 task의 baseline과 비교하면:
| turns | tool runs | cost | |
|---|---|---|---|
| task, repeat 없음 | 2 | 1 | $0.001396 |
| 같은 task, call 하나 반복 | 3 | 2 | $0.002446 |
| 반복, read-only tool에 result cache 적용 | 3 | 1 | $0.002446 |
중복 call은 $0.001050의 추가 비용, 75 % 증가를 만들었다. 그리고 사람들이 놀라는 부분은 이것이다. result를 cache해도 그 비용은 하나도 회수하지 못했다. Deduplication은 tool execution을 줄였지 turn을 줄이지 못했다. code가 repeat를 알아차릴 때쯤에는 model이 이미 그 요청을 한 대가를 받은 뒤이기 때문이다. tool이 느리거나, rate-limited이거나, call 단위로 과금될 때 절약은 실제다. 하지만 늘어난 line item에서는 0이다.
더 나쁜 버전이 있다. 같은 cache를 write하는 tool에 적용하면 두 번째 call은 조용히 일어나지 않는다.
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"]둘 중 어느 것이 맞는가? 알 수 있게도, 둘 다 아니다. protocol은 이것들이 두 call이라고 말한다. 둘은 서로 다른 tool_call_id 값을 가진다. arguments는 하나일 수도 있다고 말한다. argument 문자열을 비교해 판단하는 harness는 언젠가 의도된 동일한 두 charge 중 두 번째를 삼켜 버릴 것이다. 그리고 14장은 이미 이를 정직하게 해결하는 유일한 mechanism의 이름을 말했다. 그것은 operation이 무엇인지 아는 layer가 logical operation마다 생성하는 idempotency key다. tool이 그것을 들고 오기 전까지 방어 가능한 default는 위의 read-only gate다. read는 cache하고, write는 실행하고, 나머지는 write 자체의 idempotency가 처리하게 하라.
if (opts.dedupe && (tool.readOnly || opts.dedupeAll) && seen.has(signature)) {
state.messages.push({ role: "tool", tool_call_id: c.id, name: c.function.name, content: seen.get(signature)! });
continue;
}다섯 번째로 깨뜨리기: 무언가를 삭제한다
섹션 링크: 다섯 번째로 깨뜨리기: 무언가를 삭제한다destructive script는 file 목록을 나열한 뒤, task가 언급하지 않은 file 하나를 삭제하라고 요청한다. 지금까지의 loop 어디에도 그것을 막는 것은 없다.
needsApproval로 표시된 tool은 실패하지도 진행하지도 않는다. run을 멈추고 control을 돌려준다. 사람이 결정하는 데 필요한 모든 것을 함께 반환한다.
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."이것이 전체 mechanism이다. 그리고 이것이 callback이 아니라 return인 이유는 다음 section에 있다. stop과 verdict 사이에 process는 더 이상 존재하지 않을 수도 있다.
하지만 먼저 아무도 예상하지 못하는 측정이 있다. rejection은 result의 부재가 아니다. transcript에는 tool_call_id로 keyed된 slot이 있고, 그 안에는 무언가 들어가야 한다. 같은 rejection을 두 번 실행하되, 그 무언가가 말하는 내용만 바꿔 보라.
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."두 run 모두에서 아무것도 삭제되지 않았고, 두 번째에서는 user에게 삭제됐다고 말한다. permission system은 완벽하게 작동했다. report가 거짓말이다. 이것은 tool-error table과 같은 mechanism이며, 훨씬 더 중요한 곳에 도착했다. human은 거절했고, action은 올바르게 차단됐지만, refusal이 model이 읽는 곳에 기록되지 않았기 때문에 agent의 summary가 현실과 모순된다. 여기서 나오는 규칙은 짧다. 당신의 code가 tool call에 대해 무엇을 결정하든, 그 결정을 말로 transcript에 써라. 30장은 security 쪽에서 이 문제로 돌아온다. 거기서는 이것이 audit trail과 fiction의 차이다.
여섯 번째로 깨뜨리기: process가 죽는다
섹션 링크: 여섯 번째로 깨뜨리기: process가 죽는다approval은 몇 분 또는 몇 시간이 걸린다. deploy는 몇 초가 걸린다. run이 HTTP request 안의 local variable에 살고 있다면, 모든 restart는 run의 손실이고 모든 approval은 race다.
그래서 run은 closure가 아니다. 그것은 plain serialisable object다. messages, turn count, cost, status, interruption, 승인된 call id 목록. 그리고 loop는 그 위에서 동작하는 pure function이다. 이 단 하나의 constraint가 persistence를 한 줄짜리 concern으로 만든다.
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"));correctness question은 저장이 아니다. 돌아오는 길에 무엇이 일어나는가다. naive answer는 당신에게 이중으로 charge한다. process가 model이 tool을 요청한 뒤 result가 쓰이기 전에 죽었다면, model을 다시 call하는 것으로 시작하는 resume은 이미 가진 turn에 다시 비용을 낸다. 그리고 tool을 다시 실행하는 것으로 시작하면 write를 두 번 수행한다.
수정은 loop가 시작할 때 transcript에게 outstanding이 무엇인지 묻게 하는 것이다.
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));
}모든 iteration은 먼저 pending를 비우고, outstanding이 없을 때만 model에게 묻는다. resume은 normal path와 같은 code path가 되고, approval도 마찬가지다. approved call은 이제 실행이 허용된 pending call일 뿐이다. task 중간에 process를 죽이고 다시 시작해 보라.
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두 개가 필요한 task에서 두 process에 걸쳐 tool execution은 두 번이고, 최종 cost는 한 번도 crash하지 않은 run과 동일하다. cost는 variable이 아니라 state 안에 있었기 때문에 restart를 넘어 누적된다.
일곱 번째로 깨뜨리기: 3분간의 침묵
섹션 링크: 일곱 번째로 깨뜨리기: 3분간의 침묵scan_archive는 여기서는 3초가 걸리며, production에서 3분이 걸리는 tool을 대신한다. 실행 중에는 두 가지가 빠져 있다. user는 아무 일이 일어나고 있는지 알 수 없고, Stop button은 아무것도 하지 않는다.
둘 다 같은 fix이며, 14장의 AbortSignal를 한 단계 더 깊이 밀어 넣는 것이다. signal은 fetch만을 위한 것이 아니다. tool 안으로 전달되고, 잘 작성된 tool은 그것을 존중한다.
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"click에서 stop까지 2 millisecond다. tool 안의 sleep이 fetch와 같은 signal을 듣기 때문이다. 그것을 fetch에만 연결하면, 동일한 Stop button은 3초 — tool의 길이 — 를 기다리고, run은 cancel하려던 작업이 이미 끝난 뒤에야 “cancel”된다. 아래까지 전부 배관되지 않은 cancellation은 올바른 단어를 말하는 spinner일 뿐이다.
trace, 그리고 그것이 log가 아닌 이유
섹션 링크: trace, 그리고 그것이 log가 아닌 이유harness는 event마다 한 줄을 emit하며, vocabulary는 외울 만큼 작다: 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}세 가지 속성이 이것을 logging이 아니라 trace로 만든다. 모든 줄에는 run id가 있으므로, 세 process와 이틀에 걸친 run도 하나의 query다. 모든 turn 줄에는 자체 token count와 running cost가 있으므로, “왜 이 run이 40달러가 들었나”는 사후에 답할 수 있다. 이론적으로만 재현 가능한 질문이 아니다. 그리고 run_stopped에는 reason이 있다. 이 field가 support ticket을 한 줄짜리 답으로 바꾼다. budget에서 멈춘 agent와 crash한 agent는 밖에서 보면 똑같지만, 필요한 response는 정반대다.
latency의 산술
섹션 링크: latency의 산술13장은 당신이 소유한 hardware에서 time to first token을 측정했다. 14장은 socket을 통해 그것을 측정했다. agent는 그것을 곱하며, multiplier는 아무도 선택하지 않은 숫자다.
같은 세 turn task에서 provider의 latency만 바꿨다.
| provider latency per turn | wall clock, 3 turns |
|---|---|
| 0 ms | 15 ms |
| 200 ms | 615 ms |
| 800 ms | 2,413 ms |
harness 자체가 세 turn run에 기여하는 시간은 15 millisecond다. 나머지는 모두 에 당신이 control하지 않는 숫자를 곱한 것이다. 그 숫자는 당신의 request를 낯선 사람들의 request와 batching하는 serving scheduler 안에서 정해지고6, 는 model이 선택한다. 그래서 14장의 streaming은 chat보다 여기서 더 중요하지만, 도움은 덜 된다. final turn은 stream할 수 있지만, 그 전 네 turn은 harness가 progress를 emit하지 않는 한 침묵이다. 이것이 위의 tool_progress event에 대한 전체 논증이기도 하다. agent에서 honest feedback 단위는 token이 아니라 step이다.
같은 harness, port 뒤의 실제 model
섹션 링크: 같은 harness, port 뒤의 실제 model위의 모든 것은 scripted provider를 대상으로 실행됐다. 그것은 harness를 증명하지만 model에 대해서는 아무것도 증명하지 않는다. 그래서 한 줄 — 14장의 seam인 LLM_BASE_URL — 만 바꾸고, 동일한 code를 같은 네 tool을 가진 local Qwen2.5-0.5B-Instruct로 향하게 한다. 같은 세 file에 대한 여섯 task:
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세 가지 발견이 있었고, 세 번째가 이 section이 존재하는 이유다.
모든 task가 정확히 두 turn에 끝났다. turn cap은 한 번도 발동하지 않았고, budget도 한 번도 발동하지 않았으며, loop의 유일한 exit는 model이 prose를 생성한 것이었다. 5억 parameter model은 iterate하지 않는다. 필요한 것을 갖췄든 아니든 두 번째 숨에 답한다. turn count는 loop의 속성이 아니라 model의 속성이다.
평균 turn은 6,908 millisecond가 걸렸다. 따라서 위의 latency table은 장난감이 아니다. 이 크기에서 가상의 8 turn run은 화면에 아무것도 없는 채 거의 1분의 wall clock이다.
그리고 answer는 틀렸다. 가장 큰 file은 errors.log다. model은 file을 나열하고, 그것들을 읽지 않았으며, 그래도 하나의 이름을 댔다. 첫 task는 file name을 추측했고, 그것이 존재하지 않는다는 말을 들은 뒤 결론을 냈다. harness는 여섯 run 모두에서 flawless하게 실행됐다. harness는 agent를 governable하게 만들 뿐 correct하게 만들지는 않는다. 29장은 어느 쪽인지 알아내는 방법이고, 30장은 아무도 그렇게 하지 않았을 때의 cost다.
Subagents, 여기서 이름 붙이고 나중에 과금하기
섹션 링크: Subagents, 여기서 이름 붙이고 나중에 과금하기catalog의 tool 하나는 그 뒤에 또 다른 run을 가질 수 있다. interface는 18장의 것 — schema와 endpoint — 이며, 그 interface가 좁기 때문에 전체 agent가 그 뒤에 들어갈 수 있다.
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";
},
};그 열 줄에는 이미 세 가지가 맞게 들어 있고, 셋 모두 위에서 내린 결정의 결과다. child는 자기 window를 가지므로 parent의 transcript는 child가 읽은 모든 것이 아니라 summary를 받는다. child는 자기 limit을 가지므로 runaway child가 parent의 budget을 쓸 수 없다. 그리고 child는 signal을 inherit하므로 Stop 하나가 tree를 cancel한다. clean window가 side effect가 아니라 핵심인 이유는 24장에 있다. 다섯 orchestration pattern — prompt chaining, routing, parallelisation, orchestrator-workers, evaluator-optimiser — 과 handoff는 25장이다.
framework는 어디에 있고, 왜 이 과정은 framework를 쓰지 않았는가
섹션 링크: framework는 어디에 있고, 왜 이 과정은 framework를 쓰지 않았는가위의 어떤 내용도 library에 반대하는 주장으로 읽혀서는 안 된다. 2026년 9월 7일, 8월 29일에 끝난 한 달을 측정하면:7
| package | downloads that month | what it gives you |
|---|---|---|
ai (Vercel AI SDK) | 89,385,860 | ToolLoopAgent, stopWhen, tool approval, step hooks |
@anthropic-ai/claude-agent-sdk | 41,558,352 | library로 포장된 Claude Code harness: loop, sessions, hooks, permissions, subagents8 |
@langchain/langgraph | 12,812,815 | explicit state graph로서의 loop |
langchain | 11,359,058 | chains, agents, integrations |
@openai/agents | 6,093,155 | agents, handoffs, guardrails |
@mastra/core | 5,914,502 | agents, workflows, memory |
이 과정이 그중 하나를 가르치는 대신 loop를 직접 쓰는 이유는 암시가 아니라 선언되어야 하며, 측정 가능하다. 2026년 9월 7일까지 12개월 동안 ai는 945개 version을 publish했고 major 5에서 major 7로 이동했으며, 그 agent class는 여전히 Experimental_Agent로 export된다. langchain는 같은 기간 132개 version을 publish했다. @openai/agents는 83개를 publish했고, 첫 release 15개월 뒤에도 아직 0.x다.7 그런 API 중 하나를 기준으로 쓴 장은 한 season 안에 낡는다. 그리고 이 글은 33개 언어로 publish되므로, 매번 개정은 전체 translation의 비용이 된다. 그 모든 것의 아래에 있는 것은 움직이지 않는다. loop, stopping rule, catalog, executor, state 일부다.
그리고 reference implementation도 중요한 부분에서는 이 장과 같은 결론에 도달한다. ai version 7.0.93에서 loop의 exit는 숫자가 아니다. stopWhen, predicate의 list이며, step count는 그중 하나일 뿐이다:3
type StopCondition<TOOLS extends ToolSet> = (options: { steps: Array<StepResult<TOOLS>> }) => PromiseLike<boolean> | boolean;
declare function isStepCount(stepCount: number): StopCondition<any, any>; // exported as stepCountIsstopping은 이 loop의 가장 많이 쓰이는 구현체에서 복수다. 위 196줄에서 복수인 것과 같은 이유다.
다음은 어디로 가는가
섹션 링크: 다음은 어디로 가는가이제 당신에게는 harness가 있다. loop, catalog, executor, 다섯 가지 exit, persisted run, tool까지 닿는 signal, 그리고 모든 줄에 run id가 있는 trace. 24장, 25장, 29장, 30장은 이 file 위에 세워지고, 26장부터 28장까지는 그것이 닿을 수 있는 것 위에 세워진다.
남은 문제는 하나이며, 위의 측정들은 내내 그것을 가리키고 있었다. runaway table을 다시 보라. 8 turn에서 input token 3,431개, 100 turn에서 337,299개. 작동한 run을 보라. 204, 269, 342. 모든 turn은 전체 transcript를 다시 보내므로, agent의 context는 자기 history로 가득 찬다. 그리고 model은 긴 window의 먼 끝보다 가까운 끝을 사용하는 데 더 서툴다. 그래서 turn 5의 좋은 agent가 turn 40에서는 혼란스러운 agent가 된다.
turn cap은 그것을 고치지 않는다. 그저 당신이 그 장면을 보며 돈을 내는 것을 멈출 뿐이다. 그것을 고치는 것은 매 turn마다 어떤 token이 window를 받을 자격이 있는지 결정하는 일이다. 무엇을 compact할지, 무엇을 agent가 fetch할 수 있는 note로 밖에 둘지, 무엇을 clean window를 가진 subagent에게 넘길지, 어떤 tool definition이 영구적인 tax를 낼 가치가 있는지. 24장은 window가 실제로 어디로 가는지 측정한다. 그리고 놀라운 점은, 그것이 conversation이 아니라는 것이다.
Sources and method
섹션 링크: Sources and method이 장의 모든 숫자는 위에서 설명한 두 server에서 나왔다. loopback interface 위의 Node 22, o200k_base encoding으로 token을 세는 scripted provider, 그리고 같은 shape의 endpoint 뒤에 있는 CPU상의 greedy decoding Qwen/Qwen2.5-0.5B-Instruct. cost는 16장이 2026년 9월 6일 읽은 요율 — input token 100만 개당 $2.00, output 100만 개당 $12.00 — 로 측정된 token count에서 계산했으며, 이 장의 어떤 request도 paid endpoint로 가지 않았다. local model의 answer는 작은 model의 answer다. 어느 쪽이든 동일한 loop에 대한 evidence로 읽어야 하며, current model이 무엇을 하는지에 대한 benchmark로 읽어서는 안 된다.
-
Yao, S., Zhao, J., Yu, D., Du, N., Shafran, I., Narasimhan, K. and Cao, Y. ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629 (2022). loop가 구현하는 reasoning trace와 action의 interleaving, 그리고 acting이 model로 하여금 “exception을 handle”하게 한다는 observation의 출처다. 위 tool-error table이 정확히 그것을 측정한다. ↩
-
Sumers, T. R., Yao, S., Narasimhan, K. and Griffiths, T. L. Cognitive Architectures for Language Agents (CoALA). arXiv:2309.02427 (2023). 위 loop가 비공식적으로 하는 일을 formal하게 다룬다. modular memory component, internal memory와 external environment에 걸친 structured action space, 그리고 “action을 선택하기 위한 generalized decision-making process”. 업계 용어가 놓치고 있는 vocabulary를 위해 읽어라. 특히 working, episodic, semantic, procedural memory의 분리이며, 그 practical shadow가 24장의 three-store table이다. ↩
-
ai(Vercel AI SDK) version 7.0.93, 2026년 9월 4일 publish. type declaration은 2026년 9월 7일cdn.jsdelivr.net/npm/ai@7.0.93/dist/index.d.ts에서 읽었다. 397 KB file에는 문자열harness가 0번 등장한다. agent class는declare class ToolLoopAgent이며,ToolLoopAgent와Experimental_Agent둘 다로 export된다.declare function isStepCount(stepCount: number)—stepCountIs로 export됨 — 는 위에 그대로 인용했다.type StopCondition는 두 번째 type parameter(RUNTIME_CONTEXT extends Context = Context) 없이 표시했으며, 그것이 excerpt의 유일한 생략이다.generateText와streamText의stopWhen?: Arrayable<StopCondition<...>>shape도 마찬가지다. 같은 file은toolApproval,ToolApprovalStatus,prepareStep와repairToolCall를 선언한다. 즉 reference implementation은 approval gate, per-step preparation, error repair에 독립적으로 도달했다. ↩ ↩2 -
Jimenez, C. E., Yang, J., Wettig, A., Yao, S., Pei, K., Press, O. and Narasimhan, K. SWE-bench: Can Language Models Resolve Real-World GitHub Issues? arXiv:2310.06770 (2023). 초록은 artefact를 2,294개 문제의 “evaluation framework”라고 부르며 “harness”라는 단어를 쓰지 않는다. project 자체 README(
github.com/SWE-bench/SWE-bench, 2026년 9월 7일 읽음)는 이 단어를 다섯 번 쓰며, 항상 “evaluation harness”이고 entry point는python -m swebench.harness.run_evaluation다. 그것이 이 단어의 다른 뜻이다. agent를 실행하는 loop가 아니라, agent를 고정하고 점수를 매기는 scaffold다. ↩ -
Anthropic, Building effective agents, 2024년 12월 19일,
anthropic.com/engineering/building-effective-agents, 2026년 9월 7일 읽음. building block으로서의 augmented model, “environmental feedback에 기반해 loop 안에서 tools를 사용하는” LLM으로서의 agent, 그리고 control을 유지하기 위한 “maximum number of iterations 같은” stopping condition 권고. 22장은 그 정의를 전문 인용한다. ↩ -
Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. and Stoica, I. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023). 다른 loop — 당신의 request를 낯선 사람들의 request와 batching하고 13장의 KV cache를 관리하는 serving scheduler. 그것이 존재한다는 사실을 아는 것은 바로 그것이 당신 것이 아니기 때문에 중요하다. harness가 곱하는 latency는 그 안에서 정해지고, 당신의 loop에서 아무리 작업해도 그것은 움직이지 않는다. ↩
-
npm registry download count,
api.npmjs.org/downloads/point/2026-07-31:2026-08-29/<package>, rollinglast-monthwindow가 아니라 explicit window, 그리고registry.npmjs.org/<package>의 release history. 둘 다 2026년 9월 7일 query했다. release count는 그 날짜까지 12개월 동안 publish된 version 수이며 canary build를 포함한다.ai945개(latest 7.0.93 on 2026-09-04, major version 5, 6, 7이 모두 window 안에 등장),langchain132개(latest 1.5.10 on 2026-08-20),@openai/agents83개(latest 0.17.0 on 2026-08-19, first published 2025-06-03). ↩ ↩2 -
Claude Agent SDK(
@anthropic-ai/claude-agent-sdk)는 library로 package된 Claude Code harness다 — agent loop, built-in file and shell tools, context management, sessions, hooks, permissions, subagents —code.claude.com/docs/en/agent-sdk에 문서화되어 있다. 이 장이 손으로 만드는 각 mechanism에 대해 공개된 설명에 가장 가까운 것이며, 이 장이 암시만 하는 부분들의 이름을 확인하려면 자신의 implementation 옆에 두고 읽을 가치가 있다. ↩