에이전트 하네스(Agent Harness) 설계 실전 가이드: 모델 바깥의 골격이 성패를 가른다
같은 모델인데 SWE-bench 점수가 두 배 가까이 갈리는 이유 — 루프·도구 디스패치·종료 예산·샌드박스를 직접 짜는 법
1. 문제 정의: 왜 모델만으로는 에이전트가 안 되는가
원시 LLM은 함수다. messages in → text out. 한 번 호출하면 끝이고, 파일을 읽지도 명령을 실행하지도 자기 출력을 검증하지도 못한다. 그런데 우리가 "에이전트"라고 부르는 것 — Claude Code, Cursor agent, SWE-agent — 은 관찰하고, 도구를 쓰고, 실패하면 재시도하고, 예산이 다 되면 멈춘다. 이 차이를 만드는 코드는 전부 모델 바깥에 있다. 그게 하네스(harness)다.
하네스는 모델을 감싸는 런타임 골격이다. 구성요소를 7개로 끊어 보면 각각이 빠질 때 무슨 일이 나는지가 분명해진다.
| 구성요소 | 하는 일 | 빠지면 생기는 일 |
|---|---|---|
| 에이전트 루프 | 관찰→사고→행동 반복 | 단발 호출, 다단계 작업 불가 |
| 도구 디스패치 + 스키마 | 모델 출력→실제 함수 호출 | 도구를 안 부르거나 인자 오류 |
| 컨텍스트/메모리 관리 | 윈도우 한계 내 정보 유지 | 긴 작업 중 컨텍스트 소진 |
| 출력 파싱·검증 | JSON/툴콜 구조 검증 | 무음 실패(아래 4·9절) |
| 에러 복구·재시도 | 도구 실패 시 백오프 | 한 번 깨지면 전체 중단 |
| 샌드박스·권한 | write/exec 격리 | 호스트 파일 손상·임의 실행 |
| 종료조건·예산 | max steps/토큰 가드 | 무한 루프·런어웨이 비용 |
핵심 주장(반증가능 형태로): 동일 모델에서 하네스 품질만 바꿔도 실세계 성공률이 10~20%p 단위로 움직인다. 근거는 다음 절에서 같은 스캐폴드·다른 모델, 그리고 같은 모델·다른 스캐폴드 두 방향으로 본다.
2. 증거: 스캐폴드가 같은 모델의 점수를 두 자릿수 %p 흔든다
"하네스가 중요하다"는 틀릴 수 없는 문장이라 정보량이 0이다. 반증가능한 형태로 바꾸면: SWE-bench Verified(인간 엔지니어가 풀 수 있음을 검수한 500-task 부분집합)에서, 스캐폴드를 고정하고 모델만 바꾼 사다리와, 모델을 고정하고 스캐폴드를 바꾼 비교 둘 다 점수가 두 자릿수 %p로 갈린다.
가장 깨끗한 증거는 Anthropic이 자기네 단일 스캐폴드를 고정하고 모델만 바꿔 잰 수치다(스캐폴드가 동일하므로 델타가 순수 모델 기여):
| 모델 (동일 스캐폴드) | SWE-bench Verified |
|---|---|
| Claude 3 Opus | 22% |
| Claude 3.5 Sonnet (구버전) | 33% |
| Claude 3.5 Sonnet (개선판) | 49% |
출처: Anthropic, Raising the bar on SWE-bench Verified. 같은 글의 명시적 결론: "에이전트의 SWE-bench 성능은 같은 모델을 써도 이 스캐폴딩에 따라 크게 달라질 수 있다."
반대 방향 — 모델을 더 최신으로 고정하고 스캐폴드(하네스)를 바꾸면 더 높은 대역에서 같은 현상이 보인다. OpenHands Software Agent SDK는 **Claude Sonnet 4.5(extended thinking)로 SWE-bench Verified 72%**를 보고한다(arXiv:2511.03690).
주의(라벨): 위 수치는 vendor/논문 self-reported이고 측정 조건이 제각각이다(extended thinking on/off, 도구셋, 평가 시점). 그래서 서로 다른 하네스의 점수를 1:1로 빼서 "하네스 A가 B보다 X%p 낫다"고 말하면 안 된다 — 모델 버전·thinking 설정이 교란변수로 섞인다. 반증가능하게 살아남는 결론은 더 좁다: ①스캐폴드를 고정하면 모델 한 세대가 22→49%처럼 움직이고, ②모델을 고정하면 스캐폴드 차이가 같은 크기의 델타를 만든다. 즉 하네스는 모델 한 세대 업그레이드에 맞먹는 레버다.
실무 결론(비자명): 모델 성능은 필요조건이지 충분조건이 아니다. 신규 모델로 갈아끼우기 전에, 당신 하네스의 루프·도구·종료조건이 지금 모델의 성능을 흘리고 있지 않은지 먼저 보라. 더 싼 개선은 대개 모델 교체가 아니라 하네스 수리다.
3. 메커니즘: 에이전트 루프는 결국 while 루프다
에이전트 루프의 본질은 단순하다. 모델 출력에 툴콜이 있으면 실행해서 결과를 붙이고 다시 호출, 없으면 종료. OpenAI Agents SDK의 실행 루프도 같은 의미론을 따른다: 모델이 툴콜을 내면 실행해 결과를 붙여 루프를 다시 돌고, max_turns를 넘으면 MaxTurnsExceeded 예외를 던진다(공식 문서: Running agents).
이걸 의존성 없이 직접 짜면 구조가 다 보인다(Python 3.11+, OpenAI 호환 client 가정).
import json
def run_agent(client, model, system, user, tools, dispatch,
max_steps=12, max_tokens_budget=200_000):
messages = [{"role": "system", "content": system},
{"role": "user", "content": user}]
used_tokens = 0
for step in range(max_steps): # 종료 예산(6절)
resp = client.chat.completions.create(
model=model, messages=messages, tools=tools,
tool_choice="auto", temperature=0,
)
used_tokens += resp.usage.total_tokens
if used_tokens > max_tokens_budget: # 토큰 가드
raise RuntimeError(f"token budget blown at step {step}")
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls: # 종료조건: 툴콜 없음
return msg.content
for call in msg.tool_calls: # 도구 디스패치(4절)
result = safe_dispatch(dispatch, call)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
raise RuntimeError("max_steps exceeded") # MaxTurnsExceeded 등가
이 40줄이 "에이전트 프레임워크"의 골격 대부분이다. LangChain·Agents SDK가 추가로 주는 건 트레이싱·핸드오프·가드레일이지, 루프 자체가 마법은 아니다. 직접 짤 수 있어야 디버깅도 된다. 프레임워크가 무음 실패할 때 이 골격을 머릿속에 갖고 있어야 어디가 깨졌는지 보인다.
4. 도구 디스패치와 스키마: 무음 실패의 진원지
3절의 safe_dispatch가 하네스에서 가장 자주 깨지는 지점이다. 모델이 내놓는 건 문자열화된 JSON 인자고, 이걸 실제 함수에 넘기는 경계에서 세 가지가 동시에 틀어질 수 있다: ①JSON 파싱 실패 ②인자 타입/누락 ③함수 자체 예외.
def safe_dispatch(dispatch: dict, call) -> str:
name = call.function.name
fn = dispatch.get(name)
if fn is None:
return json.dumps({"error": f"unknown tool: {name}"})
try:
args = json.loads(call.function.arguments or "{}")
except json.JSONDecodeError as e:
# 여기서 raise하면 루프가 죽는다. 모델에게 고쳐 쓸 기회를 줘야 한다
return json.dumps({"error": f"invalid JSON args: {e}"})
try:
return json.dumps(fn(**args))
except TypeError as e: # 인자 불일치
return json.dumps({"error": f"bad args for {name}: {e}"})
except Exception as e: # 함수 실행 예외
return json.dumps({"error": f"{name} failed: {type(e).__name__}: {e}"})
핵심 설계 판단: 도구 실패를 예외로 던져 루프를 죽이지 말고, 에러를 tool 결과로 모델에 되돌려라. 모델은 에러 메시지를 보고 인자를 고쳐 재시도할 수 있다. 이게 self-healing 루프의 핵심이다.
국내 운영에서 실제로 겪은 무음 실패(
docs/engineering-pitfalls.md수록): OpenAI strict 모드 스키마가maxItems/minItems같은 키워드를 거부하면 400을 무음으로 뱉는다. 도구가 호출조차 안 되는데 루프는 "모델이 툴을 안 쓰네" 하고 그냥 최종 답을 낸다. 증상은 "에이전트가 도구를 무시함"으로 보이지만 원인은 스키마 거부다. 해결: strict 모드면additionalProperties:false+ 지원 키워드만 쓰고, 배열 길이 제약은 description으로 빼라.
같은 부류의 무음 실패: 스키마의 required에서 필드가 빠지면 모델이 인자를 안 채우고 → 함수가 KeyError → 위 핸들러가 잡아도 모델은 왜 틀렸는지 모른다. 에러 문자열에 기대 스키마를 같이 실어 돌려주면 회복률이 오른다.
5. 컨텍스트 관리: 긴 작업은 윈도우가 아니라 compaction으로 버틴다
멀티스텝 에이전트의 첫 번째 벽은 모델 지능이 아니라 컨텍스트 윈도우 소진이다. 도구 결과(특히 파일 dump, 빌드 로그)가 누적되면 수십 스텝 만에 윈도우가 찬다. 윈도우를 더 큰 모델로 키우는 건 비용만 늘리는 임시방편이다.
Anthropic이 Claude Agent SDK에서 쓰는 해법은 compaction: 윈도우 한계에 가까워지면 대화 이력을 모델에게 요약시켜 새 윈도우로 재시작한다. 보존 대상은 아키텍처 결정·미해결 버그·구현 세부고, 버리는 건 중복 도구 출력이다. 이 압축이 실제로 얼마나 버는지는 수치로 보고돼 있다: server-side compaction이 100턴 web-search 평가에서 토큰 소비를 84% 줄였다(Anthropic self-reported, Effective harnesses for long-running agents).
직접 하네스를 짠다면 최소한의 compaction 게이트는 이렇게 들어간다.
def maybe_compact(client, model, messages, soft_limit=120_000):
approx = sum(len(json.dumps(m)) for m in messages) // 4 # 대략적 토큰 추정
if approx < soft_limit:
return messages
head = messages[:2] # system + 원 task 유지
summary = client.chat.completions.create(
model=model,
messages=messages + [{"role": "user", "content":
"지금까지의 진행을 요약해라. 보존: 확정된 결정, 미해결 버그, "
"수정한 파일 경로와 핵심 diff. 버려: 중복된 파일 전문, 성공한 중간 로그."}],
temperature=0,
).choices[0].message.content
return head + [{"role": "user", "content": f"[COMPACTED CONTEXT]\n{summary}"}]
트레이드오프 명시: compaction은 컨텍스트 수명을 늘리는 대신 정보를 잃는다. 요약이 미해결 버그를 빠뜨리면 에이전트가 같은 버그를 다시 판다. 그래서 "무엇을 보존할지" 프롬프트가 곧 하네스의 품질이다. Anthropic은 클라이언트 측 압축보다 server-side compaction을 권한다(토큰 계산이 정확하고 클라이언트 한계가 없음) — 직접 짜기 전에 server-side가 있으면 그걸 먼저 쓰는 게 합리적이다. 위 추정식(
//4)은 영어 기준 어림이라, 다음 문단의 이유로 한국어 코드베이스에선 실제보다 낙관적으로 샌다.
한국 개발자 실무 비용 주의: compaction 요약 호출도 토큰을 먹는다. 그리고 한글은 영어 대비 토큰이 1.5~2배 들어간다(BPE 토크나이저가 한글을 잘게 쪼갬). 한글 코드 주석·한글 로그가 많은 국내 코드베이스는 compaction이 더 자주 트리거되고 그만큼 요약 호출 비용이 추가된다. 서울 리전 워커라도 LLM 호출은 결국 토큰 과금이므로, soft_limit을 영어 기준 예제보다 보수적으로(예: 윈도우의 60% 지점) 잡아야 비용·실패가 안정된다.
6. 종료조건과 예산: 런어웨이를 막는 세 겹의 가드
하네스에서 돈을 태우는 버그는 거의 다 종료조건 부재에서 나온다. 무한 도구 루프(같은 파일을 100번 읽음)나, 모델이 끝낼 줄 모르고 스텝을 계속 소비하는 경우다. 가드는 세 겹으로 친다.
┌─────────────────────────────────────────────┐
│ 종료 예산 3중 가드 │
├─────────────────────────────────────────────┤
│ ① step 가드 : max_steps (예: 12) │ ← 루프 횟수
│ ② token 가드 : 누적 토큰 상한 (예: 200k) │ ← 비용 상한
│ ③ wall-clock : 작업당 벽시계 타임아웃 │ ← 행 상태 방지
└─────────────────────────────────────────────┘
└─ 어느 하나라도 걸리면 즉시 raise + 부분결과 반환
추가로 진전 없음(no-progress) 감지가 실전에서 효과가 크다. 직전 N스텝의 툴콜이 동일하면 루프를 끊는다.
from collections import deque
recent = deque(maxlen=3)
def stuck(tool_calls) -> bool:
sig = tuple((c.function.name, c.function.arguments) for c in tool_calls)
recent.append(sig)
return len(recent) == 3 and len(set(recent)) == 1 # 3연속 동일 = 루프
운영 수치(작업당 측정해 대시보드에 올려라): task당 토큰 수 / task당 도구 호출 수 / 평균 step 수 / wall-clock p95 / 예산 hit 비율. 예산 hit 비율(=가드에 걸려 중단된 작업 비율)이 5%를 넘으면
max_steps가 너무 빡빡하거나 작업이 하네스 능력 밖이라는 신호다. 둘은 로그로 구분된다: 가드 직전 스텝이 "막 진전 중"이었으면 예산 부족, "같은 행동 반복"이었으면 능력 밖. (5% 임계치는 시작점 휴리스틱이지 보편 상수가 아니다 — 작업 난이도 분포에 맞춰 보정하라.)
7. 샌드박스와 권한: write/exec는 격리가 기본값
에이전트가 bash나 파일 write 도구를 갖는 순간, 하네스는 보안 경계가 된다. 모델이 악의가 없어도 환각으로 rm -rf를 부를 수 있고, 프롬프트 인젝션된 파일을 읽고 그대로 실행할 수 있다. OpenHands SDK가 native sandboxed execution을 1급 기능으로 내세우는 이유다(arXiv:2511.03690).
최소 격리는 세 가지다.
| 격리 | 구현 | 막는 것 |
|---|---|---|
| 파일시스템 | 작업 루트 chroot/컨테이너 마운트, 경로 화이트리스트 | 호스트 파일 손상 |
| 실행 | Docker/gVisor/Firecracker 안에서만 exec | 임의 명령 실행 |
| 네트워크 | egress 차단 또는 도메인 화이트리스트 | 데이터 유출·SSRF |
import os
WORKSPACE = "/sandbox/workspace"
def safe_path(p: str) -> str:
full = os.path.realpath(os.path.join(WORKSPACE, p))
if not full.startswith(WORKSPACE + os.sep): # ../ 탈출 차단
raise PermissionError(f"path escapes sandbox: {p}")
return full
최소 권한 원칙: 도구마다 권한을 분리하라. 읽기 전용 에이전트에 write 도구를 주지 마라. 검수(critic) 에이전트는 코드를 읽되 exec 권한이 없어야 한다(생성 에이전트와 권한을 격리하면 self-consistency bias 회피이자 보안 경계가 된다). 멀티에이전트의 진짜 정당화 신호 하나가 바로 이 권한 격리다 — 에이전트를 늘리는 게 아니라 권한을 쪼개는 것.
국내 함정: 사내망에서 도는 코딩 에이전트가 egress 화이트리스트 없이 curl·패키지 설치 도구를 가지면, 프롬프트 인젝션 한 방에 내부 시크릿(예: 키체인·환경변수의 토큰)을 외부로 빼낼 수 있다. macOS 빌드 환경에서 키체인 기반 셸 시크릿을 다루는 워크플로라면 위험이 더 크다. 에이전트 샌드박스의 네트워크 egress를 기본 차단으로 두고 필요한 도메인만 여는 게 안전하다.
8. 검수(critic) 게이트: 작성과 검증은 독립이어야 한다
하네스에 품질 게이트를 한 겹 넣는 건 비용 대비 효과가 큰 개선이다. 핵심은 검증을 작성과 독립시키는 것이다. 같은 컨텍스트로 "네가 쓴 거 맞아?"라고 물으면 self-consistency bias로 거의 다 통과시킨다. 별도 critic 호출(가능하면 별도 권한·별도 컨텍스트)로 돌려야 한다.
def critic_gate(client, model, task, artifact) -> dict:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content":
"너는 적대적 검수자다. 작성자가 아니다. "
"통과시키려 하지 말고 깨질 지점을 찾아라. "
"JSON으로만: {verdict: PASS|REVISE, blockers: [구체적 사유]}"},
{"role": "user", "content":
f"원 작업:\n{task}\n\n산출물:\n{artifact}"},
],
temperature=0,
)
return json.loads(resp.choices[0].message.content)
운영 팁: critic이
REVISE를 내면 blockers를 다음 작성 루프의 입력으로 되먹여라(4절의 에러 되돌림과 같은 패턴). 단, 재시도 횟수에도 예산을 둬라(보통 2~3회). 무한 작성↔검수 핑퐁도 런어웨이의 한 종류다. 또한 위 코드는 critic 출력이 항상 유효 JSON이라고 가정하는데, 실제로는 깨진다 — 4절의safe_dispatch처럼json.loads를 try로 감싸고 실패 시 REVISE로 폴백하라.
트레이드오프: critic 게이트는 호출이 곱으로 늘어 토큰·지연이 증가한다. 그래서 모든 산출물이 아니라 비가역·고비용 행동 직전에만 게이트를 거는 게 합리적이다 — 코드 머지, 외부 발송, DB mutation 같은 지점. 읽기·탐색 단계엔 게이트가 과설계다.
9. 실패 모드 카탈로그: 증상 → 원인 → 해결
하네스를 운영하면 반복되는 실패 모드가 있다. 증상으로 원인을 역추적하는 표다.
| 증상 | 원인 | 해결 |
|---|---|---|
| 에이전트가 도구를 아예 안 씀 | strict 스키마가 maxItems 등 거부 → 무음 400 | 스키마에서 미지원 키워드 제거, 배열 제약은 description으로 |
| 같은 파일/명령 무한 반복 | no-progress 감지 부재 | 6절 stuck() 게이트 추가 |
| 수십 스텝 후 횡설수설 | 컨텍스트 윈도우 소진 | 5절 compaction 게이트 |
| 비용이 예고 없이 폭발 | 종료 예산 없음(특히 토큰 가드) | 6절 3중 가드 |
| 도구 한 번 실패 후 전체 중단 | 디스패치에서 raise | 4절 에러를 tool 결과로 되돌림 |
| critic이 다 통과시킴 | 작성=검증 같은 컨텍스트 | 8절 독립 critic 호출 |
| 인자 타입 에러 반복 | 모델이 스키마를 못 봄 | 에러 문자열에 기대 스키마 동봉 |
| 멀티에이전트인데 더 멍청해짐 | 컨텍스트 유실·모순 병합 | 핸드오프에 명시적 컨텍스트 전달, 병합 규칙 정의 |
가장 악질은 무음 실패다. 에러를 던지는 실패는 로그에 남지만, "도구를 안 쓰고 그냥 답함" 류는 정상 응답처럼 보인다. 방어책: 모든 스텝에 structured 로깅(step, tool_calls 유무, 토큰, 결정 사유)을 남기고, "툴콜 0인데 작업 미완" 패턴을 알람으로 잡아라. 하네스는 관측 가능성이 없으면 디버깅이 안 된다.
10. 언제 하네스를 만들지 말아야 하는가 (when-NOT-to-use)
하네스는 공짜가 아니다. 루프·도구·샌드박스·예산·관측을 다 짜고 유지보수해야 하며, 디버깅 표면이 단발 호출보다 훨씬 넓다. 그래서 첫 질문은 항상 "정말 에이전트여야 하는가"다.
하네스가 정당화되는 신호
- 작업이 본질적으로 다단계이고, 다음 행동이 이전 결과에 의존한다(탐색→수정→검증).
- 도구가 필수다(파일·셸·API를 실제로 만져야 함).
- 경로가 불확실해 모델이 런타임에 분기를 정해야 한다.
단일 호출로 충분(하네스 안티패턴)
- 결정적 단일 변환: 번역, 요약, 분류, 추출. 도구도 루프도 필요 없다.
client.chat.completions.create한 방이면 끝이고, 거기에 루프를 씌우면 비용·지연·실패 지점만 곱으로 늘어난다. - 워크플로가 고정 DAG: 단계와 순서가 미리 정해졌다면 에이전트 루프가 아니라 그냥 코드로 오케스트레이션(함수 호출 순서)하라. 모델은 각 노드에서 단발로 부른다. LLM에게 흐름 제어를 맡기면 비결정성만 추가된다.
- 도구가 1개고 그걸로 끝: 루프의 가치는 "여러 도구를 상황 따라 고르는 것"에 있다. 도구가 하나면 디스패치 추상화가 과설계다.
판단 기준(비자명): "에이전트 vs 워크플로"의 결정선은 다음 행동을 누가 정하느냐다. 내가 순서를 알면 워크플로(코드), 모델이 런타임에 정해야 하면 에이전트(하네스). 의심스러우면 워크플로로 시작해서, 분기가 데이터에 따라 폭발하는 구체적 지점에서만 하네스로 승격하라. "멀티에이전트 = 똑똑함"이 착각인 것처럼 "에이전트화 = 자동화"도 착각이다. 가장 견고한 자동화는 대부분 결정적 코드 + 그 안의 단발 LLM 호출이다.
11. 월요일 아침 체크리스트: 기존 하네스 진단하기
새로 안 짜더라도, 이미 프레임워크(Agents SDK·LangGraph·OpenHands 등) 위에서 도는 에이전트가 있다면 이번 주에 점검할 항목.
[ ] 종료 예산이 3겹인가? (max_steps / 토큰 / wall-clock)
→ 토큰 가드 없으면 최우선으로 추가. 비용 사고의 1순위.
[ ] 도구 실패가 루프를 죽이는가, 모델에 되돌아가는가?
→ 일부러 도구 하나를 깨뜨려 보고 에이전트가 회복하는지 관찰.
[ ] strict 스키마에 maxItems/minItems 들어있나?
→ 들어있으면 도구가 무음으로 안 불릴 수 있음. 로그로 툴콜 0 비율 확인.
[ ] no-progress(동일 툴콜 반복) 감지가 있나?
[ ] compaction이 켜져 있고, 무엇을 보존하는지 프롬프트가 명시적인가?
[ ] write/exec 도구에 샌드박스 + egress 차단이 있나?
[ ] critic 게이트가 작성과 독립 컨텍스트인가? (같은 세션 재질문 = 무효)
[ ] step별 structured 로깅으로 무음 실패를 잡을 수 있나?
[ ] task당 토큰/도구호출/step/wall-clock p95가 대시보드에 있나?
우선순위: 종료 예산 > 도구 에러 되돌림 > 관측 로깅 > compaction > critic > 샌드박스 강화 순으로 ROI가 높다. 단, exec 도구가 있으면 샌드박스가 무조건 1순위로 올라온다 — 보안은 ROI 계산 대상이 아니라 전제다.
검증 출처: 같은-스캐폴드 모델 사다리(22→33→49%)는 Anthropic, Raising the bar on SWE-bench Verified, OpenHands SDK 72%(Sonnet 4.5 extended thinking) 및 native sandboxed execution은 arXiv:2511.03690, 루프 의미론·MaxTurnsExceeded는 OpenAI Agents SDK 공식 문서, compaction과 84% 토큰 절감 수치는 Anthropic, Effective harnesses for long-running agents 및 Building agents with the Claude Agent SDK. 벤치 수치는 모두 vendor/논문 self-reported이며 측정 조건이 달라 하네스 간 1:1 비교는 성립하지 않는다(2절 주의 참조). 코드 스니펫은 의존성 없이 동작하는 골격이며 프로덕션에선 재시도 백오프·트레이싱·JSON 파싱 가드를 추가하라.