에이전트가 파일을 읽고, 코드를 고치고, 테스트까지 통과시키는 모습은 이제 별로 낯설지 않다. 그런데 막상 "그 안에서 정확히 무슨 일이 일어나는가?"라고 물으면 설명하기가 쉽지 않았다.
모델이 파일을 읽는다는 건 무슨 뜻일까. 모델이 "다 했습니다"라고 말하면 누가 그 말을 확인할까. 대화가 길어질수록 쌓이는 맥락과 비용은 누가 관리할까?
궁금하면 직접 만들어보는 게 가장 빠르다. 그래서 API 키나 비용 없이 에이전트의 구조를 밑바닥부터 관찰할 수 있는 학습용 레포, mini-harness를 만들었다.
이 글은 개념을 요약하는 데서 끝나지 않는다. 가능하면 터미널을 열고 레벨별 코드를 직접 실행해보길 권한다.
git clone https://github.com/1nxeo/mini-harness.git
cd mini-harness/mini-harness
python3 level0_bare.py추가 설치는 없다. 표준 라이브러리만 쓰고, Python 3.9 이상이면 그냥 돌아간다. 진짜 모델을 붙여보고 싶을 때만 pip install anthropic이 필요하다.
모델은 엔진이고, 하네스는 나머지 전부다
레포 전체를 관통하는 비유는 하나다. 모델은 자동차 엔진이고, 하네스(harness)는 엔진을 뺀 자동차의 나머지 전부다. 엔진만 있으면 부릉거리는 것 말고는 아무것도 못 한다. 바퀴·핸들·브레이크·연료탱크가 붙어야 "이동"이라는 실제 일이 된다.
harness라는 이름은 원래 말에 씌우는 마구(馬具)에서 왔고, 소프트웨어에서는 테스트 하네스라는 말로 오래 써왔다. 함수 하나를 테스트하려면 입력을 만들어 넣고, 가짜 의존성을 붙이고, 결과를 비교하고, 리포트를 뽑는 틀이 필요한데 그 껍데기가 하네스다. AI 하네스는 같은 개념을 모델에 적용한 것뿐이다.
이 레포에서 모델은 정말로 딱 이만큼만 한다.
# model.py:73, 106 — FakeModel과 AnthropicModel의 공통 인터페이스
def call(self, messages, tools, system=None) -> Reply:
...메시지 목록과 도구 목록을 넣으면, 텍스트 또는 "이 도구를 이 인자로 불러줘"라는 요청을 돌려준다. 그게 끝이다. 이 함수 하나를 인터페이스로 못 박아두면, 뒤에 진짜 API가 있든 스크립트로 짜놓은 가짜가 있든 나머지 코드는 한 줄도 안 바뀐다. 그래서 이 레포에는 FakeModel이라는 게 있다 — 정해진 시나리오를 순서대로 뱉어주는 가짜 모델이다. 비용도, 네트워크도, API 키도 없이 하네스의 "배관"만 따로 관찰할 수 있는 이유다. 테스트 코드의 mock과 정확히 같은 역할이다.
이 함수만 놓고 보면 모델에게는 세 가지가 부족하다.
- 손이 없다 — 모델은 스스로 파일을 열거나 명령어를 실행하지 못한다. 프로젝트의 실제 상태를 확인할 도구가 없으면 학습된 일반론을 바탕으로 추측할 수밖에 없다.
- 지속되는 기억이 없다 — 기본적인 독립 API 호출은 이전 호출을 기억하지 않는다. 대화가 이어지는 것처럼 보이는 건 하네스가 지난 메시지를 저장했다가 다음 요청에 다시 넣어주기 때문이다.
- 실행 결과를 보고 다음 행동을 이어갈 수 없다 — 호출 한 번으로 답변은 만들 수 있지만, 도구 실행 결과를 관찰하고 계획을 수정하려면 다시 모델을 호출하는 루프가 필요하다.
하네스는 이 세 가지를 메우는 배관이다. 손은 도구로, 기억은 대화 기록으로, 여러 단계의 작업은 루프로 만든다. 이 레포는 그 구조를 레벨별로 하나씩 붙여나간다.
Level 0 — 손이 없으면 무슨 일이 생기는가
python3 level0_bare.py돌려보면 모델은 "테스트 실패의 흔한 원인은 1) ZeroDivisionError 2) import 경로 문제 3)…" 같은 일반론을 답하고 끝난다. 틀린 말은 아닌데, 아무 파일도 안 봤으니 이 프로젝트에 대한 답은 아니다. 코드 전체가 사실상 이 한 줄이다.
reply = model.call([{"role": "user", "content": TASK}], tools=[])이게 우리가 평소에 "그냥 챗봇"이라고 부르는 상태다. 뭐가 부족한지 눈으로 보고 나면 다음 레벨이 왜 필요한지 저절로 납득된다.
Level 1 — 루프의 탄생, 하네스의 심장
python3 level1_loop.py여기서부터 진짜 재미있어진다. Level 0과 모델은 완전히 똑같다. 딱 두 가지가 늘었다 — read_file이라는 손 하나, 그리고 루프 하나. 그런데 모델이 갑자기 실제 파일 내용을 근거로 답하기 시작한다.
루프는 이렇게 생겼다.
# level1_loop.py:74-103 (출력 코드 일부 생략)
def run(model, task: str, max_steps: int = 10):
messages = [{"role": "user", "content": task}]
for step in range(1, max_steps + 1):
reply = model.call(messages, tools=list(TOOLS.values()))
messages.append(assistant_message(reply))
if not reply.wants_tools:
print("\n모델이 더 할 일이 없다고 했습니다. 루프 종료.")
return messages
results = []
for call in reply.tool_calls:
tool = TOOLS.get(call.name)
if tool is None:
results.append((call.id, f"없는 도구입니다: {call.name}", True))
continue
try:
out = tool.run(**call.args)
err = False
except Exception as e:
out, err = f"{type(e).__name__}: {e}", True
results.append((call.id, str(out)[:4000], err))
messages.append(tool_result_message(results))그림으로 보면 이렇다.
flowchart TD
A[사용자 요청] --> B["모델 호출\nmodel.call — messages, tools 전달"]
B --> C{도구를 부르는가?}
C -- 아니오, 다 했다 선언 --> D[루프 종료]
C -- 예 --> E[하네스가 실제로 도구 실행]
E --> F[결과를 대화 기록에 추가]
F --> B
이 20줄 안에서 눈여겨볼 게 세 개 있다.
실행 주체는 하네스다. 모델은 "read_file을 부르고 싶다"는 구조화된 요청만 뱉는다. 파일을 실제로 여는 코드는 tool.run(**call.args), 즉 하네스 쪽에 있다. 모델은 끝까지 텍스트만 주고받을 뿐 실제 실행 권한이 없다.
실패도 결과로 되돌려준다. except Exception은 이 루프에서 가장 중요한 처리 중 하나다. 실제로 돌려보면 모델이 없는 파일을 읽으려다 FileNotFoundError를 받는 장면이 나오는데, 하네스가 죽지 않고 그 실패 정보를 모델에게 그대로 돌려준다. 에이전트가 자기 실수를 스스로 고칠 수 있는 건 오직 이 실패 정보가 되돌아오기 때문이다. 여기서 예외를 그냥 던져버리면 에이전트는 유리처럼 깨진다.
Level 1의 한계는 명확하다. 읽는 손만 있고 쓰는 손이 없다. 실행해보면 모델이 원인은 정확히 찾아내는데, 마지막에 "다만 저에게는 파일을 쓰는 도구가 없어서 실제로 고칠 수는 없습니다"라고 스스로 말한다. 이 문장을 직접 눈으로 보는 순간, 다음 레벨이 왜 필요한지 설명이 필요 없어진다.
Level 2 — "다 했습니다"라는 말을 믿지 않는 코드
python3 level2_agent.py여기서부터 진짜 "일하는 에이전트"가 된다. 세 가지가 더해진다 — 쓰는 손(write_file, edit_file, run_bash), 작업 폴더를 가두는 샌드박스, 그리고 이 레벨의 존재 이유인 검증 게이트다.
가짜 시나리오에는 일부러 함정을 심어놨다. 모델이 버그 두 개 중 하나만 고치고 "고쳤습니다. 완료했습니다"라고 선언한다. 검증 게이트가 없으면 여기서 작업이 그냥 끝나고, 사용자는 여전히 반쯤 깨진 코드를 받는다. 이 부분을 코드로 보면 이렇다.
# level2_agent.py:87-116 (요지만 추림)
if not reply.wants_tools: # 모델이 "다 했다"고 함
verify_rounds += 1
ok, out = verify(sandbox) # 하네스가 직접 `python3 -m unittest -q` 실행
if ok:
return True, messages # 유일한 성공 종료 출구
if verify_rounds >= max_verify:
return False, messages # 재시도 한도 초과 → 포기
messages.append({"role": "user", "content":
f"아직 테스트가 실패합니다. `{VERIFY_CMD}` 결과:\n\n{out}\n\n원인을 다시 찾아 고치세요."})
continue # 루프 맨 위로 되돌아간다핵심은 verify()가 모델의 말이 아니라 실제 종료 코드를 본다는 것이다. 통과하면 진짜로 끝나고, 실패하면 그 실패 메시지를 "사용자가 말한 것처럼" 대화에 그대로 붙여서 루프 맨 위로 돌려보낸다. 실제로 실행하면 이런 순서가 보인다.
[5] 모델 고쳤습니다. 완료했습니다.
⟳ 검증 실패 → 모델에게 되돌려 보냄 (1/3)
[6] 모델 percent() 도 같은 문제였네요. 함께 고칩니다.
...
✅ 검증 통과 (모델 호출 8회)모델의 자기 보고와 하네스의 판정이 갈라지는 순간을 두 눈으로 보는 게, 이 레포에서 개인적으로 가장 짜릿했던 지점이다.
flowchart TD
A[모델 호출] --> B{도구를 불렀나?}
B -- 예 --> C[하네스가 도구 실행]
C --> D[결과를 대화에 추가]
D --> A
B -- 아니오 다 했다 선언 --> E["검증 게이트\n하네스가 직접 테스트 실행"]
E -- 통과 --> F[성공 종료]
E -- 실패 + 한도초과 --> G[포기하고 종료]
E -- 실패 --> H[실패 메시지를 대화에 추가]
H --> A
진짜 있었던 버그 하나
이 레벨을 만들면서 개인적으로 제일 배운 게 많았던 대목은 따로 있다. 샌드박스가 작업 폴더 밖 접근을 막아야 하는데, 초안 코드는 이렇게 짜여 있었다.
# ❌ 실제로 뚫리는 버전
if not str(p).startswith(str(self.root)):경로를 그냥 문자열 접두어로 비교했다. root가 /tmp/proj일 때, /tmp/proj_secret이나 /tmp/projects도 접두어가 맞으니까 그냥 통과해버린다. 실제로 확인해보면 이렇다.
구버전 startswith: 뚫림 → SECRET
수정판 parents : 차단됨고친 버전은 문자열이 아니라 경로 관계로 판정한다.
def resolve(self, rel):
p = (self.root / rel).resolve()
if p != self.root and self.root not in p.parents:
raise PermissionError(f"작업 폴더 밖 접근 차단: {rel}")
return p교훈은 단순하다 — 경로 검사는 문자열이 아니라 경로 관계로 해야 한다. .resolve()로 정규화하고 부모 관계를 본다. 그리고 더 중요한 정직함 하나: 이 샌드박스는 파일 도구(read/write/edit/list/grep)만 가둔다. run_bash는 작업 폴더를 작업 디렉터리로 쓸 뿐이고, cwd는 감옥이 아니다. 셸 안에서는 cat /etc/passwd가 그냥 된다. 셸을 모델에게 쥐여주는 순간 진짜 격리는 프로세스 밖(컨테이너, 별도 권한, 네트워크 허용목록)에서 해야 한다는 게 이 레포가 정직하게 남겨둔 결론이다.
Level 3 — 오래 일하면 새는 것들
python3 level3_production.pyLevel 2까지는 "한 번에 끝나는 작업"을 가정한다. 실무에서는 대화가 길어지면서 세 가지가 새기 시작한다.
- 컨텍스트가 터진다 — 그래서 중간을 요약으로 압축하는데, 최초 목표와 방금 한 일은 버리면 안 된다. 순진하게 자르면
tool_use와tool_result의 짝이 깨져서 실제 API에서 400 에러가 난다. - 청구서가 샌다 — 호출당 최대 3회 시도하도록 해놓고 이를 "1턴"으로만 세면,
max_calls=25가 실제로는 최대 75건의 요청을 허용해버린다. 부모가 25회, 자식이 8회씩 독립된 예산을 가지면 최악의 경우 200회까지 나갈 수도 있다. - 재시도가 만능이 아니다 — 400(잘못된 요청)이나 401(잘못된 키)은 100번 재시도해도 100번 실패한다. "기다리면 나아지는 오류"에만 재시도를 써야 한다.
이 레벨은 압축·예산 상한·재시도·서브에이전트(하위 작업에 조사를 맡기고 결론만 돌려받는 구조)·실행 트레이스(trace.jsonl) 같은 실무 장치를 붙인다. 실제로 실행하면 한 번의 작업 안에서 이 장치들이 함께 움직이는 모습을 볼 수 있다.
[main 1] 🔧 read_file {'path': '../../../etc/passwd'}
↳ 차단: 작업 폴더 밖 접근
↻ 일시적 오류(429 rate_limit) — 재시도 1/2
[main 3] 🔧 spawn_subagent {'purpose': '실패 원인을 특정하라'}
⤵ 컨텍스트 압축 | 토큰 793 → 624 (21% 절감)
✅ 성공 — 4개 테스트 모두 통과
API 요청 15/40회
트레이스 저장: trace.jsonl한 번의 실행 안에서 경로 탈출 차단, 재시도, 서브에이전트 위임, 컨텍스트 압축, 예산 계상이 모두 일어난다. Level 2가 "일을 끝내는 구조"라면, Level 3는 그 구조가 오래 돌아가도 무너지거나 비용이 새지 않게 관리하는 단계다.
이 글에서 전부 파고들기엔 분량이 넘치니, 자세한 내용은 레포의 LEARN.md로 넘긴다. 특히 "실제로 있었던 버그 목록" 장이 재밌다 — 컨텍스트를 조작하는 코드, 경계·안전 코드, 예산 코드 세 군데에 버그가 몰려 있다는 패턴이 보인다.
하네스가 스스로를 검증한다
python3 validate.py이 레포에는 하네스 자체를 검증하는 하네스가 딸려 있다. 대화 히스토리가 실제 API에 보내도 유효한 모양인지, 샌드박스 탈출이 막히는지, 재시도가 예산을 우회하지 않는지 등을 13개 범주, 총 41개 검사로 확인한다. API 키가 없어도 이걸 돌리면 "실전에 붙였을 때 터질 지점"을 미리 잡을 수 있다. 개인적으로 이 레포에서 투자 대비 효과가 제일 큰 코드라고 생각한다.
정리
모델은 메시지를 넣으면 텍스트나 도구 호출 요청을 뱉는 함수일 뿐이다. 그 함수를 "일하는 에이전트"로 바꾸는 건 전부 주변에 붙은 코드다 — 손(도구), 기억(대화 기록 관리), 여러 발짝(루프), 그리고 무엇보다 모델의 "다 했습니다"를 기계적으로 확인하는 검증 게이트.
이 레포를 만들면서 제일 많이 배운 건 코드 자체보다 "초안에 있던 버그들"이었다. 경로를 문자열로 비교하다 뚫리고, 재시도가 예산을 조용히 우회하고, 압축이 대화 기록의 짝을 깨뜨리는 것들 — 전부 실제로 겪고 나서야 왜 그 방어 코드가 필요한지 체감했다. 레벨 하나를 실행해볼 때마다 "왜 이게 필요하지?"에 대한 답이 그 다음 레벨에서 바로 나온다는 게 이 레포를 만든 이유였다.
궁금하면 직접 클론해서 레벨 0부터 3까지 순서대로 돌려보길 권한다. 더 깊게 파고 싶다면 LEARN.md에 개념부터 코드까지, 용어집과 확인 문제까지 다 있다.
직접 만들어보고 싶다면 → mini-harness