콘텐츠로 이동

13. 실전 구축

“"13. 실전 구축"” 챕터 컨셉 일러스트

한 줄 정의. 실전 구축은 스킬 폴더에 지침을 담고, 하네스가 그 지침을 안고 도구를 연결해 실행 루프를 돌린 뒤, 테스트 실측으로 결과를 검증하는 한 줄기의 흐름이다.

실전 예제 아키텍처(스킬+하네스 구성)

앞 장들에서 스킬, 도구 사용, 실행 루프, 안전장치를 따로따로 봤습니다. 하지만 부품을 아는 것과 그것들이 한 흐름으로 맞물리는 모습을 보는 것은 다릅니다. 이 장은 “테스트를 고치는 에이전트”라는 작은 과업 하나를 끝까지 조립하면서, 각 부품이 어디에 끼워지는지를 손에 잡히게 보여 줍니다.

조립의 핵심은 두 갈래가 만난다는 점입니다. 한쪽은 스킬 — 폴더에 담긴 지침과 보조 파일입니다.

다른 한쪽은 하네스 — 모델에게 도구를 쥐여 주고 실행 루프를 돌리는 바깥 코드입니다. 스킬은 “무엇을 할지”를, 하네스는 “어떻게 돌릴지”를 맡습니다.

이 둘을 잇는 작업이 바로 실전 구축입니다.

① 스킬 폴더 만들기 — 저장 위치(스코프)와 우선순위

섹션 제목: “① 스킬 폴더 만들기 — 저장 위치(스코프)와 우선순위”

먼저 지침을 둘 폴더를 만듭니다. 스킬 기본형에서 본 대로, 절차 자체는 단순합니다 — mkdir -p ~/.claude/skills/<skill-name>으로 폴더를 만들고, 그 안에 SKILL.md 한 개를 둡니다.

디렉터리 이름이 곧 /skill-name으로 부르는 명령 이름이 됩니다. 개인 스킬은 어느 프로젝트에서 작업하든 항상 적용됩니다.1

이때 “어디에 두느냐”가 단순한 취향이 아니라 적용 범위를 결정합니다. 저장 위치는 네 단계로 나뉩니다.1

  • Enterprise — 관리형(managed) 설정. 조직이 배포하는 스킬로, 개인이 끄거나 덮어쓸 수 없습니다.
  • Personal — ~/.claude/skills/<name>/SKILL.md. 그 사용자의 모든 프로젝트에 적용됩니다.
  • Project — .claude/skills/<name>/SKILL.md. 이 프로젝트에서만 살아 있어, 팀과 공유하기 좋습니다.
  • Plugin — <plugin>/skills/<name>/SKILL.md. 해당 플러그인이 활성화된 범위에서만 보입니다.

스코프가 넷이니 같은 이름의 스킬이 여러 곳에 존재할 수 있습니다. 충돌 시 우선순위는 enterprise > personal > project 순입니다.

어느 레벨에 둔 스킬이든 동명의 번들(기본 제공) 스킬을 덮어씁니다. 단 플러그인 스킬만은 plugin-name:skill-name 형태의 네임스페이스를 갖기 때문에 애초에 이름이 겹치지 않아 충돌에서 빠집니다.1

프로젝트 스킬은 모노레포를 위해 더 정교하게 동작합니다. 시작 디렉터리부터 리포 루트까지 거슬러 올라가며 만나는 모든 상위 .claude/skills/에서 로드되고, 하위 디렉터리의 파일을 다루기 시작하면 그 하위의 .claude/skills/도 온디맨드로 발견됩니다.

중첩된 두 스킬이 동명이라면 자동으로 사라지지 않고 apps/web:deploy처럼 디렉터리를 한정한 이름으로 양쪽 다 살아남습니다.1

② SKILL.md 구조와 프런트매터 — 무엇이 정말 필수인가

섹션 제목: “② SKILL.md 구조와 프런트매터 — 무엇이 정말 필수인가”

SKILL.md는 두 부분으로 이뤄집니다. ---로 감싼 YAML 프런트매터가 “언제 쓰는지”를 알려 주고, 그 아래 마크다운 본문이 “무엇을 하는지”를 담습니다.

