
Claude Code 스킬의 효과를 A/B로 실측하는 자작 하네스(Harness) 제작 방법
요약
Claude Code의 스킬이나 플러그인 도입 효과를 객관적으로 측정하기 위한 자작 A/B 테스트 하네스 구현 방법을 소개합니다. git worktree를 통한 격리, 기계적 지표 측정, 독립 LLM judge를 활용하여 체감이 아닌 수치 기반의 평가 메커니즘을 구축하는 과정을 다룹니다.
핵심 포인트
- git worktree를 활용하여 대상 리포지토리를 오염시키지 않는 격리된 환경 구축
- 비용, 시간, 턴 수 등 객관적 지표와 독립 LLM을 이용한 자동 채점 시스템
- 판정 기준을 사전에 등록하여 결과 해석의 주관성 배제
- 실패한 스킬도 보존하여 재평가가 가능하도록 설계된 가역성 확보
스킬이나 플러그인을 넣고 "왠지 좋아진 것 같다"——그 판정, 체감으로 끝내고 계시지는 않습니까?
이 기사에서는 Claude Code의 스킬 도입 효과를 claude -p (headless=대화 없는 일괄 실행 모드)로 자동 A/B 측정하는 자작 하네스(Harness)의 구현을, 복사해서 바로 사용할 수 있는 형태로 해설합니다.
일회용 worktree를 통한 격리, 기계 측정 + 독립 LLM judge, 판정 기준의 사전 등록, 그리고 기각하더라도 삭제하지 않는 가역성——이 4가지 기둥을 실제 코드를 곁들이며 순서대로 살펴보겠습니다.
앞서 공개한 note 버전에서는 인기 있는 스킬 팩을 도입하기 전에, 자신의 설정을 감사(Audit) → 최소 도입 → 실측으로 판정하는 경험에 대해 썼습니다. 본 기사는 그 속편으로, 실측에 사용한 A/B 하네스의 구현(how)에 집중합니다.
얻을 수 있는 것은, 대상 리포지토리를 전혀 더럽히지 않고 "스킬을 장착한 실력"과 "순수한 실력"을 비교하여, 비용·시간·테스트 결과를 기계적으로 측정한 뒤, 독립된 LLM에게 합격 여부를 채점하게 하는 메커니즘입니다. 게재하는 코드는 모두 수중에 있는 구현(약 230행의 Python 1개 파일)에서 가져온 것입니다.
독자 대상으로는 스킬이나 플러그인의 도입 효과를 체감이 아닌 수치로 판정하고 싶은 개발자, 그리고 claude -p를 사용한 자동 평가에 관심이 있는 분들을 상정하고 있습니다.
전체 아키텍처
이 하네스가 하는 일은 "두 가지 실력을 같은 태스크로 싸우게 하여 결과를 기록하는 것"뿐입니다. 실력이란 비교 대상을 의미하며, 순수한 claude -p를 baseline, 프롬프트 서두에서 스킬 정의(SKILL.md)를 읽게 하여 따르게 하는 쪽을 skill이라고 부릅니다.
처리 흐름은 일직선입니다.
tasks.json (각 태스크: prompt / verify_cmd / pass_criteria / …)
│
▼ 실력별로 일회용 worktree를 생성 (baseline / skill)
...
설계는 4가지 원칙을 관통하고 있습니다.
- 격리: 각 실행을 일회용 git worktree로 실행하여 대상 리포지토리를 더럽히지 않음
- 기계 측정 + 독립 judge: 객관적 지표(exit code·비용·시간·턴 수)를 기계로 측정하고, 합격 여부 채점은 실행한 에이전트 본인에게 맡기지 않고 별도 세션의 LLM에게 맡김
- 판정 기준의 사전 등록: "어떻게 되어야 유효하다고 간주할 것인가"를 실행 전에 결정하여, 결과를 보고 난 뒤의 사후 해석을 방지
- 가역성: 기각한 스킬도 삭제하지 않고 퇴피시켜, 언제든 재평가할 수 있는 상태를 유지
이후 섹션에서 이 4가지 원칙이 코드와 운용의 어디에 녹아있는지 추적하겠습니다.
구현 워크스루 (Walkthrough)
일회용 worktree로 격리하기
대상 리포지토리를 더럽히지 않기 위해, 각 실행은 git worktree로 만든 임시 디렉토리 안에서만 동작합니다. clone보다 빠르고, --detach를 통해 임의의 커밋으로부터 독립된 작업 트리를 만들 수 있다는 점이 장점입니다.
def make_worktree(repo, ref=None):
wt = Path(tempfile.mkdtemp(prefix="ab-wt-")) / "wt"
cmd = ["git", "-C", repo, "worktree", "add", "--detach", str(wt)]
...
ref를 전달하면 그 시점부터 worktree를 생성할 수 있습니다. 이는 후술할 "과거 버그 재주입 방식"에서 유용하게 쓰입니다. 작업이 끝나면 drop_worktree로 worktree를 제거하고 임시 디렉토리째 삭제하므로 뒷정리도 자동입니다.
diff를 추출할 때 한 가지 함정이 있습니다. 생성 시점의 SHA(base)와 비교하지 않으면, 에이전트가 worktree 내에서 git commit을 했을 경우 HEAD 비교 시 diff가 빈 값(empty)이 되어 오판정을 초래합니다. 실제로 이 장애를 겪었기에 base를 고정해 두었습니다.
def capture_diff(wt, base):
# base(worktree 생성 시점의 sha)와 비교한다. HEAD 비교를 하면 에이전트가
# worktree 내에서 git commit을 했을 경우 diff가 비어있게 되어 오판정한다 (2026-07-12 실제 장애)
...
headless 실행과 비용 취득
실력의 실행은 claude -p
의 headless 모드입니다. --output-format json을 붙이면, 표준 출력(standard output)으로 총 비용(total_cost_usd)이나 턴 수(num_turns)를 포함한 JSON이 반환되므로, 거기서 기계적 측정값을 추출합니다.
def claude_p(prompt, cwd, model, allowed_tools=None, timeout=1800):
cmd = [CLAUDE, "-p", prompt, "--output-format", "json", "--model", model]
if allowed_tools:
...
소요 시간은 JSON에 의존하지 않고, time.time()으로 실측합니다. JSON 파싱(parsing)에 실패하면 is_error를 세우고 끝부분만 가져오는 방어 로직도 넣어두었습니다.
또 하나 은근히 중요한 것이, claude를 자식 프로세스(child process)로 기동할 때의 환경 변수(environment variable) 정리입니다. Claude Code의 세션 내에서 이 스크립트를 실행하면, 중첩 실행을 나타내는 환경 변수가 상속되어 동작이 불안정해집니다. 따라서 실행 전에 해당 변수들을 제거합니다.
def clean_env():
env = dict(os.environ)
for k in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SSE_PORT"):
...
이 clean_env()를 실력(arm) 실행과 verify_cmd 실행 모두에 공통으로 적용하고 있습니다.
skill 실력의 프롬프트 합성
skill 실력은 태스크(task) 프롬프트의 맨 앞에 스킬 정의를 읽으라는 지시를 삽입하는 것만으로 만듭니다. 비대화형(non-interactive) 실행이므로, "질문하는 대신 가정을 명시하며 진행하라"고 못 박아두는 것이 포인트입니다.
SKILL_ARM_PREFIX = (
"First, read the file at {skill_path} and follow its process strictly for this task. "
"This is a non-interactive run: where the process calls for asking the user questions, "
...
실력별 분기는 이것뿐입니다. baseline은 순수 프롬프트를, skill은 접두사(prefix)를 더한 프롬프트를 사용합니다.
def run_one(task, arm, run_idx, args):
prompt = task["prompt"]
if arm == "skill":
...
참고로 SKILLS_DIR은 ~/.claude/skills로 고정되어 있습니다(전체 코드 참조). task["skill"]로 지정한 이름의 디렉토리 아래에 SKILL.md를 두어야 합니다.
독립된 judge에게 채점시키기
합격 여부는 실행한 에이전트(agent)와는 다른 세션에서, 별도의 모델(기본값은 저렴한 haiku)이 채점하게 합니다. judge에게 보여주는 것은 "태스크·합격 기준·verify 커맨드 결과·diff"뿐입니다.
실행 과정(사고 로그나 중간의 주고받은 대화)은 의도적으로 보여주지 않습니다. 과정을 보여주면 "열심히 했으니까"와 같은 정서적인 가점이 들어가, 결과물의 질로부터 채점이 벗어나기 때문입니다.
JUDGE_PROMPT = """You are grading one run of an automated coding agent. Be strict.
## Task given to the agent
{prompt}
...
judge는 JSON만 반환하도록 하며, 마크다운(Markdown) 코드 펜스(code fence)가 섞여 있어도 제거할 수 있도록 파싱합니다. judge 자체의 비용도 기록해 두어, 나중에 총 비용에 합산할 수 있도록 합니다.
def judge(task, verify_exit, verify_out, diff, model):
prompt = JUDGE_PROMPT.format(
prompt=task["prompt"], criteria=task["pass_criteria"],
...
재개(Resume)와 예산 상한
재개(Resume)와 예산 상한
A/B 테스트는 한 태스크당 여러 번, 여러 아밍을 돌리기 때문에 도중에 멈춰도 이어서 재개할 수 있다는 것이 실용적인 생명선이 됩니다. 결과는 results.jsonl에 한 줄씩 추가하며, (task, arm, run)을 키로 하여 재개합니다. 누적 비용이 --max-cost를 초과하면 중단하는 안전장치도 마련했습니다.
# 레쥬메: 이미 out에 기록된 (task, arm, run)은 재실행하지 않고 결과를 재사용한다
existing = {}
if out.exists():
...
사소한 팁이지만, Windows 콘솔(cp932)에서는 judge의 일본어 평가 등을 print하면 문자 깨짐이나 예외로 인해 종료될 수 있습니다. 시작 부분에서 표준 출력을 UTF-8로 변경하여 이를 회피하고 있습니다.
# Windows 콘솔(cp932) 대책: judge 평가 등의 Unicode로 print가 죽지 않게 한다
for _s in (sys.stdout, sys.stderr):
try:
...
tasks.json 작성법
태스크는 JSON 배열로 정의합니다. 1개 태스크는 id / repo / prompt / verify_cmd / pass_criteria / skill / ref로 구성됩니다 (skill과 ref는 선택 사항). 아래는 작성법을 보여주기 위한 일반화된 예시입니다.
[
{
"id": "example-bugfix",
...
태스크를 만드는 데 가장 효과적인 것은 과거 버그 재주입 방식입니다. 실제로 발생했던 버그 수정 커밋을 revert하고, 회귀 테스트만 남긴 피クスチャ(fixture)를 대상 리포지토리에 만들고, 그 ref를 `
| 팔 | 합격 여부 | 비용(실행) | 소요 시간 | 턴 수 |
|---|---|---|---|---|
| baseline | pass | $0.0512 | 28.8s | 8 |
| skill | pass | $0.0605 | 29.9s | 7 |
judge의 비용은 baseline이 $0.0247, skill이 $0.0155였으며, 실행 비용을 합친 총합은 약 $0.15였습니다. 두 팔(arm) 모두 합격하였고, 비용과 시간 모두 비슷한 범위 내에 들어왔습니다.
함정과 적용 한계
이 하네스(Harness)는 만능이 아닙니다. 정직하게 효과가 나타나는 영역과 그렇지 않은 영역이 있습니다. 설계상의 한계를 솔직하게 적어둡니다.
첫째, 대화형 스킬에는 불리하게 작용합니다. 브레인스토밍과 같은 발상 지원 스킬은 본래 사용자와의 상호작용을 전제로 합니다. headless 환경에서는 "질문하는 대신 가정을 명시하며 진행하라"고 지시하기 때문에, 해당 스킬의 장점을 충분히 발휘하지 못해 평가가 부당하게 낮게 나올 가능성이 있습니다.
이러한 종류의 스킬은 headless A/B 테스트를 보조 수단으로 위치시키고, 실제 라이브 세션에서 사용했을 때의 체감과 결과물 차이를 여러 번 기록하는 방식이 본질적인 접근법이 됩니다.
둘째, 객관적 테스트가 없는 창작계 태스크에서는 judge의 주관 비중이 높아집니다. verify_cmd를 통해 기계적으로 합격 여부를 낼 수 없으면, 판정이 LLM의 주관에 치우치게 됩니다. 그럴 경우에는 이 하네스에 무리하게 포함시키지 말고, 결과물을 나란히 두고 팔의 이름을 숨긴 블라인드 비교(익명화된 2인 판정)로 전환하는 것이 타당합니다.
전망 — 강력한 모델 시대의 평가 장치로
현재의 하네스는 1회 실행(run)에서 얻을 수 있는 정보가 거의 1비트(pass / fail)뿐입니다. 소규모 샘플에서는 통계적인 유의차를 쫓기보다, 각 실행에서 무엇이 일어났는지를 해석하는 것이 더 많은 것을 얻을 수 있다는 실감이 듭니다.
따라서 다음 재설계로서 다음과 같은 방향을 생각하고 있습니다.
- 실행 트랜스크립트(transcript)를 보존하고, 팔의 이름을 숨긴 페어 비교(pair comparison)를 통해 "어느 쪽의 진행 방식이 더 좋았는가"를 분석한다.
- 실행 전에 "스킬이 없다면 자신은 어떻게 진행할 것인가"를 선언하게 하여, 기본 동작(default behavior)과의 차이를 대조한다.
- SKILL.md를 최소한으로 증류(distill)한 제3의 팔을 추가하여, A/B가 아닌 A/B/C로 "너무 과한가, 아니면 적당한가"를 구분한다.
이러한 내용들은 실제 태스크 × 여러 번의 본방 결과와 함께 후속편에서 보고할 예정입니다. 모델이 강력해질수록 "세세하게 지시하는 발판"은 가치가 하락하고, "격리·검증·기록과 같은 구조적인 발판"이 남는다—그 가설을 다음에는 숫자로 검증해 나가겠습니다.
요약
Claude Code의 스킬 효과를 체감이 아닌 실측으로 판정하기 위해, 4개의 기둥을 가진 하네스를 구축했습니다.
- 격리(Isolation): 일회용 git worktree를 사용하여 대상 리포지토리를 오염시키지 않음
- 기계 측정 + 독립 judge: 객관적 지표를 기계로 측정하고, 합격 여부는 별도 세션의 LLM이 채점하게 함
- 사전 등록(Pre-registration): "유효하다고 간주할 기준"을 실행 전에 표로 고정하여 사후 해석을 방지함
- 가역성(Reversibility): 기각하더라도 삭제하지 않고 대피시켜 언제든 재평가할 수 있도록 함
다음 액션은 단 하나입니다. 우선 스모크 태스크(smoke task)를 하나만 준비하고, python run_ab.py --tasks tasks.smoke.json --runs 1 --model haiku로 파이프라인을 연결하는 것부터 시작해 보세요. 효과 측정은 그 이후에 얼마든지 늘려갈 수 있습니다.
run_ab.py 전문 (축어. 내부 설계명을 포함한 도입부 docstring의 첫 줄만 "..."로 생략함)
#!/usr/bin/env python3
"""Harness A/B runner: baseline vs skill-augmented `claude -p` runs.
... (설계 방침명 생략)의 적용: 기계 측정(비용·시간·verify 결과) + LLM 판정.
...
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기