에이전트 하네스를 직접 만들며 깨진 곳들: 무한 루프, 무음 400, 토큰 폭주
정유나
@yuna_ai
사내 운영 도구에서 "GitHub 이슈 보고 PR 초안까지 만드는" 작은 에이전트가 필요해서, 프레임워크 없이 LLM 호출 루프를 직접 짰다. Claude Code나 OpenHands 같은 완성된 하네스를 안 쓰고 직접 만든 이유는 단순하다. 우리 도구는 사내 Notion·Airtable 스키마에 묶인 커스텀 툴 6개를 호출해야 했고, 범용 하네스에 그 툴들을 끼우는 것보다 루프를 직접 통제하는 게 빠를 줄 알았다. 결과적으로 "에이전트 = 모델 + 바깥 골격(하네스)"에서 골격이 얼마나 많은 실패를 떠안는지를 비용으로 배웠다. 그 과정에서 깨진 세 군데를 기록해 둔다.
먼저 전제 하나. "더 좋은 모델 쓰면 되지 않나"는 내가 시작할 때 가졌던 생각인데, 측정된 숫자가 이걸 반박한다. SWE-bench Pro에서 같은 모델(Claude Opus 4.5)을 세 가지 에이전트 시스템에 올렸을 때 50.2%~55.4%, 즉 스캐폴드(컨텍스트·툴 호출 관리 방식) 차이만으로 5.2포인트 차이가 보고됐다(digitalapplied). 같은 글이 인용한 Scale AI의 더 넓은 관찰로는 하네스 선택이 10~20포인트 스윙을 만든다고 한다(단일 모델 단일 수치는 아님, 추정 범위). 참고로 Opus 4.5의 SWE-bench Verified는 80.9%인데 이건 self-reported 수치다(같은 출처 기준; Anthropic의 발표문은 "state-of-the-art"라고만 적고 본문에 정확한 퍼센트를 노출하지 않는다). 즉 모델 점수는 천장이고, 그 천장에 실제로 닿느냐는 내가 짜는 루프가 정한다. 내가 직접 깨먹은 곳들이 정확히 그 "닿게 하는 골격"이었다.
환경
- 런타임: Node 20, TypeScript, 단일 프로세스
- 모델 호출: 처음엔 OpenAI Agents SDK(
@openai/agents)로 시작했다가, 일부는 직접 HTTP 루프로 내려옴 - 툴: 사내 Notion 조회 2개, Airtable read/write 3개, git diff 생성 1개
- 루프 형태: 관찰 → 모델 사고 → tool_call 파싱 → 디스패치 → 결과 주입 → 반복
실패 1: 종료 예산이 없어서 토큰이 폭주
처음 버전엔 종료 조건이 "모델이 final answer를 내면 끝"밖에 없었다. max_steps도 토큰 상한도 안 걸었다. 데모에선 3~5스텝이면 끝났으니까.
실제 이슈 하나가 들어오자 에이전트가 Airtable read → "데이터가 부족하다" → 다시 read → 같은 쿼리 → 또 read를 반복했다. 같은 툴을 같은 인자로 계속 부르는 전형적인 도구 루프였다. 종료 예산이 없으니 멈출 이유가 없었다. 끊은 건 모델이 아니라 내가 화면을 보다가 Ctrl+C였다. OpenAI Agents SDK를 그대로 썼다면 MaxTurnsExceeded로 기본 컷이 됐겠지만, 직접 내린 HTTP 루프엔 그 안전망이 없었다.
그 한 번의 폭주가 단일 task에서 41 step, 약 38만 토큰을 태웠다. 여기서 한국 개발자 실무 함정 하나. 우리 입력은 한글 프롬프트 + 한글 Notion 본문이 대부분인데, 같은 의미라도 한글은 영어 대비 토큰이 대략 1.5~2배 나온다(우리 입력 표본 기준 추정). 영어 기준으로 "이 정도면 8천 토큰"이라고 어림한 컨텍스트가 실제론 1.3~1.6만 토큰이었다. 종료 예산을 토큰이 아니라 step 수로만 잡았다면 이 배수를 놓쳤을 것이다.
고친 diff는 단순하다. step 가드 + 같은 (tool, args) 해시 반복 감지 + 토큰 상한을 동시에 걸었다.
// before: 종료 조건이 사실상 없음
while (true) {
const res = await model.step(ctx);
if (res.final) return res.output;
ctx.push(await dispatch(res.toolCall));
}
// after: 3중 종료 예산
const seen = new Map<string, number>();
let tokens = 0;
for (let step = 0; step < MAX_STEPS; step++) { // MAX_STEPS = 12
const res = await model.step(ctx);
tokens += res.usage.total;
if (tokens > MAX_TOKENS) throw new BudgetError("token", tokens); // 150_000
if (res.final) return res.output;
const key = res.toolCall.name + JSON.stringify(res.toolCall.args);
const n = (seen.get(key) ?? 0) + 1;
seen.set(key, n);
if (n >= 3) throw new LoopError(key, n); // 같은 호출 3회 = 진행 없음으로 간주
ctx.push(await dispatch(res.toolCall));
}
throw new BudgetError("steps", MAX_STEPS);
MAX_STEPS = 12는 우리 task에서 정상 케이스가 최대 7~8 step인 걸 측정해서 잡은 값이다. 임의로 250 같은 큰 수를 박지 않았다. 큰 step 한도는 SWE-bench류 벤치마크에서 점수를 올리려고 쓰는 값(정상 경로가 길고 어려운 task를 끝까지 끌고 가야 하는 셋업)이고, 사내 운영 도구처럼 정상 경로가 짧고 비용에 민감한 곳에선 오히려 폭주 비용만 키운다. 점수 최적화용 step 예산과 운영 비용 방어용 step 예산은 다른 숫자다.
트레이드오프: 같은 호출 3회를 컷하면 "정당하게 같은 read를 다시 해야 하는" 케이스(예: 폴링)도 잘린다. 우리 도구엔 폴링 툴이 없어서 이 절충을 택했다. 폴링이 있는 하네스라면 이 가드는 못 쓴다.
실패 2: 도구 스키마 strict가 거부한 무음 400
두 번째는 OpenAI Structured Outputs/strict 모드에서 만난, 가장 디버깅하기 짜증났던 실패다. Airtable write 툴의 인자 스키마에 "최소 1개, 최대 5개 필드를 동시 업데이트"를 표현하려고 배열에 minItems/maxItems를 붙였다.
문제는 strict 모드(strict: true)가 일부 JSON Schema 키워드를 지원하지 않는다는 것이다. OpenAI 문서도 "Structured Outputs가 JSON Schema의 상당 부분을 지원하지만 일부 기능은 성능·기술상 사용 불가"라고만 적고 supported-schemas 표로 미룬다(OpenAI structured outputs). 실제로 minItems/maxItems 같은 배열 길이 제약은 strict에서 거부 대상이다(이건 우리 사내 pitfalls에도 "OpenAI strict가 maxItems류 거부=무음 400"으로 이미 등록돼 있던 함정이다).
증상이 고약했던 이유: 툴 정의를 등록하는 시점이 아니라 그 툴을 처음 호출하려고 모델에 스키마를 보내는 첫 요청에서 400이 떴다. 그것도 우리 래퍼가 에러를 삼키고 "tool unavailable"로 폴백해서, 로그엔 모델이 그냥 그 툴을 안 부른 것처럼 보였다. 에이전트가 "쓰기 권한이 없나 봐요"라고 우회 답을 내놓길래 권한 문제인 줄 알고 한참 엉뚱한 데를 팠다.
verbatim으로 잡힌 건 래퍼를 걷어내고서야 나온 이거였다.
400 invalid_request_error: Invalid schema for function 'airtable_update':
'maxItems' is not permitted on this schema in strict mode.
원인을 한 줄로: 하네스의 "출력 파싱·검증" 계층은 모델이 내놓은 출력만 검증하는 게 아니라, 내가 모델에 밀어넣는 툴 스키마 자체도 검증 대상이라는 걸 빼먹었다. 스키마 불일치가 무음으로 흡수되면 에이전트는 "그 툴이 없는 것처럼" 행동한다. 모델 능력 문제로 오진하기 딱 좋다.
해결: strict 스키마에서 길이 제약을 빼고, 그 검증을 디스패치 직후 코드로 옮겼다.
// before: strict 스키마에 길이 제약 (첫 호출에서 400)
const updateSchema = {
type: "object",
properties: {
fields: { type: "array", items: {...}, minItems: 1, maxItems: 5 }, // ← 거부
},
required: ["fields"],
additionalProperties: false,
};
// after: 스키마는 strict가 받는 형태로, 길이는 디스패처에서 검증
const updateSchema = {
type: "object",
properties: { fields: { type: "array", items: {...} } },
required: ["fields"],
additionalProperties: false,
};
function dispatchUpdate(args: { fields: Field[] }) {
if (args.fields.length < 1 || args.fields.length > 5) {
// 모델에게 다시 보일 교정 메시지로 변환 (조용히 삼키지 않음)
return toolError(`fields는 1~5개여야 합니다. 받은 개수: ${args.fields.length}`);
}
return airtable.update(args);
}
교훈은 "검증을 없애지 말고 위치를 옮겨라"다. strict 스키마가 못 받는 제약은 모델 레벨에서 강제하는 대신, 디스패처에서 검증해 실패를 모델에게 텍스트로 되돌려주는 식으로 복구 경로를 만들었다. 무음 폴백이 가장 나빴다.
실패 3: 컨텍스트 오염 — 실패한 툴 결과를 그대로 누적
세 번째는 점수가 천천히 나빠지는 종류라 늦게 발견했다. 툴 호출이 실패하면 그 에러 텍스트(스택 일부 포함)를 통째로 컨텍스트에 다시 붙였다. 한두 번이면 괜찮은데, 같은 툴이 두세 번 실패하면 컨텍스트가 스택 트레이스로 채워지고, 모델이 그 노이즈를 "지금까지 한 일"로 오인해서 엉뚱한 요약을 내놓기 시작했다.
이건 verbatim 에러로 안 잡힌다. 출력이 그냥 점점 부정확해진다. 발견 계기는 "왜 step이 길어질수록 답이 더 나빠지지"였다. 고친 건 실패 결과를 요약·절단해서 주입하는 것.
function toolError(msg: string) {
// 스택/원문 통째 금지: 모델에 보이는 건 한 줄 교정 지시뿐
return { role: "tool", content: msg.split("\n")[0].slice(0, 200) };
}
트레이드오프: 스택을 자르면 디버깅 정보는 모델 컨텍스트에서 사라진다. 그래서 원문 에러는 별도 로그로만 남기고(사람이 봄), 모델에는 절단본만 줬다. 컨텍스트는 모델의 작업 메모리지 디버그 콘솔이 아니라는 게 핵심이다.
언제 하네스를 만들지 말아야 하나
마지막으로 정직한 한계. 이 글은 "하네스를 직접 짜라"가 아니다. 내 경우 정상 경로가 7~8 step이고 커스텀 툴이 6개라 직접 통제가 합리적이었지만, 만약 단일 결정적 호출로 끝나거나(요약 1번, 분류 1번) 툴이 필요 없다면 하네스 자체가 과설계다. 루프·종료 예산·재시도·컨텍스트 관리는 전부 비용인데, 1-shot 작업엔 그 비용을 회수할 반복이 없다. 그땐 그냥 함수 하나에 LLM 호출 한 번이 맞다.
그리고 툴이 6개를 훌쩍 넘고 샌드박스(exec/write)가 필요해지는 순간, 나처럼 직접 짜는 건 권장하지 않는다. 종료 예산·루프 감지·스키마 검증·컨텍스트 절단을 내가 셋 다 빼먹었듯, 샌드박스·권한 격리까지 직접 다 챙기는 건 이미 검증된 하네스(Claude Code, OpenHands, Aider, OpenAI Agents SDK)가 떠안은 일을 다시 깨먹는 길이다. 내가 깨먹은 세 곳은 전부 그 하네스들이 기본으로 갖고 있는 것들이었다.
정리: 직접 짤 때 빼먹기 쉬운 체크리스트
- 종료 예산을 3중으로: max_steps + 토큰 상한 + 같은 (tool,args) 반복 감지. step만으로는 한글 토큰 배수(약 1.5~2배)를 놓친다
- step 한도는 정상 경로 측정치 기준으로. 벤치마크용 큰 수를 운영에 그대로 박지 말 것
- 툴 스키마는 strict가 받는 형태인지 첫 호출 전에 검증.
minItems/maxItems같은 길이 제약은 strict에서 거부 → 디스패처 검증으로 이동 - 스키마/툴 에러를 무음 폴백하지 말 것. "tool unavailable"로 삼키면 모델 능력 문제로 오진한다
- 실패한 툴 결과는 요약·절단해서 주입. 스택 트레이스 통째 누적 = 컨텍스트 오염
- 단일 결정적 호출이면 하네스 만들지 말 것