여기서 흔한 오해 하나를 바로잡아야 합니다. “필수 필드는 namedescription 둘뿐”이라는 설명은 정확하지 않습니다.

공식 문서 기준으로 모든 프런트매터 필드는 선택(optional)이며, 그중 description만 ‘권장(recommended)‘입니다. name조차 선택이라 생략하면 디렉터리 이름이 기본값으로 쓰입니다 — 다만 name은 표시용 라벨일 뿐, 실제 호출 명령 이름은 언제나 디렉터리 이름에서 옵니다.1

description이 가장 중요한 이유는, Claude가 이 텍스트를 읽고 “이 스킬을 지금 자동으로 불러올지”를 판단하기 때문입니다. 그래서 무엇을·언제 쓰는지를 적어야 합니다.

생략하면 마크다운 본문의 첫 문단이 대신 쓰입니다. 핵심 use case를 앞쪽에 두어야 하는 데는 구체적 이유가 있습니다 — 스킬 리스팅에서 descriptionwhen_to_use를 합한 텍스트가 1,536자에서 잘리기 때문입니다.

뒤로 밀린 키워드는 사라질 수 있습니다(이 캡은 maxSkillDescriptionChars로 조정할 수 있습니다).1

name·description 외에도 호출 방식과 권한을 세밀하게 설계할 수 있는 필드가 많습니다.1

  • when_to_use — 트리거 문구. 위 1,536자 캡에 description과 합산됩니다.
  • argument-hint, arguments — 인자 입력을 돕습니다.
  • disable-model-invocation: true — Claude의 자동 로드를 차단하고, 수동 /name 호출 전용으로 만듭니다.
  • user-invocable: false/ 메뉴에서 숨겨 Claude만 호출하게 합니다.
  • allowed-tools / disallowed-tools — 도구 권한(③에서 자세히).
  • model, effort(low/medium/high/xhigh/max) — 실행 모델·노력 수준.
  • context: fork, agent, hooks — 격리 실행·서브에이전트·결정론적 훅 연결.
  • paths — 글롭으로 스킬이 활성화될 조건을 파일 경로로 제한.
  • shell(bash/powershell) — 동적 명령 실행 셸 선택.

본문은 마크다운입니다. 인라인 지식(대화 컨텍스트와 함께 늘 적용되는 Reference content)과 단계별 절차(직접 /skill-name으로 부를 때 쓰는 Task content)를 구분해 쓰면 호출 방식을 설계하기 좋습니다.1

③ 보조 파일과 점진적 공개(progressive disclosure)

섹션 제목: “③ 보조 파일과 점진적 공개(progressive disclosure)”

무거운 코드나 긴 참고 문서는 스킬 디렉터리 안에 함께 두되, SKILL.md와 다르게 다룹니다. 일반적인 구조는 엔트리포인트인 SKILL.md(필수, 내비게이션 역할) 옆에 템플릿(template.md), 예시 출력(examples/·examples.md), 실행 스크립트(scripts/), 상세 레퍼런스(reference.md)를 두는 식입니다.1

핵심 메커니즘은 점진적 공개입니다. SKILL.md 본문은 호출 시 로드되지만, 보조 파일은 ‘필요할 때만’ 로드됩니다.

특히 스크립트는 한 발 더 나아가 실행되되 컨텍스트에 로드되지 않습니다(executed, not loaded). 덕분에 거대한 레퍼런스 문서를 함께 묶어 두어도 호출 때마다 컨텍스트에 들어오지 않으므로 토큰 비용이 거의 들지 않습니다. 이것이 “긴 문서를 옆에 두되 본문은 짧게”가 가능한 이유입니다.1

단, 이 메커니즘이 작동하려면 SKILL.md에서 보조 파일을 가리켜야 합니다. [reference.md](reference.md)처럼 링크를 걸어 두어야 Claude가 그 파일에 무엇이 있고 언제 읽어야 하는지를 압니다. “본문에서 가리켜야 한다”는 규칙은 바로 여기서 나옵니다.1

스킬은 어떤 언어의 스크립트든 번들·실행할 수 있습니다. 단일 프롬프트만으로는 불가능한 능력 — 예컨대 인터랙티브 HTML 시각화 생성 같은 일 — 을 모델에게 부여하는 통로입니다. ‘스크립트가 실제 일을 하고 Claude가 오케스트레이션한다’는 패턴이 여기서 성립합니다.1

④ 스킬 콘텐츠의 수명주기와 토큰 비용

섹션 제목: “④ 스킬 콘텐츠의 수명주기와 토큰 비용”

스킬을 호출하면 렌더된 SKILL.md 내용이 단일 메시지로 대화에 들어와 세션이 끝날 때까지 남습니다. Claude Code는 이후 턴에 스킬 파일을 다시 읽지 않습니다. 그래서 스킬은 ‘한 번 쓰고 마는 일회성 단계’가 아니라, 작업 전체에 계속 적용될 상시 지침(standing instructions)으로 설계해야 합니다.1

평상시 세션에서는 각 스킬의 description만 늘 컨텍스트에 올라가 있어 Claude가 어떤 스킬이 있는지를 압니다. 전체 내용은 호출하는 순간에만 로드됩니다(서브에이전트 preload는 다르게 동작해, 시작 시 전체를 주입합니다).1

오토 컴팩션이 스킬을 이월하는 방식

섹션 제목: “오토 컴팩션이 스킬을 이월하는 방식”

컨텍스트가 가득 차 오토 컴팩션이 일어날 때, 호출됐던 스킬은 토큰 예산 안에서 이월됩니다. 각 스킬의 가장 최근 호출본을 요약 뒤에 다시 붙이되 앞 5,000토큰만 유지하고, 재부착 스킬들의 합산 예산은 25,000토큰입니다. 가장 최근에 호출한 스킬부터 채워 나가므로, 오래전에 부른 스킬은 컴팩션 후 통째로 빠질 수 있습니다.1

스킬 리스팅(목록) 예산도 따로 있습니다. 모델 컨텍스트 윈도의 1%로 스케일하며, 이를 초과하면 가장 적게 호출되는 스킬의 description부터 잘려 키워드가 사라집니다.

몇 개가 단축·드롭됐는지는 /doctor로 확인하고, skillListingBudgetFraction(예: 0.02=2%)이나 SLASH_COMMAND_TOOL_CHAR_BUDGET으로 예산을 조정할 수 있습니다.1

⑤ 하네스에 도구 연결하기 — @function_toolAgent 구성

섹션 제목: “⑤ 하네스에 도구 연결하기 — @function_tool과 Agent 구성”

스킬이 “고쳐라”라고만 적혀 있어도, 모델이 실제로 테스트를 돌리고 파일을 고치려면 손이 필요합니다. 그 손이 도구 호출입니다.

OpenAI Agents SDK는 pip install openai-agents로 설치하고, from agents import Agent, Runner, function_tool로 가져옵니다.2

함수에 @function_tool 데코레이터를 붙이면 그 함수가 곧 도구가 됩니다. 도구의 설명은 함수의 독스트링에서, 입력 스키마는 타입 힌트에서 자동으로 도출됩니다. 즉 문서화를 잘 쓰는 것이 곧 도구 인터페이스를 잘 만드는 일입니다.2

Agent() 생성자는 다음 매개변수를 받습니다 — name(str), instructions(str, 행동 지침), tools(list, 선택), handoffs(list, 위임할 에이전트), model(선택), handoff_description(str, 라우팅용 설명). 스킬의 지침을 instructions=에 넣고 도구들을 tools=[...]에 넘기면, 스킬의 “무엇을”과 하네스의 “손”이 한 에이전트 안에서 맞물립니다.2

다중 에이전트가 필요하면 handoffs=[...]로 구성합니다. 러너가 실행 중 핸드오프를 자동으로 오케스트레이션해, 한 에이전트가 다른 에이전트에게 작업을 넘기도록 합니다.2

⑥ 실행 루프와 정지 안전판 — Runner.run·max_turns·MaxTurnsExceeded

섹션 제목: “⑥ 실행 루프와 정지 안전판 — Runner.run·max_turns·MaxTurnsExceeded”

도구를 쥔 에이전트는 Runner.run(agent, 입력)으로 돌립니다. 이 한 호출이 4장에서 본 계획→도구 실행→관찰 루프를 안에서 반복합니다. 루프는 세 단계를 돕니다.3

  1. 현재 에이전트와 입력으로 LLM을 호출합니다.
  2. 출력을 처리합니다 — 도구 호출 없이 (원하는 타입의) 최종 텍스트를 내면 루프가 끝나고 그것이 final_output입니다. 핸드오프면 에이전트·입력을 갱신해 재실행합니다. 도구 호출이면 실행한 뒤 그 결과를 메시지 히스토리에 append하고 재실행합니다.
  3. 턴 한도를 검사합니다.

종료 조건의 핵심은 “LLM이 원하는 타입의 텍스트 출력을 내고 도구 호출이 없을 때” 루프가 끝난다는 점입니다. 도구 결과는 다음 반복 직전에 히스토리에 붙어 모델의 다음 판단 재료가 됩니다.3

정지 안전판은 단순 종료가 아니라 예외입니다

섹션 제목: “정지 안전판은 단순 종료가 아니라 예외입니다”

여기서 빠뜨리면 안 되는 안전판이 max_turns입니다. 모델이 끝낼 줄 모르고 같은 수정을 반복하면 루프는 무한히 돌 수 있습니다.

max_turns를 초과하면 단순히 조용히 멈추는 게 아니라 MaxTurnsExceeded 예외가 발생합니다 — 그래서 호출부에서 명시적으로 잡아 처리해야 합니다. 턴 한도를 아예 끄려면 max_turns=None을 줍니다.3

러너에는 세 가지 진입점이 있습니다.3

  • Runner.run() — async, RunResult를 반환(final_outputlast_agent 포함).
  • Runner.run_sync() — 동기 래퍼로, 내부적으로 .run()을 실행.
  • Runner.run_streamed() — async, RunResultStreaming을 반환(스트리밍).

이 밖에 잘못된 모델 출력에는 ModelBehaviorError, SDK 오용에는 UserError, 가드레일 발동에는 InputGuardrailTripwireTriggered/OutputGuardrailTripwireTriggered 예외가 납니다. 대화 상태는 수동(to_input_list()에 새 사용자 메시지를 더하기), 세션(SQLiteSession이 히스토리를 자동 관리), 서버 관리(conversation_id 또는 previous_response_id) 중에서 고를 수 있고, RunConfiginput_guardrails/output_guardrails·model_settings·추적(workflow_name, trace_id) 등을 설정합니다.3

⑦ 도구 사전승인(allowed-tools)과 동적 컨텍스트 주입

섹션 제목: “⑦ 도구 사전승인(allowed-tools)과 동적 컨텍스트 주입”

스킬 본문에 allowed-tools를 다는 의미를 정확히 알아야 합니다. 이 필드는 스킬이 활성인 동안 나열된 도구를 승인 없이 쓰게 허용할 뿐, 가용 도구를 제한하지는 않습니다. 모든 도구는 여전히 호출 가능하고, 목록에 없는 도구는 기존 권한 설정의 지배를 받습니다.1

구문은 공백·콤마로 구분한 문자열이거나 YAML 리스트입니다. 강력한 점은 명령 스코핑으로, 인자 패턴까지 좁힐 수 있다는 것입니다.

allowed-tools: Bash(git add *) Bash(git commit *) Bash(git status *)

이렇게 하면 Bash(gh *)·Bash(python3 *)처럼 특정 명령만 자동 승인됩니다. 챕터 예시의 allowed-tools: Read, Edit, Bash도 유효하지만, Bash를 좁히지 않아 광범위하게 승인된다는 점에 유의해야 합니다.1

반대로 disallowed-tools는 스킬이 활성인 동안 특정 도구를 풀에서 제거합니다(예: 백그라운드 루프에서 AskUserQuestion을 막아 사용자에게 묻지 않게). 다음 메시지를 보내면 해제됩니다.1

스킬 본문에는 !`<command>` 구문으로 동적 컨텍스트를 끼워 넣을 수 있습니다. 이것은 스킬 내용이 Claude에 전달되기 전에 셸 명령을 실행해 그 출력으로 치환하는 전처리입니다 — Claude가 실행하는 게 아닙니다.

현재 변경 사항:
!`git diff HEAD`

!는 줄의 시작이나 공백 바로 뒤에서만 인식되고, 여러 줄 명령은 ```! 펜스 블록을 씁니다. disableSkillShellExecution: true로 이 기능 전체를 차단할 수 있습니다.1

⑧ 검증 — 환경 실측(ground truth)과 정지 조건

섹션 제목: “⑧ 검증 — 환경 실측(ground truth)과 정지 조건”

에이전트가 “고쳤습니다”라고 말한다고 정말 고쳐진 건 아닙니다. Anthropic은 에이전트를 ‘환경 피드백을 바탕으로 도구를 루프에서 사용하는 LLM’으로 정의합니다. 사용자 명령이나 논의로 시작해 독립적으로 계획·작동하며, 필요할 때 사람에게 정보·판단을 요청합니다.4

핵심 원칙은 실행 중 에이전트가 매 단계에서 환경으로부터 ground truth를 얻는 것(도구 호출 결과나 코드 실행 같은)이 진척 평가에 결정적이라는 것입니다. 우리 예제에서 그 실측은 run_tests()의 결과입니다.

그래서 검증은 별도의 단계가 아니라 루프 안에 박혀 있어야 합니다 — 고치고→돌려보고→실패하면 그 실패 메시지를 다음 턴의 입력으로 받아 또 고치는 반복이, 루프를 단순한 텍스트 생성기가 아니라 실제로 문제를 푸는 에이전트로 만듭니다.4

그리고 자율 루프에는 정지 조건이 한 쌍으로 따라야 합니다. Anthropic도 ‘최대 반복 횟수 같은 stopping condition을 포함하는 것이 통제 유지에 일반적’이라고 말합니다 — 이것이 ⑥의 max_turns와 정확히 대응합니다.4

도구 설계도 검증의 일부입니다. Anthropic은 도구의 에이전트-컴퓨터 인터페이스(ACI)에 인간 인터페이스만큼 투자하라고 합니다 — 예시를 포함한 명확한 문서, 포맷팅 오버헤드 회피, 그리고 파라미터를 poka-yoke(실수 방지)하게 설계해 모델이 틀린 입력을 애초에 못 만들게 하는 것입니다.4

⑨ 스킬 평가·반복(eval)과 자주 틀리는 트러블슈팅

섹션 제목: “⑨ 스킬 평가·반복(eval)과 자주 틀리는 트러블슈팅”

마지막으로 스킬 자체를 검증해야 합니다. 검증의 본질은 이렇습니다 — 스킬이 트리거됐다는 건 Claude가 그것을 찾았다는 뜻일 뿐, 의도대로 했다는 보장이 아닙니다. 그래서 두 가지를 분리해서 측정해야 합니다. ① 호출돼야 할 프롬프트에서 실제로 호출되는가, ② 호출됐을 때 출력이 기대와 맞는가.1

방법은 현실적인 프롬프트 몇 개를 스킬이 있는 새 세션과 비활성화한 세션에서 각각 돌려 비교하는 것입니다(baseline comparison). 새 세션이 중요한 이유가 있습니다 — 스킬을 저작하던 세션에는 남은 컨텍스트가 있어 지침의 빈틈을 가려, 실제로는 부족한 스킬이 잘 동작하는 것처럼 보이게 만들기 때문입니다.1

간단한 실측 테스트는 이렇습니다. git 프로젝트에서 작은 편집을 한 뒤 claude를 실행하고, description에 맞는 요청(예: “What did I change?”)을 던져 자동 호출되는지 보거나, /skill-name으로 직접 불러 동작을 확인합니다.1

권한도 함께 알아 두면 좋습니다. /permissions에서 Skill을 deny하면 스킬 전체를 차단하고, Skill(commit)(정확 일치)·Skill(review-pr *)(접두 일치)로 개별 허용·차단합니다.

/init·/review·/security-review는 Skill 도구로 사용할 수 있지만 /compact 같은 것은 그렇지 않습니다.1

단계부품핵심 요소
① 폴더스킬~/.claude/skills/.../SKILL.md, 4스코프·우선순위
② 구조스킬프런트매터(전부 선택, description 권장), 1,536자 캡
③ 보조 파일스킬점진적 공개, ${CLAUDE_SKILL_DIR}, executed-not-loaded
④ 수명주기스킬단일 메시지·상시 지침, 5,000/25,000토큰 컴팩션 예산
⑤ 연결하네스@function_tool, Agent(tools=...)
⑥ 루프하네스Runner.run, max_turnsMaxTurnsExceeded, final_output
⑦ 권한둘 다allowed-tools 스코핑, !`cmd` 동적 주입
⑧⑨ 검증둘 다run_tests() 실측, 정지 조건, baseline 비교·eval

테스트 수리 에이전트를 끝까지 조립하면 다음과 같습니다. 스킬 폴더와 미니 하네스를 의사코드로 함께 봅니다.

~/.claude/skills/test-fixer/SKILL.md
# ---
# name: test-fixer
# description: 실패한 테스트를 고친다. 사용자가 깨진 테스트·실패 픽스를 요청할 때 사용.
# allowed-tools: Read, Edit, Bash(pytest *)
# ---
# 1. run_tests 로 어떤 테스트가 실패하는지 본다.
# 2. 원인을 찾아 최소 수정한다. references/style.md 의 규칙을 따른다. # 점진적 공개: 본문에서 가리켜야 로드됨
# 3. run_tests 를 다시 돌려 전부 통과할 때까지 반복한다.
# ② 미니 하네스: 도구 연결 (pip install openai-agents)
from agents import Agent, Runner, function_tool
@function_tool
def run_tests() -> str:
"""테스트 스위트를 실행하고 통과/실패 결과를 반환한다."""
... # pytest 를 돌려 출력 텍스트 반환 (이것이 ground truth)
@function_tool
def edit_file(path: str, new_content: str) -> str:
"""파일을 새 내용으로 덮어쓴다."""
...
agent = Agent(
name="test-fixer",
# 교차 배선: SDK는 SKILL.md 프런트매터를 해석하지 않고 그냥 텍스트로 읽는다
instructions=open("~/.claude/skills/test-fixer/SKILL.md").read(),
tools=[run_tests, edit_file], # 스킬의 지침 + 하네스의 손
)
# ③ 실행 루프 + ④ 검증(루프 안에 박힘)
try:
result = await Runner.run(
agent,
input="이 폴더의 실패한 테스트를 고쳐 줘",
max_turns=20, # 초과 시 MaxTurnsExceeded 예외 — 단순 종료가 아님
)
print(result.final_output) # 모델이 도구 없이 최종 보고를 내면 종료
except Exception as e: # MaxTurnsExceeded 등 정지 안전판
print(f"루프가 한도에서 멈춤: {e}")

이 한 덩어리에 네 단계가 다 들어 있습니다. 프런트매터 주석은 스킬 폴더, @function_tool은 도구 연결, Runner.run은 실행 루프, 그리고 run_tests가 매 턴 실측을 주고 max_turns가 폭주를 막는 부분이 검증입니다.

운영 분기의 실제 SDK 메서드 시그니처는 4장을 함께 보세요.

  • 스킬 폴더(~/.claude/skills/<name>/SKILL.md)를 만들고, 저장 위치 4스코프(enterprise/personal/project/plugin)와 우선순위를 안다.
  • 프런트매터가 전부 선택이고 description이 권장임을 알며, 1,536자 캡과 점진적 공개(${CLAUDE_SKILL_DIR}·executed-not-loaded)를 활용한다.
  • 스킬 콘텐츠가 단일 메시지로 세션에 상주하고 컴팩션 예산(5,000/25,000토큰)을 받는다는 수명주기를 안다.
  • @function_tool로 만든 도구를 Agent(tools=...)에 연결하고 Runner.run으로 루프를 돌리며, max_turns 초과 시 MaxTurnsExceeded 예외를 처리한다.
  • allowed-tools를 명령 스코핑으로 좁히고, 검증을 루프 안의 실측으로 넣고, 새 세션 baseline 비교·eval로 스킬을 검증한다.
  1. Extend Claude with skills — Claude Code Docs 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32

  2. OpenAI Agents SDK — Quickstart 2 3 4 5

  3. OpenAI Agents SDK — Running agents 2 3 4 5

  4. Building effective agents — Anthropic 2 3 4 5